// Package web is the HTTP layer of compare.sr.ht. It renders the repository
// landing, compare (base...head) and single-commit pages server-side, and
// embeds a compact JSON payload plus the vendored esbuild bundle so the browser
// renders the diff with @pierre/diffs and @pierre/trees.
//
// The package owns no state of its own: identity comes from the authz cookie
// middleware, authorization from an authz.Authorizer (git.sr.ht GraphQL), and
// git data from gitx over bare repositories on disk. Every request that touches
// a repository authorizes first (a not-found or forbidden repo is a 404, never
// a 403, so private-repo existence never leaks) and only then reads the disk.
//
// # The chrome is not ours
//
// The nav/service-switcher, the brand, the login block and the environment
// banner come from sourcecraft.dev/bigbes/sr-ht-ecore/chrome, which every custom
// service on the instance shares. This package builds one chrome.Service at
// startup, asks it for a chrome.Page per request, and embeds that Page in
// viewData so the fields promote into the templates. Nothing here rebuilds the
// switcher or re-derives a login URL: the copy that used to live in web/chrome.go
// is exactly what ecore exists to have deleted.
//
// One thing about the chrome remains this service's own, because it is about
// what compare renders and not about the instance: the full-bleed
// ContainerClass the two diff views set — a side-by-side diff in a centered
// "container" is a column of code half the window wide. The vendored bundle's
// href is no longer among them: it is a hashed build artefact like the
// stylesheet, so it lives in chrome.Service.Assets, which is the slot ecore
// grew once three services had each added their own field for it.
//
// # What the cmd layer must wire
//
// Register installs the middleware that needs this Server — the private cache
// policy, panic recovery through this package's error page, and the same-origin
// guard — and assumes the following is already applied to the router it is
// handed, in this order (outermost first):
//
//	chi middleware.RealIP
//	chi middleware.Recoverer
//	chi middleware.Logger        (optional, but recommended)
//	config.Middleware(conf, "compare.sr.ht")   // required: authz + gitx read it
//	authz.Middleware()           // required: never 401s; sets the viewer
//
// config.Middleware must run before authz.Middleware is irrelevant to authz
// itself (it only reads the cookie), but the GraphQL authorizer invoked inside
// handlers needs config.ForContext(ctx) to resolve git.sr.ht's API origin, so
// config.Middleware is mandatory on every request that reaches a handler.
package web

import (
	"io/fs"
	"log/slog"
	"net/http"

	"github.com/vaughan0/go-ini"
	"go.bigb.es/auxilia/culpa"
	"sourcecraft.dev/bigbes/sr-ht-ecore/assets"
	"sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
	"sourcecraft.dev/bigbes/sr-ht-ecore/pages"

	"sourcecraft.dev/bigbes/sr-ht-compare/authz"
)

// configSection is this service's literal section in the shared config.ini. It
// is what the switcher's "which entry is me" test compares against, so it must
// be spelled the same here, in the config file and in the middleware the cmd
// layer installs — a service that spelled it two ways would appear in the
// instance's navigation and fail to recognise itself in it.
const configSection = "compare.sr.ht"

// bundleAsset is the key compare's layout reads its front-end bundle's href
// under, in chrome.Service.Assets. It is spelled once here and once in the
// "scripts" block of the two diff pages; a third spelling would render no
// script tag at all rather than fail, which is why the two that exist are a
// constant and a template guarded on emptiness.
const bundleAsset = "bundle.js"

// Server holds the immutable configuration a request handler needs. It is built
// once at startup and is safe for concurrent use.
type Server struct {
	authorizer authz.Authorizer
	reposRoot  string

	// chromeSvc is the shared page frame of sr-ht-ecore: the brand, the service
	// switcher, the login block and the environment banner, built once from
	// config.ini and asked for a per-request chrome.Page in view (chrome.go's
	// job until this service stopped carrying its own copy).
	chromeSvc *chrome.Service

	// pages is one parsed template set per page, discovered from the embedded
	// tree by ecore rather than listed here. A page that defines no "content"
	// never gets this far: pages.Load refuses it at startup, where the
	// alternative was the chrome around a hole served with a 200.
	pages pages.Set

	// static serves the embedded asset tree with the cache policy the hashed
	// name implies, and answers everything that is not a file — a directory
	// above all — through this service's own 404 page.
	static http.Handler
}

