/*
 * Amsterdam Web Communities System
 * Copyright (c) 2025-2026 Erbosoft Metaverse Design Solutions, All Rights Reserved
 *
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at https://mozilla.org/MPL/2.0/.
 */

// Package main contains the high-level Amsterdam logic.
package main

import (
	"context"
	"fmt"
	"net/http"
	"reflect"
	"strconv"
	"strings"

	"git.erbosoft.com/amy/amsterdam/database"
	"git.erbosoft.com/amy/amsterdam/email"
	"git.erbosoft.com/amy/amsterdam/htmlcheck"
	"git.erbosoft.com/amy/amsterdam/ui"
	"github.com/CloudyKit/jet/v6"
	"github.com/labstack/echo/v4"
	log "github.com/sirupsen/logrus"
)

/* Conferences displayes the list of conferences in a community.
 * Parameters:
 *     ctxt - The AmContext for the request.
 * Returns:
 *     Command string dictating what to be rendered.
 *     Data as a parameter for the command string.
 *     Standard Go error status.
 */
func Conferences(ctxt ui.AmContext) (string, any) {
	comm := ctxt.CurrentCommunity()
	ctxt.VarMap().Set("commName", comm.Name)
	ctxt.VarMap().Set("commAlias", comm.Alias)
	ctxt.SetFrameTitle("Conference Listing: " + comm.Name)
	clist, err := database.AmGetCommunityConferences(ctxt.Ctx(), comm.Id,
		comm.TestPermission("Community.ShowHiddenObjects", ctxt.EffectiveLevel()))
	if err != nil {
		return "error", err
	}
	ctxt.VarMap().Set("conferences", clist)
	if len(clist) > 0 {
		newflag := make([]bool, len(clist))
		for i, conf := range clist {
			msgCount, err := conf.UnreadMessages(ctxt.Ctx(), ctxt.CurrentUser())
			if err != nil {
				return "error", err
			}
			newflag[i] = msgCount > 0
		}
		ctxt.VarMap().Set("newflag", newflag)
	}
	ctxt.VarMap().Set("canCreate", comm.TestPermission("Community.Create", ctxt.EffectiveLevel()))
	ctxt.VarMap().Set("canManage", comm.TestPermission("Community.Create", ctxt.EffectiveLevel()))
	return "framed", "conflist.jet"
}

/* Topics displays the list of topics in a conference.
 * Parameters:
 *     ctxt - The AmContext for the request.
 * Returns:
 *     Command string dictating what to be rendered.
 *     Data as a parameter for the command string.
 */
