package main

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"net/http"
	"os"
	"os/signal"
	"path/filepath"
	"sort"
	"strings"
	"syscall"
	"time"

	"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
	"codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
	"codeberg.org/VARASYS/ZDDC/zddc/internal/auth"
	"codeberg.org/VARASYS/ZDDC/zddc/internal/cache"
	"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
	"codeberg.org/VARASYS/ZDDC/zddc/internal/convert"
	appfs "codeberg.org/VARASYS/ZDDC/zddc/internal/fs"
	"codeberg.org/VARASYS/ZDDC/zddc/internal/handler"
	"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
	"codeberg.org/VARASYS/ZDDC/zddc/internal/tlsutil"
	"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"

	"github.com/klauspost/compress/gzhttp"
	"gopkg.in/natefinch/lumberjack.v2"
)

// version is the binary's own version, injected at build time via
// `-ldflags="-X main.version=..."`. Defaults to "dev" for unreleased
// builds; release pipelines pass the result of `git describe --tags`.
var version = "dev"

func main() {
	// --print-rego: dump the bundled reference Rego skeleton and exit.
	// A starting point for operators standing up an external OPA: it models
	// the read-ACL cascade only and is fail-closed for writes (NOT a mirror
	// of the internal decider), so it must be extended before granting writes.
	//
	//   --print-rego               → read-ACL skeleton (fail-closed)
	//   --print-rego=standard      → same
	for _, a := range os.Args[1:] {
		switch a {
		case "--print-rego", "--print-rego=standard":
			fmt.Print(policy.ReferenceRego)
			return
		case "show-defaults", "--show-defaults":
			// Emit the embedded baseline as a .zddc.zip (per-depth policy
			// tree, "*" wildcard members) to stdout. Redirect into a bundle
			// (e.g. `> $ZDDC_ROOT/.zddc.zip`) to start from the shipped
			// defaults and edit/add/delete individual members; the bundle
			// participates in the cascade (child wins). Drop it at any
			// directory to mount a subtree; add inherit:false +
			// acl.inherit:false to fully replace the baseline there.
			b, err := zddc.EmbeddedDefaultsZip()
			if err != nil {
				fmt.Fprintln(os.Stderr, "show-defaults:", err)
				os.Exit(1)
			}
			_, _ = os.Stdout.Write(b)
			return
		}
	}

	cfg, err := config.Load(os.Args[1:])
	if errors.Is(err, config.ErrHelpRequested) {
		config.Usage(os.Stderr)
		os.Exit(0)
	}
	if errors.Is(err, config.ErrVersionRequested) {
		printVersions(os.Stdout)
		os.Exit(0)
	}
	if err != nil {
		fmt.Fprintf(os.Stderr, "configuration error: %v\n\nRun with --help for usage.\n", err)
		os.Exit(1)
	}

	logRing := setupLogger(cfg.LogLevel)

	embedded := apps.EmbeddedVersions()
	slog.Info("zddc-server starting",
		"version", version,
		"root", cfg.Root,
		"addr", cfg.Addr,
		"embedded_apps", embeddedVersionsForLog(embedded))

	// Probe pandoc + chromium for the MD→{docx,html,pdf} endpoint.
	// Non-fatal: if either binary isn't on PATH (operator running
	// zddc-server outside the runtime image), conversion requests
	// return 503 and everything else keeps working.
	//
	// In the production runtime image, "pandoc" and "chromium-browser"
	// on PATH resolve to wrapper scripts at /usr/local/bin/<name>
	// that put the real binary into a cgroup v2 + bwrap sandbox
	// before exec'ing it. zddc-server is unaware — it just sees
	// the corresponding tool's behavior. The wrapper reads
	// ZDDC_CONV_MEM_MAX, ZDDC_CONV_PIDS_MAX, and ZDDC_SCRATCH from
	// the child env to drive cgroup setup + scratch-dir bind mount.
	convert.SetBinaries(cfg.ConvertPandocBinary, cfg.ConvertChromiumBinary)
	convert.SetScratchDir(cfg.ConvertScratchDir)
	probeCtx, probeCancel := context.WithTimeout(context.Background(), 5*time.Second)
	convert.Probe(probeCtx)
	probeCancel()
	convert.ConfigureLimits(cfg.ConvertMemMiB, cfg.ConvertPIDs, cfg.ConvertTimeout)

	// Client mode short-circuit: when cfg.Upstream is set, this binary
	// runs as a downstream proxy/cache/mirror rather than a master.
	// The master-side machinery below (archive index, watcher, apps
	// server, policy decider, ACL middleware, token store) is all
	// skipped — every request flows through the cache layer, which
	// forwards to upstream and (in cache/mirror modes) persists the
	// response under cfg.Root.
	if cfg.Upstream != "" {
		runClient(cfg)
		return
	}

	// Build archive index
	slog.Info("building archive index...")
	start := time.Now()
	idx, err := archive.BuildIndex(cfg.Root)
	if err != nil {
		slog.Error("failed to build archive index", "err", err)
		os.Exit(1)
	}
	slog.Info("archive index built", "duration", time.Since(start))

	// Apps fetch+cache subsystem.
	appsServer, err := setupApps(cfg)
	if err != nil {
		slog.Error("failed to set up apps subsystem", "err", err)
		os.Exit(1)
	}

	// TLS config
	tlsCfg, useTLS, err := tlsutil.TLSConfig(cfg)
	if err != nil {
		slog.Error("failed to configure TLS", "err", err)
		os.Exit(1)
	}

	// Context for graceful shutdown
	ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
	defer cancel()

	// Start file-system watcher (best-effort live updates; misses cross-client
	// writes on SMB/CIFS mounts since inotify only sees local-kernel events).
	watcher, err := archive.NewWatcher(cfg.Root, idx)
	if err != nil {
		slog.Warn("failed to start filesystem watcher (index will not auto-update)", "err", err)
	} else {
		go func() {
			if err := watcher.Start(ctx); err != nil && ctx.Err() == nil {
				slog.Error("watcher error", "err", err)
			}
		}()
	}

	// Periodic full re-scan. Required when the served root is an SMB/CIFS
	// share (Azure Files, etc.) — fsnotify sees only events the local kernel
	// generates, so writes from other clients to the share are invisible to
	// the watcher above. A periodic full walk closes that gap.
	if cfg.ArchiveRescanInterval > 0 {
		go runPeriodicRescan(ctx, cfg.Root, idx, cfg.ArchiveRescanInterval)
	} else {
		slog.Info("archive periodic rescan disabled (interval=0)")
	}

	// HTTP handler
	mux := http.NewServeMux()
	// Middleware chain (outermost → innermost):
	//   ACLMiddleware     — extract email from cfg.EmailHeader, store in
	//                       request context. Outermost so the email is
	//                       available to AccessLogMiddleware (Go's context
	//                       propagates DOWN the chain via r.WithContext, not
	//                       UP — so AccessLog can't read a context value set
	//                       by an inner middleware after next.ServeHTTP
	//                       returns).
	//   AccessLogMiddleware — structured per-request log; reads email from
	//                       the context the outer ACL middleware set.
	//   CORSMiddleware    — Origin / preflight handling.
	//   dispatch          — the actual request handler.
	auditLogger := setupAccessAuditLog(cfg.AccessLog)

	// Construct the policy decider once at startup. ZDDC_OPA_URL=internal
	// (default) routes decisions through the in-process Go evaluator;
	// http(s):// or unix:// values send each decision to an external
	// OPA-compatible server (federal customers, custom Rego policies).
	deciderCfg := policy.Config{
		URL:      cfg.OPAURL,
		FailOpen: cfg.OPAFailOpen,
		CacheTTL: cfg.OPACacheTTL,
	}
	// Translate "0" (operator opt-out) to "disable cache" (negative TTL is
	// the policy package's sentinel for "skip the wrapper").
	if deciderCfg.CacheTTL == 0 {
		deciderCfg.CacheTTL = -1
	}
	decider, err := policy.New(deciderCfg)
	if err != nil {
		slog.Error("invalid OPA URL", "url", cfg.OPAURL, "err", err)
		os.Exit(1)
	}
	// --no-auth swaps the configured decider for one that allows
	// everything. Logged at warn level so an operator who set this
	// inadvertently sees it on every restart.
	if cfg.NoAuth {
		decider = policy.AllowAllDecider{}
		slog.Warn("--no-auth enabled: ACL enforcement is disabled. Every request is permitted regardless of .zddc rules.")
	}
	slog.Info("policy decider ready",
		"mode", policyModeLabel(cfg.OPAURL),
		"url", cfg.OPAURL,
		"cache_ttl", cfg.OPACacheTTL,
		"no_auth", cfg.NoAuth)

	// Bootstrap sanity: warn loudly (but don't fail) when the root .zddc
	// grants nobody anything. Embedded internal/zddc/defaults/ ships with empty
	// role members, so a fresh deployment refuses every request until the
	// operator populates the file.
	warnIfNoBootstrap(cfg)

	// Token store: bearer-token issuance and validation.
	// Persists under <ZDDC_ROOT>/.zddc.d/tokens/ — already excluded
	// from public listings (fs.ListDirectory dot-prefix filter) and
	// direct serving (dispatch's reserved-prefix guard). Failures here
	// are non-fatal: token-based auth is opt-in per request, and
	// header-based auth keeps working without it.
	tokens, err := auth.NewStore(cfg.Root)
	if err != nil {
		slog.Warn("could not initialise token store; bearer-token auth disabled", "err", err)
		tokens = nil
	}

	// Innermost handler: dispatch.
	var inner http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		dispatch(cfg, idx, logRing, appsServer, tokens, w, r)
	})
	inner = handler.CORSMiddleware(cfg, inner)
	// HSTS only when zddc-server itself is the TLS-terminating endpoint.
	// Behind an upstream proxy terminating TLS (cfg.TLSMode=="none"), the
	// proxy is responsible for HSTS — adding it here would conflict.
	if useTLS {
		inner = handler.HSTSMiddleware(inner)
	}
	inner = handler.AccessLogMiddleware(cfg, auditLogger, inner)
	inner = handler.ACLMiddleware(cfg, decider, tokens, inner)
	mux.Handle("/", inner)

	gzWrapper, err := newGzipWrapper()
	if err != nil {
		slog.Error("gzhttp wrapper init", "err", err)
		os.Exit(1)
	}

	srv := &http.Server{
		Addr:      cfg.Addr,
		Handler:   gzWrapper(mux),
		TLSConfig: tlsCfg,
		// Conservative timeouts. ReadHeaderTimeout caps how long a slow
		// client can hold the connection before sending request headers
		// (the slowloris vector). Read/Write timeouts cap full-request
		// processing — directory listings + tool HTML serving complete
		// in milliseconds even with gzip, so 60s is generous. IdleTimeout
		// is the keep-alive ceiling between requests on the same conn.
		ReadHeaderTimeout: 10 * time.Second,
		ReadTimeout:       60 * time.Second,
		WriteTimeout:      60 * time.Second,
		IdleTimeout:       120 * time.Second,
	}

	// Serve in goroutine
	if useTLS {
		go func() {
			slog.Info("listening", "addr", cfg.Addr, "tls", true)
			if err := srv.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
				slog.Error("server error", "err", err)
				cancel()
			}
		}()
	} else {
		go func() {
			slog.Info("listening", "addr", cfg.Addr, "tls", false)
			if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
				slog.Error("server error", "err", err)
				cancel()
			}
		}()
	}

	<-ctx.Done()
	slog.Info("shutting down...")

	shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer shutdownCancel()
	if err := srv.Shutdown(shutdownCtx); err != nil {
		slog.Error("shutdown error", "err", err)
	}
	slog.Info("stopped")
}

