package pages

import (
	"crypto/sha256"
	"embed"
	"encoding/hex"
	"fmt"
	"html/template"
	"io"
	"io/fs"
	"log/slog"
	"net/http"
	"os"
	"path/filepath"
	"strings"
	"sync"
	"time"

	"tangled.org/core/api/tangled"
	"tangled.org/core/appview/commitverify"
	"tangled.org/core/appview/config"
	"tangled.org/core/appview/db"
	"tangled.org/core/appview/models"
	"tangled.org/core/appview/oauth"
	"tangled.org/core/appview/pages/markup"
	"tangled.org/core/appview/pages/repoinfo"
	"tangled.org/core/appview/pagination"
	"tangled.org/core/idresolver"
	"tangled.org/core/patchutil"
	"tangled.org/core/types"

	"github.com/bluesky-social/indigo/atproto/identity"
	"github.com/bluesky-social/indigo/atproto/syntax"
	"github.com/go-git/go-git/v5/plumbing"
)

//go:embed templates/* static legal
var Files embed.FS

type Pages struct {
	mu    sync.RWMutex
	cache *TmplCache[string, *template.Template]

	avatar      config.AvatarConfig
	resolver    *idresolver.Resolver
	db          *db.DB
	dev         bool
	embedFS     fs.FS
	templateDir string // Path to templates on disk for dev mode
	rctx        *markup.RenderContext
	logger      *slog.Logger
}

func NewPages(config *config.Config, res *idresolver.Resolver, database *db.DB, logger *slog.Logger) *Pages {
	// initialized with safe defaults, can be overriden per use
	rctx := &markup.RenderContext{
		IsDev:      config.Core.Dev,
		Hostname:   config.Core.AppviewHost,
		CamoUrl:    config.Camo.Host,
		CamoSecret: config.Camo.SharedSecret,
		Sanitizer:  markup.NewSanitizer(),
		Files:      Files,
	}

	p := &Pages{
		mu:          sync.RWMutex{},
		cache:       NewTmplCache[string, *template.Template](),
		dev:         config.Core.Dev,
		avatar:      config.Avatar,
		rctx:        rctx,
		resolver:    res,
		db:          database,
		templateDir: "appview/pages",
		logger:      logger,
	}

	if p.dev {
		p.embedFS = os.DirFS(p.templateDir)
	} else {
		p.embedFS = Files
	}

	return p
}

// reverse of pathToName
func (p *Pages) nameToPath(s string) string {
	return "templates/" + s + ".html"
}

// FuncMap returns the template function map for use by external template consumers.
func (p *Pages) FuncMap() template.FuncMap {
	return p.funcMap()
}

// FragmentPaths returns all fragment template paths from the embedded FS.
func (p *Pages) FragmentPaths() ([]string, error) {
	return p.fragmentPaths()
}

// EmbedFS returns the embedded filesystem containing templates and static assets.
func (p *Pages) EmbedFS() fs.FS {
	return p.embedFS
}

// ParseWith parses the base layout together with all appview fragments and
// an additional template from extraFS identified by extraPath (relative to
// extraFS root). The returned template is ready to ExecuteTemplate with
// "layouts/base" -- primarily for use with the blog.
func (p *Pages) ParseWith(extraFS fs.FS, extraPath string) (*template.Template, error) {
	fragmentPaths, err := p.fragmentPaths()
	if err != nil {
		return nil, err
	}

	funcs := p.funcMap()
	tpl, err := template.New("layouts/base").
		Funcs(funcs).
		ParseFS(p.embedFS, append(fragmentPaths, p.nameToPath("layouts/base"))...)
	if err != nil {
		return nil, err
	}

	err = fs.WalkDir(extraFS, ".", func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if d.IsDir() || !strings.HasSuffix(path, ".html") {
			return nil
		}
		if path != extraPath && !strings.Contains(path, "fragments/") {
			return nil
		}
		data, err := fs.ReadFile(extraFS, path)
		if err != nil {
			return err
		}
		if _, err = tpl.New(path).Parse(string(data)); err != nil {
			return err
		}
		return nil
	})
	if err != nil {
		return nil, err
	}

	return tpl, nil
}

func (p *Pages) fragmentPaths() ([]string, error) {
	var fragmentPaths []string
	err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if d.IsDir() {
			return nil
		}
		if !strings.HasSuffix(path, ".html") {
			return nil
		}
		if !strings.Contains(path, "fragments/") {
			return nil
		}
		fragmentPaths = append(fragmentPaths, path)
		return nil
	})
	if err != nil {
		return nil, err
	}

	return fragmentPaths, nil
}

// parse without memoization
func (p *Pages) rawParse(stack ...string) (*template.Template, error) {
	paths, err := p.fragmentPaths()
	if err != nil {
		return nil, err
	}
	for _, s := range stack {
		paths = append(paths, p.nameToPath(s))
	}

	funcs := p.funcMap()
	top := stack[len(stack)-1]
	parsed, err := template.New(top).
		Funcs(funcs).
		ParseFS(p.embedFS, paths...)
	if err != nil {
		return nil, err
	}

	return parsed, nil
}

func (p *Pages) parse(stack ...string) (*template.Template, error) {
	key := strings.Join(stack, "|")

	// never cache in dev mode
	if cached, exists := p.cache.Get(key); !p.dev && exists {
		return cached, nil
	}

	result, err := p.rawParse(stack...)
	if err != nil {
		return nil, err
	}

	p.cache.Set(key, result)
	return result, nil
}

func (p *Pages) parseBase(top string) (*template.Template, error) {
	stack := []string{
		"layouts/base",
		top,
	}
	return p.parse(stack...)
}

func (p *Pages) parseRepoBase(top string) (*template.Template, error) {
	stack := []string{
		"layouts/base",
		"layouts/repobase",
		top,
	}
	return p.parse(stack...)
}

func (p *Pages) parseProfileBase(top string) (*template.Template, error) {
	stack := []string{
		"layouts/base",
		"layouts/profilebase",
		top,
	}
	return p.parse(stack...)
}