func Topics(ctxt ui.AmContext) (string, any) {
	prefs, err := ctxt.CurrentUser().Prefs(ctxt.Ctx())
	if err != nil {
		return "error", err
	}
	comm := ctxt.CurrentCommunity()
	conf := ctxt.GetScratch("currentConference").(*database.Conference)
	myLevel := ctxt.GetScratch("levelInConference").(uint16)
	if !conf.TestPermission("Conference.Read", myLevel) {
		return "error", echo.NewHTTPError(http.StatusForbidden, "you are not permitted to read this conference")
	}

	// Get view and sort parameters from query, session, or defaults. Store to session.
	trustSessionValues := false
	if ctxt.IsSession("topic.conf") {
		v := ctxt.GetSession("topic.conf").(int32)
		if v == conf.ConfId {
			trustSessionValues = true
		}
	}
	if !trustSessionValues {
		ctxt.SetSession("topic.conf", conf.ConfId)
	}
	view := database.TopicViewActive
	if trustSessionValues && ctxt.IsSession("topic.view") {
		view = ctxt.GetSession("topic.view").(int)
	}
	view = ctxt.QueryParamInt("view", view)
	ctxt.SetSession("topic.view", view)
	sort := database.TopicSortNumber
	if trustSessionValues && ctxt.IsSession("topic.sort") {
		sort = ctxt.GetSession("topic.sort").(int)
	}
	sort = ctxt.QueryParamInt("sort", sort)
	ctxt.SetSession("topic.sort", sort)

	topics, err := database.AmListTopics(ctxt.Ctx(), conf.ConfId, ctxt.CurrentUserId(), view, sort, false)
	if err != nil {
		return "error", err
	}

	var hotlistTest bool = false
	if !ctxt.CurrentUser().IsAnon {
		hotlistTest, err = database.AmIsInHotlist(ctxt.Ctx(), ctxt.CurrentUser(), comm.Id, conf.ConfId)
		if err != nil {
			return "error", err
		}
	}

	topBlock, bottomBlock, err := conf.GetCustomBlocks(ctxt.Ctx())
	if err != nil {
		return "error", err
	}
	if topBlock != "" {
		ctxt.VarMap().Set("topBlock", topBlock)
	}
	if bottomBlock != "" {
		ctxt.VarMap().Set("bottomBlock", bottomBlock)
	}

	// create the "read new" URL
	urlStem := fmt.Sprintf("/comm/%s/conf/%s", comm.Alias, ctxt.GetScratch("currentAlias"))
	if !ctxt.CurrentUser().IsAnon {
		traverser := ui.NewTopicTraverser(topics)
		ctxt.SetSession("topic.traverser", traverser)
		firstTopic := traverser.FirstTopic()
		if firstTopic >= 1 {
			ctxt.VarMap().Set("urlReadNew", fmt.Sprintf("%s/r/%d", urlStem, firstTopic))
		}
	} else {
		ctxt.SetSession("topic.traverser", nil)
	}

	tz := prefs.Location()
	loc := prefs.Localizer()
	fdate := make([]string, len(topics))
	for i, t := range topics {
		fdate[i] = loc.Strftime("%x %X", t.LastUpdate.In(tz))
	}

	ctxt.VarMap().Set("isAnon", ctxt.CurrentUser().IsAnon)
	ctxt.VarMap().Set("canCreate", !ctxt.CurrentUser().IsAnon && conf.TestPermission("Conference.Create", myLevel))
	ctxt.VarMap().Set("showHotlist", !ctxt.CurrentUser().IsAnon && !hotlistTest)
	ctxt.VarMap().Set("conferenceName", conf.Name)
	ctxt.VarMap().Set("urlBack", fmt.Sprintf("/comm/%s/conf", comm.Alias))
	ctxt.VarMap().Set("urlStem", urlStem)
	ctxt.VarMap().Set("permalink", fmt.Sprintf("/go/%s!%s", comm.Alias, ctxt.GetScratch("currentAlias")))
	ctxt.VarMap().Set("view", view)
	ctxt.VarMap().Set("sort", sort)
	ctxt.VarMap().Set("topics", topics)
	ctxt.VarMap().Set("formattedDate", fdate)
	ctxt.SetFrameTitle("Topics in " + conf.Name)
	return "framed", "topiclist.jet"
}

/* NewTopicForm displays the form for creating a new topic.
 * Parameters:
 *     ctxt - The AmContext for the request.
 * Returns:
 *     Command string dictating what to be rendered.
 *     Data as a parameter for the command string.
 */
func NewTopicForm(ctxt ui.AmContext) (string, any) {
	comm := ctxt.CurrentCommunity()
	conf := ctxt.GetScratch("currentConference").(*database.Conference)
	myLevel := ctxt.GetScratch("levelInConference").(uint16)
	if !conf.TestPermission("Conference.Create", myLevel) {
		return "error", echo.NewHTTPError(http.StatusForbidden, "you are not permitted to create topics in this conference")
	}
	ctxt.VarMap().Set("conferenceName", conf.Name)
	ctxt.VarMap().Set("urlStem", fmt.Sprintf("/comm/%s/conf/%s", comm.Alias, ctxt.GetScratch("currentAlias")))
	ctxt.VarMap().Set("topicName", "")
	pseud, err := conf.DefaultPseud(ctxt.Ctx(), ctxt.CurrentUser())
	if err != nil {
		return "error", err
	}
	ctxt.VarMap().Set("pseud", pseud)
	ctxt.VarMap().Set("pb", "")
	ctxt.SetFrameTitle("Create New Topic")
	return "framed", "new_topic.jet"
}

/* NewTopic creates a new topic and posts the initial message.
 * Parameters:
 *     ctxt - The AmContext for the request.
 * Returns:
 *     Command string dictating what to be rendered.
 *     Data as a parameter for the command string.
 */