// runClient is the entry point when cfg.Upstream is set — a separate
// lifecycle from the master-side main(), with no archive index, no
// apps server, no watcher, no policy decider, no ACL middleware, no
// token store. The cache layer (zddc/internal/cache) is the entire
// request handler; AccessLog + HSTS + gzip wrap it the same way they
// wrap dispatch in master mode.
func runClient(cfg config.Config) {
	cacheLayer, err := cache.New(cfg)
	if err != nil {
		slog.Error("client mode init failed", "err", err)
		os.Exit(1)
	}
	slog.Info("client mode active",
		"upstream", cacheLayer.Upstream(),
		"mode", cacheLayer.Mode(),
		"no_auth", cfg.NoAuth,
		"skip_tls_verify", cfg.SkipTLSVerify)
	if cfg.NoAuth {
		slog.Warn("--no-auth enabled: incoming requests are not ACL-checked locally; trusting upstream's filtering.")
	}

	// Mirror walker: only constructed when --mode=mirror with at least
	// one subtree (config validation ensures a default of "/" applies
	// when the operator opted into mirror without specifying). Hooks
	// itself into cacheLayer.onAccess; no further wiring needed here.
	if cfg.Mode == "mirror" && len(cfg.MirrorSubtree) > 0 {
		sched, err := cache.NewMirrorScheduler(cacheLayer, cfg.MirrorSubtree, cfg.MirrorMinInterval, 0)
		if err != nil {
			slog.Error("mirror scheduler init failed", "err", err)
			os.Exit(1)
		}
		if sched != nil {
			slog.Info("mirror walker armed",
				"subtrees", sched.Subtrees(),
				"min_interval", sched.MinInterval())
		}
	}

	// Outbox: persist + replay offline writes. Only enabled in cache
	// or mirror modes (proxy mode doesn't persist anything by design).
	// A failure here is non-fatal: writes still flow live, but
	// transport errors return 503 instead of being queued.
	ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
	defer cancel()

	if cfg.Mode != "proxy" {
		outbox, err := cache.NewOutbox(cacheLayer)
		if err != nil {
			slog.Warn("outbox init failed; offline writes will return 503", "err", err)
		} else {
			cacheLayer.SetOutbox(outbox)
			pending, _ := outbox.Pending()
			slog.Info("outbox ready", "dir", outbox.Dir(), "pending_at_startup", len(pending))
			go outbox.RunReplayLoop(ctx)
		}
	}

	tlsCfg, useTLS, err := tlsutil.TLSConfig(cfg)
	if err != nil {
		slog.Error("failed to configure TLS", "err", err)
		os.Exit(1)
	}

	auditLogger := setupAccessAuditLog(cfg.AccessLog)

	var inner http.Handler = cacheLayer
	inner = handler.CORSMiddleware(cfg, inner)
	if useTLS {
		inner = handler.HSTSMiddleware(inner)
	}
	inner = handler.AccessLogMiddleware(cfg, auditLogger, inner)

	mux := http.NewServeMux()
	mux.Handle("/", inner)

	gzWrapper, err := newGzipWrapper()
	if err != nil {
		slog.Error("gzhttp wrapper init", "err", err)
		os.Exit(1)
	}
	srv := &http.Server{
		Addr:              cfg.Addr,
		Handler:           gzWrapper(mux),
		TLSConfig:         tlsCfg,
		ReadHeaderTimeout: 10 * time.Second,
		ReadTimeout:       60 * time.Second,
		WriteTimeout:      60 * time.Second,
		IdleTimeout:       120 * time.Second,
	}

	if useTLS {
		go func() {
			slog.Info("listening", "addr", cfg.Addr, "tls", true, "client_mode", true)
			if err := srv.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
				slog.Error("server error", "err", err)
				cancel()
			}
		}()
	} else {
		go func() {
			slog.Info("listening", "addr", cfg.Addr, "tls", false, "client_mode", true)
			if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
				slog.Error("server error", "err", err)
				cancel()
			}
		}()
	}

	<-ctx.Done()
	slog.Info("shutting down...")
	shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer shutdownCancel()
	if err := srv.Shutdown(shutdownCtx); err != nil {
		slog.Error("shutdown error", "err", err)
	}
	// Drain background cache work (revalidation kicked off on hits)
	// before exiting, so in-flight sidecar writes finish rather than
	// being abandoned mid-flight.
	cacheLayer.Wait()
	slog.Info("stopped")
}

