package main

import (
	"archive/zip"
	"bytes"
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"codeberg.org/VARASYS/ZDDC/zddc/internal/archive"
	"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
	"codeberg.org/VARASYS/ZDDC/zddc/internal/handler"
	"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)

// TestDispatchReservesZddcD asserts the dispatch() gate that reserves the one
// bookkeeping namespace, .zddc.d/. Non-admin requests to .zddc.d/ are 404'd at
// every depth (existence-hidden token store), while every OTHER dot-/underscore-
// prefixed path is ordinary ACL-governed content and is served like any normal
// file (a leading dot only hides it from listings). The recognized virtual
// prefixes (.archive, /.profile) are routed before the gate.
func TestDispatchReservesZddcD(t *testing.T) {
	root := t.TempDir()

	// Realistic shape: a project dir, a reserved .zddc.d/ token store, and a
	// hidden sibling of a normal file inside the project.
	mustMkdir(t, filepath.Join(root, "Project-A"))
	mustWrite(t, filepath.Join(root, "Project-A", "doc.txt"), "ok")
	mustMkdir(t, filepath.Join(root, ".zddc.d", "tokens"))
	mustWrite(t, filepath.Join(root, ".zddc.d", "tokens", "abc123"), "secret")
	mustMkdir(t, filepath.Join(root, "Project-A", ".internal"))
	mustWrite(t, filepath.Join(root, "Project-A", ".internal", "notes.md"), "secret")

	idx, err := archive.BuildIndex(root)
	if err != nil {
		t.Fatalf("BuildIndex: %v", err)
	}

	cfg := config.Config{
		Root:        root,
		IndexPath:   ".archive",
		EmailHeader: "X-Auth-Request-Email",
	}
	ring := handler.NewLogRing(10)

	cases := []struct {
		name       string
		path       string
		wantStatus int
	}{
		// Reserved .zddc.d/ bookkeeping — every shape 404'd for a non-admin
		// (no ACLMiddleware here ⇒ unelevated principal).
		{"reserved .zddc.d dir", "/.zddc.d/", http.StatusNotFound},
		{"reserved .zddc.d token", "/.zddc.d/tokens/abc123", http.StatusNotFound},
		// Case variants must ALSO be reserved: on a case-insensitive root
		// (SMB/CIFS/Azure Files) `.ZDDC.D` resolves to the same dir, so the
		// gate folds case (HasReservedSidecar uses EqualFold).
		{"reserved .ZDDC.D upper", "/.ZDDC.D/tokens/abc123", http.StatusNotFound},
		{"reserved .Zddc.D mixed", "/Project-A/.Zddc.D/history/x", http.StatusNotFound},
		// The reserve's own .zddc cascade is hidden too: the sidecar gate runs
		// before the raw .zddc view, so this never reaches ServeZddcFile.
		{"reserved .zddc.d/.zddc cascade", "/Project-A/.zddc.d/.zddc", http.StatusNotFound},

		// Other dot-prefixed content is no longer blocked by dispatch — it's
		// ACL-governed like any file. This harness passes a nil decider, so the
		// hidden file serves (the cascade is what gates it in production).
		{"hidden dot content served", "/Project-A/.internal/notes.md", http.StatusOK},

		// Sanity: recognized virtual prefixes are routed before the gate.
		// .archive falls through to its own handler (404 on missing tracking);
		// .profile is public. /.admin was hard-cut and now simply 404s as a
		// not-found file (no dot-prefix guard left to reject it).
		{".archive prefix passes guard", "/.archive/UNKNOWN", http.StatusNotFound}, // unknown tracking → 404 from archive handler
		{".profile not blocked by guard", "/.profile/", http.StatusOK},             // public page renders for anonymous
		{".admin → not found", "/.admin/whoami", http.StatusNotFound},

		// Normal files unaffected.
		{"plain file", "/Project-A/doc.txt", http.StatusOK},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			req := httptest.NewRequest(http.MethodGet, tc.path, nil)
			rec := httptest.NewRecorder()
			dispatch(cfg, idx, ring, nil, nil, rec, req)
			if rec.Code != tc.wantStatus {
				t.Errorf("path=%q status=%d want=%d body=%q",
					tc.path, rec.Code, tc.wantStatus, rec.Body.String())
			}
		})
	}
}

// TestDispatchAppsResolution drives local tool-HTML resolution through
// dispatch(): the site .zddc.zip member overrides the embedded default, the
// embedded default is served when no bundle member exists, GET / serves
// landing, the bundle itself is 404 over HTTP, and folder-availability rules
// still gate which tools are served where.
func TestDispatchAppsResolution(t *testing.T) {
	root := t.TempDir()

	// Allow-all ACL so the test doesn't need email headers.
	zf := zddc.ZddcFile{ACL: zddc.ACLRules{Permissions: map[string]string{"*": "rwcd"}}}
	if err := zddc.WriteFile(root, zf); err != nil {
		t.Fatalf("WriteFile: %v", err)
	}
	// Site config bundle overriding archive.html.
	writeRootBundle(t, root, map[string]string{"archive.html": "<!doctype html>BUNDLE archive"})
	// Folder-convention dir so classifier availability passes below.
	mustMkdir(t, filepath.Join(root, "Project-A", "working", "Acme"))

	idx, err := archive.BuildIndex(root)
	if err != nil {
		t.Fatalf("BuildIndex: %v", err)
	}
	cfg := config.Config{
		Root:        root,
		IndexPath:   ".archive",
		EmailHeader: "X-Auth-Request-Email",
	}
	ring := handler.NewLogRing(10)
	appsSrv, err := setupApps(cfg)
	if err != nil {
		t.Fatalf("setupApps: %v", err)
	}

	// GET /archive.html → served from the bundle member (overrides embedded).
	rec := httptest.NewRecorder()
	dispatch(cfg, idx, ring, appsSrv, nil, rec, httptest.NewRequest(http.MethodGet, "/archive.html", nil))
	if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "BUNDLE archive") {
		t.Fatalf("/archive.html: status=%d body=%s (want bundle override)", rec.Code, rec.Body.String())
	}
	if rec.Header().Get("X-ZDDC-Source") != "bundle:archive.html" {
		t.Errorf("X-ZDDC-Source=%q, want bundle:archive.html", rec.Header().Get("X-ZDDC-Source"))
	}

	// GET / → landing (no bundle member → embedded).
	rec3 := httptest.NewRecorder()
	dispatch(cfg, idx, ring, appsSrv, nil, rec3, httptest.NewRequest(http.MethodGet, "/", nil))
	if rec3.Code != http.StatusOK {
		t.Errorf("GET /: status=%d", rec3.Code)
	}

	// The site bundle is config, not content: a direct GET is 404 for everyone.
	rec4 := httptest.NewRecorder()
	dispatch(cfg, idx, ring, appsSrv, nil, rec4, httptest.NewRequest(http.MethodGet, "/.zddc.zip", nil))
	if rec4.Code != http.StatusNotFound {
		t.Errorf("GET /.zddc.zip: status=%d, want 404", rec4.Code)
	}

	// Folder availability rules: classifier should NOT be served at root
	// (root has no per-party working/staging/incoming ancestor), but
	// SHOULD work at /Project-A/archive/<party>/working/ where the per-
	// party cascade declares classifier available.
	rec5 := httptest.NewRecorder()
	dispatch(cfg, idx, ring, appsSrv, nil, rec5, httptest.NewRequest(http.MethodGet, "/classifier.html", nil))
	if rec5.Code != http.StatusNotFound {
		t.Errorf("/classifier.html at root: status=%d, want 404 (not in per-party working/staging/incoming)", rec5.Code)
	}
	rec6 := httptest.NewRecorder()
	dispatch(cfg, idx, ring, appsSrv, nil, rec6, httptest.NewRequest(http.MethodGet, "/Project-A/working/Acme/classifier.html", nil))
	if rec6.Code != http.StatusOK {
		t.Errorf("/Project-A/working/Acme/classifier.html: status=%d, want 200", rec6.Code)
	}
}

