// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT

package repo

import (
	"encoding/json"
	"errors"
	"fmt"
	"html/template"
	"maps"
	"net/http"
	"slices"
	"sort"
	"strconv"
	"strings"

	issues_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
	"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/organization"
	project_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/project"
	repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
	"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/unit"
	user_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user"
	"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/base"
	"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/container"
	issue_template "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/issue/template"
	"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
	"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
	api "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/structs"
	"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/util"
	"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/web"
	"code.mokoconsulting.tech/MokoConsulting/MokoGitea/routers/utils"
	"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
	"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context/upload"
	"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/forms"
	issue_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/issue"
)

// Tries to load and set an issue template. The first return value indicates if a template was loaded.
// The third return value contains the template's custom_fields map (field name → default value).
func setTemplateIfExists(ctx *context.Context, ctxDataKey string, possibleFiles []string, metaData *IssuePageMetaData) (bool, map[string]error, map[string]string) {
	commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.Repository.DefaultBranch)
	if err != nil {
		return false, nil, nil
	}

	templateCandidates := make([]string, 0, 1+len(possibleFiles))
	if t := ctx.FormString("template"); t != "" {
		templateCandidates = append(templateCandidates, t)
	}
	templateCandidates = append(templateCandidates, possibleFiles...) // Append files to the end because they should be fallback

	templateErrs := map[string]error{}
	for _, filename := range templateCandidates {
		if ok, _ := commit.HasFile(filename); !ok {
			continue
		}
		template, err := issue_template.UnmarshalFromCommit(commit, filename)
		if err != nil {
			templateErrs[filename] = err
			continue
		}
		ctx.Data[issueTemplateTitleKey] = template.Title
		ctx.Data[ctxDataKey] = template.Content

		if template.Type() == api.IssueTemplateTypeYaml {
			// Replace field default values by values from query
			for _, field := range template.Fields {
				fieldValue := ctx.FormString("field:" + field.ID)
				if fieldValue != "" {
					field.Attributes["value"] = fieldValue
				}
			}

			ctx.Data["Fields"] = template.Fields
			ctx.Data["TemplateFile"] = template.FileName
		}

		metaData.LabelsData.SetSelectedLabelNames(template.Labels)

		selectedAssigneeIDStrings := make([]string, 0, len(template.Assignees))
		if userIDs, err := user_model.GetUserIDsByNames(ctx, template.Assignees, true); err == nil {
			for _, userID := range userIDs {
				selectedAssigneeIDStrings = append(selectedAssigneeIDStrings, strconv.FormatInt(userID, 10))
			}
		}
		metaData.AssigneesData.SelectedAssigneeIDs = strings.Join(selectedAssigneeIDStrings, ",")

		return true, templateErrs, template.CustomFields
	}
	return false, templateErrs, nil
}