func NewTopic(ctxt ui.AmContext) (string, any) {
	comm := ctxt.CurrentCommunity()
	conf := ctxt.GetScratch("currentConference").(*database.Conference)
	myLevel := ctxt.GetScratch("levelInConference").(uint16)
	if !conf.TestPermission("Conference.Create", myLevel) {
		return "error", echo.NewHTTPError(http.StatusForbidden, "you are not permitted to create topics in this conference")
	}

	urlStem := fmt.Sprintf("/comm/%s/conf/%s", comm.Alias, ctxt.GetScratch("currentAlias"))
	if ctxt.FormFieldIsSet("cancel") {
		return "redirect", urlStem
	}
	if ctxt.FormFieldIsSet("preview") {
		// start by escaping the title
		checker, err := htmlcheck.AmNewHTMLChecker(ctxt.Ctx(), "escaper")
		if err != nil {
			return "error", err
		}
		checker.Append(ctxt.FormField("title"))
		checker.Finish()
		v, _ := checker.Value()
		ctxt.VarMap().Set("topicName", v)

		// escape the pseud
		checker.Reset()
		checker.Append(ctxt.FormField("pseud"))
		checker.Finish()
		v, _ = checker.Value()
		ctxt.VarMap().Set("pseud", v)

		// escape the data
		postdata := ctxt.FormField("pb")
		checker.Reset()
		checker.Append(postdata)
		checker.Finish()
		v, _ = checker.Value()
		ctxt.VarMap().Set("pb", v)

		// run the preview
		checker, err = htmlcheck.AmNewHTMLChecker(ctxt.Ctx(), "preview")
		if err != nil {
			return "error", err
		}
		checker.SetContext("PostLinkDecoderContext", database.AmCreatePostLinkContext(comm.Alias, ctxt.URLParam("cid"), conf.TopTopic+1))
		checker.Append(postdata)
		checker.Finish()
		v, _ = checker.Value()
		ctxt.VarMap().Set("previewPb", v)
		nErr, _ := checker.Counter("spelling")
		ctxt.VarMap().Set("nError", nErr)

		if ctxt.FormFieldIsSet("attach") {
			ctxt.VarMap().Set("attachFile", true)
		}
		ctxt.VarMap().Set("conferenceName", conf.Name)
		ctxt.VarMap().Set("urlStem", urlStem)
		ctxt.SetFrameTitle("Preview New Topic")
		return "framed", "new_topic.jet"
	}
	if ctxt.FormFieldIsSet("post1") {
		// start by checking the title and pseud
		checker, err := htmlcheck.AmNewHTMLChecker(ctxt.Ctx(), "post-pseud")
		if err != nil {
			return "error", err
		}
		checker.Append(ctxt.FormField("title"))
		checker.Finish()
		topicName, _ := checker.Value()
		checker.Reset()
		checker.Append(ctxt.FormField("pseud"))
		checker.Finish()
		zeroPostPseud, _ := checker.Value()

		// now check the post data itself
		checker, err = htmlcheck.AmNewHTMLChecker(ctxt.Ctx(), "post-body")
		if err != nil {
			return "error", err
		}
		checker.SetContext("PostLinkDecoderContext", database.AmCreatePostLinkContext(comm.Alias, ctxt.URLParam("cid"), conf.TopTopic+1))
		checker.Append(ctxt.FormField("pb"))
		checker.Finish()
		zeroPost, _ := checker.Value()
		lines, _ := checker.Lines()

		// Add the topic!
		topic, err := database.AmNewTopic(ctxt.Ctx(), conf, ctxt.CurrentUser(), topicName, zeroPostPseud, zeroPost, int32(lines), ctxt.RemoteIP())
		if err != nil {
			return "error", err
		}

		if !ctxt.FormFieldIsSet("attach") {
			return "redirect", urlStem // no attachment - just redisplay topic list
		}

		post, err := topic.GetPost(ctxt.Ctx(), 0) // get the initial post in the new topic
		if err != nil {
			return "error", err
		}

		// go upload the attachment
		ctxt.VarMap().Set("target", urlStem)
		ctxt.VarMap().Set("post", post.PostId)
		ctxt.SetFrameTitle("Upload Attachment")
		return "framed", "attachment_upload.jet"
	}

	return "error", EBUTTON
}

/* breakRange breaks up a post range into two elements.
 * Parameters:
 *     topic - The topic within which the range is defined.
 *     into - The 2-element array into which the range will be filled.
 *     param - The range parameter to be broken up.
 *     sep - The separator character to use
 */
func breakRange(topic *database.Topic, into []int32, param string, sep string) error {
	rstr := strings.Split(param, sep)
	if len(rstr) == 0 {
		return fmt.Errorf("posts not found: %s in topic %d", param, topic.Number)
	}
	v, err := strconv.ParseInt(strings.TrimSpace(rstr[0]), 10, 32)
	if err != nil {
		return fmt.Errorf("posts not found: %s in topic %d", param, topic.Number)
	}
	into[0] = int32(v)
	if len(rstr) > 1 {
		if len(rstr[1]) == 0 {
			into[1] = -1
		} else {
			v, err = strconv.ParseInt(strings.TrimSpace(rstr[1]), 10, 32)
			if err != nil {
				return fmt.Errorf("posts not found: %s in topic %d", param, topic.Number)
			}
			into[1] = int32(v)
		}
	} else {
		into[1] = into[0]
	}
	if into[1] < 0 {
		into[1] = topic.TopMessage
	}
	if into[0] > into[1] {
		t := into[0]
		into[0] = into[1]
		into[1] = t
	}
	into[0] = max(into[0], 0)
	into[1] = min(into[1], topic.TopMessage)
	return nil
}

