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

package repo

import (
	"errors"
	"fmt"
	"html/template"
	"net/http"
	"strconv"
	"strings"
	"time"

	"code.gitea.io/gitea/models/db"
	git_model "code.gitea.io/gitea/models/git"
	repo_model "code.gitea.io/gitea/models/repo"
	unit_model "code.gitea.io/gitea/models/unit"
	user_model "code.gitea.io/gitea/models/user"
	"code.gitea.io/gitea/modules/git"
	"code.gitea.io/gitea/modules/gitrepo"
	"code.gitea.io/gitea/modules/htmlutil"
	"code.gitea.io/gitea/modules/httplib"
	"code.gitea.io/gitea/modules/log"
	repo_module "code.gitea.io/gitea/modules/repository"
	"code.gitea.io/gitea/modules/setting"
	"code.gitea.io/gitea/modules/svg"
	"code.gitea.io/gitea/modules/util"
	"code.gitea.io/gitea/routers/web/feed"
	"code.gitea.io/gitea/services/context"
	repo_service "code.gitea.io/gitea/services/repository"
)

func checkOutdatedBranch(ctx *context.Context) {
	if !(ctx.Repo.IsAdmin() || ctx.Repo.IsOwner()) {
		return
	}

	// get the head commit of the branch since ctx.Repo.CommitID is not always the head commit of `ctx.Repo.BranchName`
	commit, err := ctx.Repo.GitRepo.GetBranchCommit(ctx.Repo.BranchName)
	if err != nil {
		log.Error("GetBranchCommitID: %v", err)
		// Don't return an error page, as it can be rechecked the next time the user opens the page.
		return
	}

	dbBranch, err := git_model.GetBranch(ctx, ctx.Repo.Repository.ID, ctx.Repo.BranchName)
	if err != nil {
		log.Error("GetBranch: %v", err)
		// Don't return an error page, as it can be rechecked the next time the user opens the page.
		return
	}

	if dbBranch.CommitID != commit.ID.String() {
		ctx.Flash.Warning(ctx.Tr("repo.error.broken_git_hook", "https://docs.gitea.com/help/faq#push-hook--webhook--actions-arent-running"), true)
	}
}

func prepareHomeSidebarRepoTopics(ctx *context.Context) {
	topics, err := db.Find[repo_model.Topic](ctx, &repo_model.FindTopicOptions{
		RepoID: ctx.Repo.Repository.ID,
	})
	if err != nil {
		ctx.ServerError("models.FindTopics", err)
		return
	}
	ctx.Data["Topics"] = topics
}

func prepareOpenWithEditorApps(ctx *context.Context) {
	var tmplApps []map[string]any
	apps := setting.Config().Repository.OpenWithEditorApps.Value(ctx)
	if len(apps) == 0 {
		apps = setting.DefaultOpenWithEditorApps()
	}
	for _, app := range apps {
		schema, _, _ := strings.Cut(app.OpenURL, ":")
		var iconHTML template.HTML
		if schema == "vscode" || schema == "vscodium" || schema == "jetbrains" {
			iconHTML = svg.RenderHTML("gitea-"+schema, 16)
		} else {
			iconHTML = svg.RenderHTML("gitea-git", 16) // TODO: it could support user's customized icon in the future
		}
		tmplApps = append(tmplApps, map[string]any{
			"DisplayName": app.DisplayName,
			"OpenURL":     app.OpenURL,
			"IconHTML":    iconHTML,
		})
	}
	ctx.Data["OpenWithEditorApps"] = tmplApps
}

func prepareHomeSidebarCitationFile(entry *git.TreeEntry) func(ctx *context.Context) {
	return func(ctx *context.Context) {
		if entry.Name() != "" {
			return
		}
		tree, err := ctx.Repo.Commit.SubTree(ctx.Repo.TreePath)
		if err != nil {
			HandleGitError(ctx, "Repo.Commit.SubTree", err)
			return
		}
		allEntries, err := tree.ListEntries()
		if err != nil {
			ctx.ServerError("ListEntries", err)
			return
		}
		for _, entry := range allEntries {
			if entry.Name() == "CITATION.cff" || entry.Name() == "CITATION.bib" {
				// Read Citation file contents
				if content, err := entry.Blob().GetBlobContent(setting.UI.MaxDisplayFileSize); err != nil {
					log.Error("checkCitationFile: GetBlobContent: %v", err)
				} else {
					ctx.Data["CitiationExist"] = true
					ctx.PageData["citationFileContent"] = content
					break
				}
			}
		}
	}
}

