// Copyright 2014 The Gogs Authors. All rights reserved.
// Copyright 2018 The Gitea Authors. All rights reserved.
// Copyright 2026 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later

package setting

import (
	stdCtx "context"
	"fmt"
	"html/template"
	"net/http"
	"slices"
	"strings"

	auth_model "forgejo.org/models/auth"
	"forgejo.org/models/db"
	access_model "forgejo.org/models/perm/access"
	repo_model "forgejo.org/models/repo"
	"forgejo.org/modules/base"
	"forgejo.org/modules/log"
	"forgejo.org/modules/optional"
	"forgejo.org/modules/setting"
	"forgejo.org/modules/web"
	"forgejo.org/routers/web/shared/user"
	"forgejo.org/services/authz"
	"forgejo.org/services/context"
	"forgejo.org/services/forms"

	"xorm.io/builder"
)

const (
	tplAccessTokenEdit base.TplName = "user/settings/access_token_edit"
)

func getSelectedRepos(ctx *context.Context, selectedReposRaw []string) []*repo_model.Repository {
	ownerAndName := make([][2]string, len(selectedReposRaw))
	for i, selected := range selectedReposRaw {
		split := strings.SplitN(selected, "/", 2) // ownername/reponame
		if len(split) != 2 {
			ctx.Error(http.StatusBadRequest, fmt.Sprintf("invalid selected_repo: %s", selected))
			return nil
		}
		ownerAndName[i] = [2]string{split[0], split[1]}
	}

	repoSearch := &repo_model.SearchRepoOptions{
		OwnerAndName: ownerAndName,
		OrderBy:      db.SearchOrderByAlphabetically, // match sorting in loadAccessTokenCreateData for consistency
		Private:      true,
	}

	cond := repo_model.SearchRepositoryCondition(repoSearch)
	repos, _, err := repo_model.SearchRepositoryByCondition(ctx, repoSearch, cond, false)
	if err != nil {
		ctx.ServerError("SearchRepositoryByCondition", err)
		return nil
	} else if len(repos) != len(selectedReposRaw) {
		// One or more of the repositories couldn't be found by search by owner & name.
		ctx.Error(http.StatusBadRequest, "GetRepositoryByOwnerAndName") // keep in sync w/ !permission.HasAccess below
		return nil
	}

	selectedRepos := make([]*repo_model.Repository, len(selectedReposRaw))
	for i, repo := range repos {
		permission, err := access_model.GetUserRepoPermission(ctx, repo, ctx.Doer)
		if err != nil {
			ctx.ServerError("GetUserRepoPermission", err)
			return nil
		} else if !permission.HasAccess() {
			// Prevent data existence probing -- ensure this error is the exact same as the (len(repos) !=
			// len(selectedReposRaw)) case above
			ctx.Error(http.StatusBadRequest, "GetRepositoryByOwnerAndName")
			return nil
		}
		selectedRepos[i] = repo
	}
	return selectedRepos
}