// setupAccessAuditLog constructs a slog.Logger writing JSON lines to a
// size-rotated file at the operator-configured path. Returns nil if no
// path is configured (operator opted out via --access-log=) —
// AccessLogMiddleware then logs only to stderr.
//
// Auto-creates the parent directory (mode 0750) if missing, so the
// default path of <ZDDC_ROOT>/.zddc.d/logs/access-<host>.log "just
// works" on a fresh deployment without operator setup.
//
// Every record is tagged with `host` (os.Hostname). When multiple
// zddc-server replicas serve the same dataset (and write to the same
// .zddc.d/logs/ directory via per-host filenames), the host field also
// makes downstream-aggregated streams disambiguable.
//
// Rotation: lumberjack — 100 MB per file, 10 backups, 90-day max age,
// gzip compression on rotated files.
//
// File-permission posture: lumberjack creates new logs with mode 0600
// (running user only). For multi-user audit access, the operator should
// use group-readable parent directory permissions and either chmod the
// log out-of-band or run a forwarder that has its own read access.
// policyModeLabel collapses cfg.OPAURL to a one-word mode label for the
// startup log so operators can grep for the active decider quickly.
func policyModeLabel(opaURL string) string {
	switch {
	case opaURL == "" || strings.EqualFold(opaURL, "internal"):
		return "internal"
	case strings.HasPrefix(opaURL, "unix://"):
		return "external-unix"
	case strings.HasPrefix(opaURL, "https://"):
		return "external-https"
	case strings.HasPrefix(opaURL, "http://"):
		return "external-http"
	default:
		return "unknown"
	}
}

func setupAccessAuditLog(path string) *slog.Logger {
	if path == "" {
		return nil
	}
	if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
		slog.Error("could not create access-log directory; falling back to stderr-only",
			"dir", filepath.Dir(path), "err", err)
		return nil
	}
	rotator := &lumberjack.Logger{
		Filename:   path,
		MaxSize:    100, // megabytes per file before rotation
		MaxBackups: 10,
		MaxAge:     90, // days
		Compress:   true,
	}
	host, _ := os.Hostname()
	if host == "" {
		host = "unknown"
	}
	// JSON handler — line-delimited JSON is the format every standard
	// log shipper (Vector, Loki promtail, fluentbit, journalbeat) parses
	// natively, and stays grep-friendly for ad-hoc inspection.
	h := slog.NewJSONHandler(rotator, &slog.HandlerOptions{Level: slog.LevelInfo})
	slog.Info("access log file enabled",
		"path", path, "host", host,
		"max_size_mb", 100, "max_backups", 10, "max_age_days", 90)
	return slog.New(h).With("host", host)
}

// newGzipWrapper builds the gzip middleware applied to the entire mux.
// MinSize(1024) skips compressing tiny responses where the framing
// overhead exceeds the savings (304 Not Modified, error pages, small
// JSON listings under ~1 KB). The wrapper honors Accept-Encoding (passes
// through unchanged when the client doesn't advertise gzip), appends
// Vary: Accept-Encoding automatically, and passes through 304s untouched.
// Yields ~75% size reduction on the larger embedded HTML responses
// (browse: ~2 MB → a few hundred KB on the wire).
//
// Extracted so tests can construct an equivalent wrapper without going
// through the full main() server boot.
func newGzipWrapper() (func(http.Handler) http.HandlerFunc, error) {
	return gzhttp.NewWrapper(gzhttp.MinSize(1024))
}

// setupApps builds the tool-HTML server. Resolution is LOCAL-ONLY: a real
// file on disk at the request path (handled upstream by dispatch) → a
// "<app>.html" member of the site-root <ZDDC_ROOT>/.zddc.zip bundle → the
// embedded default. No fetch, no cache, no signatures.
func setupApps(cfg config.Config) (*apps.Server, error) {
	return apps.NewServer(cfg.Root, version), nil
}

// warnIfNoBootstrap fires a startup slog.Warn when the root .zddc grants
// nobody anything — the embedded defaults ships with empty role
// members, so a deployment without operator-populated admins / acl
// permissions / role members refuses every request. Skipped under
// --no-auth (auth disabled; warning would be redundant). Per-project
// .zddc files may legitimately carry all grants, so the warning text
// tells the operator they can ignore it in that case.
//
// Master-mode only — the bootstrap concept doesn't apply in client
// (proxy/cache/mirror) mode, where cfg.Root is the cache directory.
func warnIfNoBootstrap(cfg config.Config) {
	if cfg.NoAuth {
		return
	}
	rootPath := filepath.Join(cfg.Root, ".zddc")
	rootZddc, err := zddc.ParseFile(rootPath)
	if err != nil {
		slog.Warn("root .zddc not present or unreadable; ZDDC will refuse every request until you create it. "+
			"See README.md '## Deploy: bootstrap config' or AGENTS.md '## zddc-server / ### Bootstrap config'.",
			"path", rootPath, "err", err)
		return
	}
	hasAdmin := len(rootZddc.Admins) > 0
	hasPerm := len(rootZddc.ACL.Permissions) > 0
	hasRoleMembers := false
	for _, role := range rootZddc.Roles {
		if len(role.Members) > 0 {
			hasRoleMembers = true
			break
		}
	}
	if !hasAdmin && !hasPerm && !hasRoleMembers {
		slog.Warn("root .zddc grants nobody anything (no admins, no acl.permissions, no role members). "+
			"ZDDC will refuse every request until you populate it. "+
			"If you intentionally grant only at per-project levels, you can ignore this. "+
			"See README.md '## Deploy: bootstrap config' or AGENTS.md '## zddc-server / ### Bootstrap config'.",
			"path", rootPath)
	}
}