func prepareHomeSidebarLicenses(ctx *context.Context) {
	repoLicenses, err := repo_model.GetRepoLicenses(ctx, ctx.Repo.Repository)
	if err != nil {
		ctx.ServerError("GetRepoLicenses", err)
		return
	}
	ctx.Data["DetectedRepoLicenses"] = repoLicenses.StringList()
	ctx.Data["LicenseFileName"] = repo_service.LicenseFileName
}

func prepareToRenderDirectory(ctx *context.Context) {
	entries := renderDirectoryFiles(ctx, 1*time.Second)
	if ctx.Written() {
		return
	}

	if ctx.Repo.TreePath != "" {
		ctx.Data["HideRepoInfo"] = true
		ctx.Data["Title"] = ctx.Tr("repo.file.title", ctx.Repo.Repository.Name+"/"+ctx.Repo.TreePath, ctx.Repo.RefFullName.ShortName())
	}

	subfolder, readmeFile, err := findReadmeFileInEntries(ctx, ctx.Repo.TreePath, entries, true)
	if err != nil {
		ctx.ServerError("findReadmeFileInEntries", err)
		return
	}

	prepareToRenderReadmeFile(ctx, subfolder, readmeFile)
}

func prepareHomeSidebarLanguageStats(ctx *context.Context) {
	langs, err := repo_model.GetTopLanguageStats(ctx, ctx.Repo.Repository, 5)
	if err != nil {
		ctx.ServerError("Repo.GetTopLanguageStats", err)
		return
	}

	ctx.Data["LanguageStats"] = langs
}

func prepareHomeSidebarLatestRelease(ctx *context.Context) {
	if !ctx.Repo.Repository.UnitEnabled(ctx, unit_model.TypeReleases) {
		return
	}

	release, err := repo_model.GetLatestReleaseByRepoID(ctx, ctx.Repo.Repository.ID)
	if err != nil && !repo_model.IsErrReleaseNotExist(err) {
		ctx.ServerError("GetLatestReleaseByRepoID", err)
		return
	}

	if release != nil {
		if err = release.LoadAttributes(ctx); err != nil {
			ctx.ServerError("release.LoadAttributes", err)
			return
		}
		ctx.Data["LatestRelease"] = release
	}
}

func prepareUpstreamDivergingInfo(ctx *context.Context) {
	if !ctx.Repo.Repository.IsFork || !ctx.Repo.RefFullName.IsBranch() || ctx.Repo.TreePath != "" {
		return
	}
	upstreamDivergingInfo, err := repo_service.GetUpstreamDivergingInfo(ctx, ctx.Repo.Repository, ctx.Repo.BranchName)
	if err != nil {
		if !errors.Is(err, util.ErrNotExist) && !errors.Is(err, util.ErrInvalidArgument) {
			log.Error("GetUpstreamDivergingInfo: %v", err)
		}
		return
	}
	ctx.Data["UpstreamDivergingInfo"] = upstreamDivergingInfo
}

func updateContextRepoEmptyAndStatus(ctx *context.Context, empty bool, status repo_model.RepositoryStatus) {
	if ctx.Repo.Repository.IsEmpty == empty && ctx.Repo.Repository.Status == status {
		return
	}
	ctx.Repo.Repository.IsEmpty = empty
	if ctx.Repo.Repository.Status == repo_model.RepositoryReady || ctx.Repo.Repository.Status == repo_model.RepositoryBroken {
		ctx.Repo.Repository.Status = status // only handle ready and broken status, leave other status as-is
	}
	if err := repo_model.UpdateRepositoryColsNoAutoTime(ctx, ctx.Repo.Repository, "is_empty", "status"); err != nil {
		ctx.ServerError("updateContextRepoEmptyAndStatus: UpdateRepositoryCols", err)
		return
	}
}