func (p *Pages) parseLoginBase(top string) (*template.Template, error) {
	stack := []string{
		"layouts/base",
		"layouts/loginbase",
		top,
	}
	return p.parse(stack...)
}

func (p *Pages) executePlain(name string, w io.Writer, params any) error {
	tpl, err := p.parse(name)
	if err != nil {
		return err
	}

	return tpl.Execute(w, params)
}

func (p *Pages) executeLogin(name string, w io.Writer, params any) error {
	tpl, err := p.parseLoginBase(name)
	if err != nil {
		return err
	}

	return tpl.ExecuteTemplate(w, "layouts/base", params)
}

func (p *Pages) execute(name string, w io.Writer, params any) error {
	tpl, err := p.parseBase(name)
	if err != nil {
		return err
	}

	return tpl.ExecuteTemplate(w, "layouts/base", params)
}

func (p *Pages) executeRepo(name string, w io.Writer, params any) error {
	tpl, err := p.parseRepoBase(name)
	if err != nil {
		return err
	}

	return tpl.ExecuteTemplate(w, "layouts/base", params)
}

func (p *Pages) executeProfile(name string, w io.Writer, params any) error {
	tpl, err := p.parseProfileBase(name)
	if err != nil {
		return err
	}

	return tpl.ExecuteTemplate(w, "layouts/base", params)
}

type DollyParams struct {
	Classes   string
	FillColor string
}

func (p *Pages) Dolly(w io.Writer, params DollyParams) error {
	return p.executePlain("fragments/dolly/logo", w, params)
}

func (p *Pages) Favicon(w io.Writer) error {
	return p.Dolly(w, DollyParams{
		Classes: "text-black dark:text-white",
	})
}

type LoginParams struct {
	ReturnUrl    string
	ErrorCode    string
	AddAccount   bool
	LoggedInUser *oauth.MultiAccountUser
}

func (p *Pages) Login(w io.Writer, params LoginParams) error {
	return p.executeLogin("user/login", w, params)
}

type SignupParams struct {
	CloudflareSiteKey string
	EmailId           string
}

func (p *Pages) Signup(w io.Writer, params SignupParams) error {
	return p.executeLogin("user/signup", w, params)
}

func (p *Pages) CompleteSignup(w io.Writer) error {
	return p.executeLogin("user/completeSignup", w, nil)
}

type TermsOfServiceParams struct {
	LoggedInUser *oauth.MultiAccountUser
	Content      template.HTML
}

func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error {
	filename := "terms.md"
	filePath := filepath.Join("legal", filename)

	file, err := p.embedFS.Open(filePath)
	if err != nil {
		return fmt.Errorf("failed to read %s: %w", filename, err)
	}
	defer file.Close()

	markdownBytes, err := io.ReadAll(file)
	if err != nil {
		return fmt.Errorf("failed to read %s: %w", filename, err)
	}

	rctx := p.rctx.Clone()
	rctx.RendererType = markup.RendererTypeDefault
	htmlString := rctx.RenderMarkdown(string(markdownBytes))
	sanitized := rctx.SanitizeDefault(htmlString)
	params.Content = template.HTML(sanitized)

	return p.execute("legal/terms", w, params)
}

type PrivacyPolicyParams struct {
	LoggedInUser *oauth.MultiAccountUser
	Content      template.HTML
}

func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error {
	filename := "privacy.md"
	filePath := filepath.Join("legal", filename)

	file, err := p.embedFS.Open(filePath)
	if err != nil {
		return fmt.Errorf("failed to read %s: %w", filename, err)
	}
	defer file.Close()

	markdownBytes, err := io.ReadAll(file)
	if err != nil {
		return fmt.Errorf("failed to read %s: %w", filename, err)
	}

	rctx := p.rctx.Clone()
	rctx.RendererType = markup.RendererTypeDefault
	htmlString := rctx.RenderMarkdown(string(markdownBytes))
	sanitized := rctx.SanitizeDefault(htmlString)
	params.Content = template.HTML(sanitized)

	return p.execute("legal/privacy", w, params)
}

type BrandParams struct {
	LoggedInUser *oauth.MultiAccountUser
}

func (p *Pages) Brand(w io.Writer, params BrandParams) error {
	return p.execute("brand/brand", w, params)
}

type TimelineParams struct {
	LoggedInUser *oauth.MultiAccountUser
	Timeline     []models.TimelineEvent
	Repos        []models.Repo
	GfiLabel     *models.LabelDefinition
	BlueskyPosts []models.BskyPost
}

func (p *Pages) Timeline(w io.Writer, params TimelineParams) error {
	return p.execute("timeline/timeline", w, params)
}

type GoodFirstIssuesParams struct {
	LoggedInUser *oauth.MultiAccountUser
	Issues       []models.Issue
	RepoGroups   []*models.RepoGroup
	LabelDefs    map[string]*models.LabelDefinition
	GfiLabel     *models.LabelDefinition
	Page         pagination.Page
}

func (p *Pages) GoodFirstIssues(w io.Writer, params GoodFirstIssuesParams) error {
	return p.execute("goodfirstissues/index", w, params)
}

type UserProfileSettingsParams struct {
	LoggedInUser        *oauth.MultiAccountUser
	Tab                 string
	PunchcardPreference models.PunchcardPreference
	IsTnglSh            bool
	IsDeactivated       bool
	PdsDomain           string
	HandleOpen          bool
}

func (p *Pages) UserProfileSettings(w io.Writer, params UserProfileSettingsParams) error {
	params.Tab = "profile"
	return p.execute("user/settings/profile", w, params)
}

type NotificationsParams struct {
	LoggedInUser  *oauth.MultiAccountUser
	Notifications []*models.NotificationWithEntity
	UnreadCount   int
	Page          pagination.Page
	Total         int64
}

func (p *Pages) Notifications(w io.Writer, params NotificationsParams) error {
	return p.execute("notifications/list", w, params)
}

type NotificationItemParams struct {
	Notification *models.Notification
}