// templateExtractUserName extracts the user name from the post.
func templateExtractUserName(args jet.Arguments) reflect.Value {
	rc := "<<ERROR>>"
	post := args.Get(0).Convert(reflect.TypeFor[*database.PostHeader]()).Interface().(*database.PostHeader)
	ctxt := args.Get(1).Convert(reflect.TypeFor[ui.AmContext]()).Interface().(ui.AmContext)
	user, err := database.AmGetUser(ctxt.Ctx(), post.CreatorUid)
	if err == nil {
		rc = user.Username
	} else {
		log.Errorf("templateExtractUserName failed to get user #%d: %v", post.CreatorUid, err)
	}
	return reflect.ValueOf(rc)
}

// templatePostText gets the text of a post.
func templatePostText(args jet.Arguments) reflect.Value {
	post := args.Get(0).Convert(reflect.TypeFor[*database.PostHeader]()).Interface().(*database.PostHeader)
	ctxt := args.Get(1).Convert(reflect.TypeFor[ui.AmContext]()).Interface().(ui.AmContext)
	rc, err := post.Text(ctxt.Ctx())
	if err != nil {
		log.Errorf("templatePostText could not get post text from post #%d: %v", post.PostId, err)
		rc = ""
	}
	return reflect.ValueOf(rc)
}

// templateOverrideLine creates the "override line" for a post, that is, what gets displayed in place of the post text.
func templateOverrideLine(args jet.Arguments) reflect.Value {
	post := args.Get(0).Convert(reflect.TypeFor[*database.PostHeader]()).Interface().(*database.PostHeader)
	ctxt := args.Get(1).Convert(reflect.TypeFor[ui.AmContext]()).Interface().(ui.AmContext)
	rc := ""
	if post.IsScribbled() {
		scr_date := ""
		scr_user, err := database.AmGetUser(ctxt.Ctx(), *post.ScribbleUid)
		if err == nil {
			var p *database.UserPrefs
			p, err = ctxt.CurrentUser().Prefs(ctxt.Ctx())
			if err == nil {
				scr_date = p.Localizer().Strftime("%b %e, %Y %r", *post.ScribbleDate)
			}
		}
		if err == nil {
			rc = fmt.Sprintf("(Scribbled by %s on %s)", scr_user.Username, scr_date)
		} else {
			rc = fmt.Sprintf("<<<%v>>>", err)
		}
	} else if post.Hidden {
		rc = fmt.Sprintf("(Hidden Message: %d Lines)", *post.LineCount)
	}
	return reflect.ValueOf(rc)
}

// templateOverrideLink creates the "override link" for a post, which can make the override line a hyperlink.
func templateOverrideLink(args jet.Arguments) reflect.Value {
	post := args.Get(0).Convert(reflect.TypeFor[*database.PostHeader]()).Interface().(*database.PostHeader)
	root := args.Get(1).Convert(reflect.TypeFor[string]()).String()
	rc := ""
	if post.Hidden {
		rc = fmt.Sprintf("%s?r=%d&ac=1", root, post.Num)
	}
	return reflect.ValueOf(rc)
}

// templateAttachmentInfo gets the attachment info for a post.
func templateAttachmentInfo(args jet.Arguments) reflect.Value {
	post := args.Get(0).Convert(reflect.TypeFor[*database.PostHeader]()).Interface().(*database.PostHeader)
	ctxt := args.Get(1).Convert(reflect.TypeFor[ui.AmContext]()).Interface().(ui.AmContext)
	rc, _ := post.AttachmentInfo(ctxt.Ctx())
	if rc == nil {
		rc = &database.PostAttachInfo{}
	}
	return reflect.ValueOf(rc)
}

// templateBozo returns true if the post's creator user has been filtered by the current one.
func templateBozo(args jet.Arguments) reflect.Value {
	post := args.Get(0).Convert(reflect.TypeFor[*database.PostHeader]()).Interface().(*database.PostHeader)
	topic := args.Get(1).Convert(reflect.TypeFor[*database.Topic]()).Interface().(*database.Topic)
	ctxt := args.Get(2).Convert(reflect.TypeFor[ui.AmContext]()).Interface().(ui.AmContext)
	rc, _ := topic.IsBozo(ctxt.Ctx(), ctxt.CurrentUser(), post.CreatorUid)
	return reflect.ValueOf(rc)
}