// TestDispatchRootAppShellPublicButDataGated locks in the root-path tool-shell
// bypass: a non-root, un-elevated user (no read grant anywhere at/under root)
// can GET /archive.html — the shell is a static app, served like the landing
// page — but the underlying data stays ACL-gated, so the same user is still
// Forbidden from reading a project directory they have no grant on. This is
// what makes the root-level multi-project archive (/archive.html?projects=A,B)
// usable by per-project-scoped users without admin elevation.
func TestDispatchRootAppShellPublicButDataGated(t *testing.T) {
	root := t.TempDir()

	// Root grants only alice; eve has no read grant at root or anywhere under it.
	zf := zddc.ZddcFile{
		ACL: zddc.ACLRules{Permissions: map[string]string{"alice@example.com": "rwcd"}},
	}
	if err := zddc.WriteFile(root, zf); err != nil {
		t.Fatalf("WriteFile: %v", err)
	}
	mustMkdir(t, filepath.Join(root, "Project-A"))

	idx, err := archive.BuildIndex(root)
	if err != nil {
		t.Fatalf("BuildIndex: %v", err)
	}
	cfg := config.Config{Root: root, IndexPath: ".archive", EmailHeader: "X-Auth-Request-Email"}
	ring := handler.NewLogRing(10)
	appsSrv, err := setupApps(cfg)
	if err != nil {
		t.Fatalf("setupApps: %v", err)
	}

	// eve: no grant anywhere, NOT elevated (ElevatedKey unset → false).
	eveReq := func(method, path string) *http.Request {
		req := httptest.NewRequest(method, path, nil)
		return req.WithContext(context.WithValue(req.Context(), handler.EmailKey, "eve@example.com"))
	}

	// The shell at root is served regardless of a root read grant.
	rec := httptest.NewRecorder()
	dispatch(cfg, idx, ring, appsSrv, nil, rec, eveReq(http.MethodGet, "/archive.html"))
	if rec.Code != http.StatusOK {
		t.Fatalf("GET /archive.html as non-root user: status=%d, want 200 (root tool shell is public); body=%s",
			rec.Code, rec.Body.String())
	}

	// ...but data is still ACL-gated: eve cannot read a project she has no grant on.
	rec2 := httptest.NewRecorder()
	dispatch(cfg, idx, ring, appsSrv, nil, rec2, eveReq(http.MethodGet, "/Project-A/"))
	if rec2.Code != http.StatusForbidden {
		t.Errorf("GET /Project-A/ as non-root user: status=%d, want 403 (data stays ACL-gated)", rec2.Code)
	}
}

// TestDispatchRoutesWritesToFileAPI verifies dispatch sends PUT/DELETE/POST
// to the file API rather than to the read pipeline.
func TestDispatchRoutesWritesToFileAPI(t *testing.T) {
	root := t.TempDir()
	mustWrite(t, filepath.Join(root, ".zddc"),
		"acl:\n  permissions:\n    \"*@example.com\": rwcd\n")
	mustMkdir(t, filepath.Join(root, "Project-A", "working", "Acme"))
	// Register the party (party_source: ssr).
	mustMkdir(t, filepath.Join(root, "Project-A", "ssr"))
	mustWrite(t, filepath.Join(root, "Project-A", "ssr", "Acme.yaml"), "kind: SSR\n")

	idx, err := archive.BuildIndex(root)
	if err != nil {
		t.Fatalf("BuildIndex: %v", err)
	}
	cfg := config.Config{
		Root:          root,
		IndexPath:     ".archive",
		EmailHeader:   "X-Auth-Request-Email",
		MaxWriteBytes: 1 << 20,
	}
	ring := handler.NewLogRing(10)

	withEmail := func(req *http.Request, email string) *http.Request {
		// dispatch reads email from context (ACLMiddleware would normally
		// set it), so set it directly here.
		return req.WithContext(handler.WithEmail(req.Context(), email))
	}

	// PUT a new file via dispatch. Content is party-scoped under
	// archive/<party>/working/ — the project-level working/ aggregator is
	// virtual (see TestFileAPI_MkdirInAggregatorRejected).
	body := []byte("note body")
	req := withEmail(httptest.NewRequest(http.MethodPut, "/Project-A/working/Acme/note.md", strings.NewReader(string(body))), "alice@example.com")
	rec := httptest.NewRecorder()
	dispatch(cfg, idx, ring, nil, nil, rec, req)
	if rec.Code != http.StatusCreated {
		t.Fatalf("PUT: want 201, got %d: %s", rec.Code, rec.Body.String())
	}

	// GET it back.
	req = withEmail(httptest.NewRequest(http.MethodGet, "/Project-A/working/Acme/note.md", nil), "alice@example.com")
	rec = httptest.NewRecorder()
	dispatch(cfg, idx, ring, nil, nil, rec, req)
	if rec.Code != http.StatusOK || rec.Body.String() != string(body) {
		t.Fatalf("GET back: code=%d body=%q", rec.Code, rec.Body.String())
	}

	// MOVE it.
	req = withEmail(httptest.NewRequest(http.MethodPost, "/Project-A/working/Acme/note.md", nil), "alice@example.com")
	req.Header.Set("X-ZDDC-Op", "move")
	req.Header.Set("X-ZDDC-Destination", "/Project-A/working/Acme/renamed.md")
	rec = httptest.NewRecorder()
	dispatch(cfg, idx, ring, nil, nil, rec, req)
	if rec.Code != http.StatusOK {
		t.Fatalf("MOVE: want 200, got %d: %s", rec.Code, rec.Body.String())
	}

	// DELETE it.
	req = withEmail(httptest.NewRequest(http.MethodDelete, "/Project-A/working/Acme/renamed.md", nil), "alice@example.com")
	rec = httptest.NewRecorder()
	dispatch(cfg, idx, ring, nil, nil, rec, req)
	if rec.Code != http.StatusNoContent {
		t.Fatalf("DELETE: want 204, got %d: %s", rec.Code, rec.Body.String())
	}

	// Reserved segment guard still applies to writes.
	req = withEmail(httptest.NewRequest(http.MethodPut, "/.zddc.d/foo.txt", strings.NewReader("x")), "alice@example.com")
	rec = httptest.NewRecorder()
	dispatch(cfg, idx, ring, nil, nil, rec, req)
	if rec.Code != http.StatusNotFound {
		t.Fatalf("PUT /.zddc.d/...: want 404, got %d", rec.Code)
	}
}

