// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later

package issues

import (
	"context"
	"slices"

	"forgejo.org/models/organization"
	user_model "forgejo.org/models/user"
)

type ActionAggregator struct {
	StartUnix int64
	AggAge    int64
	PosterID  int64
	StartInd  int
	EndInd    int

	PrevClosed bool
	IsClosed   bool

	AddedLabels   []*Label
	RemovedLabels []*Label

	AddedRequestReview   []RequestReviewTarget
	RemovedRequestReview []RequestReviewTarget
}

// Get the time threshold for aggregation of multiple actions together
func (agg *ActionAggregator) timeThreshold() int64 {
	if agg.AggAge > (60 * 60 * 24 * 30) { // Age > 1 month, aggregate by day
		return 60 * 60 * 24
	} else if agg.AggAge > (60 * 60 * 24) { // Age > 1 day, aggregate by hour
		return 60 * 60
	} else if agg.AggAge > (60 * 60) { // Age > 1 hour, aggregate by 10 mins
		return 60 * 10
	}
	// Else, aggregate by minute
	return 60
}

// TODO    Aggregate also
//   - Dependency added / removed
//   - Added / Removed due date
//   - Milestone Added / Removed
func (agg *ActionAggregator) aggregateAction(c *Comment, index int) {
	if agg.StartInd == -1 {
		agg.StartInd = index
	}
	agg.EndInd = index

	switch c.Type {
	case CommentTypeClose:
		agg.IsClosed = true
	case CommentTypeReopen:
		agg.IsClosed = false
	case CommentTypeReviewRequest:
		if c.AssigneeID > 0 {
			req := RequestReviewTarget{User: c.Assignee}
			if c.RemovedAssignee {
				agg.delReviewRequest(req)
			} else {
				agg.addReviewRequest(req)
			}
		} else if c.AssigneeTeamID > 0 {
			req := RequestReviewTarget{Team: c.AssigneeTeam}
			if c.RemovedAssignee {
				agg.delReviewRequest(req)
			} else {
				agg.addReviewRequest(req)
			}
		}

		for _, r := range c.RemovedRequestReview {
			agg.delReviewRequest(r)
		}

		for _, r := range c.AddedRequestReview {
			agg.addReviewRequest(r)
		}
	case CommentTypeLabel:
		if c.Content == "1" {
			agg.addLabel(c.Label)
		} else {
			agg.delLabel(c.Label)
		}
	case CommentTypeAggregator:
		agg.Merge(c.Aggregator)
	}
}

// Merge a past CommentAggregator with the next one in the issue comments list
func (agg *ActionAggregator) Merge(next *ActionAggregator) {
	agg.IsClosed = next.IsClosed

	for _, l := range next.AddedLabels {
		agg.addLabel(l)
	}

	for _, l := range next.RemovedLabels {
		agg.delLabel(l)
	}

	for _, r := range next.AddedRequestReview {
		agg.addReviewRequest(r)
	}

	for _, r := range next.RemovedRequestReview {
		agg.delReviewRequest(r)
	}
}

// Check if a comment can be aggregated or not depending on its type
func (agg *ActionAggregator) IsAggregated(t *CommentType) bool {
	switch *t {
	case CommentTypeAggregator, CommentTypeClose, CommentTypeReopen, CommentTypeLabel, CommentTypeReviewRequest:
		{
			return true
		}
	default:
		{
			return false
		}
	}
}

// Add a label to the aggregated list
func (agg *ActionAggregator) addLabel(lbl *Label) {
	for l, agglbl := range agg.RemovedLabels {
		if agglbl.ID == lbl.ID {
			agg.RemovedLabels = slices.Delete(agg.RemovedLabels, l, l+1)
			return
		}
	}

	if !slices.ContainsFunc(agg.AddedLabels, func(l *Label) bool { return l.ID == lbl.ID }) {
		agg.AddedLabels = append(agg.AddedLabels, lbl)
	}
}

// Remove a label from the aggregated list
func (agg *ActionAggregator) delLabel(lbl *Label) {
	for l, agglbl := range agg.AddedLabels {
		if agglbl.ID == lbl.ID {
			agg.AddedLabels = slices.Delete(agg.AddedLabels, l, l+1)
			return
		}
	}

	if !slices.ContainsFunc(agg.RemovedLabels, func(l *Label) bool { return l.ID == lbl.ID }) {
		agg.RemovedLabels = append(agg.RemovedLabels, lbl)
	}
}

