// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program.  If not, see <http://www.gnu.org/licenses/>.

package status

import (
	"context"
	"errors"
	"slices"
	"strings"

	apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model"
	"code.superseriousbusiness.org/gotosocial/internal/gtserror"
	"code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
)

// internalThreadContext is like
// *apimodel.ThreadContext, but
// for internal use only.
type internalThreadContext struct {
	targetStatus *gtsmodel.Status
	ancestors    []*gtsmodel.Status
	descendants  []*gtsmodel.Status
}

func (p *Processor) contextGet(
	ctx context.Context,
	requester *gtsmodel.Account,
	targetStatusID string,
) (*internalThreadContext, gtserror.WithCode) {
	targetStatus, errWithCode := p.c.GetVisibleTargetStatus(ctx,
		requester,
		targetStatusID,
		nil, // default freshness
	)
	if errWithCode != nil {
		return nil, errWithCode
	}

	// Don't generate thread for boosts/reblogs.
	if targetStatus.BoostOfID != "" {
		err := gtserror.New("target status is a boost wrapper / reblog")
		return nil, gtserror.NewErrorNotFound(err)
	}

	// Fetch up to the top of the thread.
	ancestors, err := p.state.DB.GetStatusParents(ctx, targetStatus)
	if err != nil {
		return nil, gtserror.NewErrorInternalError(err)
	}

	// Do a simple ID sort of ancestors
	// to arrange them by creation time.
	slices.SortFunc(ancestors, func(lhs, rhs *gtsmodel.Status) int {
		return strings.Compare(lhs.ID, rhs.ID)
	})

	// Fetch down to the bottom of the thread.
	descendants, err := p.state.DB.GetStatusChildren(ctx, targetStatus.ID)
	if err != nil {
		return nil, gtserror.NewErrorInternalError(err)
	}

	// Topographically sort descendants,
	// to place them in sub-threads.
	TopoSort(descendants, targetStatus.AccountID)

	return &internalThreadContext{
		targetStatus: targetStatus,
		ancestors:    ancestors,
		descendants:  descendants,
	}, nil
}

// Returns true if status counts as a self-reply
// *within the current context*, ie., status is a
// self-reply by contextAcctID to contextAcctID.
func isSelfReply(
	status *gtsmodel.Status,
	contextAcctID string,
) bool {
	if status.AccountID != contextAcctID {
		// Doesn't belong
		// to context acct.
		return false
	}

	return status.InReplyToAccountID == contextAcctID
}