// TestDispatchZddcWriteRouting pins the dispatcher's .zddc routing:
// GET/HEAD lands on ServeZddcFile (which serves the YAML view or the
// virtual placeholder), and PUT/DELETE/POST falls through past the
// dot-prefix guard into ServeFileAPI. Before the .zddc-leaf carve-out,
// PUT/DELETE 405'd at ServeZddcFile (or 404'd at the dot-prefix guard)
// and the YAML editor's save flow had no live path.
func TestDispatchZddcWriteRouting(t *testing.T) {
	root := t.TempDir()
	mustWrite(t, filepath.Join(root, ".zddc"),
		"admins:\n  - admin@example.com\nacl:\n  permissions:\n    \"*@example.com\": r\n")
	mustMkdir(t, filepath.Join(root, "Project-A"))

	idx, err := archive.BuildIndex(root)
	if err != nil {
		t.Fatalf("BuildIndex: %v", err)
	}
	cfg := config.Config{
		Root:          root,
		IndexPath:     ".archive",
		EmailHeader:   "X-Auth-Request-Email",
		MaxWriteBytes: 1 << 20,
	}
	ring := handler.NewLogRing(10)

	withAuth := func(req *http.Request, email string, elevated bool) *http.Request {
		ctx := handler.WithEmail(req.Context(), email)
		ctx = handler.WithElevation(ctx, elevated)
		return req.WithContext(ctx)
	}

	// GET routes to ServeZddcFile — serves YAML bytes for an authorised reader.
	req := withAuth(httptest.NewRequest(http.MethodGet, "/.zddc", nil), "admin@example.com", true)
	rec := httptest.NewRecorder()
	dispatch(cfg, idx, ring, nil, nil, rec, req)
	if rec.Code != http.StatusOK {
		t.Fatalf("GET /.zddc: want 200, got %d body=%s", rec.Code, rec.Body.String())
	}
	if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/yaml") {
		t.Errorf("GET /.zddc Content-Type = %q, want application/yaml*", ct)
	}

	// PUT must route to ServeFileAPI (not 405 from ServeZddcFile).
	body := []byte("admins:\n  - admin@example.com\n  - extra@example.com\n")
	req = withAuth(httptest.NewRequest(http.MethodPut, "/.zddc", bytes.NewReader(body)), "admin@example.com", true)
	rec = httptest.NewRecorder()
	dispatch(cfg, idx, ring, nil, nil, rec, req)
	if rec.Code != http.StatusOK && rec.Code != http.StatusCreated {
		t.Fatalf("PUT /.zddc: want 200/201, got %d body=%s", rec.Code, rec.Body.String())
	}

	// Read back via GET to confirm the write landed.
	req = withAuth(httptest.NewRequest(http.MethodGet, "/.zddc", nil), "admin@example.com", true)
	rec = httptest.NewRecorder()
	dispatch(cfg, idx, ring, nil, nil, rec, req)
	if !strings.Contains(rec.Body.String(), "extra@example.com") {
		t.Errorf("GET after PUT: body missing PUT bytes; got %q", rec.Body.String())
	}

	// Project-level .zddc that doesn't exist yet — PUT creates it.
	req = withAuth(httptest.NewRequest(http.MethodPut, "/Project-A/.zddc", bytes.NewReader([]byte("title: A\n"))), "admin@example.com", true)
	rec = httptest.NewRecorder()
	dispatch(cfg, idx, ring, nil, nil, rec, req)
	if rec.Code != http.StatusCreated {
		t.Fatalf("PUT /Project-A/.zddc: want 201, got %d body=%s", rec.Code, rec.Body.String())
	}

	// DELETE removes a .zddc.
	req = withAuth(httptest.NewRequest(http.MethodDelete, "/Project-A/.zddc", nil), "admin@example.com", true)
	rec = httptest.NewRecorder()
	dispatch(cfg, idx, ring, nil, nil, rec, req)
	if rec.Code != http.StatusNoContent {
		t.Fatalf("DELETE /Project-A/.zddc: want 204, got %d body=%s", rec.Code, rec.Body.String())
	}

	// Non-admin elevated still 403 on PUT — the carve-out only opens
	// the path past the segment guard; the decider gates ActionAdmin.
	req = withAuth(httptest.NewRequest(http.MethodPut, "/.zddc", bytes.NewReader([]byte("title: probe\n"))), "stranger@example.com", true)
	rec = httptest.NewRecorder()
	dispatch(cfg, idx, ring, nil, nil, rec, req)
	if rec.Code != http.StatusForbidden {
		t.Fatalf("PUT /.zddc by stranger: want 403, got %d body=%s", rec.Code, rec.Body.String())
	}

	// The reserved .zddc.d/ sidecar is admin-only, but to an admin it's normal
	// files — an elevated root admin can PUT into it.
	req = withAuth(httptest.NewRequest(http.MethodPut, "/.zddc.d/something", bytes.NewReader([]byte("x"))), "admin@example.com", true)
	rec = httptest.NewRecorder()
	dispatch(cfg, idx, ring, nil, nil, rec, req)
	if rec.Code != http.StatusCreated {
		t.Fatalf("PUT /.zddc.d/something by admin: want 201, got %d body=%s", rec.Code, rec.Body.String())
	}

	// A non-admin (even elevated) is hard-denied on .zddc.d/ — the dispatch
	// gate 404s it (existence-hidden) before the file API is reached, keeping
	// the token store closed regardless of any operator ACL. (The file API's
	// own authorizeAction 403 is the defense-in-depth layer for direct
	// callers; see fileapi_test.go TestFileAPI_DotContentAllowedButZddcDReserved.)
	req = withAuth(httptest.NewRequest(http.MethodPut, "/.zddc.d/evil", bytes.NewReader([]byte("x"))), "stranger@example.com", true)
	rec = httptest.NewRecorder()
	dispatch(cfg, idx, ring, nil, nil, rec, req)
	if rec.Code != http.StatusNotFound {
		t.Fatalf("PUT /.zddc.d/evil by stranger: want 404, got %d body=%s", rec.Code, rec.Body.String())
	}
}

