package main

import (
	"context"
	"crypto/ed25519"
	"crypto/rand"
	"net/http"
	"net/http/httptest"
	"net/url"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"codeberg.org/VARASYS/ZDDC/zddc/internal/apps"
	"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"
)

// TestDispatchHidesDotPrefixedSegments asserts the dispatch() guard that
// rejects requests whose URL contains a dot-prefixed segment (other than
// the recognized virtual prefixes .archive and /.profile handled separately).
//
// The guard exists so the in-image dev-shell can keep persistent state
// (settings, source clones, Go module cache) under /srv/.devshell on the
// same Azure Files PVC as served data without ever exposing those files
// via direct HTTP fetch.
func TestDispatchHidesDotPrefixedSegments(t *testing.T) {
	root := t.TempDir()

	// Realistic shape: a project dir, a hidden top-level dir, 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, ".devshell"))
	mustMkdir(t, filepath.Join(root, ".devshell", "coder"))
	mustWrite(t, filepath.Join(root, ".devshell", "coder", "settings.json"), "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
	}{
		// Hidden top-level dir — every shape blocked.
		{"hidden top dir", "/.devshell/", http.StatusNotFound},
		{"hidden top dir nested", "/.devshell/coder/settings.json", http.StatusNotFound},

		// Hidden segment under a real project dir — also blocked.
		{"hidden segment mid path", "/Project-A/.internal/notes.md", http.StatusNotFound},

		// Sanity: recognized virtual prefixes are NOT blocked. .archive falls
		// through to its own handler (which 404s on missing tracking number);
		// .profile is handled by ServeProfile and the page itself is public.
		// /.admin no longer exists — it is hard-cut and falls through to the
		// dot-prefix guard, which 404s.
		{".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 hard-cut → dot-prefix guard", "/.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 the full apps fetch+cache flow through
// dispatch() with a fake upstream. Confirms that:
//   - GET / serves the landing app from the apps subsystem
//   - GET /archive.html serves the archive app via fetch+cache
//   - second GET /archive.html serves from cache (X-ZDDC-Source: cache:)
//   - direct URL access to /_zddc/... is rejected
func TestDispatchAppsResolution(t *testing.T) {
	root := t.TempDir()

	body := []byte("<!doctype html>archive content")
	pub, priv, err := ed25519.GenerateKey(rand.Reader)
	if err != nil {
		t.Fatalf("GenerateKey: %v", err)
	}
	sig := ed25519.Sign(priv, body)
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Same body for every artifact; same signature for every .sig
		// (since the body is identical across the five tools in this
		// fixture). Real deployments publish a distinct .sig per
		// artifact; the test only cares that the verify gate passes.
		if strings.HasSuffix(r.URL.Path, ".sig") {
			_, _ = w.Write(sig)
			return
		}
		w.Header().Set("ETag", `"v1"`)
		_, _ = w.Write(body)
	}))
	defer upstream.Close()
	upstreamURL, _ := url.Parse(upstream.URL)
	upstreamHost := upstreamURL.Host
	if i := strings.Index(upstreamHost, ":"); i >= 0 {
		upstreamHost = upstreamHost[:i]
	}

	_ = upstreamHost // referenced below

	// Seed root .zddc with subdir-cascade Apps entries pointing at the
	// fake upstream. Allow all email patterns (anonymous) so the test
	// doesn't have to set up email headers.
	zf := zddc.ZddcFile{
		ACL: zddc.ACLRules{Allow: []string{"*"}},
		Apps: map[string]string{
			"archive":     upstream.URL + "/archive_stable.html",
			"transmittal": upstream.URL + "/transmittal_stable.html",
			"classifier":  upstream.URL + "/classifier_stable.html",
			"mdedit":      upstream.URL + "/mdedit_stable.html",
			"landing":     upstream.URL + "/landing_stable.html",
		},
	}
	if err := zddc.WriteFile(root, zf); err != nil {
		t.Fatalf("WriteFile: %v", err)
	}
	// Create folder convention dirs so classifier/mdedit/transmittal
	// availability rules pass for the test paths used below.
	mustMkdir(t, filepath.Join(root, "Project-A", "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)

	appsSrv, err := setupApps(cfg)
	if err != nil {
		t.Fatalf("setupApps: %v", err)
	}
	// Override the production embedded public key with the test fixture's
	// pubkey so signature verification of upstream.Sign'd bodies succeeds.
	appsSrv.Fetcher.VerifyKey = pub

	// GET /archive.html → fetched from upstream (archive is available everywhere)
	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/archive.html", nil)
	dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
	if rec.Code != http.StatusOK {
		t.Fatalf("first /archive.html: status=%d body=%s", rec.Code, rec.Body.String())
	}
	if rec.Body.String() != string(body) {
		t.Errorf("first /archive.html: body mismatch")
	}

	// GET /archive.html again → cache hit (no new upstream fetch)
	rec2 := httptest.NewRecorder()
	dispatch(cfg, idx, ring, appsSrv, nil, rec2, httptest.NewRequest(http.MethodGet, "/archive.html", nil))
	if rec2.Code != http.StatusOK {
		t.Errorf("second /archive.html: status=%d", rec2.Code)
	}

	// GET / → landing
	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)
	}

	// Direct URL access to /_app/ → 404
	rec4 := httptest.NewRecorder()
	dispatch(cfg, idx, ring, appsSrv, nil, rec4, httptest.NewRequest(http.MethodGet, "/_app/foo.html", nil))
	if rec4.Code != http.StatusNotFound {
		t.Errorf("/_app/ direct: status=%d, want 404", rec4.Code)
	}

	// Folder availability rules: classifier should NOT be served at root
	// (root has no Incoming/Working/Staging ancestor), but SHOULD work in
	// /Project-A/Working/.
	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 Incoming/Working/Staging)", rec5.Code)
	}
	rec6 := httptest.NewRecorder()
	dispatch(cfg, idx, ring, appsSrv, nil, rec6, httptest.NewRequest(http.MethodGet, "/Project-A/Working/classifier.html", nil))
	if rec6.Code != http.StatusOK {
		t.Errorf("/Project-A/Working/classifier.html: status=%d, want 200", rec6.Code)
	}
}