// New assembles a Server from the shared SourceHut config. It reads
// [git.sr.ht] repos, [meta.sr.ht] origin and [compare.sr.ht] origin (all
// required), hands the whole file to chrome.NewService — the switcher is a
// question about every [*.sr.ht] section the instance defines, not about our own
// keys — resolves the hashed stylesheet and bundle through ecore's assets, and
// parses the page templates through ecore's pages. A missing required key is a
// clear error, not a panic, so the cmd layer can fail startup loudly.
//
// A missing build artefact is not one of those errors. assets.Resolve answers
// "" for a stylesheet or a bundle this binary was built without, because a
// checkout that has not run `make css` must still be runnable; the layout
// guards both hrefs on emptiness so a bare page is what such a build serves,
// rather than a <link href=""> that re-requests the page it is on.
func New(conf ini.File, authorizer authz.Authorizer) (*Server, error) {
	// Every refusal below carries a hint naming the config key or the build step
	// that fixes it. These are the only errors this package returns, they all
	// arrive at one slog.Error in the cmd layer, and the reader of that record
	// is an operator who wants the remedy rather than the call path.
	reposRoot, ok := conf.Get("git.sr.ht", "repos")
	if !ok || reposRoot == "" {
		return nil, missingKey("git.sr.ht", "repos", "the root directory holding the bare repositories")
	}

	// The two origins are checked through the chrome that will render them
	// rather than read a second time here, so the startup refusal and the links
	// on the page cannot disagree about which origins this service has.
	chromeSvc := chrome.NewService(conf, configSection)
	if chromeSvc.MetaOrigin() == "" {
		return nil, missingKey("meta.sr.ht", "origin", "the login and logout links in the nav are built from it")
	}
	if chromeSvc.SelfOrigin() == "" {
		return nil, missingKey(configSection, "origin", "the same-origin guard and every return_to are built from it")
	}

	cssHref, err := assets.Resolve(staticFS, cssGlob, assets.DefaultPrefix)
	if err != nil {
		return nil, culpa.WithHint(culpa.Wrap(err, "web: resolve the stylesheet"),
			"the glob is a literal in this package, so this is a bug and not a deployment fault")
	}
	bundleHref, err := assets.Resolve(staticFS, bundleGlob, assets.DefaultPrefix)
	if err != nil {
		return nil, culpa.WithHint(culpa.Wrap(err, "web: resolve the bundle"),
			"the glob is a literal in this package, so this is a bug and not a deployment fault")
	}
	if cssHref == "" || bundleHref == "" {
		slog.Warn("web: built without a front-end artefact; run `make` before `go build`",
			"css", cssHref, "bundle", bundleHref)
	}
	chromeSvc.StyleHref = cssHref
	chromeSvc.Assets = map[string]string{bundleAsset: bundleHref}

	set, err := pages.Load(tmplFS, pages.Options{Funcs: funcMap})
	if err != nil {
		return nil, culpa.WithHint(culpa.Wrap(err, "web: load the page templates"),
			"a page in web/templates defines no {{define \"content\"}}, or the layout is missing")
	}

	staticSub, err := fs.Sub(staticFS, "static")
	if err != nil {
		return nil, culpa.Wrap(err, "web: sub static FS")
	}

	s := &Server{
		authorizer: authorizer,
		reposRoot:  reposRoot,
		chromeSvc:  chromeSvc,
		pages:      set,
	}
	// Built after the Server exists because the not-found arm is this service's
	// own error page: an asset URL typed by hand lands on a page with a nav to
	// get out of, and a directory — /static/, which the file server alone would
	// answer with a listing of every artefact in the binary — lands there too.
	s.static = assets.Handler(staticSub, assets.DefaultPrefix, http.HandlerFunc(s.handleNotFound))
	return s, nil
}

// missingKey is the refusal for a config key this service cannot start without:
// the key in the message, and what it is for in the hint. why completes the
// sentence "it is ...", so it reads as an answer to the question an operator
// staring at a failed unit actually has.
func missingKey(section, key, why string) error {
	return culpa.WithHint(
		culpa.Errorf("web: [%s] %s is required", section, key),
		"it is "+why,
	)
}

// viewData is the root value every template is executed against.
//
// chrome.Page is embedded rather than copied field by field, so the shared
// partials — "srht-nav", "srht-env-banner", "srht-repo-list" — find the fields
// they need on the dot they are handed, and a field ecore adds later arrives here
// without an edit. The page's own payload lives under Data and is reached as
// {{.Data.Something}}, which is what keeps a page from shadowing a chrome field.
type viewData struct {
	chrome.Page

	// Data is the page's own payload.
	Data any
}

// view builds the frame for one request: the shared chrome plus a title.
//
// The username is whatever the authz cookie middleware resolved, which is "" for
// a viewer whose cookie is missing, expired or unreadable — so the nav offers
// login to exactly the viewers the handlers treat as anonymous.
func (s *Server) view(r *http.Request, title string) viewData {
	return viewData{Page: s.chromeSvc.Page(r, title, authz.ForContext(r.Context()))}
}