// NewIssue render creating issue page
func NewIssue(ctx *context.Context) {
	issueConfig, _ := issue_service.GetTemplateConfigFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo)
	hasTemplates := issue_service.HasTemplatesOrContactLinks(ctx.Repo.Repository, ctx.Repo.GitRepo)

	ctx.Data["Title"] = ctx.Tr("repo.issues.new")
	ctx.Data["PageIsIssueList"] = true
	ctx.Data["NewIssueChooseTemplate"] = hasTemplates
	ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes
	title := ctx.FormString("title")
	ctx.Data["TitleQuery"] = title
	body := ctx.FormString("body")
	ctx.Data["BodyQuery"] = body

	isProjectsEnabled := ctx.Repo.Permission.CanRead(unit.TypeProjects)
	ctx.Data["IsProjectsEnabled"] = isProjectsEnabled
	ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
	upload.AddUploadContext(ctx, "comment")

	pageMetaData := retrieveRepoIssueMetaData(ctx, ctx.Repo.Repository, nil, false)
	if ctx.Written() {
		return
	}

	pageMetaData.MilestonesData.SelectedMilestoneID = ctx.FormInt64("milestone")

	pageMetaData.SetSelectedProjectIDs(parseProjectIDsFromQuery(ctx))
	if len(pageMetaData.ProjectsData.SelectedProjectIDs) == 1 {
		ctx.Data["redirect_after_creation"] = "project"
	}

	tags, err := repo_model.GetTagNamesByRepoID(ctx, ctx.Repo.Repository.ID)
	if err != nil {
		ctx.ServerError("GetTagNamesByRepoID", err)
		return
	}
	ctx.Data["Tags"] = tags

	ret := issue_service.ParseTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo)
	templateLoaded, errs, templateCustomFields := setTemplateIfExists(ctx, issueTemplateKey, IssueTemplateCandidates, pageMetaData)
	maps.Copy(ret.TemplateErrors, errs)
	if ctx.Written() {
		return
	}

	if len(ret.TemplateErrors) > 0 {
		ctx.Flash.Warning(renderErrorOfTemplates(ctx, ret.TemplateErrors), true)
	}

	ctx.Data["HasIssuesOrPullsWritePermission"] = ctx.Repo.Permission.CanWrite(unit.TypeIssues)

	// Load org-level status/priority/type definitions for the new issue sidebar.
	issueStatusDefs, _ := issues_model.GetIssueStatusDefsByOrg(ctx, ctx.Repo.Repository.OwnerID)
	ctx.Data["IssueStatusDefs"] = issueStatusDefs
	issuePriorityDefs, _ := issues_model.GetIssuePriorityDefsByOrg(ctx, ctx.Repo.Repository.OwnerID)
	ctx.Data["IssuePriorityDefs"] = issuePriorityDefs
	issueTypeDefs, _ := issues_model.GetIssueTypeDefsByOrg(ctx, ctx.Repo.Repository.OwnerID)
	ctx.Data["IssueTypeDefs"] = issueTypeDefs

	// Load org-level issue-scoped custom fields for the new issue sidebar.
	customFieldDefs, cfErr := issues_model.GetCustomFieldsByOwner(ctx, ctx.Repo.Repository.OwnerID, issues_model.CustomFieldScopeIssue)
	if cfErr != nil {
		log.Error("NewIssue: GetCustomFieldsByOwner: %v", cfErr)
	}
	ctx.Data["CustomFieldDefs"] = customFieldDefs
	customFieldValues := make(map[int64]string)
	fieldOptions := make(map[int64][]string)
	if len(customFieldDefs) > 0 {
		// Resolve template custom_fields (name → value) to field IDs.
		if len(templateCustomFields) > 0 {
			for _, def := range customFieldDefs {
				if val, ok := templateCustomFields[def.Name]; ok {
					customFieldValues[def.ID] = val
				}
			}
		}
		for _, f := range customFieldDefs {
			if f.Options != "" {
				var opts []string
				if err := json.Unmarshal([]byte(f.Options), &opts); err == nil {
					fieldOptions[f.ID] = opts
				}
			}
		}
	}
	ctx.Data["CustomFieldValues"] = customFieldValues
	ctx.Data["CustomFieldOptions"] = fieldOptions

	if !issueConfig.BlankIssuesEnabled && hasTemplates && !templateLoaded {
		// The "issues/new" and "issues/new/choose" share the same query parameters "project" and "milestone", if blank issues are disabled, just redirect to the "issues/choose" page with these parameters.
		ctx.Redirect(fmt.Sprintf("%s/issues/new/choose?%s", ctx.Repo.Repository.Link(), ctx.Req.URL.RawQuery), http.StatusSeeOther)
		return
	}

	ctx.HTML(http.StatusOK, tplIssueNew)
}