/* ReadPosts displays posts in a topic.
 * Parameters:
 *     ctxt - The AmContext for the request.
 * Returns:
 *     Command string dictating what to be rendered.
 *     Data as a parameter for the command string.
 */
func ReadPosts(ctxt ui.AmContext) (string, any) {
	// Extract the traverser first, so we can unclear it in background tasks.
	var traverser ui.TopicTraverser = nil
	if ctxt.IsSession("topic.traverser") {
		traverser = ctxt.GetSession("topic.traverser").(ui.TopicTraverser)
	}

	// If we need to reset a topic's last read count (as with "Next & Keep New"), spin the task off.
	if ctxt.HasParameter("rst") {
		rst := strings.Split(ctxt.Parameter("rst"), ",")
		if len(rst) >= 2 {
			user := ctxt.CurrentUser()
			ampool.Submit(func(ctx context.Context) {
				topicId, e1 := strconv.ParseInt(rst[0], 10, 32)
				lastRead, e2 := strconv.ParseInt(rst[1], 10, 32)
				if e1 == nil && e2 == nil {
					topic, _ := database.AmGetTopic(ctx, int32(topicId))
					if topic != nil {
						topic.SetLastRead(ctx, user, int32(lastRead))
						if traverser != nil && int32(lastRead) < topic.TopMessage {
							traverser.UnclearTopic(topic.Number)
						}
					}
				}
			})
		}
	}
	// Get user prefs.
	prefs, err := ctxt.CurrentUser().Prefs(ctxt.Ctx())
	if err != nil {
		return "error", err
	}
	// Locate community, conference, and topic.
	comm := ctxt.CurrentCommunity()
	conf := ctxt.GetScratch("currentConference").(*database.Conference)
	myLevel := ctxt.GetScratch("levelInConference").(uint16)
	topic := ctxt.GetScratch("currentTopic").(*database.Topic)
	ctxt.VarMap().Set("post_topic", topic)

	// Determine the range of posts to display.  The "pin" is the post number after which we display the horizontal line separating old and new posts.
	lastRead, err := topic.GetLastRead(ctxt.Ctx(), ctxt.CurrentUser())
	if err != nil {
		return "error", echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("posts not found in topic %d - %v", topic.Number, err))
	}
	postRange := make([]int32, 2)
	var pin int32 = -1
	resetLastRead := false
	if ctxt.HasParameter("r") {
		if err := breakRange(topic, postRange, ctxt.Parameter("r"), ","); err != nil {
			return "error", echo.NewHTTPError(http.StatusNotFound).SetInternal(err)
		}
	} else if ctxt.HasParameter("rgo") {
		if err := breakRange(topic, postRange, ctxt.Parameter("rgo"), "-"); err != nil {
			return "error", echo.NewHTTPError(http.StatusNotFound).SetInternal(err)
		}
	} else {
		postRange[0] = lastRead + 1
		postRange[1] = topic.TopMessage
		count := postRange[1] - postRange[0] + 1
		if count == 0 {
			postRange[0] = max(postRange[1]-ctxt.Globals().PostsPerPage+1, 0)
		} else if count > ctxt.Globals().PostsPerPage {
			postRange[0] = postRange[1] - ctxt.Globals().PostsPerPage + 1
		} else if count < ctxt.Globals().PostsPerPage {
			pin = postRange[0] - 1
			postRange[0] = max(0, postRange[0]-ctxt.Globals().OldPostsAtTop)
			if pin < postRange[0] || pin >= postRange[1] {
				pin = -1
			}
		}
		resetLastRead = true
	}

	// Load the actual posts.
	posts, err := database.AmGetPostRange(ctxt.Ctx(), topic, postRange[0], postRange[1])
	if err != nil {
		return "error", fmt.Sprintf("internal error getting posts <%d:%d-%d> - %v", topic.Number, postRange[0], postRange[1], err)
	}

	// Determine other required data.
	hidden, _ := topic.IsHidden(ctxt.Ctx(), ctxt.CurrentUser())
	ctxt.VarMap().Set("isTopicHidden", hidden)
	var flags strings.Builder
	if topic.Sticky {
		flags.WriteString("📌")
	}
	if hidden {
		flags.WriteString("🙈")
	}
	if topic.Archived {
		flags.WriteString("🗄️")
	} else if topic.Frozen {
		flags.WriteString("🧊")
	}
	summaryLine := fmt.Sprintf("%d Total; %d New; Last: %s", topic.TopMessage+1, topic.TopMessage-lastRead, prefs.Localizer().Strftime("%b %e, %Y %r", topic.LastUpdate))
	ctxt.VarMap().Set("summaryLine", flags.String()+summaryLine)
	ctxt.SetFrameTitle(fmt.Sprintf("%s: %s%s", topic.Name, flags.String(), summaryLine))
	plc := database.AmCreatePostLinkContext("", ctxt.GetScratch("currentAlias").(string), topic.Number)
	ctxt.VarMap().Set("post_confRef", plc.AsString())
	plc.Community = comm.Alias
	ctxt.VarMap().Set("post_topicPermalink", fmt.Sprintf("/go/%s", plc.AsString()))
	plc.FirstPost = postRange[0]
	plc.LastPost = postRange[1]
	ctxt.VarMap().Set("postsPermalink", fmt.Sprintf("/go/%s", plc.AsString()))

	// Set the user's pseud.
	pseud, err := conf.DefaultPseud(ctxt.Ctx(), ctxt.CurrentUser())
	if err != nil {
		return "error", err
	}
	ctxt.VarMap().Set("pseud", pseud)

	// Set permission and status flags.
	confHidePerm := conf.TestPermission("Conference.Hide", myLevel)
	ctxt.VarMap().Set("canFreeze", confHidePerm)
	ctxt.VarMap().Set("canArchive", confHidePerm)
	ctxt.VarMap().Set("canStick", confHidePerm)
	ctxt.VarMap().Set("isFrozen", topic.Frozen)
	ctxt.VarMap().Set("isArchived", topic.Archived)
	ctxt.VarMap().Set("isSticky", topic.Sticky)
	confNukePerm := conf.TestPermission("Conference.Nuke", myLevel)
	ctxt.VarMap().Set("canDelete", confNukePerm)
	confPostPerm := conf.TestPermission("Conference.Post", myLevel)
	ctxt.VarMap().Set("canPost", (!(topic.Frozen || topic.Archived) || confHidePerm) && confPostPerm)
	ctxt.VarMap().Set("showFrozenArchiveMessages", confPostPerm)

	// Set advanced controls.
	advancedControls := ctxt.HasParameter("ac") && (len(posts) == 1)
	if advancedControls {
		nbozo := ctxt.QueryParamInt("bozo", -1)
		if nbozo >= 0 {
			err = topic.SetBozo(ctxt.Ctx(), ctxt.CurrentUser(), posts[0].CreatorUid, nbozo != 0)
			if err != nil {
				return "error", err
			}
		}
		isMyPost := (posts[0].CreatorUid == ctxt.CurrentUserId()) && !ctxt.CurrentUser().IsAnon
		isScribbled := posts[0].IsScribbled()
		canHide := !isScribbled && (isMyPost || confHidePerm)
		ctxt.VarMap().Set("canHide", canHide)
		ctxt.VarMap().Set("isPostHidden", posts[0].Hidden)
		canScribble := !isScribbled && (isMyPost || confNukePerm)
		ctxt.VarMap().Set("canScribble", canScribble)
		ctxt.VarMap().Set("canNuke", confNukePerm)
		ctxt.VarMap().Set("canMove", confNukePerm && conf.TestPermission("Conference.Post", myLevel) && topic.TopMessage > 0)
		canPublish := !isScribbled && database.AmTestPermission("Global.PublishFP", myLevel)
		if canPublish {
			published, _ := posts[0].IsPublished(ctxt.Ctx())
			if published {
				canPublish = false
			}
		}
		ctxt.VarMap().Set("canPublish", canPublish)
	}
	ctxt.VarMap().Set("advancedControls", advancedControls)

	// Adjust the traverser and get the "next" link.
	urlStem := fmt.Sprintf("/comm/%s/conf/%s", comm.Alias, ctxt.GetScratch("currentAlias").(string))
	if traverser != nil {
		traverser.ClearTopic(topic.Number)
		nextTopic := traverser.NextTopic(topic.Number)
		if nextTopic >= 0 {
			nextTopicURL := fmt.Sprintf("%s/r/%d", urlStem, nextTopic)
			ctxt.VarMap().Set("urlNextTopic", nextTopicURL)
			ctxt.VarMap().Set("urlNextKeepNew", fmt.Sprintf("%s?rst=%d,%d", nextTopicURL, topic.TopicId, lastRead))
		}
	}

	// Render the output.
	ctxt.VarMap().Set("isAnon", ctxt.CurrentUser().IsAnon)
	ctxt.VarMap().Set("topicName", topic.Name)
	ctxt.VarMap().Set("lastRead", lastRead)
	ctxt.VarMap().Set("pageSize", ctxt.Globals().PostsPerPage)
	ctxt.VarMap().SetFunc("post_getOverrideLine", templateOverrideLine)
	ctxt.VarMap().SetFunc("post_getOverrideLink", templateOverrideLink)
	ctxt.VarMap().SetFunc("post_getText", templatePostText)
	ctxt.VarMap().SetFunc("post_getUserName", templateExtractUserName)
	ctxt.VarMap().SetFunc("post_getAttachmentInfo", templateAttachmentInfo)
	ctxt.VarMap().SetFunc("post_isBozo", templateBozo)
	ctxt.VarMap().Set("post_stem", fmt.Sprintf("%s/r/%d", urlStem, topic.Number))
	ctxt.VarMap().Set("post_max", topic.TopMessage)
	ctxt.VarMap().Set("posts", posts)
	ctxt.VarMap().Set("pin", pin)
	ctxt.VarMap().Set("rangeEnd", postRange[1])
	ctxt.VarMap().Set("rangeStart", postRange[0])
	ctxt.VarMap().Set("topicListLink", urlStem)
	ctxt.VarMap().Set("topicNum", topic.Number)
	if resetLastRead {
		user := ctxt.CurrentUser()
		ampool.Submit(func(ctx context.Context) {
			topic.SetLastRead(ctx, user, topic.TopMessage)
		})
	}
	return "framed", "posts.jet"
}