// TopoSort sorts the given slice of *descendant*
// statuses topologically, by self-reply, and by ID.
//
// "contextAcctID" should be the ID of the account that owns
// the status the thread context is being constructed around.
//
// Can handle cycles but the output order will be arbitrary.
// (But if there are cycles, something went wrong upstream.)
func TopoSort(
	statuses []*gtsmodel.Status,
	contextAcctID string,
) {
	if len(statuses) == 0 {
		return
	}

	// Simple map of status IDs to statuses.
	//
	// Eg.,
	//
	//	01J2BC6DQ37A6SQPAVCZ2BYSTN: *gtsmodel.Status
	//	01J2BC8GT9THMPWMCAZYX48PXJ: *gtsmodel.Status
	//	01J2BC8M56C5ZAH76KN93D7F0W: *gtsmodel.Status
	//	01J2BC90QNW65SM2F89R5M0NGE: *gtsmodel.Status
	//	01J2BC916YVX6D6Q0SA30JV82D: *gtsmodel.Status
	//	01J2BC91J2Y75D4Z3EEDF3DYAV: *gtsmodel.Status
	//	01J2BC91VBVPBZACZMDA7NEZY9: *gtsmodel.Status
	//	01J2BCMM3CXQE70S831YPWT48T: *gtsmodel.Status
	lookup := make(map[string]*gtsmodel.Status, len(statuses))
	for _, status := range statuses {
		lookup[status.ID] = status
	}

	// Tree of statuses to their children.
	//
	// The nil status may have children: any who don't
	// have a parent, or whose parent isn't in the input.
	//
	// Eg.,
	//
	//	*gtsmodel.Status (01J2BC916YVX6D6Q0SA30JV82D): [     <- parent2 (1 child)
	//		*gtsmodel.Status (01J2BC91J2Y75D4Z3EEDF3DYAV)    <- p2 child1
	//	],
	//	*gtsmodel.Status (01J2BC6DQ37A6SQPAVCZ2BYSTN): [     <- parent1 (3 children)
	//		*gtsmodel.Status (01J2BC8M56C5ZAH76KN93D7F0W)    <- p1 child3  |
	//		*gtsmodel.Status (01J2BC90QNW65SM2F89R5M0NGE)    <- p1 child1  |- Not sorted
	//		*gtsmodel.Status (01J2BC8GT9THMPWMCAZYX48PXJ)    <- p1 child2  |
	//	],
	//	*gtsmodel.Status (01J2BC91VBVPBZACZMDA7NEZY9): [     <- parent3 (no children 😢)
	//	]
	//	*gtsmodel.Status (nil): [                            <- parent4 (nil status)
	//		*gtsmodel.Status (01J2BCMM3CXQE70S831YPWT48T)    <- p4 child1 (no parent 😢)
	//	]
	tree := make(map[*gtsmodel.Status][]*gtsmodel.Status, len(statuses))
	for _, status := range statuses {
		var parent *gtsmodel.Status
		if status.InReplyToID != "" {
			// May be nil if reply is missing.
			parent = lookup[status.InReplyToID]
		}

		tree[parent] = append(tree[parent], status)
	}

	// Sort children of each parent by self-reply status and then ID, *in reverse*.
	// This results in the tree looking something like:
	//
	//	*gtsmodel.Status (01J2BC916YVX6D6Q0SA30JV82D): [     <- parent2 (1 child)
	//		*gtsmodel.Status (01J2BC91J2Y75D4Z3EEDF3DYAV)    <- p2 child1
	//	],
	//	*gtsmodel.Status (01J2BC6DQ37A6SQPAVCZ2BYSTN): [     <- parent1 (3 children)
	//		*gtsmodel.Status (01J2BC90QNW65SM2F89R5M0NGE)    <- p1 child1  |
	//		*gtsmodel.Status (01J2BC8GT9THMPWMCAZYX48PXJ)    <- p1 child2  |- Sorted
	//		*gtsmodel.Status (01J2BC8M56C5ZAH76KN93D7F0W)    <- p1 child3  |
	//	],
	//	*gtsmodel.Status (01J2BC91VBVPBZACZMDA7NEZY9): [     <- parent3 (no children 😢)
	//	],
	//	*gtsmodel.Status (nil): [                            <- parent4 (nil status)
	//		*gtsmodel.Status (01J2BCMM3CXQE70S831YPWT48T)    <- p4 child1 (no parent 😢)
	//	]
	for id, children := range tree {
		slices.SortFunc(children, func(lhs, rhs *gtsmodel.Status) int {
			lhsIsSelfReply := isSelfReply(lhs, contextAcctID)
			rhsIsSelfReply := isSelfReply(rhs, contextAcctID)

			if lhsIsSelfReply && !rhsIsSelfReply {
				// lhs is the end
				// of a sub-thread.
				return 1
			} else if !lhsIsSelfReply && rhsIsSelfReply {
				// lhs is the start
				// of a sub-thread.
				return -1
			}

			// Sort by created-at descending.
			return -strings.Compare(lhs.ID, rhs.ID)
		})
		tree[id] = children
	}

	// Traverse the tree using preorder depth-first
	// search, topologically sorting the statuses
	// until the stack is empty.
	//
	// The stack starts with one nil status in it
	// to account for potential nil key in the tree,
	// which means the below "for" loop will always
	// iterate at least once.
	//
	// The result will look something like:
	//
	//	*gtsmodel.Status (01J2BC6DQ37A6SQPAVCZ2BYSTN)   <- parent1 (3 children)
	//	*gtsmodel.Status (01J2BC90QNW65SM2F89R5M0NGE)   <- p1 child1  |
	//	*gtsmodel.Status (01J2BC8GT9THMPWMCAZYX48PXJ)   <- p1 child2  |- Sorted
	//	*gtsmodel.Status (01J2BC8M56C5ZAH76KN93D7F0W)   <- p1 child3  |
	//	*gtsmodel.Status (01J2BC916YVX6D6Q0SA30JV82D)   <- parent2 (1 child)
	//	*gtsmodel.Status (01J2BC91J2Y75D4Z3EEDF3DYAV)   <- p2 child1
	//	*gtsmodel.Status (01J2BC91VBVPBZACZMDA7NEZY9)   <- parent3 (no children 😢)
	//	*gtsmodel.Status (01J2BCMM3CXQE70S831YPWT48T)   <- p4 child1 (no parent 😢)

	stack := make([]*gtsmodel.Status, 1, len(tree))
	statusIndex := 0
	for len(stack) > 0 {
		parent := stack[len(stack)-1]
		children := tree[parent]

		if len(children) == 0 {
			// No (more) children so we're
			// done with this node.
			// Remove it from the tree.
			delete(tree, parent)

			// Also remove this node from
			// the stack, then continue
			// from its parent.
			stack = stack[:len(stack)-1]

			continue
		}

		// Pop the last child entry
		// (the first in sorted order).
		child := children[len(children)-1]
		tree[parent] = children[:len(children)-1]

		// Explore its children next.
		stack = append(stack, child)

		// Overwrite the next entry of the input slice.
		statuses[statusIndex] = child
		statusIndex++
	}

	// There should only be orphan nodes remaining
	// (or other nodes in the event of a cycle).
	// Append them to the end in arbitrary order.
	//
	// The fact we put them in a map first just
	// ensures the slice of statuses has no duplicates.
	for orphan := range tree {
		statuses[statusIndex] = orphan
		statusIndex++
	}
}