// Add a review request to the aggregated list
func (agg *ActionAggregator) addReviewRequest(req RequestReviewTarget) {
	reqid := req.ID()
	reqty := req.Type()
	for r, aggreq := range agg.RemovedRequestReview {
		if (aggreq.ID() == reqid) && (aggreq.Type() == reqty) {
			agg.RemovedRequestReview = slices.Delete(agg.RemovedRequestReview, r, r+1)
			return
		}
	}

	if !slices.ContainsFunc(agg.AddedRequestReview, func(r RequestReviewTarget) bool { return (r.ID() == reqid) && (r.Type() == reqty) }) {
		agg.AddedRequestReview = append(agg.AddedRequestReview, req)
	}
}

// Delete a review request from the aggregated list
func (agg *ActionAggregator) delReviewRequest(req RequestReviewTarget) {
	reqid := req.ID()
	reqty := req.Type()
	for r, aggreq := range agg.AddedRequestReview {
		if (aggreq.ID() == reqid) && (aggreq.Type() == reqty) {
			agg.AddedRequestReview = slices.Delete(agg.AddedRequestReview, r, r+1)
			return
		}
	}

	if !slices.ContainsFunc(agg.RemovedRequestReview, func(r RequestReviewTarget) bool { return (r.ID() == reqid) && (r.Type() == reqty) }) {
		agg.RemovedRequestReview = append(agg.RemovedRequestReview, req)
	}
}

// Check if anything has changed with this aggregated list of comments
func (agg *ActionAggregator) Changed() bool {
	return (agg.IsClosed != agg.PrevClosed) ||
		(len(agg.AddedLabels) > 0) ||
		(len(agg.RemovedLabels) > 0) ||
		(len(agg.AddedRequestReview) > 0) ||
		(len(agg.RemovedRequestReview) > 0)
}

func (agg *ActionAggregator) OnlyLabelsChanged() bool {
	return ((len(agg.AddedLabels) > 0) || (len(agg.RemovedLabels) > 0)) &&
		(len(agg.AddedRequestReview) == 0) && (len(agg.RemovedRequestReview) == 0) &&
		(agg.PrevClosed == agg.IsClosed)
}

func (agg *ActionAggregator) OnlyRequestReview() bool {
	return ((len(agg.AddedRequestReview) > 0) || (len(agg.RemovedRequestReview) > 0)) &&
		(len(agg.AddedLabels) == 0) && (len(agg.RemovedLabels) == 0) &&
		(agg.PrevClosed == agg.IsClosed)
}

func (agg *ActionAggregator) OnlyClosedReopened() bool {
	return (agg.IsClosed != agg.PrevClosed) &&
		(len(agg.AddedLabels) == 0) && (len(agg.RemovedLabels) == 0) &&
		(len(agg.AddedRequestReview) == 0) && (len(agg.RemovedRequestReview) == 0)
}

// Reset the aggregator to start a new aggregating context
func (agg *ActionAggregator) Reset(cur *Comment, now int64) {
	agg.StartUnix = int64(cur.CreatedUnix)
	agg.AggAge = now - agg.StartUnix
	agg.PosterID = cur.PosterID

	agg.PrevClosed = agg.IsClosed

	agg.StartInd = -1
	agg.EndInd = -1
	agg.AddedLabels = []*Label{}
	agg.RemovedLabels = []*Label{}
	agg.AddedRequestReview = []RequestReviewTarget{}
	agg.RemovedRequestReview = []RequestReviewTarget{}
}