func loadAccessTokenCreateData(ctx *context.Context) {
	ctx.Data["AccessTokenScopePublicOnly"] = string(auth_model.AccessTokenScopePublicOnly) // note: SliceUtils.Contains won't work in the template if this is a `auth_model.AccessTokenScope`, so it's cast to a string here

	categories := []string{
		"activitypub",
		"issue",
		"misc",
		"notification",
		"organization",
		"package",
		"repository",
		"user",
	}
	if ctx.Doer.IsAdmin {
		categories = append(categories, "admin")
	}
	slices.Sort(categories)
	ctx.Data["Categories"] = categories

	// Awkward -- GET and POST use different form bindings for the reasons explained on NewAccessTokenGetForm -- and
	// this method can be called in both situations, on all GETs, and on some POSTs when validation errors occur.  So
	// both forms need to be handled here.
	getForm, isGet := web.GetForm(ctx).(*forms.NewAccessTokenGetForm)
	postForm, isPost := web.GetForm(ctx).(*forms.NewAccessTokenPostForm)

	if isGet {
		// Manage the result of adding or removing a repository before we do anything with `form.SelectedRepo`...
		changed := false
		if getForm.AddSelectedRepo != "" {
			getForm.SelectedRepo = append(getForm.SelectedRepo, getForm.AddSelectedRepo)
			changed = true
		}
		if getForm.RemoveSelectedRepo != "" {
			getForm.SelectedRepo = slices.DeleteFunc(
				getForm.SelectedRepo,
				func(r string) bool { return r == getForm.RemoveSelectedRepo },
			)
			changed = true
		}
		if changed {
			// We've changed getForm.SelectedRepo, but a reference to this slice was already present in `ctx.Data` (the
			// Bind middleware invokes AssignForm to put getForm values into `ctx.Data`).  Replace the reference:
			ctx.Data["selected_repo"] = getForm.SelectedRepo
		}
	}

	repoSearchText := ""
	if isGet {
		repoSearchText = getForm.RepoSearch
	}

	selectedReposRaw := []string{}
	if isGet {
		selectedReposRaw = getForm.SelectedRepo
	} else if isPost {
		selectedReposRaw = postForm.SelectedRepo
	}
	selectedRepos := getSelectedRepos(ctx, selectedReposRaw)
	if ctx.Written() {
		return
	}
	ctx.Data["SelectedRepos"] = selectedRepos

	page := 1
	if isGet {
		// Pagination on the repo search has form submit buttons that send the `set_page` param.  It's then encoded into
		// the page in the hidden input `page` which we fall back to, if anything else causes a form get (eg. adding or
		// removing a repo).
		if getForm.SetPage > 0 {
			page = getForm.SetPage
		} else if getForm.Page > 0 {
			page = getForm.Page
		}
	}
	pageSize := 10
	repoSearch := &repo_model.SearchRepoOptions{
		Actor:    ctx.Doer,
		Keyword:  repoSearchText,
		Private:  true,
		Archived: optional.Some(false),

		// Restrict repositories to those owned by, or collaborated with, by the user.  Repo-specific access tokens
		// could theoretically be created on any public repository as well, but there wouldn't be much point to that and
		// it would really balloon the search results to an impractical number of repos.
		OwnerID: ctx.Doer.ID,

		ListOptions: db.ListOptions{
			Page:     page,
			PageSize: pageSize,
		},
		OrderBy: db.SearchOrderByAlphabetically, // match sorting in getSelectedRepos for consistency
	}
	cond := repo_model.SearchRepositoryCondition(repoSearch)
	// Exclude all the repos that are currently in `form.SelectedRepo` from the search, by omitting them from the search
	// condition.  This prevents the UI from displaying the same repo on the left and right, and maintains the repo
	// search and page-size correctly.
	for _, selected := range selectedRepos {
		cond = cond.And(builder.Neq{"id": selected.ID})
	}
	repos, count, err := repo_model.SearchRepositoryByCondition(ctx, repoSearch, cond, false)
	if err != nil {
		log.Error("SearchRepository: %v", err)
		ctx.JSON(http.StatusInternalServerError, nil)
		return
	}
	ctx.Data["Repos"] = repos

	pager := context.NewPagination(int(count), pageSize, page, 3)
	pager.SetDefaultParams(ctx)
	ctx.Data["Page"] = pager

	autofocus := ""
	if isGet {
		switch {
		// Token name will be autofocused the first time the page is loaded -- if form.Scope is empty then that would be
		// a good sign it's the first load.
		case len(getForm.Scope) == 0:
			autofocus = "name"
		// After submitting a search, refocus the search text box.  Search invokes set_page=1 to reset the pagination
		// which we'll use to detect this case.
		case getForm.SetPage == 1:
			autofocus = "search"
		}
	}
	ctx.Data["Autofocus"] = autofocus
}

// Applications render manage access token page
func AccessTokenCreate(ctx *context.Context) {
	ctx.Data["Title"] = ctx.Tr("settings.applications")
	ctx.Data["PageIsSettingsApplications"] = true

	loadAccessTokenCreateData(ctx)
	if ctx.Written() {
		return
	}

	ctx.HTML(http.StatusOK, tplAccessTokenEdit)
}