// TestDispatchArchiveRedirect: any /<project>/<sub>/.../.archive/... is 302'd
// to the canonical /<project>/.archive/... so all tracking-number references
// converge on a single stable URL per (project, tracking) regardless of the
// folder a relative "../.archive/..." link was resolved from.
func TestDispatchArchiveRedirect(t *testing.T) {
	root := t.TempDir()
	mustWrite(t, filepath.Join(root, ".zddc"),
		"acl:\n  permissions:\n    \"*\": rwcd\n")
	mustMkdir(t, filepath.Join(root, "ProjectA", "Working"))

	idx, err := archive.BuildIndex(root)
	if err != nil {
		t.Fatalf("BuildIndex: %v", err)
	}
	cfg := config.Config{
		Root:        root,
		IndexPath:   ".archive",
		EmailHeader: "X-Auth-Request-Email",
	}
	ring := handler.NewLogRing(10)

	cases := []struct {
		name       string
		path       string
		query      string
		wantStatus int
		wantLoc    string
	}{
		{
			"deep two segments",
			"/ProjectA/Working/.archive/100.html",
			"",
			http.StatusFound,
			"/ProjectA/.archive/100.html",
		},
		{
			"deep three segments",
			"/ProjectA/sub/sub2/.archive/100.html",
			"",
			http.StatusFound,
			"/ProjectA/.archive/100.html",
		},
		{
			"deep with trailing slash (listing)",
			"/ProjectA/Working/.archive/",
			"",
			http.StatusFound,
			"/ProjectA/.archive/",
		},
		{
			"deep with query string preserved",
			"/ProjectA/Working/.archive/100.html",
			"v=42",
			http.StatusFound,
			"/ProjectA/.archive/100.html?v=42",
		},
		{
			"already canonical (no redirect)",
			"/ProjectA/.archive/100.html",
			"",
			// 100.html doesn't resolve in this index (no transmittal
			// folders), so the handler 404s rather than redirecting.
			http.StatusNotFound,
			"",
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			rawURL := tc.path
			if tc.query != "" {
				rawURL += "?" + tc.query
			}
			req := httptest.NewRequest(http.MethodGet, rawURL, nil)
			rec := httptest.NewRecorder()
			dispatch(cfg, idx, ring, nil, nil, rec, req)
			if rec.Code != tc.wantStatus {
				t.Fatalf("path=%q status=%d, want %d; body=%s", tc.path, rec.Code, tc.wantStatus, rec.Body.String())
			}
			if tc.wantLoc != "" {
				if got := rec.Header().Get("Location"); got != tc.wantLoc {
					t.Errorf("path=%q Location=%q, want %q", tc.path, got, tc.wantLoc)
				}
			}
		})
	}
}

func TestDispatchSlashRouting(t *testing.T) {
	// Convention: <dir>/ → browse (directory view, via DirTool which
	// defaults to browse); <dir> → the directory's default_tool ("the
	// specialized app": browse under working/+reviewing/, transmittal
	// under staging/, archive under archive/, tables under
	// archive/<party>/mdl). Without a default_tool, no-slash falls
	// through to the trailing-slash redirect (302).
	//
	// The only trailing-slash redirect is for a directory that is the
	// rows-dir of a table declared via a REAL on-disk parent .zddc
	// `tables:` map with an existing *.table.yaml spec — it bounces to
	// <parent>/<name>.table.html. The default-MDL virtual fallback at
	// archive/<party>/mdl/ does NOT redirect: the slash form there shows
	// the browse listing of the row YAMLs (the no-slash mdl form serves
	// the table view).
	root := t.TempDir()
	mustWrite(t, filepath.Join(root, ".zddc"),
		"acl:\n  permissions:\n    \"*\": rwcda\n")
	for _, sub := range []string{
		"Project/working",
		"Project/staging",
		"Project/archive",
		"Project/archive/Acme",
		"Project/archive/Acme/incoming",
		"Project/archive/Acme/mdl",
		"Project/scratch",
	} {
		mustMkdir(t, filepath.Join(root, sub))
	}

	idx, err := archive.BuildIndex(root)
	if err != nil {
		t.Fatalf("BuildIndex: %v", err)
	}
	cfg := config.Config{
		Root:        root,
		IndexPath:   ".archive",
		EmailHeader: "X-Auth-Request-Email",
	}
	ring := handler.NewLogRing(10)
	appsSrv, err := setupApps(cfg)
	if err != nil {
		t.Fatalf("setupApps: %v", err)
	}

	cases := []struct {
		name           string
		path           string
		wantStatus     int
		wantNoRedirect bool
		wantLoc        string // checked when wantStatus is a redirect
	}{
		{"working no-slash → browse", "/Project/working", http.StatusOK, true, ""},
		{"working slash → browse", "/Project/working/", http.StatusOK, true, ""},
		{"staging no-slash → transmittal", "/Project/staging", http.StatusOK, true, ""},
		{"staging slash → browse", "/Project/staging/", http.StatusOK, true, ""},
		{"archive no-slash → archive", "/Project/archive", http.StatusOK, true, ""},
		{"archive slash → browse", "/Project/archive/", http.StatusOK, true, ""},
		{"archive/<party> no-slash → archive", "/Project/archive/Acme", http.StatusOK, true, ""},
		{"archive/<party> slash → browse", "/Project/archive/Acme/", http.StatusOK, true, ""},
		{"archive/<party>/mdl no-slash → tables", "/Project/archive/Acme/mdl", http.StatusOK, true, ""},
		// The default-MDL virtual fallback does NOT redirect the slash
		// form — it shows the browse listing of the row YAMLs. (Only a
		// real on-disk parent .zddc tables: + *.table.yaml triggers the
		// bounce to <parent>/<name>.table.html.)
		{"archive/<party>/mdl slash → browse", "/Project/archive/Acme/mdl/", http.StatusOK, true, ""},
		{"archive/<party>/incoming no-slash → archive", "/Project/archive/Acme/incoming", http.StatusOK, true, ""},
		{"archive/<party>/incoming slash → browse", "/Project/archive/Acme/incoming/", http.StatusOK, true, ""},
		{"non-canonical no-slash → 302 to slash", "/Project/scratch", http.StatusFound, false, ""},
		{"non-canonical slash → browse", "/Project/scratch/", http.StatusOK, true, ""},
		// Project root no-slash → synthetic landing page (handler.ServeProjectLanding).
		{"project root no-slash → landing", "/Project", http.StatusOK, true, ""},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			req := httptest.NewRequest(http.MethodGet, tc.path, nil)
			rec := httptest.NewRecorder()
			dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
			if rec.Code != tc.wantStatus {
				t.Fatalf("path=%q status=%d, want %d; body=%s",
					tc.path, rec.Code, tc.wantStatus, rec.Body.String())
			}
			if tc.wantNoRedirect && rec.Code >= 300 && rec.Code < 400 {
				t.Errorf("path=%q unexpected redirect to %q",
					tc.path, rec.Header().Get("Location"))
			}
			if tc.wantLoc != "" {
				if got := rec.Header().Get("Location"); got != tc.wantLoc {
					t.Errorf("path=%q Location=%q, want %q", tc.path, got, tc.wantLoc)
				}
			}
		})
	}
}