func (p *Pages) NotificationItem(w io.Writer, params NotificationItemParams) error {
	return p.executePlain("notifications/fragments/item", w, params)
}

type NotificationCountParams struct {
	Count int64
}

func (p *Pages) NotificationCount(w io.Writer, params NotificationCountParams) error {
	return p.executePlain("notifications/fragments/count", w, params)
}

type UserKeysSettingsParams struct {
	LoggedInUser *oauth.MultiAccountUser
	PubKeys      []models.PublicKey
	Tab          string
}

func (p *Pages) UserKeysSettings(w io.Writer, params UserKeysSettingsParams) error {
	params.Tab = "keys"
	return p.execute("user/settings/keys", w, params)
}

type UserEmailsSettingsParams struct {
	LoggedInUser *oauth.MultiAccountUser
	Emails       []models.Email
	Tab          string
}

func (p *Pages) UserEmailsSettings(w io.Writer, params UserEmailsSettingsParams) error {
	params.Tab = "emails"
	return p.execute("user/settings/emails", w, params)
}

type UserNotificationSettingsParams struct {
	LoggedInUser *oauth.MultiAccountUser
	Preferences  *models.NotificationPreferences
	Tab          string
}

func (p *Pages) UserNotificationSettings(w io.Writer, params UserNotificationSettingsParams) error {
	params.Tab = "notifications"
	return p.execute("user/settings/notifications", w, params)
}

type UserSiteSettingsParams struct {
	LoggedInUser *oauth.MultiAccountUser
	Claim        *models.DomainClaim
	SitesDomain  string
	IsTnglHandle bool
	Tab          string
}

func (p *Pages) UserSiteSettings(w io.Writer, params UserSiteSettingsParams) error {
	params.Tab = "sites"
	return p.execute("user/settings/sites", w, params)
}

type UpgradeBannerParams struct {
	Registrations []models.Registration
	Spindles      []models.Spindle
}

func (p *Pages) UpgradeBanner(w io.Writer, params UpgradeBannerParams) error {
	return p.executePlain("banner", w, params)
}

type KnotsParams struct {
	LoggedInUser  *oauth.MultiAccountUser
	Registrations []models.Registration
	Tab           string
}

func (p *Pages) Knots(w io.Writer, params KnotsParams) error {
	params.Tab = "knots"
	return p.execute("knots/index", w, params)
}

type KnotParams struct {
	LoggedInUser *oauth.MultiAccountUser
	Registration *models.Registration
	Members      []string
	Repos        map[string][]models.Repo
	IsOwner      bool
	Tab          string
}

func (p *Pages) Knot(w io.Writer, params KnotParams) error {
	return p.execute("knots/dashboard", w, params)
}

type KnotListingParams struct {
	*models.Registration
}

func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error {
	return p.executePlain("knots/fragments/knotListing", w, params)
}

type SpindlesParams struct {
	LoggedInUser *oauth.MultiAccountUser
	Spindles     []models.Spindle
	Tab          string
}

func (p *Pages) Spindles(w io.Writer, params SpindlesParams) error {
	params.Tab = "spindles"
	return p.execute("spindles/index", w, params)
}

type SpindleListingParams struct {
	models.Spindle
	Tab string
}

func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error {
	return p.executePlain("spindles/fragments/spindleListing", w, params)
}

type SpindleDashboardParams struct {
	LoggedInUser *oauth.MultiAccountUser
	Spindle      models.Spindle
	Members      []string
	Repos        map[string][]models.Repo
	Tab          string
}

func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error {
	return p.execute("spindles/dashboard", w, params)
}

type NewRepoParams struct {
	LoggedInUser *oauth.MultiAccountUser
	Knots        []string
}

func (p *Pages) NewRepo(w io.Writer, params NewRepoParams) error {
	return p.execute("repo/new", w, params)
}

type ForkRepoParams struct {
	LoggedInUser *oauth.MultiAccountUser
	Knots        []string
	RepoInfo     repoinfo.RepoInfo
}

func (p *Pages) ForkRepo(w io.Writer, params ForkRepoParams) error {
	return p.execute("repo/fork", w, params)
}

type ProfileCard struct {
	UserDid      string
	HasProfile   bool
	FollowStatus models.FollowStatus
	Punchcard    *models.Punchcard
	Profile      *models.Profile
	Stats        ProfileStats
	Active       string
}

type ProfileStats struct {
	RepoCount      int64
	StarredCount   int64
	StringCount    int64
	FollowersCount int64
	FollowingCount int64
}

func (p *ProfileCard) GetTabs() [][]any {
	tabs := [][]any{
		{"overview", "overview", "square-chart-gantt", nil},
		{"repos", "repos", "book-marked", p.Stats.RepoCount},
		{"starred", "starred", "star", p.Stats.StarredCount},
		{"strings", "strings", "line-squiggle", p.Stats.StringCount},
	}

	return tabs
}

type ProfileOverviewParams struct {
	LoggedInUser       *oauth.MultiAccountUser
	Repos              []models.Repo
	CollaboratingRepos []models.Repo
	ProfileTimeline    *models.ProfileTimeline
	Card               *ProfileCard
	Active             string
	ShowPunchcard      bool
}

func (p *Pages) ProfileOverview(w io.Writer, params ProfileOverviewParams) error {
	params.Active = "overview"
	return p.executeProfile("user/overview", w, params)
}

type ProfileReposParams struct {
	LoggedInUser *oauth.MultiAccountUser
	Repos        []models.Repo
	Card         *ProfileCard
	Active       string
	Page         pagination.Page
	RepoCount    int
	FilterQuery  string
}

func (p *Pages) ProfileRepos(w io.Writer, params ProfileReposParams) error {
	params.Active = "repos"
	return p.executeProfile("user/repos", w, params)
}

type ProfileStarredParams struct {
	LoggedInUser *oauth.MultiAccountUser
	Repos        []models.Repo
	Card         *ProfileCard
	Active       string
}

func (p *Pages) ProfileStarred(w io.Writer, params ProfileStarredParams) error {
	params.Active = "starred"
	return p.executeProfile("user/starred", w, params)
}