// silence "imported and not used" if apps not referenced elsewhere — keep
// import even when we trim test cases later.
var _ = apps.DefaultUpstream

// 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  allow:\n    - \"*@example.com\"\n  deny: []\n")
	mustMkdir(t, filepath.Join(root, "Project-A", "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",
		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.
	body := []byte("note body")
	req := withEmail(httptest.NewRequest(http.MethodPut, "/Project-A/Working/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/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/note.md", nil), "alice@example.com")
	req.Header.Set("X-ZDDC-Op", "move")
	req.Header.Set("X-ZDDC-Destination", "/Project-A/Working/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/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, "/.devshell/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 /.devshell/...: want 404, got %d", rec.Code)
	}
}

// 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  allow:\n    - \"*\"\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); <dir> → the canonical
	// default tool for the directory (mdedit under working/, transmittal
	// under staging/, archive under archive/, tables under
	// archive/<party>/mdl/). Without a default app, no-slash falls
	// through to the trailing-slash redirect (302).
	//
	// Exception: a directory that is the rows-dir of a registered table
	// (declared via parent .zddc tables:) — including the default-MDL
	// fallback at archive/<party>/mdl/ — redirects the trailing-slash
	// form too, bouncing to <parent>/<name>.table.html. Bare folder
	// listings here would just be a row-of-yaml-files preview that the
	// table view subsumes.
	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 → mdedit", "/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, ""},
		// Trailing-slash form on a tables rows-dir bounces to the canonical
		// .table.html URL so users land on the table view rather than a
		// browse listing of the row-yaml files.
		{"archive/<party>/mdl slash → 302 in-dir table.html", "/Project/archive/Acme/mdl/", http.StatusFound, false, "/Project/archive/Acme/mdl/table.html"},
		{"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 folder → default app
	// (mdedit for working/, transmittal for staging/, archive for
	// archive/). Mirror of the existing "no-slash → default app"
	// behavior at the IsDir branch, extended to cover the case where
	// the folder doesn't exist on disk yet.
	noSlashDefaultApp := []struct {
		stage  string
		expect string // substring that should appear in the response body
	}{
		{"working", "ZDDC Markdown"},
		{"staging", "ZDDC Transmittal"},
		{"archive", "ZDDC Archive"},
		// reviewing/ also routes to mdedit; the polyfill follows the
		// virtual aggregator's listing into canonical archive/+staging
		// paths from there.
		{"reviewing", "ZDDC Markdown"},
	}
	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  allow:\n    - \"*\"\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  allow:\n    - \"*\"\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 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)
		}
	})
}

// TestDispatchZddcEditorAtPath verifies the per-directory <dir>/.zddc.html
// virtual URL is recognised by the dispatcher and routed to the editor
// handler (carved out from the dot-prefix guard). Permission gate is
// hasAnyAdminScope; non-admins get 404.
func TestDispatchZddcEditorAtPath(t *testing.T) {
	root := t.TempDir()
	mustWrite(t, filepath.Join(root, ".zddc"),
		"admins:\n  - root@example.com\n")
	mustMkdir(t, filepath.Join(root, "Project", "working"))
	mustWrite(t, filepath.Join(root, "Project", ".zddc"),
		"title: Demo Project\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)

	cases := []struct {
		name       string
		path       string
		email      string
		wantStatus int
		wantSubstr string
	}{
		{
			"root admin opens project editor",
			"/Project/.zddc.html", "root@example.com",
			http.StatusOK, "Demo Project",
		},
		{
			"root admin opens working/ editor (no .zddc on disk yet)",
			"/Project/working/.zddc.html", "root@example.com",
			http.StatusOK, ".zddc editor",
		},
		{
			"root admin opens deployment-root editor",
			"/.zddc.html", "root@example.com",
			http.StatusOK, ".zddc editor",
		},
		{
			"non-admin gets 404",
			"/Project/.zddc.html", "stranger@example.com",
			http.StatusNotFound, "",
		},
		{
			"anonymous gets 404",
			"/Project/.zddc.html", "",
			http.StatusNotFound, "",
		},
		{
			"missing directory gets 404",
			"/Project/no-such-dir/.zddc.html", "root@example.com",
			http.StatusNotFound, "",
		},
		{
			"deeper than leaf rejected",
			"/Project/.zddc.html/extra", "root@example.com",
			http.StatusNotFound, "",
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			req := httptest.NewRequest(http.MethodGet, tc.path, nil)
			req = req.WithContext(context.WithValue(req.Context(), handler.EmailKey, tc.email))
			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.wantSubstr != "" && !strings.Contains(rec.Body.String(), tc.wantSubstr) {
				t.Errorf("path=%q body missing %q", tc.path, tc.wantSubstr)
			}
		})
	}
}