// Canonical project-root folders (archive/working/staging/reviewing)
// that don't yet exist on disk must render as 200 + empty listing
// rather than 404, so the stage-strip nav lands on a usable view on a
// fresh project. Mirror of fs.ListDirectory's read-side fallback at
// the dispatcher level — without this, os.Stat 404s before
// ServeDirectory ever runs.
func TestDispatchEmptyCanonicalProjectFolders(t *testing.T) {
	root := t.TempDir()
	mustWrite(t, filepath.Join(root, ".zddc"),
		"acl:\n  permissions:\n    \"*\": rwcda\n")
	// Project exists, but NO subdirs (no archive/, working/, staging/,
	// reviewing/). Fresh-project state.
	mustMkdir(t, filepath.Join(root, "Project"))

	idx, err := archive.BuildIndex(root)
	if err != nil {
		t.Fatalf("BuildIndex: %v", err)
	}
	cfg := config.Config{
		Root:        root,
		IndexPath:   ".archive",
		EmailHeader: "X-Auth-Request-Email",
	}
	ring := handler.NewLogRing(10)
	appsSrv, err := setupApps(cfg)
	if err != nil {
		t.Fatalf("setupApps: %v", err)
	}

	// JSON request → 200 + JSON array (not null, not 404).
	// Virtual user-home injection at <project>/working/ depends on a
	// context-bound email; this test calls dispatch directly without
	// the ACL middleware that sets that context, so email is "" here
	// and working/ also returns []. virtualUserHomeEntry's email
	// branch is covered separately by tests in internal/fs/tree_test.go.
	for _, stage := range []string{"archive", "working", "staging", "reviewing"} {
		t.Run("json/"+stage, func(t *testing.T) {
			req := httptest.NewRequest(http.MethodGet, "/Project/"+stage+"/", nil)
			req.Header.Set("Accept", "application/json")
			rec := httptest.NewRecorder()
			dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
			if rec.Code != http.StatusOK {
				t.Fatalf("status=%d, want 200; body=%s", rec.Code, rec.Body.String())
			}
			body := strings.TrimSpace(rec.Body.String())
			if body != "[]" {
				t.Errorf("%s/ body=%q, want %q", stage, body, "[]")
			}
		})
	}

	// No-trailing-slash form on a canonical peer → its default app.
	// In the flat-peer layout these are physical peers: working/reviewing
	// default to browse, staging to transmittal, archive to the archive
	// tool.
	noSlashDefaultApp := []struct {
		stage  string
		expect string // substring that should appear in the response body
	}{
		{"working", "ZDDC Browse"},
		{"staging", "ZDDC Transmittal"},
		{"archive", "ZDDC Archive"},
		{"reviewing", "ZDDC Browse"},
	}
	for _, tc := range noSlashDefaultApp {
		t.Run("no-slash/"+tc.stage+" → default app", func(t *testing.T) {
			req := httptest.NewRequest(http.MethodGet, "/Project/"+tc.stage, nil)
			rec := httptest.NewRecorder()
			dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
			if rec.Code != http.StatusOK {
				t.Fatalf("status=%d, want 200; body=%s", rec.Code, rec.Body.String())
			}
			if !strings.Contains(rec.Body.String(), tc.expect) {
				t.Errorf("%s/ body missing %q", tc.stage, tc.expect)
			}
		})
	}

	// Trailing-slash form on a canonical folder serves the browse
	// app for HTML requests — same convention as the existing IsDir
	// branch. The slash-vs-no-slash distinction is the user's signal:
	// "show me the directory contents" vs "open the default tool".
	for _, stage := range []string{"working", "staging", "archive", "reviewing"} {
		t.Run("slash/"+stage+" → browse", func(t *testing.T) {
			req := httptest.NewRequest(http.MethodGet, "/Project/"+stage+"/", nil)
			req.Header.Set("Accept", "text/html")
			rec := httptest.NewRecorder()
			dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
			if rec.Code != http.StatusOK {
				t.Fatalf("status=%d, want 200; body=%s", rec.Code, rec.Body.String())
			}
			if !strings.Contains(rec.Body.String(), "ZDDC Browse") {
				t.Errorf("%s/ HTML response missing 'ZDDC Browse'", stage)
			}
		})
	}

	// Non-canonical missing folder still 404s (the fallback is
	// scoped to the four canonical names, not a blanket "missing →
	// empty" rule).
	t.Run("non-canonical missing → 404", func(t *testing.T) {
		req := httptest.NewRequest(http.MethodGet, "/Project/random-folder/", nil)
		req.Header.Set("Accept", "application/json")
		rec := httptest.NewRecorder()
		dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
		if rec.Code != http.StatusNotFound {
			t.Errorf("status=%d, want 404", rec.Code)
		}
	})
}

// TestDispatchArchiveMethodGate: .archive is read-only. PUT/POST/DELETE on
// any .archive URL returns 405 with Allow: GET, HEAD — ahead of the file
// API's write path, so a write to an archive URL never silently mutates
// anything (and so a 302 redirect can never silently downgrade a write
// to a GET on the canonical URL).
func TestDispatchArchiveMethodGate(t *testing.T) {
	root := t.TempDir()
	mustWrite(t, filepath.Join(root, ".zddc"),
		"acl:\n  permissions:\n    \"*\": rwcd\n")
	mustMkdir(t, filepath.Join(root, "ProjectA"))

	idx, err := archive.BuildIndex(root)
	if err != nil {
		t.Fatalf("BuildIndex: %v", err)
	}
	cfg := config.Config{
		Root:          root,
		IndexPath:     ".archive",
		EmailHeader:   "X-Auth-Request-Email",
		MaxWriteBytes: 1 << 20,
	}
	ring := handler.NewLogRing(10)

	for _, method := range []string{http.MethodPut, http.MethodPost, http.MethodDelete} {
		for _, path := range []string{
			"/ProjectA/.archive/100.html",
			"/ProjectA/Working/.archive/100.html",
		} {
			t.Run(method+" "+path, func(t *testing.T) {
				req := httptest.NewRequest(method, path, strings.NewReader("body"))
				req = req.WithContext(handler.WithEmail(req.Context(), "alice@example.com"))
				rec := httptest.NewRecorder()
				dispatch(cfg, idx, ring, nil, nil, rec, req)
				if rec.Code != http.StatusMethodNotAllowed {
					t.Errorf("%s %s: status %d, want 405; body=%s", method, path, rec.Code, rec.Body.String())
				}
				if allow := rec.Header().Get("Allow"); !strings.Contains(allow, "GET") {
					t.Errorf("%s %s: Allow=%q, want to contain GET", method, path, allow)
				}
			})
		}
	}
}