// Function that replaces all the comments aggregated with a single one
// Its CommentType depend on whether multiple type of comments are been aggregated or not
// If nothing has changed, we remove all the comments that get nullified
//
// The function returns how many comments has been removed, in order for the "for" loop
// of the main algorithm to change its index
func (agg *ActionAggregator) createAggregatedComment(issue *Issue, final bool) int {
	// If the aggregation of comments make the whole thing null, erase all the comments
	if !agg.Changed() {
		if final {
			issue.Comments = issue.Comments[:agg.StartInd]
		} else {
			issue.Comments = slices.Replace(issue.Comments, agg.StartInd, agg.EndInd+1)
		}
		return (agg.EndInd - agg.StartInd) + 1
	}

	newAgg := *agg // Trigger a memory allocation, get a COPY of the aggregator

	// Keep the same author, time, etc... But reset the parts we may want to use
	comment := issue.Comments[agg.StartInd]
	comment.Content = ""
	comment.Label = nil
	comment.Aggregator = nil
	comment.Assignee = nil
	comment.AssigneeID = 0
	comment.AssigneeTeam = nil
	comment.AssigneeTeamID = 0
	comment.RemovedAssignee = false
	comment.AddedLabels = nil
	comment.RemovedLabels = nil

	// In case there's only a single change, create a comment of this type
	// instead of an aggregator
	if agg.OnlyLabelsChanged() {
		comment.Type = CommentTypeLabel
	} else if agg.OnlyClosedReopened() {
		if agg.IsClosed {
			comment.Type = CommentTypeClose
		} else {
			comment.Type = CommentTypeReopen
		}
	} else if agg.OnlyRequestReview() {
		comment.Type = CommentTypeReviewRequest
	} else {
		comment.Type = CommentTypeAggregator
		comment.Aggregator = &newAgg
	}

	if len(newAgg.AddedLabels) > 0 {
		comment.AddedLabels = newAgg.AddedLabels
	}

	if len(newAgg.RemovedLabels) > 0 {
		comment.RemovedLabels = newAgg.RemovedLabels
	}

	if len(newAgg.AddedRequestReview) > 0 {
		comment.AddedRequestReview = newAgg.AddedRequestReview
	}

	if len(newAgg.RemovedRequestReview) > 0 {
		comment.RemovedRequestReview = newAgg.RemovedRequestReview
	}

	if final {
		issue.Comments = append(issue.Comments[:agg.StartInd], comment)
	} else {
		issue.Comments = slices.Replace(issue.Comments, agg.StartInd, agg.EndInd+1, comment)
	}
	return agg.EndInd - agg.StartInd
}

// combineCommentsHistory combines nearby elements in the history as one
func CombineCommentsHistory(issue *Issue, now int64) {
	if len(issue.Comments) < 1 {
		return
	}

	// Initialise a new empty aggregator, ready to combine comments
	var agg ActionAggregator
	agg.Reset(issue.Comments[0], now)

	for i := 0; i < len(issue.Comments); i++ {
		cur := issue.Comments[i]
		// If the comment we encounter is not accepted inside an aggregator
		if !agg.IsAggregated(&cur.Type) {
			// If we aggregated some data, create the resulting comment for it
			if agg.StartInd != -1 {
				i -= agg.createAggregatedComment(issue, false)
			}

			agg.StartInd = -1
			if i+1 < len(issue.Comments) {
				agg.Reset(issue.Comments[i+1], now)
			}

			// Do not need to continue the aggregation loop, skip to next comment
			continue
		}

		// If the comment we encounter cannot be aggregated with the current aggregator,
		// we create a new empty aggregator
		threshold := agg.timeThreshold()
		if ((int64(cur.CreatedUnix) - agg.StartUnix) > threshold) || (cur.PosterID != agg.PosterID) {
			// First, create the aggregated comment if there's data in it
			if agg.StartInd != -1 {
				i -= agg.createAggregatedComment(issue, false)
			}
			agg.Reset(cur, now)
		}

		agg.aggregateAction(cur, i)
	}

	// Create the aggregated comment if there's data in it
	if agg.StartInd != -1 {
		agg.createAggregatedComment(issue, true)
	}
}

type RequestReviewTarget struct {
	User *user_model.User
	Team *organization.Team
}

func (t *RequestReviewTarget) ID() int64 {
	if t.User != nil {
		return t.User.ID
	}
	return t.Team.ID
}

func (t *RequestReviewTarget) Name() string {
	if t.User != nil {
		return t.User.GetDisplayName()
	}
	return t.Team.Name
}

func (t *RequestReviewTarget) Type() string {
	if t.User != nil {
		return "user"
	}
	return "team"
}

func (t *RequestReviewTarget) Link(ctx context.Context) string {
	if t.User != nil {
		return t.User.HomeLink()
	}
	return t.Team.Link(ctx)
}