// ApplicationsPost response for add user's access token
func AccessTokenCreatePost(ctx *context.Context) {
	form := web.GetForm(ctx).(*forms.NewAccessTokenPostForm)
	ctx.Data["Title"] = ctx.Tr("settings")
	ctx.Data["PageIsSettingsApplications"] = true

	renderWithError := func(msg template.HTML) {
		loadAccessTokenCreateData(ctx)
		if ctx.Written() {
			return
		}
		ctx.RenderWithErr(msg, tplAccessTokenEdit, form)
	}

	if ctx.HasError() {
		loadAccessTokenCreateData(ctx)
		if ctx.Written() {
			return
		}
		ctx.HTML(http.StatusOK, tplAccessTokenEdit)
		return
	}

	scope, err := form.GetScope()
	if err != nil {
		ctx.ServerError("GetScope", err)
		return
	}
	if !scope.HasPermissionScope() {
		renderWithError(ctx.Tr("settings.at_least_one_permission"))
		return
	}

	t := &auth_model.AccessToken{
		UID:   ctx.Doer.ID,
		Name:  form.Name,
		Scope: scope,
	}

	var resourceRepos []*auth_model.AccessTokenResourceRepo
	switch form.Resource {
	case "all":
		t.ResourceAllRepos = true
	case "public-only":
		t.ResourceAllRepos = true
		newScopeUnnormalized := fmt.Sprintf("%s,%s", scope, auth_model.AccessTokenScopePublicOnly)
		newScope, err := auth_model.AccessTokenScope(newScopeUnnormalized).Normalize()
		if err != nil {
			ctx.ServerError("AccessTokenScope.Normalize", err)
			return
		}
		t.Scope = newScope
	case "repo-specific":
		t.ResourceAllRepos = false
		selectedRepos := getSelectedRepos(ctx, form.SelectedRepo)
		if ctx.Written() {
			return
		}
		for _, repo := range selectedRepos {
			resourceRepos = append(resourceRepos, &auth_model.AccessTokenResourceRepo{RepoID: repo.ID})
		}
	}

	exist, err := auth_model.AccessTokenByNameExists(ctx, t)
	if err != nil {
		ctx.ServerError("AccessTokenByNameExists", err)
		return
	} else if exist {
		renderWithError(ctx.Tr("settings.generate_token_name_duplicate", t.Name))
		return
	}

	if err := authz.ValidateAccessToken(t, resourceRepos); err != nil {
		s := user.TranslateAccessTokenValidationError(ctx.Base, err)
		if has, str := s.Get(); has {
			renderWithError(template.HTML(template.HTMLEscapeString(str)))
			return
		}
		ctx.ServerError("ValidateAccessToken", err)
		return
	}

	err = db.WithTx(ctx, func(ctx stdCtx.Context) error {
		if err := auth_model.NewAccessToken(ctx, t); err != nil {
			return err
		}
		return auth_model.InsertAccessTokenResourceRepos(ctx, t.ID, resourceRepos)
	})
	if err != nil {
		ctx.ServerError("NewAccessToken", err)
		return
	}

	ctx.Flash.Success(ctx.Tr("settings.generate_token_success"))
	ctx.Flash.Info(t.Token)

	ctx.Redirect(setting.AppSubURL + "/user/settings/applications")
}

// DeleteAccessToken response for delete user access token
func DeleteAccessToken(ctx *context.Context) {
	if err := auth_model.DeleteAccessTokenByID(ctx, ctx.FormInt64("id"), ctx.Doer.ID); err != nil {
		ctx.Flash.Error("DeleteAccessTokenByID: " + err.Error())
	} else {
		ctx.Flash.Success(ctx.Tr("settings.delete_token_success"))
	}

	ctx.JSONRedirect(setting.AppSubURL + "/user/settings/applications")
}

// RegenerateAccessToken response for regenerating user access token
func RegenerateAccessToken(ctx *context.Context) {
	if t, err := auth_model.RegenerateAccessTokenByID(ctx, ctx.FormInt64("id"), ctx.Doer.ID); err != nil {
		if auth_model.IsErrAccessTokenNotExist(err) {
			ctx.Flash.Error(ctx.Tr("error.not_found"))
		} else {
			ctx.Flash.Error(ctx.Tr("error.server_internal"))
			log.Error("DeleteAccessTokenByID", err)
		}
	} else {
		ctx.Flash.Success(ctx.Tr("settings.regenerate_token_success"))
		ctx.Flash.Info(t.Token)
	}

	ctx.JSONRedirect(setting.AppSubURL + "/user/settings/applications")
}