func renderErrorOfTemplates(ctx *context.Context, errs map[string]error) template.HTML {
	var files []string
	for k := range errs {
		files = append(files, k)
	}
	sort.Strings(files) // keep the output stable

	var lines []string
	for _, file := range files {
		lines = append(lines, fmt.Sprintf("%s: %v", file, errs[file]))
	}

	flashError, err := ctx.RenderToHTML(tplAlertDetails, map[string]any{
		"Message": ctx.Tr("repo.issues.choose.ignore_invalid_templates"),
		"Summary": ctx.Tr("repo.issues.choose.invalid_templates", len(errs)),
		"Details": utils.EscapeFlashErrorString(strings.Join(lines, "\n")),
	})
	if err != nil {
		log.Debug("render flash error: %v", err)
		flashError = ctx.Locale.Tr("repo.issues.choose.ignore_invalid_templates")
	}
	return flashError
}

// NewIssueChooseTemplate render creating issue from template page
func NewIssueChooseTemplate(ctx *context.Context) {
	ctx.Data["Title"] = ctx.Tr("repo.issues.new")
	ctx.Data["PageIsIssueList"] = true

	ret := issue_service.ParseTemplatesFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo)
	ctx.Data["IssueTemplates"] = ret.IssueTemplates

	if len(ret.TemplateErrors) > 0 {
		ctx.Flash.Warning(renderErrorOfTemplates(ctx, ret.TemplateErrors), true)
	}

	if !issue_service.HasTemplatesOrContactLinks(ctx.Repo.Repository, ctx.Repo.GitRepo) {
		// The "issues/new" and "issues/new/choose" share the same query parameters "project" and "milestone", if no template here, just redirect to the "issues/new" page with these parameters.
		ctx.Redirect(fmt.Sprintf("%s/issues/new?%s", ctx.Repo.Repository.Link(), ctx.Req.URL.RawQuery), http.StatusSeeOther)
		return
	}

	issueConfig, err := issue_service.GetTemplateConfigFromDefaultBranch(ctx.Repo.Repository, ctx.Repo.GitRepo)
	ctx.Data["IssueConfig"] = issueConfig
	ctx.Data["IssueConfigError"] = err // ctx.Flash.Err makes problems here

	ctx.Data["milestone"] = ctx.FormInt64("milestone")
	ctx.Data["project"] = ctx.FormInt64("project")

	ctx.HTML(http.StatusOK, tplIssueChoose)
}

// DeleteIssue deletes an issue
func DeleteIssue(ctx *context.Context) {
	issue := GetActionIssue(ctx)
	if ctx.Written() {
		return
	}

	if err := issue_service.DeleteIssue(ctx, ctx.Doer, issue); err != nil {
		ctx.ServerError("DeleteIssueByID", err)
		return
	}

	if issue.IsPull {
		ctx.Redirect(ctx.Repo.Repository.Link()+"/pulls", http.StatusSeeOther)
		return
	}

	ctx.Redirect(ctx.Repo.Repository.Link()+"/issues", http.StatusSeeOther)
}

func toSet[ItemType any, KeyType comparable](slice []ItemType, keyFunc func(ItemType) KeyType) container.Set[KeyType] {
	s := make(container.Set[KeyType])
	for _, item := range slice {
		s.Add(keyFunc(item))
	}
	return s
}