type ProfileStringsParams struct {
	LoggedInUser *oauth.MultiAccountUser
	Strings      []models.String
	Card         *ProfileCard
	Active       string
}

func (p *Pages) ProfileStrings(w io.Writer, params ProfileStringsParams) error {
	params.Active = "strings"
	return p.executeProfile("user/strings", w, params)
}

type FollowCard struct {
	UserDid        string
	LoggedInUser   *oauth.MultiAccountUser
	FollowStatus   models.FollowStatus
	FollowersCount int64
	FollowingCount int64
	Profile        *models.Profile
}

type ProfileFollowersParams struct {
	LoggedInUser *oauth.MultiAccountUser
	Followers    []FollowCard
	Card         *ProfileCard
	Active       string
}

func (p *Pages) ProfileFollowers(w io.Writer, params ProfileFollowersParams) error {
	params.Active = "overview"
	return p.executeProfile("user/followers", w, params)
}

type ProfileFollowingParams struct {
	LoggedInUser *oauth.MultiAccountUser
	Following    []FollowCard
	Card         *ProfileCard
	Active       string
}

func (p *Pages) ProfileFollowing(w io.Writer, params ProfileFollowingParams) error {
	params.Active = "overview"
	return p.executeProfile("user/following", w, params)
}

type FollowFragmentParams struct {
	UserDid        string
	FollowStatus   models.FollowStatus
	FollowersCount int64
}

func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error {
	return p.executePlain("user/fragments/follow-oob", w, params)
}

type EditBioParams struct {
	LoggedInUser *oauth.MultiAccountUser
	Profile      *models.Profile
	AlsoKnownAs  []string
}

func (p *Pages) EditBioFragment(w io.Writer, params EditBioParams) error {
	return p.executePlain("user/fragments/editBio", w, params)
}

type EditPinsParams struct {
	LoggedInUser *oauth.MultiAccountUser
	Profile      *models.Profile
	AllRepos     []PinnedRepo
}

type PinnedRepo struct {
	IsPinned bool
	models.Repo
}

func (p *Pages) EditPinsFragment(w io.Writer, params EditPinsParams) error {
	return p.executePlain("user/fragments/editPins", w, params)
}

type StarBtnFragmentParams struct {
	IsStarred bool
	SubjectAt syntax.ATURI
	StarCount int
	HxSwapOob bool
}

func (p *Pages) StarBtnFragment(w io.Writer, params StarBtnFragmentParams) error {
	params.HxSwapOob = true
	return p.executePlain("fragments/starBtn", w, params)
}

type RepoIndexParams struct {
	LoggedInUser  *oauth.MultiAccountUser
	RepoInfo      repoinfo.RepoInfo
	Active        string
	TagMap        map[string][]string
	CommitsTrunc  []types.Commit
	TagsTrunc     []*types.TagReference
	BranchesTrunc []types.Branch
	// ForkInfo           *types.ForkInfo
	HTMLReadme       template.HTML
	Raw              bool
	EmailToDid       map[string]string
	VerifiedCommits  commitverify.VerifiedCommits
	Languages        []types.RepoLanguageDetails
	Pipelines        map[string]models.Pipeline
	NeedsKnotUpgrade bool
	KnotUnreachable  bool
	types.RepoIndexResponse
}

func (p *Pages) RepoIndexPage(w io.Writer, params RepoIndexParams) error {
	params.Active = "overview"
	if params.IsEmpty {
		return p.executeRepo("repo/empty", w, params)
	}

	if params.NeedsKnotUpgrade {
		return p.executeRepo("repo/needsUpgrade", w, params)
	}

	if params.KnotUnreachable {
		return p.executeRepo("repo/knotUnreachable", w, params)
	}

	rctx := p.rctx.Clone()
	rctx.RepoInfo = params.RepoInfo
	rctx.RepoInfo.Ref = params.Ref
	rctx.RendererType = markup.RendererTypeRepoMarkdown

	if params.ReadmeFileName != "" {
		ext := filepath.Ext(params.ReadmeFileName)
		switch ext {
		case ".md", ".markdown", ".mdown", ".mkdn", ".mkd":
			params.Raw = false
			htmlString := rctx.RenderMarkdown(params.Readme)
			sanitized := rctx.SanitizeDefault(htmlString)
			params.HTMLReadme = template.HTML(sanitized)
		default:
			params.Raw = true
		}
	}

	return p.executeRepo("repo/index", w, params)
}

type RepoLogParams struct {
	LoggedInUser    *oauth.MultiAccountUser
	RepoInfo        repoinfo.RepoInfo
	TagMap          map[string][]string
	Active          string
	EmailToDid      map[string]string
	VerifiedCommits commitverify.VerifiedCommits
	Pipelines       map[string]models.Pipeline

	types.RepoLogResponse
}

func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error {
	params.Active = "overview"
	return p.executeRepo("repo/log", w, params)
}

type RepoCommitParams struct {
	LoggedInUser *oauth.MultiAccountUser
	RepoInfo     repoinfo.RepoInfo
	Active       string
	EmailToDid   map[string]string
	Pipeline     *models.Pipeline
	DiffOpts     types.DiffOpts

	// singular because it's always going to be just one
	VerifiedCommit commitverify.VerifiedCommits

	types.RepoCommitResponse
}

func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error {
	params.Active = "overview"
	return p.executeRepo("repo/commit", w, params)
}

type RepoTreeParams struct {
	LoggedInUser   *oauth.MultiAccountUser
	RepoInfo       repoinfo.RepoInfo
	Active         string
	BreadCrumbs    [][]string
	Path           string
	Raw            bool
	HTMLReadme     template.HTML
	EmailToDid     map[string]string
	LastCommitInfo *types.LastCommitInfo
	types.RepoTreeResponse
}

type RepoTreeStats struct {
	NumFolders uint64
	NumFiles   uint64
}