func handleRepoEmptyOrBroken(ctx *context.Context) {
	showEmpty := true
	if ctx.Repo.GitRepo == nil {
		// in case the repo really exists and works, but the status was incorrectly marked as "broken", we need to open and check it again
		ctx.Repo.GitRepo, _ = gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx.Repo.Repository)
	}
	if ctx.Repo.GitRepo != nil {
		reallyEmpty, err := ctx.Repo.GitRepo.IsEmpty()
		if err != nil {
			showEmpty = true // the repo is broken
			updateContextRepoEmptyAndStatus(ctx, true, repo_model.RepositoryBroken)
			log.Error("GitRepo.IsEmpty: %v", err)
			ctx.Flash.Error(ctx.Tr("error.occurred"), true)
		} else if reallyEmpty {
			showEmpty = true // the repo is really empty
			updateContextRepoEmptyAndStatus(ctx, true, repo_model.RepositoryReady)
		} else if branches, _, _ := ctx.Repo.GitRepo.GetBranchNames(0, 1); len(branches) == 0 {
			showEmpty = true // it is not really empty, but there is no branch
			// at the moment, other repo units like "actions" are not able to handle such case,
			// so we just mark the repo as empty to prevent from displaying these units.
			ctx.Data["RepoHasContentsWithoutBranch"] = true
			updateContextRepoEmptyAndStatus(ctx, true, repo_model.RepositoryReady)
		} else {
			// the repo is actually not empty and has branches, need to update the database later
			showEmpty = false
		}
	}
	if showEmpty {
		ctx.HTML(http.StatusOK, tplRepoEMPTY)
		return
	}

	// The repo is not really empty, so we should update the model in database, such problem may be caused by:
	// 1) an error occurs during pushing/receiving.
	// 2) the user replaces an empty git repo manually.
	updateContextRepoEmptyAndStatus(ctx, false, repo_model.RepositoryReady)
	if err := repo_module.UpdateRepoSize(ctx, ctx.Repo.Repository); err != nil {
		ctx.ServerError("UpdateRepoSize", err)
		return
	}

	// the repo's IsEmpty has been updated, redirect to this page to make sure middlewares can get the correct values
	link := ctx.Link
	if ctx.Req.URL.RawQuery != "" {
		link += "?" + ctx.Req.URL.RawQuery
	}
	ctx.Redirect(link)
}

func isViewHomeOnlyContent(ctx *context.Context) bool {
	return ctx.FormBool("only_content")
}

func handleRepoViewSubmodule(ctx *context.Context, commitSubmoduleFile *git.CommitSubmoduleFile) {
	submoduleWebLink := commitSubmoduleFile.SubmoduleWebLinkTree(ctx)
	if submoduleWebLink == nil {
		ctx.Data["NotFoundPrompt"] = ctx.Repo.TreePath
		ctx.NotFound(nil)
		return
	}

	redirectLink := submoduleWebLink.CommitWebLink
	if isViewHomeOnlyContent(ctx) {
		ctx.Resp.Header().Set("Content-Type", "text/html; charset=utf-8")
		_, _ = ctx.Resp.Write([]byte(htmlutil.HTMLFormat(`<a href="%s">%s</a>`, redirectLink, redirectLink)))
	} else if !httplib.IsCurrentGiteaSiteURL(ctx, redirectLink) {
		// don't auto-redirect to external URL, to avoid open redirect or phishing
		ctx.Data["NotFoundPrompt"] = redirectLink
		ctx.NotFound(nil)
	} else {
		ctx.Redirect(submoduleWebLink.CommitWebLink)
	}
}