// ValidateRepoMetasForNewIssue check and returns repository's meta information
func ValidateRepoMetasForNewIssue(ctx *context.Context, form forms.CreateIssueForm, isPull bool) (ret struct {
	LabelIDs, AssigneeIDs []int64
	MilestoneID           int64
	ProjectIDs            []int64

	Reviewers     []*user_model.User
	TeamReviewers []*organization.Team
},
) {
	pageMetaData := retrieveRepoIssueMetaData(ctx, ctx.Repo.Repository, nil, isPull)
	if ctx.Written() {
		return ret
	}

	inputLabelIDs := ctx.FormStringInt64s("label_ids")
	candidateLabels := toSet(pageMetaData.LabelsData.AllLabels, func(label *issues_model.Label) int64 { return label.ID })
	if len(inputLabelIDs) > 0 && !candidateLabels.Contains(inputLabelIDs...) {
		ctx.NotFound(nil)
		return ret
	}
	pageMetaData.LabelsData.SetSelectedLabelIDs(inputLabelIDs)

	allMilestones := append(slices.Clone(pageMetaData.MilestonesData.OpenMilestones), pageMetaData.MilestonesData.ClosedMilestones...)
	candidateMilestones := toSet(allMilestones, func(milestone *issues_model.Milestone) int64 { return milestone.ID })
	if form.MilestoneID > 0 && !candidateMilestones.Contains(form.MilestoneID) {
		ctx.NotFound(nil)
		return ret
	}
	pageMetaData.MilestonesData.SelectedMilestoneID = form.MilestoneID

	inputProjectIDs := ctx.FormStringInt64s("project_ids")
	pageMetaData.SetSelectedProjectIDs(inputProjectIDs)

	// prepare assignees
	candidateAssignees := toSet(pageMetaData.AssigneesData.CandidateAssignees, func(user *user_model.User) int64 { return user.ID })
	inputAssigneeIDs, _ := base.StringsToInt64s(strings.Split(form.AssigneeIDs, ","))
	var assigneeIDStrings []string
	for _, inputAssigneeID := range inputAssigneeIDs {
		if candidateAssignees.Contains(inputAssigneeID) {
			assigneeIDStrings = append(assigneeIDStrings, strconv.FormatInt(inputAssigneeID, 10))
		}
	}
	pageMetaData.AssigneesData.SelectedAssigneeIDs = strings.Join(assigneeIDStrings, ",")

	// Check if the passed reviewers (user/team) actually exist
	var reviewers []*user_model.User
	var teamReviewers []*organization.Team
	reviewerIDs, _ := base.StringsToInt64s(strings.Split(form.ReviewerIDs, ","))
	if isPull && len(reviewerIDs) > 0 {
		userReviewersMap := map[int64]*user_model.User{}
		teamReviewersMap := map[int64]*organization.Team{}
		for _, r := range pageMetaData.ReviewersData.Reviewers {
			userReviewersMap[r.User.ID] = r.User
		}
		for _, r := range pageMetaData.ReviewersData.TeamReviewers {
			teamReviewersMap[r.Team.ID] = r.Team
		}
		for _, rID := range reviewerIDs {
			if rID < 0 { // negative reviewIDs represent team requests
				team, ok := teamReviewersMap[-rID]
				if !ok {
					ctx.NotFound(nil)
					return ret
				}
				teamReviewers = append(teamReviewers, team)
			} else {
				user, ok := userReviewersMap[rID]
				if !ok {
					ctx.NotFound(nil)
					return ret
				}
				reviewers = append(reviewers, user)
			}
		}
	}

	// Return only the validated IDs.
	ret.LabelIDs, ret.AssigneeIDs, ret.MilestoneID, ret.ProjectIDs = inputLabelIDs, inputAssigneeIDs, form.MilestoneID, inputProjectIDs
	ret.Reviewers, ret.TeamReviewers = reviewers, teamReviewers
	return ret
}