func (r RepoTreeParams) TreeStats() RepoTreeStats {
	numFolders, numFiles := 0, 0
	for _, f := range r.Files {
		if !f.IsFile() {
			numFolders += 1
		} else if f.IsFile() {
			numFiles += 1
		}
	}

	return RepoTreeStats{
		NumFolders: uint64(numFolders),
		NumFiles:   uint64(numFiles),
	}
}

func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error {
	params.Active = "overview"

	rctx := p.rctx.Clone()
	rctx.RepoInfo = params.RepoInfo
	rctx.RepoInfo.Ref = params.Ref
	rctx.RendererType = markup.RendererTypeRepoMarkdown

	if params.ReadmeFileName != "" {
		ext := filepath.Ext(params.ReadmeFileName)
		switch ext {
		case ".md", ".markdown", ".mdown", ".mkdn", ".mkd":
			params.Raw = false
			htmlString := rctx.RenderMarkdown(params.Readme)
			sanitized := rctx.SanitizeDefault(htmlString)
			params.HTMLReadme = template.HTML(sanitized)
		default:
			params.Raw = true
		}
	}

	return p.executeRepo("repo/tree", w, params)
}

type RepoBranchesParams struct {
	LoggedInUser *oauth.MultiAccountUser
	RepoInfo     repoinfo.RepoInfo
	Active       string
	types.RepoBranchesResponse
}

func (p *Pages) RepoBranches(w io.Writer, params RepoBranchesParams) error {
	params.Active = "overview"
	return p.executeRepo("repo/branches", w, params)
}

type RepoTagsParams struct {
	LoggedInUser *oauth.MultiAccountUser
	RepoInfo     repoinfo.RepoInfo
	Active       string
	types.RepoTagsResponse
	ArtifactMap       map[plumbing.Hash][]models.Artifact
	DanglingArtifacts []models.Artifact
}

func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error {
	params.Active = "overview"
	return p.executeRepo("repo/tags", w, params)
}

type RepoTagParams struct {
	LoggedInUser *oauth.MultiAccountUser
	RepoInfo     repoinfo.RepoInfo
	Active       string
	types.RepoTagResponse
	ArtifactMap       map[plumbing.Hash][]models.Artifact
	DanglingArtifacts []models.Artifact
}

func (p *Pages) RepoTag(w io.Writer, params RepoTagParams) error {
	params.Active = "overview"
	return p.executeRepo("repo/tag", w, params)
}

type RepoArtifactParams struct {
	LoggedInUser *oauth.MultiAccountUser
	RepoInfo     repoinfo.RepoInfo
	Artifact     models.Artifact
}

func (p *Pages) RepoArtifactFragment(w io.Writer, params RepoArtifactParams) error {
	return p.executePlain("repo/fragments/artifact", w, params)
}

type RepoBlobParams struct {
	LoggedInUser   *oauth.MultiAccountUser
	RepoInfo       repoinfo.RepoInfo
	Active         string
	BreadCrumbs    [][]string
	BlobView       models.BlobView
	EmailToDid     map[string]string
	LastCommitInfo *types.LastCommitInfo
	*tangled.RepoBlob_Output
}

func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error {
	params.Active = "overview"
	return p.executeRepo("repo/blob", w, params)
}

type Collaborator struct {
	Did  string
	Role string
}

type RepoSettingsParams struct {
	LoggedInUser   *oauth.MultiAccountUser
	RepoInfo       repoinfo.RepoInfo
	Collaborators  []Collaborator
	Active         string
	Branches       []types.Branch
	Spindles       []string
	CurrentSpindle string
	Secrets        []*tangled.RepoListSecrets_Secret

	// TODO: use repoinfo.roles
	IsCollaboratorInviteAllowed bool
}

func (p *Pages) RepoSettings(w io.Writer, params RepoSettingsParams) error {
	params.Active = "settings"
	return p.executeRepo("repo/settings", w, params)
}

type RepoGeneralSettingsParams struct {
	LoggedInUser       *oauth.MultiAccountUser
	RepoInfo           repoinfo.RepoInfo
	Labels             []models.LabelDefinition
	DefaultLabels      []models.LabelDefinition
	SubscribedLabels   map[string]struct{}
	ShouldSubscribeAll bool
	Active             string
	Tab                string
	Branches           []types.Branch
}

func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error {
	params.Active = "settings"
	params.Tab = "general"
	return p.executeRepo("repo/settings/general", w, params)
}

type RepoAccessSettingsParams struct {
	LoggedInUser  *oauth.MultiAccountUser
	RepoInfo      repoinfo.RepoInfo
	Active        string
	Tab           string
	Collaborators []Collaborator
}

func (p *Pages) RepoAccessSettings(w io.Writer, params RepoAccessSettingsParams) error {
	params.Active = "settings"
	params.Tab = "access"
	return p.executeRepo("repo/settings/access", w, params)
}

type RepoPipelineSettingsParams struct {
	LoggedInUser   *oauth.MultiAccountUser
	RepoInfo       repoinfo.RepoInfo
	Active         string
	Tab            string
	Spindles       []string
	CurrentSpindle string
	Secrets        []map[string]any
}

func (p *Pages) RepoPipelineSettings(w io.Writer, params RepoPipelineSettingsParams) error {
	params.Active = "settings"
	params.Tab = "pipelines"
	return p.executeRepo("repo/settings/pipelines", w, params)
}

type RepoWebhooksSettingsParams struct {
	LoggedInUser      *oauth.MultiAccountUser
	RepoInfo          repoinfo.RepoInfo
	Active            string
	Tab               string
	Webhooks          []models.Webhook
	WebhookDeliveries map[int64][]models.WebhookDelivery
}

func (p *Pages) RepoWebhooksSettings(w io.Writer, params RepoWebhooksSettingsParams) error {
	params.Active = "settings"
	params.Tab = "hooks"
	return p.executeRepo("repo/settings/hooks", w, params)
}

type WebhookDeliveriesListParams struct {
	LoggedInUser *oauth.MultiAccountUser
	RepoInfo     repoinfo.RepoInfo
	Webhook      *models.Webhook
	Deliveries   []models.WebhookDelivery
}