func prepareToRenderDirOrFile(entry *git.TreeEntry) func(ctx *context.Context) {
	return func(ctx *context.Context) {
		if entry.IsSubModule() {
			commitSubmoduleFile, err := git.GetCommitInfoSubmoduleFile(ctx.Repo.RepoLink, ctx.Repo.TreePath, ctx.Repo.Commit, entry.ID)
			if err != nil {
				HandleGitError(ctx, "prepareToRenderDirOrFile: GetCommitInfoSubmoduleFile", err)
				return
			}
			handleRepoViewSubmodule(ctx, commitSubmoduleFile)
		} else if entry.IsDir() {
			prepareToRenderDirectory(ctx)
		} else {
			prepareFileView(ctx, entry)
		}
	}
}

func handleRepoHomeFeed(ctx *context.Context) bool {
	if !setting.Other.EnableFeed {
		return false
	}
	isFeed, showFeedType := feed.GetFeedType(ctx.PathParam("reponame"), ctx.Req)
	if !isFeed {
		return false
	}
	if ctx.Link == fmt.Sprintf("%s.%s", ctx.Repo.RepoLink, showFeedType) {
		feed.ShowRepoFeed(ctx, ctx.Repo.Repository, showFeedType)
	} else if ctx.Repo.TreePath == "" {
		feed.ShowBranchFeed(ctx, ctx.Repo.Repository, showFeedType)
	} else {
		feed.ShowFileFeed(ctx, ctx.Repo.Repository, showFeedType)
	}
	return true
}

func prepareHomeTreeSideBarSwitch(ctx *context.Context) {
	showFileTree := true
	if ctx.Doer != nil {
		v, err := user_model.GetUserSetting(ctx, ctx.Doer.ID, user_model.SettingsKeyCodeViewShowFileTree, "true")
		if err != nil {
			log.Error("GetUserSetting: %v", err)
		} else {
			showFileTree, _ = strconv.ParseBool(v)
		}
	}
	ctx.Data["UserSettingCodeViewShowFileTree"] = showFileTree
}

func redirectSrcToRaw(ctx *context.Context) bool {
	// GitHub redirects a tree path with "?raw=1" to the raw path
	// It is useful to embed some raw contents into Markdown files,
	// then viewing the Markdown in "src" path could embed the raw content correctly.
	if ctx.Repo.TreePath != "" && ctx.FormBool("raw") {
		ctx.Redirect(ctx.Repo.RepoLink + "/raw/" + ctx.Repo.RefTypeNameSubURL() + "/" + util.PathEscapeSegments(ctx.Repo.TreePath))
		return true
	}
	return false
}

func redirectFollowSymlink(ctx *context.Context, treePathEntry *git.TreeEntry) bool {
	if ctx.Repo.TreePath == "" || !ctx.FormBool("follow_symlink") {
		return false
	}
	if treePathEntry.IsLink() {
		if res, err := git.EntryFollowLinks(ctx.Repo.Commit, ctx.Repo.TreePath, treePathEntry); err == nil {
			redirect := ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL() + "/" + util.PathEscapeSegments(res.TargetFullPath) + "?" + ctx.Req.URL.RawQuery
			ctx.Redirect(redirect)
			return true
		} // else: don't handle the links we cannot resolve, so ignore the error
	}
	return false
}