// NewIssuePost response for creating new issue
func NewIssuePost(ctx *context.Context) {
	form := web.GetForm(ctx).(*forms.CreateIssueForm)

	repo := ctx.Repo.Repository

	validateRet := ValidateRepoMetasForNewIssue(ctx, *form, false)
	if ctx.Written() {
		return
	}

	labelIDs, assigneeIDs, milestoneID, projectIDs := validateRet.LabelIDs, validateRet.AssigneeIDs, validateRet.MilestoneID, validateRet.ProjectIDs

	if len(projectIDs) > 0 {
		if !ctx.Repo.Permission.CanRead(unit.TypeProjects) {
			// User must also be able to see the project.
			ctx.HTTPError(http.StatusBadRequest, "user hasn't permissions to read projects")
			return
		}
	}

	var attachments []string
	if setting.Attachment.Enabled {
		attachments = form.Files
	}

	if ctx.HasError() {
		ctx.JSONError(ctx.GetErrMsg())
		return
	}

	if util.IsEmptyString(form.Title) {
		ctx.JSONError(ctx.Tr("repo.issues.new.title_empty"))
		return
	}

	content := form.Content
	if filename := ctx.Req.Form.Get("template-file"); filename != "" {
		if template, err := issue_template.UnmarshalFromRepo(ctx.Repo.GitRepo, ctx.Repo.Repository.DefaultBranch, filename); err == nil {
			content = issue_template.RenderToMarkdown(template, ctx.Req.Form)
		}
	}

	issue := &issues_model.Issue{
		RepoID:      repo.ID,
		Repo:        repo,
		Title:       form.Title,
		PosterID:    ctx.Doer.ID,
		Poster:      ctx.Doer,
		MilestoneID: milestoneID,
		Content:     content,
		Ref:         form.Ref,
	}

	if err := issue_service.NewIssue(ctx, repo, issue, labelIDs, attachments, assigneeIDs, projectIDs); err != nil {
		if repo_model.IsErrUserDoesNotHaveAccessToRepo(err) {
			ctx.HTTPError(http.StatusBadRequest, "UserDoesNotHaveAccessToRepo", err.Error())
		} else if errors.Is(err, user_model.ErrBlockedUser) {
			ctx.JSONError(ctx.Tr("repo.issues.new.blocked_user"))
		} else {
			ctx.ServerError("NewIssue", err)
		}
		return
	}

	// Save status/priority/type from sidebar dropdowns.
	if statusID := ctx.FormInt64("status_id"); statusID > 0 {
		_ = issues_model.SetIssueStatusID(ctx, issue.ID, statusID)
	}
	if priorityID := ctx.FormInt64("priority_id"); priorityID > 0 {
		_ = issues_model.SetIssuePriorityID(ctx, issue.ID, priorityID)
	}
	if typeID := ctx.FormInt64("type_id"); typeID > 0 {
		_ = issues_model.SetIssueTypeID(ctx, issue.ID, typeID)
	}

	// Save custom field values submitted from the new issue form.
	saveCustomFieldsFromForm(ctx, repo.OwnerID, issue.ID)

	log.Trace("Issue created: %d/%d", repo.ID, issue.ID)
	if ctx.FormString("redirect_after_creation") == "project" && len(projectIDs) > 0 {
		// When issue is in multiple projects, redirect to first project from form order.
		project, err := project_model.GetProjectByID(ctx, projectIDs[0])
		if err == nil {
			if project.Type == project_model.TypeOrganization {
				ctx.JSONRedirect(project_model.ProjectLinkForOrg(ctx.Repo.Owner, project.ID))
			} else {
				ctx.JSONRedirect(project_model.ProjectLinkForRepo(repo, project.ID))
			}
			return
		}
	}
	ctx.JSONRedirect(issue.Link())
}

// saveCustomFieldsFromForm reads custom field values from the form
// (submitted as "custom-field-{fieldID}") and persists them for the issue.
// Returns true if all required fields are satisfied.
func saveCustomFieldsFromForm(ctx *context.Context, ownerID, issueID int64) {
	defs, err := issues_model.GetCustomFieldsByOwner(ctx, ownerID, issues_model.CustomFieldScopeIssue)
	if err != nil {
		log.Error("saveCustomFieldsFromForm: GetCustomFieldsByOwner: %v", err)
		return
	}
	if len(defs) == 0 {
		return
	}
	vals := make(map[int64]string)
	for _, def := range defs {
		v := ctx.Req.FormValue(fmt.Sprintf("custom-field-%d", def.ID))
		if v != "" {
			vals[def.ID] = v
		} else if def.Required {
			ctx.Flash.Error(fmt.Sprintf("Custom field %q is required", def.Name))
		}
	}
	if len(vals) > 0 {
		if err := issues_model.SetCustomFieldValues(ctx, issueID, vals); err != nil {
			log.Error("saveCustomFieldsFromForm: %v", err)
			ctx.Flash.Error("Failed to save custom field values")
		}
	}
}