func (p *Pages) WebhookDeliveriesList(w io.Writer, params WebhookDeliveriesListParams) error {
	tpl, err := p.parse("repo/settings/fragments/webhookDeliveries")
	if err != nil {
		return err
	}
	return tpl.ExecuteTemplate(w, "repo/settings/fragments/webhookDeliveries", params)
}

type RepoSiteSettingsParams struct {
	LoggedInUser     *oauth.MultiAccountUser
	RepoInfo         repoinfo.RepoInfo
	Active           string
	Tab              string
	Branches         []types.Branch
	SiteConfig       *models.RepoSite
	OwnerClaim       *models.DomainClaim
	Deploys          []models.SiteDeploy
	IndexSiteTakenBy string // repo_at of another repo that already holds is_index, or ""
}

func (p *Pages) RepoSiteSettings(w io.Writer, params RepoSiteSettingsParams) error {
	params.Active = "settings"
	params.Tab = "sites"
	return p.executeRepo("repo/settings/sites", w, params)
}

type RepoIssuesParams struct {
	LoggedInUser *oauth.MultiAccountUser
	RepoInfo     repoinfo.RepoInfo
	Active       string
	Issues       []models.Issue
	IssueCount   int
	LabelDefs    map[string]*models.LabelDefinition
	Page         pagination.Page
	FilterState  string
	FilterQuery  string
}

func (p *Pages) RepoIssues(w io.Writer, params RepoIssuesParams) error {
	params.Active = "issues"
	return p.executeRepo("repo/issues/issues", w, params)
}

type RepoSingleIssueParams struct {
	LoggedInUser *oauth.MultiAccountUser
	RepoInfo     repoinfo.RepoInfo
	Active       string
	Issue        *models.Issue
	CommentList  []models.CommentListItem
	Backlinks    []models.RichReferenceLink
	LabelDefs    map[string]*models.LabelDefinition

	Reactions   map[models.ReactionKind]models.ReactionDisplayData
	UserReacted map[models.ReactionKind]bool
}

func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error {
	params.Active = "issues"
	return p.executeRepo("repo/issues/issue", w, params)
}

type EditIssueParams struct {
	LoggedInUser *oauth.MultiAccountUser
	RepoInfo     repoinfo.RepoInfo
	Issue        *models.Issue
	Action       string
}

func (p *Pages) EditIssueFragment(w io.Writer, params EditIssueParams) error {
	params.Action = "edit"
	return p.executePlain("repo/issues/fragments/putIssue", w, params)
}

type ThreadReactionFragmentParams struct {
	ThreadAt  syntax.ATURI
	Kind      models.ReactionKind
	Count     int
	Users     []string
	IsReacted bool
}

func (p *Pages) ThreadReactionFragment(w io.Writer, params ThreadReactionFragmentParams) error {
	return p.executePlain("repo/fragments/reaction", w, params)
}

type RepoNewIssueParams struct {
	LoggedInUser *oauth.MultiAccountUser
	RepoInfo     repoinfo.RepoInfo
	Issue        *models.Issue // existing issue if any -- passed when editing
	Active       string
	Action       string
}

func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error {
	params.Active = "issues"
	params.Action = "create"
	return p.executeRepo("repo/issues/new", w, params)
}

type EditIssueCommentParams struct {
	LoggedInUser *oauth.MultiAccountUser
	RepoInfo     repoinfo.RepoInfo
	Issue        *models.Issue
	Comment      *models.IssueComment
}

func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error {
	return p.executePlain("repo/issues/fragments/editIssueComment", w, params)
}

type ReplyIssueCommentPlaceholderParams struct {
	LoggedInUser *oauth.MultiAccountUser
	RepoInfo     repoinfo.RepoInfo
	Issue        *models.Issue
	Comment      *models.IssueComment
}

func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error {
	return p.executePlain("repo/issues/fragments/replyIssueCommentPlaceholder", w, params)
}

type ReplyIssueCommentParams struct {
	LoggedInUser *oauth.MultiAccountUser
	RepoInfo     repoinfo.RepoInfo
	Issue        *models.Issue
	Comment      *models.IssueComment
}

func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error {
	return p.executePlain("repo/issues/fragments/replyComment", w, params)
}

type IssueCommentBodyParams struct {
	LoggedInUser *oauth.MultiAccountUser
	RepoInfo     repoinfo.RepoInfo
	Issue        *models.Issue
	Comment      *models.IssueComment
}

func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error {
	return p.executePlain("repo/issues/fragments/issueCommentBody", w, params)
}

type RepoNewPullParams struct {
	LoggedInUser *oauth.MultiAccountUser
	RepoInfo     repoinfo.RepoInfo
	Branches     []types.Branch
	Strategy     string
	SourceBranch string
	TargetBranch string
	Title        string
	Body         string
	Active       string
}

func (p *Pages) RepoNewPull(w io.Writer, params RepoNewPullParams) error {
	params.Active = "pulls"
	return p.executeRepo("repo/pulls/new", w, params)
}

type RepoPullsParams struct {
	LoggedInUser *oauth.MultiAccountUser
	RepoInfo     repoinfo.RepoInfo
	Pulls        []*models.Pull
	Active       string
	FilterState  string
	FilterQuery  string
	Stacks       map[string]models.Stack
	Pipelines    map[string]models.Pipeline
	LabelDefs    map[string]*models.LabelDefinition
	Page         pagination.Page
	PullCount    int
}

func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error {
	params.Active = "pulls"
	return p.executeRepo("repo/pulls/pulls", w, params)
}

type ResubmitResult uint64

const (
	ShouldResubmit ResubmitResult = iota
	ShouldNotResubmit
	Unknown
)

func (r ResubmitResult) Yes() bool {
	return r == ShouldResubmit
}
func (r ResubmitResult) No() bool {
	return r == ShouldNotResubmit
}
func (r ResubmitResult) Unknown() bool {
	return r == Unknown
}