// Home render repository home page
func Home(ctx *context.Context) {
	if handleRepoHomeFeed(ctx) {
		return
	}
	if redirectSrcToRaw(ctx) {
		return
	}

	// Check whether the repo is viewable: not in migration, and the code unit should be enabled
	// Ideally the "feed" logic should be after this, but old code did so, so keep it as-is.
	checkHomeCodeViewable(ctx)
	if ctx.Written() {
		return
	}

	title := ctx.Repo.Repository.Owner.Name + "/" + ctx.Repo.Repository.Name
	if ctx.Repo.Repository.Description != "" {
		title += ": " + ctx.Repo.Repository.Description
	}
	ctx.Data["Title"] = title
	ctx.Data["PageIsViewCode"] = true
	ctx.Data["RepositoryUploadEnabled"] = setting.Repository.Upload.Enabled // show New File / Upload File buttons

	if ctx.Repo.Commit == nil || ctx.Repo.Repository.IsEmpty || ctx.Repo.Repository.IsBroken() {
		// empty or broken repositories need to be handled differently
		handleRepoEmptyOrBroken(ctx)
		return
	}

	prepareHomeTreeSideBarSwitch(ctx)

	// get the current git entry which doer user is currently looking at.
	entry, err := ctx.Repo.Commit.GetTreeEntryByPath(ctx.Repo.TreePath)
	if err != nil {
		HandleGitError(ctx, "Repo.Commit.GetTreeEntryByPath", err)
		return
	}

	if redirectFollowSymlink(ctx, entry) {
		return
	}

	// prepare the tree path
	var treeNames, paths []string
	branchLink := ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL()
	treeLink := branchLink
	if ctx.Repo.TreePath != "" {
		treeLink += "/" + util.PathEscapeSegments(ctx.Repo.TreePath)
		treeNames = strings.Split(ctx.Repo.TreePath, "/")
		for i := range treeNames {
			paths = append(paths, strings.Join(treeNames[:i+1], "/"))
		}
		ctx.Data["HasParentPath"] = true
		if len(paths)-2 >= 0 {
			ctx.Data["ParentPath"] = "/" + paths[len(paths)-2]
		}
	}
	ctx.Data["Paths"] = paths
	ctx.Data["TreeLink"] = treeLink
	ctx.Data["TreeNames"] = treeNames
	ctx.Data["BranchLink"] = branchLink

	// some UI components are only shown when the tree path is root
	isTreePathRoot := ctx.Repo.TreePath == ""

	prepareFuncs := []func(*context.Context){
		prepareOpenWithEditorApps,
		prepareHomeSidebarRepoTopics,
		checkOutdatedBranch,
		prepareToRenderDirOrFile(entry),
		prepareRecentlyPushedNewBranches,
	}

	if isTreePathRoot {
		prepareFuncs = append(prepareFuncs,
			prepareUpstreamDivergingInfo,
			prepareHomeSidebarLicenses,
			prepareHomeSidebarCitationFile(entry),
			prepareHomeSidebarLanguageStats,
			prepareHomeSidebarLatestRelease,
		)
	}

	for _, prepare := range prepareFuncs {
		prepare(ctx)
		if ctx.Written() {
			return
		}
	}

	if isViewHomeOnlyContent(ctx) {
		ctx.HTML(http.StatusOK, tplRepoViewContent)
	} else if len(treeNames) != 0 {
		ctx.HTML(http.StatusOK, tplRepoView)
	} else {
		ctx.HTML(http.StatusOK, tplRepoHome)
	}
}

func RedirectRepoTreeToSrc(ctx *context.Context) {
	// Redirect "/owner/repo/tree/*" requests to "/owner/repo/src/*",
	// then use the deprecated "/src/*" handler to guess the ref type and render a file list page.
	// This is done intentionally so that Gitea's repo URL structure matches other forges (GitHub/GitLab) provide,
	// allowing us to construct submodule URLs across forges easily.
	// For example, when viewing a submodule, we can simply construct the link as:
	// * "https://gitea/owner/repo/tree/{CommitID}"
	// * "https://github/owner/repo/tree/{CommitID}"
	// * "https://gitlab/owner/repo/tree/{CommitID}"
	// Then no matter which forge the submodule is using, the link works.
	redirect := ctx.Repo.RepoLink + "/src/" + ctx.PathParamRaw("*")
	if ctx.Req.URL.RawQuery != "" {
		redirect += "?" + ctx.Req.URL.RawQuery
	}
	ctx.Redirect(redirect)
}

func RedirectRepoBlobToCommit(ctx *context.Context) {
	// redirect "/owner/repo/blob/*" requests to "/owner/repo/src/commit/*"
	// just like GitHub: browse files of a commit by "https://github/owner/repo/blob/{CommitID}"
	// TODO: maybe we could guess more types to redirect to the related pages in the future
	redirect := ctx.Repo.RepoLink + "/src/commit/" + ctx.PathParamRaw("*")
	if ctx.Req.URL.RawQuery != "" {
		redirect += "?" + ctx.Req.URL.RawQuery
	}
	ctx.Redirect(redirect)
}