// printVersions writes the binary version + the build label of every app
// embedded into the binary. Called by --version and reused for the
// startup log line.
func printVersions(w *os.File) {
	fmt.Fprintf(w, "zddc-server %s\n\n", version)
	embedded := apps.EmbeddedVersions()
	if len(embedded) == 0 {
		fmt.Fprintln(w, "Embedded tools: (none — run `sh build.sh` to populate)")
		return
	}
	fmt.Fprintln(w, "Embedded tools:")
	keys := make([]string, 0, len(embedded))
	for k := range embedded {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	for _, k := range keys {
		fmt.Fprintf(w, "  %-12s %s\n", k, embedded[k])
	}
}

// embeddedVersionsForLog formats the embedded-versions map as a single
// short string suitable for the startup `log/slog` line. Sorted by app
// name for stable output.
func embeddedVersionsForLog(embedded map[string]string) string {
	if len(embedded) == 0 {
		return "(none)"
	}
	keys := make([]string, 0, len(embedded))
	for k := range embedded {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	parts := make([]string, 0, len(keys))
	for _, k := range keys {
		// Strip any " · timestamp · sha" suffix so the log line stays compact;
		// operators who want full detail run `zddc-server --version`.
		v := embedded[k]
		if i := strings.Index(v, " "); i > 0 {
			v = v[:i]
		}
		parts = append(parts, k+"="+v)
	}
	return strings.Join(parts, " ")
}

// serveSpecializedNoSlash handles a GET/HEAD request to a directory
// URL without a trailing slash by serving the directory's cascade-
// declared default_tool — the "specialized app" half of the slash/
// no-slash routing convention. (The slash half is DirTool, resolved
// in handler.ServeDirectory; it defaults to "browse".) Works for both
// real on-disk directories and purely-virtual ones (default_tool may
// come from an ancestor's paths: tree).
//
// Returns true once it has written a response. Returns false when
// there is nothing specialized to serve — no default_tool, or
// default_tool=tables with no matching table spec, or the tool isn't
// available at this path — so the caller falls through to its own
// fallback (landing at a project root, then a 302 to the slash form
// where DirTool/browse renders the listing).
//
// ACL is enforced here against dirAbs's effective policy, so every
// default_tool route is gated identically regardless of which call
// site reached it. dirAbs is the directory's filesystem path (it need
// not exist on disk); urlPath is the request URL path; email is the
// authenticated user (may be empty).
func serveSpecializedNoSlash(cfg config.Config, appsSrv *apps.Server, w http.ResponseWriter, r *http.Request, dirAbs, urlPath, email string) bool {
	app := apps.DefaultAppAt(cfg.Root, dirAbs)
	// An explicit `views.dir` in the cascade overrides the default_tool-
	// derived app for the no-slash directory URL — the generalization's
	// dir-shape routing. default_tool remains the sugar fallback (ViewAt
	// returns it when no views.dir is declared), so existing deployments
	// are unaffected.
	if v, ok := zddc.ViewAt(cfg.Root, dirAbs, "dir"); ok && v.Tool != "" {
		app = v.Tool
	}
	if app == "" {
		return false
	}
	chain, _ := zddc.EffectivePolicy(cfg.Root, dirAbs)
	if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
		http.Error(w, "Forbidden", http.StatusForbidden)
		return true
	}
	if app == "tables" {
		// tables isn't an apps-subsystem app — it's the table view,
		// served by handler.ServeTable from a synthesized
		// <dir>/table.html request (which also resolves the embedded
		// default-MDL spec for archive/<party>/mdl). No spec → caller
		// falls through.
		if tr := handler.RecognizeTableRequest(cfg.Root, http.MethodGet, urlPath+"/table.html"); tr != nil {
			handler.ServeTable(cfg, tr, w, r)
			return true
		}
		return false
	}
	if appsSrv != nil && apps.AppAvailableAt(cfg.Root, dirAbs, app) {
		appsSrv.Serve(w, r, app, chain, dirAbs)
		return true
	}
	return false
}

// splitZipPath detects a "<…>.zip/<member>" URL: a path where some
// ancestor segment resolves to a regular .zip file on disk and there's
// a tail segment after it (or a trailing slash). On a match it returns
// the zip's absolute filesystem path and the slash-separated member
// path inside the zip ("" when the URL is "<…>.zip/" with nothing
// after). ok is false for everything else — including "<…>.zip" with
// no trailing slash (that's a plain file download, handled downstream).
//
// Segments are stat'd one at a time against cfg.Root; case-folding has
// already been applied to on-disk segments by appfs.ResolveCanonical
// upstream, so the .zip segment matches by exact name here. The
// per-segment os.Stat walk is gated by a cheap ".zip/" substring check
// at the call site, so it never runs for ordinary requests.
func splitZipPath(fsRoot, urlPath string) (zipAbs, member string, ok bool) {
	trimmed := strings.Trim(urlPath, "/")
	if trimmed == "" {
		return "", "", false
	}
	segs := strings.Split(trimmed, "/")
	cur := fsRoot
	for i, seg := range segs {
		cur = filepath.Join(cur, seg)
		if cur != fsRoot && !strings.HasPrefix(cur, fsRoot+string(filepath.Separator)) {
			return "", "", false
		}
		info, err := os.Stat(cur)
		if err != nil {
			return "", "", false // a segment doesn't exist on disk — not a zip path
		}
		if info.IsDir() {
			continue
		}
		// cur is a non-directory. Only a regular .zip file with a tail
		// (or trailing slash) is "browse into the zip"; anything else
		// falls through to the normal file path.
		if !info.Mode().IsRegular() || !strings.EqualFold(filepath.Ext(seg), ".zip") {
			return "", "", false
		}
		if i < len(segs)-1 {
			return cur, strings.Join(segs[i+1:], "/"), true
		}
		// Last segment is the .zip itself: only a trailing slash means
		// "browse into it" (member == root); a bare "<…>.zip" is a file.
		if strings.HasSuffix(urlPath, "/") {
			return cur, "", true
		}
		return "", "", false
	}
	return "", "", false
}

// configEditorForBundle reports whether the request principal holds STANDING
// config-edit authority over the directory that holds the .zddc.zip config
// bundle referenced by urlPath — a subtree admin (admins: cascade) or `a`-verb
// holder, WITHOUT elevation. Both browsing the bundle's members and writing
// them are gated by this: config you administer is visible+editable without a
// toggle. The bundle is NOT wide-readable, because it packs many subtrees'
// policy into one file — exposing it to every reader would leak a tightened
// subtree's rules; per-level transparency is served by ServeZddcFile instead.
// Elevation isn't required here; it only adds the WORM/destructive overrides
// elsewhere. Works for every bundle URL shape (bare, trailing-slash listing,
// and <bundle>/<member>) since it keys off the segment before the bundle name.
func configEditorForBundle(cfg config.Config, r *http.Request, urlPath string) bool {
	p := handler.PrincipalFromContext(r)
	if p.Email == "" {
		return false
	}
	parent := make([]string, 0)
	for _, seg := range strings.Split(strings.Trim(urlPath, "/"), "/") {
		if strings.EqualFold(seg, apps.BundleName) {
			break
		}
		parent = append(parent, seg)
	}
	dir := filepath.Join(cfg.Root, filepath.FromSlash(strings.Join(parent, "/")))
	chain, _ := zddc.EffectivePolicy(cfg.Root, dir)
	return zddc.IsConfigEditor(chain, p.Email)
}

// dispatch routes a request to the appropriate handler.
func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, appsSrv *apps.Server, tokens *auth.Store, w http.ResponseWriter, r *http.Request) {
	// URL paths are case-insensitive: resolve each segment against the
	// on-disk casing under cfg.Root, preferring the all-lowercase
	// variant when multiple case variants exist (lowercase is the
	// project-wide canonical case for folders). The walk stops at the
	// first segment that doesn't exist on disk so virtual prefixes
	// (.profile, .archive, .tokens) and 404 paths flow through with
	// their tail preserved verbatim. Downstream handlers see the
	// canonical r.URL.Path; the access log captures the as-typed path
	// before this rewrite.
	if absPath, canonical, ok := appfs.ResolveCanonical(cfg.Root, r.URL.Path); ok {
		_ = absPath
		// Restore trailing slash so directory routing (which redirects
		// no-trailing-slash requests) keeps its existing semantics.
		if strings.HasSuffix(r.URL.Path, "/") && !strings.HasSuffix(canonical, "/") && canonical != "/" {
			canonical += "/"
		}
		if canonical != r.URL.Path {
			r.URL.Path = canonical
			r.URL.RawPath = ""
		}
	}

	urlPath := r.URL.Path
	email := handler.EmailFromContext(r)

	// Profile routes — the page itself is reachable to anyone (anonymous
	// included); admin-only sub-resources (whoami / config / logs /
	// projects / .zddc editor) keep their existing per-resource 404
	// existence-leakage gates inside ServeProfile.
	if urlPath == handler.ProfilePathPrefix || strings.HasPrefix(urlPath, handler.ProfilePathPrefix+"/") {
		handler.ServeProfile(cfg, ring, idx, w, r)
		return
	}

	// Token self-service: HTML page at /.tokens, JSON API at
	// /.api/tokens. Both routes require an authenticated user (the
	// existing email middleware injects the email from upstream auth).
	// Both routes refuse to serve when no token store is available
	// (e.g. NewStore failed at startup) — handled inside the handlers.
	if urlPath == handler.TokensPathPrefix || urlPath == handler.TokensPathPrefix+"/" {
		handler.ServeTokensPage(cfg, tokens, w, r)
		return
	}
	if urlPath == handler.TokensAPIPathPrefix || strings.HasPrefix(urlPath, handler.TokensAPIPathPrefix+"/") {
		handler.ServeTokensAPI(cfg, tokens, w, r)
		return
	}

	// Recognised markdown front-matter fields + editor placeholder (JSON).
	// The browse markdown editor fetches this to hint the valid keys; it's
	// static, read-only, and leaks nothing, so no auth gate.
	if urlPath == handler.FrontMatterTemplatePath {
		handler.ServeFrontMatterTemplate(w, r)
		return
	}

	// The .zddc JSON Schema (machine grammar) — drives the .zddc form view +
	// client validation. Static, read-only, no auth.
	if urlPath == handler.ZddcSchemaPath {
		handler.ServeZddcSchema(w, r)
		return
	}

	// /_apps/ — virtual, public directory of the standalone tool HTMLs, so
	// people can download one and run it against their own local filesystem.
	// Tool UI only, no data, no auth. Before the reserved-prefix ('_'/'.')
	// guard so it isn't 404'd.
	if urlPath == "/_apps" {
		http.Redirect(w, r, handler.AppsVirtualPrefix, http.StatusMovedPermanently)
		return
	}
	if strings.HasPrefix(urlPath, handler.AppsVirtualPrefix) {
		handler.ServeApps(appsSrv, w, r)
		return
	}

	// Auth check endpoints — machine-only forward_auth targets used by
	// upstream proxies (e.g. the dev-shell pod's Caddy in front of
	// code-server) to gate routes on root-admin status. Handled before
	// the reserved-prefix guard below so the .auth namespace passes
	// through without being 404'd by the dot-prefix rule.
	if urlPath == handler.AuthPathPrefix+"/admin" {
		handler.ServeAuthAdmin(cfg, w, r)
		return
	}

	// (Project list at GET / with Accept: application/json used to be
	// served by a bespoke handler that returned a custom JSON shape.
	// Removed in favour of routing /through the generic ServeDirectory:
	// the directory listing now carries `title` per entry, so the
	// landing page reads project names from the same shape every other
	// listing has. Single canonical wire format > exception that
	// reveals a special perspective.)

	// Split path into segments
	segments := strings.Split(strings.Trim(urlPath, "/"), "/")

	// One reserved namespace: <dir>/.zddc.d/ holds all server bookkeeping
	// (tokens, history, logs, apps + converted caches). It is admin-only at
	// every depth — a hard rule that overrides operator ACLs so a broad grant
	// (e.g. `*: rwcd`) can never expose the token store — gated here by segment
	// name and mirrored on the write path in authorizeAction. 404 (not 403)
	// keeps the store existence-hidden from non-admins.
	//
	// This gate runs BEFORE the raw .zddc view below so a request for the
	// reserve's own cascade (e.g. /<dir>/.zddc.d/.zddc) is existence-hidden
	// too — otherwise IsZddcFileRequest would match the leaf and ServeZddcFile
	// would leak the reserve's effective cascade to a non-admin.
	//
	// Everything else dot-/underscore-prefixed is ordinary ACL-governed
	// content: the listing pipeline (internal/fs, internal/listing) hides such
	// entries from directory views unless ?hidden=1, but direct URL access is
	// governed by the ACL chain like any other file. (.profile/.tokens/.auth
	// were routed above; non-reserved .zddc GET goes to ServeZddcFile just
	// below and .zddc writes fall through to ServeFileAPI; .archive follows.)
	//
	// Bearer-token validation reads .zddc.d/tokens via the filesystem in
	// ACLMiddleware, before this gate, so it is unaffected.
	if handler.HasReservedSidecar(urlPath) && !handler.ActiveAdminForSidecar(cfg, r, urlPath) {
		http.NotFound(w, r)
		return
	}

	// The site-root config bundle <ZDDC_ROOT>/.zddc.zip is config, not
	// ordinary content: existence-hidden over HTTP for everyone EXCEPT a
	// standing config-editor over its directory (a subtree admin or `a`-verb
	// holder — NO elevation required), who may browse it in the file tree.
	// It's NOT wide-readable because one file packs many subtrees' policy;
	// per-level transparency is served by ServeZddcFile. For a config-editor
	// every bundle URL falls through to normal handling — GET <bundle>/ lists
	// its members (the zip-as-directory intercept below), GET <bundle>/member
	// extracts one, and a bare GET <bundle> downloads it. Everyone else gets
	// 404 for every form, which also keeps individual members from being
	// fetched by name. The server reads members from the filesystem internally
	// (apps.Bundle) to resolve tool HTML — that path never goes through
	// dispatch, so this gate doesn't affect resolution.
	bundlePath := false
	for _, seg := range segments {
		if strings.EqualFold(seg, apps.BundleName) {
			bundlePath = true
			break
		}
	}
	if bundlePath && !configEditorForBundle(cfg, r, urlPath) {
		http.NotFound(w, r)
		return
	}

	// Raw .zddc YAML view: <dir>/.zddc is reachable at every depth
	// and returns the on-disk file's bytes (Content-Type: application/yaml)
	// or — when no file exists — a synthetic placeholder body with a
	// cascade summary so the user can see what's effective here. The
	// reserved-sidecar gate above already filtered out .zddc.d/.zddc, so
	// GET/HEAD land here for ordinary paths and PUT/DELETE/POST fall
	// through to ServeFileAPI. A .zddc *inside* a zip (".zip/…/.zddc", e.g.
	// a policy member of the .zddc.zip bundle) is NOT a real on-disk file —
	// it's served by the zip intercept below, so exclude it here.
	if handler.IsZddcFileRequest(urlPath) && !strings.Contains(strings.ToLower(urlPath), ".zip/") &&
		(r.Method == http.MethodGet || r.Method == http.MethodHead) {
		handler.ServeZddcFile(cfg, w, r)
		return
	}

	// Check for .archive segment in the path. .archive is project-scoped
	// and addressed at exactly one depth — /<project>/.archive/... — even
	// though offline-built HTML files reference siblings via
	// "../.archive/<tracking>.html" from arbitrary depths. Any deeper form
	// (/<project>/<sub>/.../.archive/...) gets a 302 to the project-rooted
	// canonical so anchored links and bookmarks normalize to a single
	// stable URL per tracking number. The redirect target preserves the
	// path tail after .archive/ verbatim and the query string; browsers
	// preserve the fragment automatically across redirects.
	//
	// .archive is read-only: only GET/HEAD reach the handler. Anything
	// else (PUT/POST/DELETE) returns 405 here, before the file API would
	// otherwise see the request. This avoids the 302→GET silent-method-
	// downgrade trap and makes the contract explicit.
	for i, seg := range segments {
		if seg != cfg.IndexPath {
			continue
		}
		if r.Method != http.MethodGet && r.Method != http.MethodHead {
			w.Header().Set("Allow", "GET, HEAD")
			http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
			return
		}
		// segments[0] is the project; segments[i] is .archive. i==0
		// means /.archive/... at the very root, with no project to
		// scope by — 404 (a tracking-number reference must be project-
		// rooted; cross-project tracking-number collisions otherwise
		// silently pick a winner).
		if i == 0 {
			http.NotFound(w, r)
			return
		}
		project := segments[0]
		var filename string
		if i+1 < len(segments) {
			filename = strings.Join(segments[i+1:], "/")
		}
		// Canonicalize anything below /<project>/.archive/. Building
		// the target by hand (rather than re-encoding) keeps any
		// already-encoded characters in the original URL.RawPath
		// trailing segments intact for the browser to follow.
		if i > 1 {
			target := "/" + project + "/" + cfg.IndexPath + "/" + filename
			if r.URL.RawQuery != "" {
				target += "?" + r.URL.RawQuery
			}
			http.Redirect(w, r, target, http.StatusFound)
			return
		}
		handler.ServeArchive(cfg, idx, w, r, project, filename)
		return
	}

	// Tables-system intercept: *.table.html is a virtual URL that the
	// table handler renders inline, reading rows from a directory of
	// *.yaml files declared in the directory's .zddc tables: map.
	// Discovery is .zddc-declarative — no auto-mount on file presence —
	// so RecognizeTableRequest returns nil whenever there's no matching
	// declaration and the URL falls through to the static-file path
	// (or to the form intercept below for *.form.html / *.yaml.html).
	//
	// One exception: archive/<party>/mdl.table.html falls back to the
	// embedded default MDL spec when no operator declaration exists.
	// RecognizeTableRequest implements that fallback internally.
	if tableReq := handler.RecognizeTableRequest(cfg.Root, r.Method, urlPath); tableReq != nil {
		handler.ServeTable(cfg, tableReq, w, r)
		return
	}

	// Form-system intercept: *.form.html and *.yaml.html under a sibling form
	// folder are virtual URLs that the form handler renders inline, reading
	// the underlying *.form.yaml spec (and, for re-edit, the *.yaml data) from
	// disk. RecognizeFormRequest returns nil when the spec doesn't exist, so
	// non-form .html URLs fall through to the static-file path below.
	if formReq := handler.RecognizeFormRequest(cfg.Root, r.Method, urlPath); formReq != nil {
		handler.ServeForm(cfg, formReq, w, r)
		return
	}

	// Zip-as-directory intercept: a "<…>.zip/<member>" URL is a virtual
	// surface over a real .zip file on disk — GET "<…>.zip/" lists the
	// members, GET "<…>.zip/member.pdf" extracts and streams that one
	// member, so a client never has to download the whole archive. The
	// bare "<…>.zip" (no trailing slash) is NOT matched here and falls
	// through to the normal file path (a plain download). Like .archive,
	// a zip carries no .zddc of its own — ACL is the chain of the
	// directory CONTAINING the zip. Read-only: write methods are
	// rejected before ServeFileAPI could try to create a path under a
	// file. (The os.Stat walk in splitZipPath is gated by this cheap
	// substring check, so it doesn't run for ordinary requests.)
	if strings.Contains(strings.ToLower(urlPath), ".zip/") {
		if zipAbs, member, ok := splitZipPath(cfg.Root, urlPath); ok {
			if handler.IsWriteMethod(r.Method) {
				// In-place editing is allowed ONLY inside the .zddc.zip config
				// bundle and ONLY for a standing config-editor over its dir
				// (the bundle gate above already 404s the bundle to everyone
				// else, so visibility ⇒ edit authority — no elevation). Content
				// zips — transmittal packages, WORM records — stay read-only.
				if strings.EqualFold(filepath.Base(zipAbs), apps.BundleName) &&
					configEditorForBundle(cfg, r, urlPath) {
					handler.ServeZipWrite(cfg, w, r, zipAbs, member)
					return
				}
				w.Header().Set("Allow", "GET, HEAD")
				http.Error(w, "Zip archives are read-only", http.StatusMethodNotAllowed)
				return
			}
			if r.Method != http.MethodGet && r.Method != http.MethodHead {
				w.Header().Set("Allow", "GET, HEAD")
				http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
				return
			}
			chain, err := zddc.EffectivePolicy(cfg.Root, filepath.Dir(zipAbs))
			if err != nil {
				slog.Warn("ACL policy error on zip parent", "path", filepath.Dir(zipAbs), "err", err)
			}
			if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
				http.Error(w, "Forbidden", http.StatusForbidden)
				return
			}
			handler.ServeZip(cfg, w, r, zipAbs, member)
			return
		}
	}

	// File API — authenticated CRUD over the served tree. Catches PUT,
	// DELETE, and POST on any non-reserved path. Read methods (GET/HEAD)
	// fall through to the static / apps / directory pipeline below.
	// Forms and .profile/.archive POSTs are already routed above this
	// point so they take precedence.
	if handler.IsWriteMethod(r.Method) {
		handler.ServeFileAPI(cfg, w, r)
		return
	}

	// Apps resolution for the root landing path: GET / or /index.html with
	// no real index.html on disk → serve via apps.Serve("landing"). The
	// other four apps are caught by the "stat fails → app HTML?" branch
	// below, which only triggers when no concrete file is at the URL path.
	//
	// Gated by Accept: HTML requests get the landing tool, JSON requests
	// fall through to ServeDirectory and get the generic listing (with
	// per-entry titles via listing.FileInfo.Title). That keeps the wire
	// protocol uniform — a JSON listing is a JSON listing whether you
	// fetch /Project-1/ or /. Landing itself consumes the same shape.
	//
	// The landing page is intentionally public (no ACL gate). It's a
	// project picker — the per-project ACL filtering done by
	// fs.ListDirectory still hides projects an anonymous (or unauthorized)
	// caller can't reach. See also handler.ServeDirectory's matching
	// root-path bypass.
	//
	// (Browsers normalize `https://host` → `https://host/`, so the
	// no-slash vs slash distinction the user might want — picker on
	// bare host, browse on trailing slash — can't be expressed: the
	// HTTP request for both forms is `GET /`. The picker wins because
	// it's the only meaningful entry point that scopes ACL per-project.)
	if appsSrv != nil && (urlPath == "/" || urlPath == "/index.html") &&
		!strings.Contains(r.Header.Get("Accept"), "application/json") {
		realIndex := filepath.Join(cfg.Root, "index.html")
		if _, err := os.Stat(realIndex); os.IsNotExist(err) {
			chain, _ := zddc.EffectivePolicy(cfg.Root, cfg.Root)
			if apps.AppAvailableAt(cfg.Root, cfg.Root, "landing") {
				appsSrv.Serve(w, r, "landing", chain, cfg.Root)
				return
			}
		}
	}

	// Resolve the physical path
	cleanPath := filepath.FromSlash(strings.TrimPrefix(urlPath, "/"))
	absPath := filepath.Join(cfg.Root, cleanPath)

	// Guard against path traversal
	if !strings.HasPrefix(absPath, cfg.Root+string(filepath.Separator)) && absPath != cfg.Root {
		http.Error(w, "Not Found", http.StatusNotFound)
		return
	}

	// Check filesystem
	info, err := os.Stat(absPath)
	if err != nil {
		if os.IsNotExist(err) {
			// Default-spec fallback for the embedded table.yaml / form.yaml
			// files served when no operator file exists on disk:
			//
			//   <project>/{ssr,mdl,rsk}/{table,form}.yaml        (aggregate/registry)
			//   <project>/{mdl,rsk}/<party>/{table,form}.yaml    (per-party)
			//
			// The table app fetches these client-side; the fallback lets
			// a fresh project work out of the box. ACL gates against the
			// chain at the request directory; for project-level virtual
			// specs that chain is the project's, and for per-party paths
			// it's the party's archive folder.
			if r.Method == http.MethodGet || r.Method == http.MethodHead {
				if bytes, ok := handler.IsDefaultSpec(cfg.Root, urlPath); ok {
					chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(absPath))
					if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
						http.Error(w, "Forbidden", http.StatusForbidden)
						return
					}
					w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
					w.Header().Set("Cache-Control", "no-store")
					w.Header().Set("X-ZDDC-Source", "default-spec")
					if r.Method == http.MethodHead {
						return
					}
					_, _ = w.Write(bytes)
					return
				}
			}
			// (Register rows are real files now — ssr/<party>.yaml and
			// mdl|rsk/<party>/<file>.yaml — so a GET of one hits the
			// on-disk serve path below, where $party/name is injected;
			// it never lands in this not-found branch. working/staging/
			// reviewing are real directories navigated normally. The old
			// virtual-row serve + folder-nav 302 are gone.)

			// File doesn't exist at this path. Before falling through to
			// app-HTML routing or 404, check the two virtual-file-extension
			// shapes that ZDDC exposes through the listing convention:
			//
			//   <dir>.zip            — subtree download (replaces `<dir>/?zip=1`)
			//   <file>.docx|html|pdf — MD-source conversion of sibling <file>.md
			//                          (replaces `<file>.md?convert=<fmt>`)
			//
			// Both fire ONLY when stat failed at the requested URL — a
			// real file always wins. The path-suffix form lets clients
			// emit a plain <a href> + lets `curl -O` produce the right
			// filename, no query-string handling required.
			if r.Method == http.MethodGet || r.Method == http.MethodHead {
				if absDir, ok := handler.RecognizeVirtualSubtreeZip(cfg.Root, urlPath); ok {
					chain, _ := zddc.EffectivePolicy(cfg.Root, absDir)
					if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
						http.Error(w, "Forbidden", http.StatusForbidden)
						return
					}
					handler.ServeSubtreeZip(cfg, w, r, absDir)
					return
				}
				if mdAbs, format, ok := handler.RecognizeVirtualConvert(cfg.Root, urlPath); ok {
					chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(mdAbs))
					if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
						http.Error(w, "Forbidden", http.StatusForbidden)
						return
					}
					handler.ServeConverted(cfg, w, r, mdAbs, format, chain)
					return
				}
			}

			// If the URL matches one of the canonical app HTML names AND
			// the request directory is one where that app is available
			// (working/staging/incoming for classifier, staging for
			// transmittal, anywhere for archive + browse, root only for
			// landing), resolve via the apps subsystem.
			if appsSrv != nil {
				if app, requestDirRel := apps.MatchAppHTML(urlPath); app != "" {
					requestDir := filepath.Join(cfg.Root, filepath.FromSlash(requestDirRel))
					if apps.AppAvailableAt(cfg.Root, requestDir, app) {
						chain, _ := zddc.EffectivePolicy(cfg.Root, requestDir)
						// Root-path tool shells are public, mirroring the
						// landing page + ServeDirectory's root bypass: the
						// shell is a static app that carries no data, and the
						// tool's own per-project/per-dir fetches are
						// independently ACL-gated (fs.ListDirectory filters
						// per entry). Gating the shell here would block the
						// root-level multi-project archive/browse views for
						// any caller without a root-level read grant — which
						// no normal (per-project-scoped) user has. Non-root
						// tool paths (e.g. /<project>/archive.html) keep the
						// read gate so a project you can't read won't serve
						// its tool there.
						if requestDir != cfg.Root {
							if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
								http.Error(w, "Forbidden", http.StatusForbidden)
								return
							}
						}
						appsSrv.Serve(w, r, app, chain, requestDir)
						return
					}
				}
			}
			// (Top-level <project>/{working,staging,reviewing} URLs
			// resolve as folder-nav virtuals — the per-party redirect
			// is handled above; the bare top-level URL falls through
			// to ServeDirectory, where ListDirectory synthesises the
			// folder-nav listing from ListPartyDirsInSlot.)
			//
			// Virtual received/ window. <workflow>/received/[...] is a
			// synthetic view onto the canonical received/<tracking>/
			// declared by the workflow folder's .zddc.received_path.
			// ResolveVirtualReceived validates the parent .zddc; on a
			// match, route through the normal directory/file handlers,
			// which swap the read source to the canonical based on the
			// URL (ListDirectory and ServeFile via the absolute path).
			if r.Method == http.MethodGet || r.Method == http.MethodHead {
				if vr := zddc.ResolveVirtualReceived(cfg.Root, urlPath); vr.Resolved {
					if strings.HasSuffix(urlPath, "/") {
						handler.ServeDirectory(cfg, appsSrv, w, r)
						return
					}
					// File read — ACL-check against the canonical
					// received's chain, then serve the canonical bytes
					// while keeping the workflow URL in the address bar.
					chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(vr.ReceivedAbs))
					if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
						http.Error(w, "Forbidden", http.StatusForbidden)
						return
					}
					handler.ServeFile(w, r, vr.ReceivedAbs)
					return
				}
			}
			// Cascade-declared paths: the .zddc cascade (embedded
			// defaults + on-disk overrides) declares this URL even
			// if the on-disk directory doesn't exist yet. Land on a
			// usable view rather than 404, via the same slash/no-slash
			// routing convention used for real directories:
			//   - slash    → ServeDirectory (DirTool; browse by default)
			//   - no-slash → default_tool ("specialized app") if any,
			//                else a 302 to the slash form.
			//
			// Guard: only directory-shaped URLs qualify. The bare "*"
			// project glob matches *any* first-level segment — including
			// "foo.html", "foo.txt", etc. — so without the extension
			// check a non-existent top-level file would 302-to-slash
			// instead of 404. A trailing slash, or no file extension on
			// the last segment, means "asking for a directory".
			if (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
				(strings.HasSuffix(urlPath, "/") || filepath.Ext(urlPath) == "") &&
				zddc.IsDeclaredPath(cfg.Root, absPath) {
				// (Empty-subtree zip for cascade-declared paths is now
				// handled by RecognizeVirtualSubtreeZip at the top of
				// this branch — same handler, path-suffix grammar.)
				if strings.HasSuffix(urlPath, "/") {
					handler.ServeDirectory(cfg, appsSrv, w, r)
					return
				}
				if serveSpecializedNoSlash(cfg, appsSrv, w, r, absPath, urlPath, email) {
					return
				}
				http.Redirect(w, r, urlPath+"/", http.StatusFound)
				return
			}
			http.Error(w, "Not Found", http.StatusNotFound)
		} else {
			http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		}
		return
	}

	if info.IsDir() {
		// ACL check — bypassed at the root path so the landing page (the
		// project picker) is reachable by anyone, including anonymous.
		// Per-project filtering happens inside ServeDirectory →
		// fs.ListDirectory, which hides directories the caller can't
		// reach. Subdirectory requests still hit this gate.
		isRoot := urlPath == "/"
		if !isRoot {
			chain, _ := zddc.EffectivePolicy(cfg.Root, absPath)
			if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
				http.Error(w, "Forbidden", http.StatusForbidden)
				return
			}
		}
		// (Subtree downloads use the virtual `GET /dir.zip` URL —
		// see RecognizeVirtualSubtreeZip handling at the top of the
		// stat-fails branch above. Real directories stat-succeed
		// here, so the virtual zip URL stat-fails at /dir.zip and
		// matches there.)

		// Slash/no-slash routing convention: trailing slash → the
		// directory view (handler.ServeDirectory → DirTool, which
		// resolves to browse by default; JSON requests always get the
		// raw listing regardless). No trailing slash → the directory's
		// default_tool ("specialized app") — browse under working/+
		// reviewing/ (hosts the markdown editor), transmittal under
		// staging/, archive under archive/, tables under
		// archive/<party>/mdl/ — if one is declared; otherwise
		// (after the project-root landing case below) a 302 to the
		// slash form.
		if !strings.HasSuffix(urlPath, "/") && (r.Method == http.MethodGet || r.Method == http.MethodHead) && !isRoot {
			if serveSpecializedNoSlash(cfg, appsSrv, w, r, absPath, urlPath, email) {
				return
			}
		}
		// Project root (depth-1 dir, no trailing slash) serves the
		// landing tool, which detects mode='project' from
		// location.pathname and renders the lifecycle-stage cards +
		// MDL section. Same single-file SPA as the deployment-root
		// project picker — one tool, two URL shapes. With trailing
		// slash, the project falls through to the regular browse
		// listing.
		if !strings.HasSuffix(urlPath, "/") &&
			(r.Method == http.MethodGet || r.Method == http.MethodHead) &&
			handler.IsProjectRootURL(urlPath) {
			if appsSrv != nil {
				chain, _ := zddc.EffectivePolicy(cfg.Root, absPath)
				appsSrv.Serve(w, r, "landing", chain, absPath)
				return
			}
		}
		if !strings.HasSuffix(urlPath, "/") {
			http.Redirect(w, r, urlPath+"/", http.StatusFound)
			return
		}
		handler.ServeDirectory(cfg, appsSrv, w, r)
		return
	}

	// Regular file: ACL on parent directory
	chain, _ := zddc.EffectivePolicy(cfg.Root, filepath.Dir(absPath))
	if allowed, _ := policy.AllowFromChainP(r.Context(), handler.DeciderFromContext(r), chain, handler.PrincipalFromContext(r), urlPath); !allowed {
		http.Error(w, "Forbidden", http.StatusForbidden)
		return
	}

	// views.file → form editor. A browser NAVIGATION (Accept: text/html) to a
	// no-slash data file whose cascade declares views.file = {tool: form}
	// serves the form editor bound to that file. Programmatic reads — the
	// tables client fetches rows with Accept: */* — and an explicit ?raw fall
	// through to the raw bytes (the injected-row / ServeFile path below), so
	// this never breaks row fetching. The POST goes to the canonical
	// <file>.yaml.html update URL (the existing form-update handler).
	if r.Method == http.MethodGet && !r.URL.Query().Has("raw") &&
		strings.Contains(r.Header.Get("Accept"), "text/html") {
		if v, ok := zddc.ViewAt(cfg.Root, filepath.Dir(absPath), "file"); ok && v.Tool == "form" {
			fr := &handler.FormRequest{
				Kind:      "render-edit",
				SpecPath:  filepath.Join(filepath.Dir(absPath), "form.yaml"),
				DataPath:  absPath,
				SubmitURL: urlPath + ".html",
			}
			handler.ServeForm(cfg, fr, w, r)
			return
		}
	}

	// (MD→{docx,html,pdf} on-demand conversion now lives at
	// `GET /<dir>/<file>.{docx,html,pdf}` (virtual file URL,
	// see RecognizeVirtualConvert). The .md source serves
	// normally here.)

	// Edit-history: ACL already passed (parent-dir chain).
	//   - Records (.yaml rows): GET <record>.yaml?history=1 lists prior
	//     revisions stored under <dir>/.history/<base>/ (audit in-body).
	//   - Text (markdown) under a history: true subtree:
	//     ?history=1 lists versions; ?history=<sha> returns that version's
	//     bytes. Audit lives in <dir>/.history/<stem>/log.jsonl.
	// Non-history paths fall through to the normal file serve.
	if (r.Method == http.MethodGet || r.Method == http.MethodHead) && r.URL.Query().Has("history") {
		version := r.URL.Query().Get("history")
		if handler.IsTextHistoryCandidate(cfg.Root, absPath) {
			// Reading recorded history does NOT require history to be
			// currently enabled — snapshots already on disk stay readable
			// (empty list when there are none) even if the `history:` flag
			// was later turned off. The file's read ACL was already checked
			// above; WRITES remain gated by EffectiveHistory in serveFilePut.
			handler.ServeTextHistory(w, r, cfg.Root, absPath, version)
			return
		}
		handler.ServeHistoryList(w, r, absPath)
		return
	}

	// Register rows are real files; inject the path-derived source column
	// ($party for mdl/rsk rows, name for ssr rows) on read so the tables
	// tool renders it as a read-only column. The client strips it on save.
	if r.Method == http.MethodGet || r.Method == http.MethodHead {
		if field, value, ok := registerRowField(urlPath); ok {
			handler.ServeInjectedRow(w, r, absPath, field, value)
			return
		}
	}

	handler.ServeFile(w, r, absPath)
}