type RepoSinglePullParams struct {
	LoggedInUser       *oauth.MultiAccountUser
	RepoInfo           repoinfo.RepoInfo
	Active             string
	Pull               *models.Pull
	Stack              models.Stack
	AbandonedPulls     []*models.Pull
	Backlinks          []models.RichReferenceLink
	BranchDeleteStatus *models.BranchDeleteStatus
	MergeCheck         types.MergeCheckResponse
	ResubmitCheck      ResubmitResult
	Pipelines          map[string]models.Pipeline
	Diff               types.DiffRenderer
	DiffOpts           types.DiffOpts
	ActiveRound        int
	IsInterdiff        bool

	Reactions   map[models.ReactionKind]models.ReactionDisplayData
	UserReacted map[models.ReactionKind]bool

	LabelDefs map[string]*models.LabelDefinition
}

func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error {
	params.Active = "pulls"
	return p.executeRepo("repo/pulls/pull", w, params)
}

type RepoPullPatchParams struct {
	LoggedInUser *oauth.MultiAccountUser
	RepoInfo     repoinfo.RepoInfo
	Pull         *models.Pull
	Stack        models.Stack
	Diff         *types.NiceDiff
	Round        int
	Submission   *models.PullSubmission
	DiffOpts     types.DiffOpts
}

// this name is a mouthful
func (p *Pages) RepoPullPatchPage(w io.Writer, params RepoPullPatchParams) error {
	return p.execute("repo/pulls/patch", w, params)
}

type RepoPullInterdiffParams struct {
	LoggedInUser *oauth.MultiAccountUser
	RepoInfo     repoinfo.RepoInfo
	Pull         *models.Pull
	Round        int
	Interdiff    *patchutil.InterdiffResult
	DiffOpts     types.DiffOpts
}

// this name is a mouthful
func (p *Pages) RepoPullInterdiffPage(w io.Writer, params RepoPullInterdiffParams) error {
	return p.execute("repo/pulls/interdiff", w, params)
}

type PullPatchUploadParams struct {
	RepoInfo repoinfo.RepoInfo
}

func (p *Pages) PullPatchUploadFragment(w io.Writer, params PullPatchUploadParams) error {
	return p.executePlain("repo/pulls/fragments/pullPatchUpload", w, params)
}

type PullCompareBranchesParams struct {
	RepoInfo     repoinfo.RepoInfo
	Branches     []types.Branch
	SourceBranch string
}

func (p *Pages) PullCompareBranchesFragment(w io.Writer, params PullCompareBranchesParams) error {
	return p.executePlain("repo/pulls/fragments/pullCompareBranches", w, params)
}

type PullCompareForkParams struct {
	RepoInfo repoinfo.RepoInfo
	Forks    []models.Repo
	Selected string
}

func (p *Pages) PullCompareForkFragment(w io.Writer, params PullCompareForkParams) error {
	return p.executePlain("repo/pulls/fragments/pullCompareForks", w, params)
}

type PullCompareForkBranchesParams struct {
	RepoInfo       repoinfo.RepoInfo
	SourceBranches []types.Branch
	TargetBranches []types.Branch
}

func (p *Pages) PullCompareForkBranchesFragment(w io.Writer, params PullCompareForkBranchesParams) error {
	return p.executePlain("repo/pulls/fragments/pullCompareForksBranches", w, params)
}

type PullResubmitParams struct {
	LoggedInUser *oauth.MultiAccountUser
	RepoInfo     repoinfo.RepoInfo
	Pull         *models.Pull
	SubmissionId int
}

func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error {
	return p.executePlain("repo/pulls/fragments/pullResubmit", w, params)
}

type PullActionsParams struct {
	LoggedInUser       *oauth.MultiAccountUser
	RepoInfo           repoinfo.RepoInfo
	Pull               *models.Pull
	RoundNumber        int
	MergeCheck         types.MergeCheckResponse
	ResubmitCheck      ResubmitResult
	BranchDeleteStatus *models.BranchDeleteStatus
	Stack              models.Stack
}

func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error {
	return p.executePlain("repo/pulls/fragments/pullActions", w, params)
}

type PullNewCommentParams struct {
	LoggedInUser *oauth.MultiAccountUser
	RepoInfo     repoinfo.RepoInfo
	Pull         *models.Pull
	RoundNumber  int
}

func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error {
	return p.executePlain("repo/pulls/fragments/pullNewComment", w, params)
}

type RepoCompareParams struct {
	LoggedInUser *oauth.MultiAccountUser
	RepoInfo     repoinfo.RepoInfo
	Forks        []models.Repo
	Branches     []types.Branch
	Tags         []*types.TagReference
	Base         string
	Head         string
	Diff         *types.NiceDiff
	DiffOpts     types.DiffOpts

	Active string
}

func (p *Pages) RepoCompare(w io.Writer, params RepoCompareParams) error {
	params.Active = "overview"
	return p.executeRepo("repo/compare/compare", w, params)
}

type RepoCompareNewParams struct {
	LoggedInUser *oauth.MultiAccountUser
	RepoInfo     repoinfo.RepoInfo
	Forks        []models.Repo
	Branches     []types.Branch
	Tags         []*types.TagReference
	Base         string
	Head         string

	Active string
}

func (p *Pages) RepoCompareNew(w io.Writer, params RepoCompareNewParams) error {
	params.Active = "overview"
	return p.executeRepo("repo/compare/new", w, params)
}

type RepoCompareAllowPullParams struct {
	LoggedInUser *oauth.MultiAccountUser
	RepoInfo     repoinfo.RepoInfo
	Base         string
	Head         string
}

func (p *Pages) RepoCompareAllowPullFragment(w io.Writer, params RepoCompareAllowPullParams) error {
	return p.executePlain("repo/fragments/compareAllowPull", w, params)
}

type RepoCompareDiffFragmentParams struct {
	Diff     types.NiceDiff
	DiffOpts types.DiffOpts
}

func (p *Pages) RepoCompareDiffFragment(w io.Writer, params RepoCompareDiffFragmentParams) error {
	return p.executePlain("repo/fragments/diff", w, []any{&params.Diff, &params.DiffOpts})
}