// ContextGet returns the context (previous
// and following posts) from the given status ID.
func (p *Processor) ContextGet(
	ctx context.Context,
	requester *gtsmodel.Account,
	targetStatusID string,
) (*apimodel.ThreadContext, gtserror.WithCode) {
	// Retrieve the full thread context.
	threadContext, errWithCode := p.contextGet(ctx,
		requester,
		targetStatusID,
	)
	if errWithCode != nil {
		return nil, errWithCode
	}

	var apiContext apimodel.ThreadContext

	// Convert and filter the thread context ancestors.
	apiContext.Ancestors = p.c.GetVisibleAPIStatuses(ctx,
		requester,
		threadContext.ancestors,
		gtsmodel.FilterContextThread,
	)

	// Convert and filter the thread context descendants
	apiContext.Descendants = p.c.GetVisibleAPIStatuses(ctx,
		requester,
		threadContext.descendants,
		gtsmodel.FilterContextThread,
	)

	return &apiContext, nil
}

// WebContextGet is like ContextGet, but is explicitly
// for viewing statuses via the unauthenticated web UI.
//
// The returned statuses in the ThreadContext will be
// populated with ThreadMeta annotations for more easily
// positioning the status in a web view of a thread.
func (p *Processor) WebContextGet(
	ctx context.Context,
	targetStatusID string,
) (*apimodel.WebThreadContext, gtserror.WithCode) {
	// Retrieve the internal thread context.
	iCtx, errWithCode := p.contextGet(ctx,

		nil, // No authed requester.
		targetStatusID,
	)
	if errWithCode != nil {
		return nil, errWithCode
	}

	// Recreate the whole thread so we can go
	// through it again add ThreadMeta annotations
	// from the perspective of the OG status.
	// nolint:gocritic
	wholeThread := append(
		// Ancestors at the beginning.
		iCtx.ancestors,
		append(
			// Target status in the middle.
			[]*gtsmodel.Status{iCtx.targetStatus},
			// Descendants at the end.
			iCtx.descendants...,
		)...,
	)

	// Start preparing web context.
	wCtx := &apimodel.WebThreadContext{
		Statuses:  make([]*apimodel.WebStatus, 0, len(wholeThread)),
		Indexable: true,
	}

	var (
		threadLength = len(wholeThread)

		// Track how much each reply status
		// should be indented (if at all).
		statusIndents = make(map[string]int, threadLength)

		// Who the current thread "belongs" to,
		// ie., who created first post in the thread.
		contextAcctID = wholeThread[0].AccountID

		// Whether we've reached end of "main"
		// thread and are now looking at replies.
		inReplies bool

		// Index in wholeThread
		// where replies begin.
		firstReplyIdx int

		// We should mark the next **VISIBLE**
		// reply as the first reply.
		markNextVisibleAsFirstReply bool

		// Map of statuses that didn't pass visi
		// checks and won't be shown via the web.
		hiddenStatuses = make(map[string]struct{})
	)

	for idx, status := range wholeThread {
		if !inReplies {
			// Check if we've reached replies
			// by looking for the first status
			// that's not a self-reply, ie.,
			// not a post in the "main" thread.
			switch {
			case idx == 0:
				// First post in wholeThread can't
				// be a self reply anyway because
				// it (very likely) doesn't reply
				// to anything, so ignore it.

			case !isSelfReply(status, contextAcctID):
				// This is not a self-reply, which
				// means it's a reply from another
				// account. So, replies start here.
				inReplies = true
				firstReplyIdx = idx
				markNextVisibleAsFirstReply = true
			}
		}

		// Ensure status is actually visible to just
		// anyone, and hide / don't include it if not.
		//
		// Include a check to see if the parent status
		// is hidden; if so, we shouldn't show the child
		// as it leads to weird-looking threading where
		// a status seems to reply to nothing.
		_, parentHidden := hiddenStatuses[status.InReplyToID]
		v, err := p.visFilter.StatusVisible(ctx, nil, status)
		if err != nil || !v || parentHidden {
			// If this is the main status whose
			// context we're looking for, and it's
			// not visible for whatever reason, we
			// should just return a 404 here, as we
			// can't meaningfully render the thread.
			if status.ID == targetStatusID {
				var thisErr error
				switch {
				case err != nil:
					thisErr = gtserror.Newf("error checking visibility of target status: %w", err)

				case !v:
					const errText = "target status not visible"
					thisErr = gtserror.New(errText)

				case parentHidden:
					const errText = "target status parent is hidden"
					thisErr = gtserror.New(errText)
				}

				return nil, gtserror.NewErrorNotFound(thisErr)
			}

			// This isn't the main status whose
			// context we're looking for, just
			// your standard not-visible status,
			// so add it to the count + map.
			if !inReplies {
				// Main thread entry hidden.
				wCtx.ThreadHidden++
			} else {
				// Reply hidden.
				wCtx.ThreadRepliesHidden++
			}

			hiddenStatuses[status.ID] = struct{}{}
			continue
		}

		// Prepare visible status to add to thread context.
		webStatus, err := p.converter.StatusToWebStatus(ctx, status)
		if err != nil {
			hiddenStatuses[status.ID] = struct{}{}
			continue
		}

		if markNextVisibleAsFirstReply {
			// This is the first visible
			// "reply / comment", so the
			// little "x amount of replies"
			// header should go above this.
			webStatus.ThreadFirstReply = true
			markNextVisibleAsFirstReply = false
		}

		// If this is a reply, work out the indent of
		// this status based on its parent's indent.
		if inReplies {
			parentIndent, ok := statusIndents[status.InReplyToID]
			switch {
			case !ok:
				// No parent with
				// indent, start at 0.
				webStatus.Indent = 0

			case isSelfReply(status, status.AccountID):
				// Self reply, so indent at same
				// level as own replied-to status.
				webStatus.Indent = parentIndent

			case parentIndent == 5:
				// Already indented as far as we
				// can go to keep things readable
				// on thin screens, so just keep
				// parent's indent.
				webStatus.Indent = parentIndent

			default:
				// Reply to someone else who's
				// indented, but not to TO THE MAX.
				// Indent by another one.
				webStatus.Indent = parentIndent + 1
			}

			// Store the indent for this status.
			statusIndents[status.ID] = webStatus.Indent
		}

		if webStatus.ID == targetStatusID {
			// This is the og
			// thread context status.
			webStatus.ThreadContextStatus = true
			wCtx.Status = webStatus
		}

		// If this status isn't indexable, mark the thread as not indexable.
		// webStatus.Account from StatusToWebStatus() should always be non-nil.
		if !webStatus.Account.Indexable {
			wCtx.Indexable = false
		}

		wCtx.Statuses = append(wCtx.Statuses, webStatus)
	}

	// Now we've gone through the whole
	// thread, we can add some additional info.

	// Length of the "main" thread. If there are
	// visible replies then it's up to where the
	// replies start, else it's the whole thing.
	if inReplies {
		wCtx.ThreadLength = firstReplyIdx
	} else {
		wCtx.ThreadLength = threadLength
	}

	// Jot down number of "main" thread entries shown.
	wCtx.ThreadShown = wCtx.ThreadLength - wCtx.ThreadHidden

	// If there's no posts visible in the
	// "main" thread we shouldn't show replies
	// via the web as that's just weird.
	if wCtx.ThreadShown < 1 {
		const text = "no statuses visible in main thread"
		return nil, gtserror.NewErrorNotFound(errors.New(text))
	}

	// Mark the last "main" visible status.
	wCtx.Statuses[wCtx.ThreadShown-1].ThreadLastMain = true

	// Number of replies is equal to number
	// of statuses in the thread that aren't
	// part of the "main" thread.
	wCtx.ThreadReplies = threadLength - wCtx.ThreadLength

	// Jot down number of "replies" shown.
	wCtx.ThreadRepliesShown = wCtx.ThreadReplies - wCtx.ThreadRepliesHidden

	// Return the finished context.
	return wCtx, nil
}