// registerRowField returns the path-derived column to inject when urlPath
// names an aggregate register row: ($party, <party>) for
// /<project>/{mdl,rsk}/<party>/<file>.yaml, or (name, <party>) for
// /<project>/ssr/<party>.yaml. ok=false otherwise (incl. spec files).
func registerRowField(urlPath string) (field, value string, ok bool) {
	parts := strings.Split(strings.Trim(urlPath, "/"), "/")
	switch len(parts) {
	case 3:
		if parts[1] == "ssr" && strings.HasSuffix(parts[2], ".yaml") &&
			parts[2] != "table.yaml" && parts[2] != "form.yaml" {
			return "name", strings.TrimSuffix(parts[2], ".yaml"), true
		}
	case 4:
		if (parts[1] == "mdl" || parts[1] == "rsk") && strings.HasSuffix(parts[3], ".yaml") &&
			parts[3] != "table.yaml" && parts[3] != "form.yaml" {
			return "$party", parts[2], true
		}
	}
	return "", "", false
}

// runPeriodicRescan calls idx.Rebuild on `interval` until ctx is cancelled.
// Each tick walks fsRoot from scratch and atomically replaces the live index;
// concurrent reads are safe via the index's RWMutex. Errors are logged but do
// not stop the loop — a transient walk failure shouldn't disable rescans.
func runPeriodicRescan(ctx context.Context, fsRoot string, idx *archive.Index, interval time.Duration) {
	ticker := time.NewTicker(interval)
	defer ticker.Stop()
	slog.Info("archive periodic rescan started", "interval", interval)
	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			dur, projects, tracking, err := idx.Rebuild(fsRoot)
			if err != nil {
				slog.Warn("archive rescan failed", "err", err, "duration", dur)
				continue
			}
			slog.Debug("archive rescan ok", "duration", dur, "projects", projects, "tracking", tracking)
		}
	}
}

// setupLogger installs a slog default that fans every record out to stderr
// (the existing TextHandler — user-visible logging is unchanged) AND to an
// in-memory ring buffer that backs the /.profile/logs endpoint. Returns
// the ring so handlers can read it.
func setupLogger(level string) *handler.LogRing {
	var l slog.Level
	switch strings.ToLower(level) {
	case "debug":
		l = slog.LevelDebug
	case "warn":
		l = slog.LevelWarn
	case "error":
		l = slog.LevelError
	default:
		l = slog.LevelInfo
	}
	ring := handler.NewLogRing(500)
	text := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: l})
	rh := handler.NewRingHandler(ring, l)
	slog.SetDefault(slog.New(handler.NewMultiHandler(text, rh)))
	return ring
}