type LabelPanelParams struct {
	LoggedInUser *oauth.MultiAccountUser
	RepoInfo     repoinfo.RepoInfo
	Defs         map[string]*models.LabelDefinition
	Subject      string
	State        models.LabelState
}

func (p *Pages) LabelPanel(w io.Writer, params LabelPanelParams) error {
	return p.executePlain("repo/fragments/labelPanel", w, params)
}

type EditLabelPanelParams struct {
	LoggedInUser *oauth.MultiAccountUser
	RepoInfo     repoinfo.RepoInfo
	Defs         map[string]*models.LabelDefinition
	Subject      string
	State        models.LabelState
}

func (p *Pages) EditLabelPanel(w io.Writer, params EditLabelPanelParams) error {
	return p.executePlain("repo/fragments/editLabelPanel", w, params)
}

type PipelinesParams struct {
	LoggedInUser *oauth.MultiAccountUser
	RepoInfo     repoinfo.RepoInfo
	Pipelines    []models.Pipeline
	Active       string
	FilterKind   string
	Total        int64
}

func (p *Pages) Pipelines(w io.Writer, params PipelinesParams) error {
	params.Active = "pipelines"
	return p.executeRepo("repo/pipelines/pipelines", w, params)
}

type LogBlockParams struct {
	Id        int
	Name      string
	Command   string
	Collapsed bool
	StartTime time.Time
}

func (p *Pages) LogBlock(w io.Writer, params LogBlockParams) error {
	return p.executePlain("repo/pipelines/fragments/logBlock", w, params)
}

type LogBlockEndParams struct {
	Id        int
	StartTime time.Time
	EndTime   time.Time
}

func (p *Pages) LogBlockEnd(w io.Writer, params LogBlockEndParams) error {
	return p.executePlain("repo/pipelines/fragments/logBlockEnd", w, params)
}

type LogLineParams struct {
	Id      int
	Content string
}

func (p *Pages) LogLine(w io.Writer, params LogLineParams) error {
	return p.executePlain("repo/pipelines/fragments/logLine", w, params)
}

type WorkflowParams struct {
	LoggedInUser *oauth.MultiAccountUser
	RepoInfo     repoinfo.RepoInfo
	Pipeline     models.Pipeline
	Workflow     string
	LogUrl       string
	Active       string
}

func (p *Pages) Workflow(w io.Writer, params WorkflowParams) error {
	params.Active = "pipelines"
	return p.executeRepo("repo/pipelines/workflow", w, params)
}

type PutStringParams struct {
	LoggedInUser *oauth.MultiAccountUser
	Action       string

	// this is supplied in the case of editing an existing string
	String models.String
}

func (p *Pages) PutString(w io.Writer, params PutStringParams) error {
	return p.execute("strings/put", w, params)
}

type StringsDashboardParams struct {
	LoggedInUser *oauth.MultiAccountUser
	Card         ProfileCard
	Strings      []models.String
}

func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error {
	return p.execute("strings/dashboard", w, params)
}

type StringTimelineParams struct {
	LoggedInUser *oauth.MultiAccountUser
	Strings      []models.String
}

func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error {
	return p.execute("strings/timeline", w, params)
}

type SingleStringParams struct {
	LoggedInUser     *oauth.MultiAccountUser
	ShowRendered     bool
	RenderToggle     bool
	RenderedContents template.HTML
	String           *models.String
	Stats            models.StringStats
	IsStarred        bool
	StarCount        int
	Owner            identity.Identity
}

func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error {
	return p.execute("strings/string", w, params)
}

type SearchReposParams struct {
	LoggedInUser *oauth.MultiAccountUser
	Repos        []models.Repo
	Page         pagination.Page
	ResultCount  int
	FilterQuery  string
	SortParam    string
	TimeTaken    time.Duration
	DocCount     int64
}

func (p *Pages) SearchRepos(w io.Writer, params SearchReposParams) error {
	return p.execute("search/search", w, params)
}

func (p *Pages) Home(w io.Writer, params TimelineParams) error {
	return p.execute("timeline/home", w, params)
}

func (p *Pages) Static() http.Handler {
	if p.dev {
		return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static")))
	}

	sub, err := fs.Sub(p.embedFS, "static")
	if err != nil {
		p.logger.Error("no static dir found? that's crazy", "err", err)
		panic(err)
	}
	// Custom handler to apply Cache-Control headers for font files
	return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub))))
}

func Cache(h http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		path := strings.Split(r.URL.Path, "?")[0]

		if strings.HasSuffix(path, ".css") {
			// on day for css files
			w.Header().Set("Cache-Control", "public, max-age=86400")
		} else {
			w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
		}
		h.ServeHTTP(w, r)
	})
}

func (p *Pages) CssContentHash() string {
	cssFile, err := p.embedFS.Open("static/tw.css")
	if err != nil {
		slog.Debug("Error opening CSS file", "err", err)
		return ""
	}
	defer cssFile.Close()

	hasher := sha256.New()
	if _, err := io.Copy(hasher, cssFile); err != nil {
		slog.Debug("Error hashing CSS file", "err", err)
		return ""
	}

	return hex.EncodeToString(hasher.Sum(nil))[:8] // Use first 8 chars of hash
}

func (p *Pages) DangerPasswordTokenStep(w io.Writer) error {
	return p.executePlain("user/settings/fragments/dangerPasswordToken", w, nil)
}

func (p *Pages) DangerPasswordSuccess(w io.Writer) error {
	return p.executePlain("user/settings/fragments/dangerPasswordSuccess", w, nil)
}

func (p *Pages) DangerDeleteTokenStep(w io.Writer) error {
	return p.executePlain("user/settings/fragments/dangerDeleteToken", w, nil)
}

func (p *Pages) Error500(w io.Writer) error {
	return p.execute("errors/500", w, nil)
}

func (p *Pages) Error404(w io.Writer) error {
	return p.execute("errors/404", w, nil)
}

func (p *Pages) ErrorKnot404(w io.Writer) error {
	return p.execute("errors/knot404", w, nil)
}

func (p *Pages) Error503(w io.Writer) error {
	return p.execute("errors/503", w, nil)
}