/* PostInTopic adds a new post to a topic.
 * Parameters:
 *     ctxt - The AmContext for the request.
 * Returns:
 *     Command string dictating what to be rendered.
 *     Data as a parameter for the command string.
 */
func PostInTopic(ctxt ui.AmContext) (string, any) {
	// Locate community, conference, and topic.
	comm := ctxt.CurrentCommunity()
	conf := ctxt.GetScratch("currentConference").(*database.Conference)
	level := ctxt.GetScratch("levelInConference").(uint16)
	topic := ctxt.GetScratch("currentTopic").(*database.Topic)
	ctxt.VarMap().Set("post_topic", topic)

	urlStem := fmt.Sprintf("/comm/%s/conf/%s/r/%d", comm.Alias, ctxt.GetScratch("currentAlias"), topic.Number)
	if ctxt.FormFieldIsSet("cancel") {
		return "redirect", urlStem
	}

	if !conf.TestPermission("Conference.Post", level) {
		return "error", echo.NewHTTPError(http.StatusForbidden, "you do not have permission to post in this conference")
	}

	if topic.Frozen && !conf.TestPermission("Conference.Hide", level) {
		return "error", echo.NewHTTPError(http.StatusForbidden, "this topic is frozen, and you do not have permission to post to it")
	}

	if topic.Archived && !conf.TestPermission("Conference.Hide", level) {
		return "error", echo.NewHTTPError(http.StatusForbidden, "this topic is archived, and you do not have permission to post to it")
	}

	// Set the escaped version of the text into the varmap, because it'll be needed if we do anything other than redirect.
	checker, err := htmlcheck.AmNewHTMLChecker(ctxt.Ctx(), "escaper")
	if err != nil {
		return "error", err
	}
	checker.Append(ctxt.FormField("pseud"))
	checker.Finish()
	v, _ := checker.Value()
	ctxt.VarMap().Set("pseud", v)
	postdata := ctxt.FormField("pb")
	checker.Reset()
	checker.Append(postdata)
	checker.Finish()
	v, _ = checker.Value()
	ctxt.VarMap().Set("pb", v)

	// also set the "attach" flag and "next topic" link (where applicable) into the post data
	if ctxt.FormFieldIsSet("attach") {
		ctxt.VarMap().Set("attachFile", true)
	}
	urlNextTopic := ctxt.FormField("nextlink")
	if len(urlNextTopic) > 0 {
		ctxt.VarMap().Set("urlNextTopic", urlNextTopic)
	}

	if ctxt.FormFieldIsSet("preview") {
		// Preview the post.
		checker, err = htmlcheck.AmNewHTMLChecker(ctxt.Ctx(), "preview")
		if err != nil {
			return "error", err
		}
		checker.SetContext("PostLinkDecoderContext", database.AmCreatePostLinkContext(comm.Alias, ctxt.URLParam("cid"), topic.Number))
		checker.Append(postdata)
		checker.Finish()
		v, _ = checker.Value()
		ctxt.VarMap().Set("previewPb", v)
		nErr, _ := checker.Counter("spelling")
		ctxt.VarMap().Set("nError", nErr)

		ctxt.VarMap().Set("maxPost", ctxt.FormField("xp"))
		ctxt.VarMap().Set("urlStem", urlStem)
		ctxt.SetFrameTitle("Previewing Message")
		return "framed", "preview_post.jet"
	}
	// Figure out which URL to return to once this post is made.
	var returnURL string
	if ctxt.FormFieldIsSet("post") {
		returnURL = urlStem
	} else if ctxt.FormFieldIsSet("postnext") && len(urlNextTopic) > 0 {
		returnURL = urlNextTopic
	} else if ctxt.FormFieldIsSet("posttopics") {
		returnURL = fmt.Sprintf("/comm/%s/conf/%s", comm.Alias, ctxt.GetScratch("currentAlias"))
	} else {
		return "error", EBUTTON
	}

	// Check for slippage.
	maxPost, err := ctxt.FormFieldInt("xp")
	if err != nil {
		return "error", err
	}
	if int32(maxPost) < topic.TopMessage {
		// Slippage detected! Display the slipped posts and another post box.
		// Start by getting the slipped posts.
		posts, err := database.AmGetPostRange(ctxt.Ctx(), topic, int32(maxPost), topic.TopMessage)
		if err != nil {
			return "error", err
		}

		plc := database.AmCreatePostLinkContext("", ctxt.GetScratch("currentAlias").(string), topic.Number)
		ctxt.VarMap().Set("post_confRef", plc.AsString())
		plc.Community = comm.Alias
		ctxt.VarMap().Set("post_topicPermalink", fmt.Sprintf("/go/%s", plc.AsString()))

		ctxt.VarMap().SetFunc("post_getOverrideLine", templateOverrideLine)
		ctxt.VarMap().SetFunc("post_getOverrideLink", templateOverrideLink)
		ctxt.VarMap().SetFunc("post_getText", templatePostText)
		ctxt.VarMap().SetFunc("post_getUserName", templateExtractUserName)
		ctxt.VarMap().SetFunc("post_getAttachmentInfo", templateAttachmentInfo)
		ctxt.VarMap().SetFunc("post_isBozo", templateBozo)
		ctxt.VarMap().Set("post_stem", fmt.Sprintf("/comm/%s/conf/%s/r/%d", comm.Alias, ctxt.GetScratch("currentAlias"), topic.Number))
		ctxt.VarMap().Set("post_max", topic.TopMessage)
		ctxt.VarMap().Set("posts", posts)
		ctxt.VarMap().Set("topicName", topic.Name)
		ctxt.SetFrameTitle("Slippage or Double-Click Detected")

		return "framed", "slippage.jet"
	}
	// if we get here, we are posting - start by checking the title and pseud
	checker, err = htmlcheck.AmNewHTMLChecker(ctxt.Ctx(), "post-pseud")
	if err != nil {
		return "error", err
	}
	checker.Append(ctxt.FormField("pseud"))
	checker.Finish()
	postPseud, _ := checker.Value()

	// now check the post data itself
	checker, err = htmlcheck.AmNewHTMLChecker(ctxt.Ctx(), "post-body")
	if err != nil {
		return "error", err
	}
	checker.SetContext("PostLinkDecoderContext", database.AmCreatePostLinkContext(comm.Alias, ctxt.URLParam("cid"), topic.Number))
	checker.Append(ctxt.FormField("pb"))
	checker.Finish()
	postText, _ := checker.Value()
	lines, _ := checker.Lines()

	// Add the post!
	hdr, err := database.AmNewPost(ctxt.Ctx(), conf, topic, ctxt.CurrentUser(), postPseud, postText, int32(lines), ctxt.RemoteIP())
	if err != nil {
		return "error", err
	}

	// Check who's subscribed to this topic.
	subs, err := topic.GetSubscribers(ctxt.Ctx())
	if err != nil {
		log.Errorf("unable to deliver message to subscribers: %v", err)
	} else if len(subs) > 0 {
		// kick off a task to compose E-mails and deliver them to everyone
		alias := ctxt.GetScratch("currentAlias").(string)
		poster := ctxt.CurrentUser()
		ipaddr := ctxt.RemoteIP()
		ampool.Submit(func(ctx context.Context) {
			email.AmDeliverSubscription(ctx, comm, conf, alias, topic, poster, hdr, postText, subs, ipaddr)
		})
	}

	if !ctxt.FormFieldIsSet("attach") {
		return "redirect", returnURL // no attachment - just bounce directly to the destination
	}

	// go upload the attachment
	ctxt.VarMap().Set("target", returnURL)
	ctxt.VarMap().Set("post", hdr.PostId)
	ctxt.SetFrameTitle("Upload Attachment")
	return "framed", "attachment_upload.jet"
}
