// Command comparesrht is the compare.sr.ht daemon: a stateless HTTP service
// that renders git ref-to-ref diffs and single-commit views for a self-hosted
// SourceHut instance.
//
// It reuses core-go's server.New assembly so it shares the standard SourceHut
// daemon lifecycle (crypto initialization, -b/-m/-p flags, warm shutdown on
// SIGINT) with the rest of the fleet, but it deliberately does NOT call
// WithDefaultMiddleware: compare.sr.ht owns no Postgres or Redis and must serve
// anonymous viewers, so it installs its own lightweight middleware group on the
// anonymous router instead (see the web package documentation for the exact
// chain).
//
// Flags (parsed by core-go's server.New):
//
//	-b addr   bind address (repeatable); default 127.0.0.1:5090
//	-d        debug (verbose request logging in core-go)
//	-m addr   Prometheus metrics bind (default random port)
//	-p addr   pprof bind (default random localhost port)
//
// Configuration is loaded from the shared SourceHut config.ini via core-go's
// fixed search path (./config.ini, ../config.ini, /etc/sr.ht/config.ini,
// /etc/sr.ht/*.ini). All required keys are validated up front with clear
// messages so a misconfiguration fails loudly at startup rather than as a deep
// panic on the first request.
package main

import (
	"errors"
	"log/slog"
	"os"
	"strings"
	"time"

	"github.com/go-chi/chi/v5"
	chimiddleware "github.com/go-chi/chi/v5/middleware"
	"github.com/vaughan0/go-ini"
	"go.bigb.es/auxilia/scribe"
	"sourcecraft.dev/bigbes/sr-ht-core/config"
	coreserver "sourcecraft.dev/bigbes/sr-ht-core/server"
	"sourcecraft.dev/bigbes/sr-ht-ecore/chimw"
	"sourcecraft.dev/bigbes/sr-ht-ecore/instconf"
	"sourcecraft.dev/bigbes/sr-ht-ecore/logging"
	"sourcecraft.dev/bigbes/sr-ht-ecore/login"

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

const (
	service     = "compare.sr.ht"
	defaultBind = "127.0.0.1:5090"

	// authzTTL is how long the GraphQL authorizer memoizes positive and
	// not-found repository lookups, sparing git.sr.ht a round trip per page.
	authzTTL = 60 * time.Second
)

// initLogging installs the instance's log handler as slog's default.
//
// Setting the *default* is the load-bearing part, not the formatting: the
// packages that log below this one hold no logger of their own — the web tier
// calls slog's package functions, and so do ecore's panic-recovery middleware
// and its request logger, whose records would otherwise go to Go's plain
// stderr handler with the stack as one unreadable field.
//
// The policy is ecore's and the handler is ours, which is the split logging
// documents: what is masked and how verbose to be are facts about the instance,
// while a tinting handler is auxilia's and does not belong in a library every
// service links for its page chrome.
//
// Defaults(nil, "") because config.ini is not loaded yet, and deliberately so:
// -d is read straight out of the argument vector — core-go's server.New owns
// the real flag parse and does not run until after config validation, and a
// daemon that only became verbose once it had finished starting would be silent
// for exactly the part of its life an operator passes -d to watch. The two
// knobs that work here are -d and $LOG_LEVEL; a [compare.sr.ht] log-level key
// would be read too late to matter and is not supported.
func initLogging() {
	opts := logging.Defaults(nil, "")
	logging.Install(scribe.NewTintHandler(
		scribe.WithWriter(os.Stderr),
		scribe.WithLevel(opts.Level),
		scribe.WithSource(opts.AddSource),
		scribe.WithTimeFormat(opts.TimeFormat),
		// Colour is for a terminal; under systemd stderr is the journal, where
		// the escapes are noise in every stored record. logging.ColorEnabled
		// answers that from one Stat, and honours NO_COLOR besides.
		scribe.WithNoColor(!opts.Color),
		// This daemon logs no credential deliberately, which is precisely why
		// the masks are here: the one that leaks is the attribute somebody adds
		// later, and a request or a cookie is the likeliest thing to be handed
		// to a log line while debugging the very cookie path this service reads.
		// The list is the instance's, so a key another service learned to redact
		// is redacted here without anybody editing this file.
		scribe.WithMaskKeys(opts.MaskKeys...),
		scribe.WithMask(opts.MaskPattern, opts.MaskReplacement),
	))
}

func main() {
	initLogging()

	// LoadConfig never panics on a missing file (it returns a nil ini.File);
	// validateConfig turns any absent required key into a single clear fatal.
	conf := config.LoadConfig()
	apiOrigin := validateConfig(conf)

	// server.New parses -b/-d/-m/-p from the argument vector and runs
	// crypto.InitCrypto(conf) — the network-key and webhook key validated above
	// are exactly what it needs, so this cannot fatal after validateConfig.
	// It expects the full os.Args (core-go's getopt skips argv[0] as the
	// program name, exactly as every upstream SourceHut daemon calls it).
	srv := coreserver.New(service, defaultBind, conf, os.Args)

	authorizer := authz.NewAuthorizer(authzTTL)
	app, err := web.New(conf, authorizer)
	if err != nil {
		// slog has no Fatal, and the explicit exit is the better shape anyway:
		// the line above is a log record like any other, and the decision to
		// stop is visible on its own line rather than hidden in a logger call.
		slog.Error("initialize the web server", scribe.Err(err))
		os.Exit(1)
	}

	// Middleware chain per the web package contract (outermost first). This is
	// the hand-rolled substitute for WithDefaultMiddleware: no database, no
	// redis, and login.Optional never issues a 401 so anonymous browsing
	// works. config.Middleware must be present because the GraphQL authorizer
	// resolves git.sr.ht's API origin from config.ForContext at request time.
	//
	// server.New already froze the anonymous router for direct middleware
	// registration (it built inline sub-routers during construction), so the
	// group + middleware + routes are installed together inside a Group, which
	// chi permits on a fresh inline mux sharing the same routing tree.
	//
	// Register installs three more of its own inside a nested group: the
	// private cache policy, panic recovery through the service's error page,
	// and the same-origin guard. chi's Recoverer stays here as the outer net
	// for a panic in the middlewares above, which are outside that group — it
	// re-panics http.ErrAbortHandler, which is what the inner one raises for a
	// panic arriving after the response has already started.
	//
	// The request line is chimw's and no longer chi's. chi's Logger writes an
	// unstructured, colourised line to *stdout*, which on this daemon is the
	// highest-volume record it emits and the only one not beside the rest in
	// the journal: an operator grepping stderr for a path finds every panic and
	// none of the requests that caused them. RequestID goes above it so the
	// request line and the panic report carry the same id, and Recoverer below
	// it so its own report goes through the log entry rather than to stdout.
	// /healthz is skipped: it is a probe every second and says nothing.
	srv.AnonRouter().Group(func(r chi.Router) {
		r.Use(chimiddleware.RequestID)
		r.Use(chimiddleware.RealIP)
		r.Use(chimw.RequestLogger(chimw.SlogFormatter{
			Skip: chimw.SkipPaths("/healthz"),
		}))
		r.Use(chimiddleware.Recoverer)
		r.Use(config.Middleware(conf, service))
		// The instance's one cookie decode, with the default validator: a name
		// this service narrowed further would be an account logged out of
		// compare alone, and meta.sr.ht is the authority on which names exist.
		r.Use(login.Optional())
		app.Register(r)
	})

	reposRoot, _ := conf.Get("git.sr.ht", "repos")
	slog.Info("compare.sr.ht starting",
		"bind", resolveBind(os.Args[1:]),
		"repos", reposRoot,
		"git.sr.ht-api", apiOrigin,
	)

	// Run blocks until SIGINT, then performs a warm shutdown. systemd should
	// stop this unit with KillSignal=SIGINT (see contrib/compare-srht.service).
	srv.Run()
}

// validateConfig verifies every configuration key compare.sr.ht needs before it
// can serve or authorize a request. It reports all missing keys at once, in a
// single record followed by a single exit, so operators fix the config in one
// pass instead of discovering each gap on a separate restart. It returns the
// git.sr.ht internal API origin that GraphQL authorization will use (also
// logged at startup).
//
// This runs BEFORE anything can reach config.GetAPI, which panics when no
// origin candidate is configured, and before crypto.InitCrypto (invoked by
// server.New), which would otherwise fatal with a terse message on a missing
// network-key or webhook key.
func validateConfig(conf ini.File) string {
	err := instconf.Require(conf,
		instconf.Need("sr.ht", "network-key"),    // crypto: unified-login cookie fernet key
		instconf.Need("webhooks", "private-key"), // crypto: webhook signing key (shared)
		instconf.Need("git.sr.ht", "repos"),      // bare repository root on disk
		instconf.Need("meta.sr.ht", "origin"),    // login/logout links in the nav
		instconf.Need(service, "origin"),         // our own external origin

		// git.sr.ht needs at least one internal API origin candidate; without
		// it config.GetAPI panics on the first authorization request. The
		// ladder is asked for by name rather than written out, so this check
		// and the lookup below cannot come to disagree about it.
		instconf.NeedAny("git.sr.ht", instconf.APIOriginKeys()...),
	)
	if err != nil {
		// One record and one exit, deliberately: an operator fixing a config
		// wants every gap in front of them at once, and a line per missing key
		// is a restart per missing key. Strings() keeps them a slice attribute
		// rather than a joined string, so the structured sinks keep them as a
		// list — the tint handler still renders them on one line.
		var missing *instconf.MissingKeysError
		if errors.As(err, &missing) {
			slog.Error("incomplete configuration", "missing", missing.Strings())
		} else {
			slog.Error("incomplete configuration", scribe.Err(err))
		}
		os.Exit(1)
	}

	// Cannot report false: NeedAny above walked the same ladder.
	apiOrigin, _ := instconf.InternalAPIOrigin(conf, "git.sr.ht")
	return apiOrigin
}

// resolveBind reconstructs the primary bind address server.New will use, for
// logging only. server.New owns the authoritative parse; this mirrors its -b
// handling (last -b wins here; the real server binds every -b given) and falls
// back to the same default.
func resolveBind(args []string) string {
	bind := defaultBind
	for i := 0; i < len(args); i++ {
		switch a := args[i]; {
		case a == "-b" && i+1 < len(args):
			bind = args[i+1]
			i++
		case strings.HasPrefix(a, "-b") && len(a) > 2:
			bind = a[2:]
		}
	}
	return bind
}