// TestDispatchCaseInsensitiveURL: mixed-case URLs resolve to the on-disk
// canonical case, with the lowercase variant winning when both case
// variants exist as siblings on disk.
func TestDispatchCaseInsensitiveURL(t *testing.T) {
	root := t.TempDir()
	mustWrite(t, filepath.Join(root, ".zddc"),
		"acl:\n  permissions:\n    \"*\": rwcd\n")
	mustMkdir(t, filepath.Join(root, "project-a", "working"))
	mustWrite(t, filepath.Join(root, "project-a", "working", "note.md"), "lowercase note")

	// Sibling Mixed-Case dir present too. Lowercase must win on the
	// case-insensitive resolution; the Mixed-Case dir's contents must
	// not bleed through under any URL casing.
	mustMkdir(t, filepath.Join(root, "Project-A", "Working"))
	mustWrite(t, filepath.Join(root, "Project-A", "Working", "note.md"), "MIXEDCASE note")

	idx, err := archive.BuildIndex(root)
	if err != nil {
		t.Fatalf("BuildIndex: %v", err)
	}
	cfg := config.Config{
		Root:        root,
		IndexPath:   ".archive",
		EmailHeader: "X-Auth-Request-Email",
	}
	ring := handler.NewLogRing(10)

	cases := []struct {
		name string
		url  string
	}{
		{"all lowercase", "/project-a/working/note.md"},
		{"mixed case top", "/Project-A/working/note.md"},
		{"mixed case nested", "/PROJECT-A/Working/Note.md"},
		{"all uppercase", "/PROJECT-A/WORKING/NOTE.MD"},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			req := httptest.NewRequest(http.MethodGet, tc.url, nil)
			rec := httptest.NewRecorder()
			dispatch(cfg, idx, ring, nil, nil, rec, req)
			if rec.Code != http.StatusOK {
				t.Fatalf("status=%d body=%q", rec.Code, rec.Body.String())
			}
			if got := rec.Body.String(); got != "lowercase note" {
				t.Errorf("body=%q want %q (lowercase variant must win)",
					got, "lowercase note")
			}
		})
	}
}

func mustMkdir(t *testing.T, path string) {
	t.Helper()
	if err := os.MkdirAll(path, 0o755); err != nil {
		t.Fatalf("mkdir %s: %v", path, err)
	}
}

func mustWriteZip(t *testing.T, path string, entries map[string]string) {
	t.Helper()
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		t.Fatalf("mkdir for zip %s: %v", path, err)
	}
	f, err := os.Create(path)
	if err != nil {
		t.Fatalf("create zip %s: %v", path, err)
	}
	defer f.Close()
	zw := zip.NewWriter(f)
	for name, body := range entries {
		w, err := zw.Create(name)
		if err != nil {
			t.Fatalf("zip.Create(%q): %v", name, err)
		}
		if _, err := w.Write([]byte(body)); err != nil {
			t.Fatalf("zip write %q: %v", name, err)
		}
	}
	if err := zw.Close(); err != nil {
		t.Fatalf("zip close %s: %v", path, err)
	}
}

// TestDispatchZipRouting exercises the .zip-as-virtual-directory
// intercept: <…>.zip/ lists members, <…>.zip/member streams one
// member, bare <…>.zip is still a plain file download, writes into a
// zip are refused, and ACL is inherited from the directory containing
// the zip (a zip has no .zddc of its own — same as .archive).
func TestDispatchZipRouting(t *testing.T) {
	root := t.TempDir()
	// Only alice@x may read under staging/; bob@x is denied there.
	mustWrite(t, filepath.Join(root, ".zddc"),
		"acl:\n  permissions:\n    \"*\": r\n")
	mustMkdir(t, filepath.Join(root, "Proj", "staging"))
	mustWrite(t, filepath.Join(root, "Proj", "staging", ".zddc"),
		"acl:\n  inherit: false\n  permissions:\n    \"alice@x\": rwcda\n")
	zipPath := filepath.Join(root, "Proj", "staging", "T.zip")
	mustWriteZip(t, zipPath, map[string]string{
		"DOC-001.pdf":  "PDFDATA",
		"sub/note.txt": "a note",
	})
	zipBytes, _ := os.ReadFile(zipPath)

	idx, err := archive.BuildIndex(root)
	if err != nil {
		t.Fatalf("BuildIndex: %v", err)
	}
	cfg := config.Config{Root: root, IndexPath: ".archive", EmailHeader: "X-Auth-Request-Email"}
	ring := handler.NewLogRing(10)
	appsSrv, err := setupApps(cfg)
	if err != nil {
		t.Fatalf("setupApps: %v", err)
	}

	do := func(method, path, email string, hdr map[string]string) *httptest.ResponseRecorder {
		req := httptest.NewRequest(method, path, nil)
		for k, v := range hdr {
			req.Header.Set(k, v)
		}
		req = req.WithContext(context.WithValue(req.Context(), handler.EmailKey, email))
		rec := httptest.NewRecorder()
		dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
		return rec
	}

	t.Run("listing JSON", func(t *testing.T) {
		rec := do(http.MethodGet, "/Proj/staging/T.zip/", "alice@x", map[string]string{"Accept": "application/json"})
		if rec.Code != http.StatusOK {
			t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
		}
		var fis []map[string]any
		if err := json.Unmarshal(rec.Body.Bytes(), &fis); err != nil {
			t.Fatalf("decode listing: %v; body=%s", err, rec.Body.String())
		}
		names := map[string]bool{}
		for _, fi := range fis {
			names[fi["name"].(string)] = fi["is_dir"] == true
		}
		if d, ok := names["DOC-001.pdf"]; !ok || d {
			t.Errorf("expected file DOC-001.pdf; got %v", names)
		}
		if d, ok := names["sub/"]; !ok || !d {
			t.Errorf("expected dir sub/; got %v", names)
		}
	})

	t.Run("member extracted", func(t *testing.T) {
		rec := do(http.MethodGet, "/Proj/staging/T.zip/sub/note.txt", "alice@x", nil)
		if rec.Code != http.StatusOK || rec.Body.String() != "a note" {
			t.Fatalf("status=%d body=%q", rec.Code, rec.Body.String())
		}
		if rec.Header().Get("X-ZDDC-Source") != "zip:T.zip" {
			t.Errorf("X-ZDDC-Source=%q", rec.Header().Get("X-ZDDC-Source"))
		}
	})

	t.Run("bare .zip is a plain download", func(t *testing.T) {
		rec := do(http.MethodGet, "/Proj/staging/T.zip", "alice@x", nil)
		if rec.Code != http.StatusOK {
			t.Fatalf("status=%d", rec.Code)
		}
		if rec.Body.Len() != len(zipBytes) {
			t.Errorf("bare .zip body len=%d, want %d (raw zip bytes)", rec.Body.Len(), len(zipBytes))
		}
		// It must NOT have the zip-virtual-dir source header.
		if rec.Header().Get("X-ZDDC-Source") == "zip:T.zip" {
			t.Errorf("bare .zip should be served as a file, not the virtual-dir handler")
		}
	})

	t.Run("write into zip refused", func(t *testing.T) {
		rec := do(http.MethodPut, "/Proj/staging/T.zip/new.txt", "alice@x", nil)
		if rec.Code != http.StatusMethodNotAllowed {
			t.Errorf("PUT into zip status=%d, want 405", rec.Code)
		}
	})

	t.Run("ACL inherited from containing dir — denied", func(t *testing.T) {
		rec := do(http.MethodGet, "/Proj/staging/T.zip/sub/note.txt", "bob@x", nil)
		if rec.Code != http.StatusForbidden {
			t.Errorf("bob denied under staging/ → zip member status=%d, want 403", rec.Code)
		}
		rec2 := do(http.MethodGet, "/Proj/staging/T.zip/", "bob@x", map[string]string{"Accept": "application/json"})
		if rec2.Code != http.StatusForbidden {
			t.Errorf("bob denied → zip listing status=%d, want 403", rec2.Code)
		}
	})

	t.Run("missing member 404", func(t *testing.T) {
		rec := do(http.MethodGet, "/Proj/staging/T.zip/no/such.txt", "alice@x", nil)
		if rec.Code != http.StatusNotFound {
			t.Errorf("status=%d, want 404", rec.Code)
		}
	})

	t.Run("directory member 302 to slash", func(t *testing.T) {
		rec := do(http.MethodGet, "/Proj/staging/T.zip/sub", "alice@x", nil)
		if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/Proj/staging/T.zip/sub/" {
			t.Errorf("status=%d loc=%q", rec.Code, rec.Header().Get("Location"))
		}
	})
}

func mustWrite(t *testing.T, path, body string) {
	t.Helper()
	if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
		t.Fatalf("write %s: %v", path, err)
	}
}

// TestGzhttpWrapper_CompressesLargeResponses asserts the gzhttp wrapper
// behavior we wire in main(): responses above MinSize get gzip-encoded
// when the client advertises Accept-Encoding: gzip; small responses
// pass through uncompressed; HEAD requests still set Vary correctly.
//
// We construct the wrapper the same way main() does (1024 byte minsize)
// and exercise it against a tiny test handler — full end-to-end is
// covered by the live curl smoke test in CI / dev verification.
func TestGzhttpWrapper_CompressesLargeResponses(t *testing.T) {
	// Re-create the wrapper config from main.go so this test stays in
	// sync with the real wiring.
	wrapper, err := newGzipWrapper()
	if err != nil {
		t.Fatalf("newGzipWrapper: %v", err)
	}

	largeBody := strings.Repeat("ZDDC ", 4000) // ~20 KB, well over MinSize
	smallBody := "ok"

	handler := wrapper(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/html; charset=utf-8")
		if r.URL.Path == "/large" {
			_, _ = w.Write([]byte(largeBody))
		} else {
			_, _ = w.Write([]byte(smallBody))
		}
	}))

	srv := httptest.NewServer(handler)
	defer srv.Close()

	t.Run("large body with Accept-Encoding gzip → compressed", func(t *testing.T) {
		req, _ := http.NewRequest(http.MethodGet, srv.URL+"/large", nil)
		req.Header.Set("Accept-Encoding", "gzip")
		// Disable transparent decompression so we can read the raw bytes
		// and confirm the wire format.
		client := &http.Client{Transport: &http.Transport{DisableCompression: true}}
		resp, err := client.Do(req)
		if err != nil {
			t.Fatal(err)
		}
		defer resp.Body.Close()
		if got := resp.Header.Get("Content-Encoding"); got != "gzip" {
			t.Errorf("Content-Encoding = %q, want gzip", got)
		}
		if got := resp.Header.Get("Vary"); !strings.Contains(strings.ToLower(got), "accept-encoding") {
			t.Errorf("Vary = %q, want to contain Accept-Encoding", got)
		}
	})

	t.Run("small body → not compressed", func(t *testing.T) {
		req, _ := http.NewRequest(http.MethodGet, srv.URL+"/small", nil)
		req.Header.Set("Accept-Encoding", "gzip")
		client := &http.Client{Transport: &http.Transport{DisableCompression: true}}
		resp, err := client.Do(req)
		if err != nil {
			t.Fatal(err)
		}
		defer resp.Body.Close()
		if got := resp.Header.Get("Content-Encoding"); got == "gzip" {
			t.Errorf("Content-Encoding = gzip; small response should not be compressed")
		}
	})

	t.Run("no Accept-Encoding → not compressed", func(t *testing.T) {
		req, _ := http.NewRequest(http.MethodGet, srv.URL+"/large", nil)
		client := &http.Client{Transport: &http.Transport{DisableCompression: true}}
		resp, err := client.Do(req)
		if err != nil {
			t.Fatal(err)
		}
		defer resp.Body.Close()
		if got := resp.Header.Get("Content-Encoding"); got != "" {
			t.Errorf("Content-Encoding = %q; client without Accept-Encoding should get plain", got)
		}
	})
}

// (TestDispatchHistoryReadCarveOut was removed: history snapshots now live
// under the reserved .zddc.d/history/ namespace — blocked raw by the
// dot-prefix guard, like any bookkeeping, and surfaced only through the
// history endpoints. Raw-block coverage is in TestDispatchHidesDotPrefixedSegments;
// the viewer is covered in mdhistory_test.go.)

// writeRootBundle writes <root>/.zddc.zip containing the given members.
// Used by dispatch tests exercising the local tool-HTML bundle override.
func writeRootBundle(t *testing.T, root string, members map[string]string) {
	t.Helper()
	var buf bytes.Buffer
	zw := zip.NewWriter(&buf)
	for name, body := range members {
		w, err := zw.Create(name)
		if err != nil {
			t.Fatalf("zip create %s: %v", name, err)
		}
		if _, err := w.Write([]byte(body)); err != nil {
			t.Fatalf("zip write %s: %v", name, err)
		}
	}
	if err := zw.Close(); err != nil {
		t.Fatalf("zip close: %v", err)
	}
	if err := os.WriteFile(filepath.Join(root, ".zddc.zip"), buf.Bytes(), 0o644); err != nil {
		t.Fatalf("write bundle: %v", err)
	}
}

// TestDispatchFileToFormView locks in the views.file → form shape: a browser
// NAVIGATION (Accept: text/html) to a no-slash data file, in a dir whose
// cascade declares views.file = {tool: form}, serves the form editor bound to
// that file — while a programmatic fetch (Accept: */*, the tables client) and
// an explicit ?raw still get raw bytes, so row fetching never breaks. A dir
// without views.file keeps serving raw bytes on navigation too.
func TestDispatchFileToFormView(t *testing.T) {
	root := t.TempDir()
	mustWrite(t, filepath.Join(root, ".zddc"),
		"acl:\n  permissions:\n    \"*\": rwcd\n")
	dir := filepath.Join(root, "Proj", "records")
	mustMkdir(t, dir)
	// views.file declared on the records dir → form editor for its files.
	mustWrite(t, filepath.Join(dir, ".zddc"),
		"views:\n  file:\n    tool: form\n")
	// Form schema lives in the supporting-files reserve.
	mustMkdir(t, filepath.Join(dir, ".zddc.d"))
	mustWrite(t, filepath.Join(dir, ".zddc.d", "form.yaml"),
		"schema:\n  type: object\n  properties:\n    title:\n      type: string\n")
	mustWrite(t, filepath.Join(dir, "rec1.yaml"), "title: Hello\n")
	// A sibling file in a dir WITHOUT views.file stays raw.
	mustWrite(t, filepath.Join(root, "Proj", "plain.yaml"), "x: 1\n")

	idx, err := archive.BuildIndex(root)
	if err != nil {
		t.Fatalf("BuildIndex: %v", err)
	}
	cfg := config.Config{Root: root, IndexPath: ".archive", EmailHeader: "X-Auth-Request-Email"}
	ring := handler.NewLogRing(10)
	appsSrv, err := setupApps(cfg)
	if err != nil {
		t.Fatalf("setupApps: %v", err)
	}

	do := func(path string, hdr map[string]string) *httptest.ResponseRecorder {
		req := httptest.NewRequest(http.MethodGet, path, nil)
		for k, v := range hdr {
			req.Header.Set(k, v)
		}
		rec := httptest.NewRecorder()
		dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
		return rec
	}

	// Navigation → form editor HTML.
	rec := do("/Proj/records/rec1.yaml", map[string]string{"Accept": "text/html"})
	if rec.Code != http.StatusOK {
		t.Fatalf("navigation: status=%d body=%s", rec.Code, rec.Body.String())
	}
	if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
		t.Errorf("navigation Content-Type=%q, want text/html (form)", ct)
	}
	if strings.Contains(rec.Body.String(), "title: Hello") {
		t.Errorf("navigation served raw YAML, want the form editor")
	}

	// Programmatic fetch (Accept: */*) → raw YAML bytes.
	rec2 := do("/Proj/records/rec1.yaml", map[string]string{"Accept": "*/*"})
	if rec2.Code != http.StatusOK || !strings.Contains(rec2.Body.String(), "title: Hello") {
		t.Errorf("fetch: status=%d body=%q, want raw YAML", rec2.Code, rec2.Body.String())
	}

	// ?raw escape hatch → raw bytes even for a browser.
	rec3 := do("/Proj/records/rec1.yaml?raw=1", map[string]string{"Accept": "text/html"})
	if !strings.Contains(rec3.Body.String(), "title: Hello") {
		t.Errorf("?raw body=%q, want raw YAML", rec3.Body.String())
	}

	// No views.file declared → navigation still serves raw bytes.
	rec4 := do("/Proj/plain.yaml", map[string]string{"Accept": "text/html"})
	if !strings.Contains(rec4.Body.String(), "x: 1") {
		t.Errorf("no-views file body=%q, want raw YAML", rec4.Body.String())
	}
}

// TestDispatchBundleAdminView locks in admin-mode visibility of the site-root
// .zddc.zip config bundle: an active (elevated) admin may browse it as a zip
// directory (list members, extract a member) and download it, while everyone
// else — including the same admin un-elevated, and non-admins — gets 404 for
// every bundle URL shape (closing the previous by-name member read).
func TestDispatchBundleAdminView(t *testing.T) {
	root := t.TempDir()
	// alice is a root admin; bob is a plain reader.
	mustWrite(t, filepath.Join(root, ".zddc"),
		"acl:\n  permissions:\n    \"alice@x\": rwcda\n    \"bob@x\": r\nadmins:\n  - alice@x\n")
	writeRootBundle(t, root, map[string]string{
		"archive.html": "<!doctype html>BUNDLE archive",
		"sub/note.txt": "a member note",
	})

	idx, err := archive.BuildIndex(root)
	if err != nil {
		t.Fatalf("BuildIndex: %v", err)
	}
	cfg := config.Config{Root: root, IndexPath: ".archive", EmailHeader: "X-Auth-Request-Email"}
	ring := handler.NewLogRing(10)
	appsSrv, err := setupApps(cfg)
	if err != nil {
		t.Fatalf("setupApps: %v", err)
	}

	do := func(path, email string, elevated bool) *httptest.ResponseRecorder {
		req := httptest.NewRequest(http.MethodGet, path, nil)
		ctx := context.WithValue(req.Context(), handler.EmailKey, email)
		ctx = context.WithValue(ctx, handler.ElevatedKey, elevated)
		req = req.WithContext(ctx)
		rec := httptest.NewRecorder()
		dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
		return rec
	}

	// Elevated admin: member listing, member extract, and bare download all work.
	if rec := do("/.zddc.zip/", "alice@x", true); rec.Code != http.StatusOK {
		t.Errorf("admin GET /.zddc.zip/ : status=%d body=%s, want 200 listing", rec.Code, rec.Body.String())
	}
	if rec := do("/.zddc.zip/archive.html", "alice@x", true); rec.Code != http.StatusOK ||
		!strings.Contains(rec.Body.String(), "BUNDLE archive") {
		t.Errorf("admin GET member: status=%d body=%s, want 200 member bytes", rec.Code, rec.Body.String())
	}
	if rec := do("/.zddc.zip", "alice@x", true); rec.Code != http.StatusOK {
		t.Errorf("admin GET bare /.zddc.zip : status=%d, want 200 download", rec.Code)
	}

	// Same admin un-elevated → 404 (sudo model: powers are per-request).
	if rec := do("/.zddc.zip/", "alice@x", false); rec.Code != http.StatusNotFound {
		t.Errorf("un-elevated admin GET /.zddc.zip/ : status=%d, want 404", rec.Code)
	}
	// Non-admin reader → 404 for listing AND by-name member (no leak).
	if rec := do("/.zddc.zip/", "bob@x", true); rec.Code != http.StatusNotFound {
		t.Errorf("non-admin GET /.zddc.zip/ : status=%d, want 404", rec.Code)
	}
	if rec := do("/.zddc.zip/archive.html", "bob@x", true); rec.Code != http.StatusNotFound {
		t.Errorf("non-admin GET member: status=%d, want 404 (no by-name leak)", rec.Code)
	}
}
