package handler

import (
	"context"
	"encoding/json"
	"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/listing"
	"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)

// archiveTestRoot lays down a two-project tree so listings exercise project
// scoping, ACL cascading, and the per-project bucket boundary. ACLs are
// written per-test in the helper that calls this.
//
//	<root>/
//	  ProjectA/
//	    2025-01-01_T1 (IFR) - Title/100_~A (IFR) - Title.pdf
//	    2025-01-01_T1 (IFR) - Title/100_A  (IFC) - Title.pdf
//	    2025-02-01_T2 (RTN) - Comments/100_~A+C1 (RTN) - Comments.pdf
//	  ProjectB/
//	    2025-01-01_T3 (IFR) - Title/200_0 (IFR) - Other.pdf
func archiveTestRoot(t *testing.T) (string, *archive.Index) {
	t.Helper()
	root := t.TempDir()

	// Write each fixture file's relative path as its content so the
	// in-place .archive serve can be verified body-side (the resolver
	// no longer issues a redirect — see archivehandler.go).
	mk := func(rel string) {
		path := filepath.Join(root, filepath.FromSlash(rel))
		if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
			t.Fatalf("mkdir: %v", err)
		}
		if err := os.WriteFile(path, []byte(rel), 0o644); err != nil {
			t.Fatalf("write %s: %v", path, err)
		}
	}

	mk("ProjectA/2025-01-01_T1 (IFR) - Title/100_~A (IFR) - Title.pdf")
	mk("ProjectA/2025-01-01_T1 (IFR) - Title/100_A (IFC) - Title.pdf")
	mk("ProjectA/2025-02-01_T2 (RTN) - Comments/100_~A+C1 (RTN) - Comments.pdf")
	mk("ProjectB/2025-01-01_T3 (IFR) - Title/200_0 (IFR) - Other.pdf")

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

// writeZddc writes a .zddc YAML at <root>/<rel>/.zddc and clears the
// per-directory policy cache so a previous test's permissive .zddc doesn't
// bleed into this one.
func writeZddc(t *testing.T, root, rel, body string) {
	t.Helper()
	dir := filepath.Join(root, filepath.FromSlash(rel))
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir %s: %v", dir, err)
	}
	if err := os.WriteFile(filepath.Join(dir, ".zddc"), []byte(body), 0o644); err != nil {
		t.Fatalf("write .zddc: %v", err)
	}
	zddc.InvalidateCache(dir)
}

func archiveCfg(root string) config.Config {
	return config.Config{Root: root, EmailHeader: "X-Auth-Request-Email", IndexPath: ".archive"}
}

// callArchive drives ServeArchive directly with (project, filename). The
// dispatcher is responsible for canonicalizing deeper /<project>/<sub>/
// .archive/... paths to this shape (see TestDispatchArchiveRedirect in
// the cmd package). Tests that want a specific Accept header set it on
// the recorder request before calling.
func callArchive(t *testing.T, cfg config.Config, idx *archive.Index, email, project, filename string) *httptest.ResponseRecorder {
	t.Helper()
	urlPath := "/"
	if project != "" {
		urlPath = "/" + url.PathEscape(project) + "/" + cfg.IndexPath + "/"
	}
	if filename != "" {
		urlPath += url.PathEscape(filename)
	}
	req := httptest.NewRequest(http.MethodGet, urlPath, nil)
	req = req.WithContext(context.WithValue(req.Context(), EmailKey, email))
	rec := httptest.NewRecorder()
	ServeArchive(cfg, idx, rec, req, project, filename)
	return rec
}

// callArchiveAccept is callArchive plus a custom Accept header — used to
// drive the listing's content-negotiation branches.
func callArchiveAccept(t *testing.T, cfg config.Config, idx *archive.Index, email, project, filename, accept string) *httptest.ResponseRecorder {
	t.Helper()
	urlPath := "/"
	if project != "" {
		urlPath = "/" + url.PathEscape(project) + "/" + cfg.IndexPath + "/"
	}
	if filename != "" {
		urlPath += url.PathEscape(filename)
	}
	req := httptest.NewRequest(http.MethodGet, urlPath, nil)
	if accept != "" {
		req.Header.Set("Accept", accept)
	}
	req = req.WithContext(context.WithValue(req.Context(), EmailKey, email))
	rec := httptest.NewRecorder()
	ServeArchive(cfg, idx, rec, req, project, filename)
	return rec
}

func decodeListing(t *testing.T, body []byte) []listing.FileInfo {
	t.Helper()
	var out []listing.FileInfo
	if err := json.Unmarshal(body, &out); err != nil {
		t.Fatalf("invalid JSON: %v\n%s", err, body)
	}
	return out
}

func names(entries []listing.FileInfo) []string {
	out := make([]string, 0, len(entries))
	for _, e := range entries {
		out = append(out, e.Name)
	}
	return out
}

func contains(xs []string, x string) bool {
	for _, v := range xs {
		if v == x {
			return true
		}
	}
	return false
}

// Empty project (no first segment) is rejected at the handler. The
// dispatcher already 404s /.archive/ before reaching here, but the handler
// keeps a defense-in-depth guard so a future direct caller can't bypass.
func TestServeArchive_EmptyProject404(t *testing.T) {
	root, idx := archiveTestRoot(t)
	writeZddc(t, root, ".", `acl:
  allow: ["*"]
`)
	cfg := archiveCfg(root)

	rec := callArchive(t, cfg, idx, "alice@example.com", "", "")
	if rec.Code != http.StatusNotFound {
		t.Errorf("listing with empty project: status %d, want 404", rec.Code)
	}
	rec = callArchive(t, cfg, idx, "alice@example.com", "", "100.html")
	if rec.Code != http.StatusNotFound {
		t.Errorf("resolve with empty project: status %d, want 404", rec.Code)
	}
}

// Unknown / empty project bucket returns 404 (not 403) — a probe for
// project names gets the same shape whether or not the project exists.
func TestServeArchive_UnknownProject404(t *testing.T) {
	root, idx := archiveTestRoot(t)
	writeZddc(t, root, ".", `acl:
  allow: ["*"]
`)
	cfg := archiveCfg(root)

	rec := callArchive(t, cfg, idx, "alice@example.com", "NoSuchProject", "")
	if rec.Code != http.StatusNotFound {
		t.Errorf("listing for unknown project: status %d, want 404; body=%s", rec.Code, rec.Body.String())
	}
	rec = callArchive(t, cfg, idx, "alice@example.com", "NoSuchProject", "100.html")
	if rec.Code != http.StatusNotFound {
		t.Errorf("resolve in unknown project: status %d, want 404", rec.Code)
	}
}

// Listing scoping: each project's bucket surfaces only its own entries,
// and entry URLs are always project-rooted (/<project>/.archive/...) —
// independent of any deeper request path the caller might have started
// from (the dispatcher canonicalizes those before reaching the handler).
func TestServeArchive_ListingScopedToProject(t *testing.T) {
	root, idx := archiveTestRoot(t)
	writeZddc(t, root, ".", `acl:
  allow: ["*"]
`)
	cfg := archiveCfg(root)
	const email = "alice@example.com"

	cases := []struct {
		name      string
		project   string
		urlPrefix string
		wantNames []string
		denyNames []string
	}{
		{
			"ProjectA",
			"ProjectA",
			"/ProjectA/.archive/",
			[]string{"100.html", "100_A.html", "100_~A.html"},
			[]string{"200.html", "200_0.html"},
		},
		{
			"ProjectB",
			"ProjectB",
			"/ProjectB/.archive/",
			[]string{"200.html", "200_0.html"},
			[]string{"100.html", "100_A.html", "100_~A.html"},
		},
	}

	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			rec := callArchiveAccept(t, cfg, idx, email, c.project, "", "application/json")
			if rec.Code != http.StatusOK {
				t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String())
			}
			got := decodeListing(t, rec.Body.Bytes())
			gotNames := names(got)
			for _, want := range c.wantNames {
				if !contains(gotNames, want) {
					t.Errorf("missing %q in %s; got %v", want, c.project, gotNames)
				}
			}
			for _, deny := range c.denyNames {
				if contains(gotNames, deny) {
					t.Errorf("unexpected cross-project entry %q in %s; got %v", deny, c.project, gotNames)
				}
			}
			for _, e := range got {
				if !strings.HasPrefix(e.URL, c.urlPrefix) {
					t.Errorf("entry %q URL = %q, want %s prefix", e.Name, e.URL, c.urlPrefix)
				}
			}
		})
	}
}

// Listing existence-leak guard: a user who can read no entries in a
// non-empty project bucket gets 403, NOT 200 with an empty list. The
// project must not confirm its existence to a caller with no permissions.
func TestServeArchive_ListingForbiddenWhenUserCanReadNothing(t *testing.T) {
	root, idx := archiveTestRoot(t)
	// Default-deny: only alice listed at any level. mallory is in no
	// allow list anywhere → every per-target check returns deny → the
	// filtered listing is empty → 403.
	writeZddc(t, root, ".", `acl:
  allow: ["alice@example.com"]
`)
	cfg := archiveCfg(root)

	rec := callArchiveAccept(t, cfg, idx, "mallory@example.com", "ProjectA", "", "application/json")
	if rec.Code != http.StatusForbidden {
		t.Errorf("mallory listing: status %d, want 403; body=%s", rec.Code, rec.Body.String())
	}

	rec = callArchiveAccept(t, cfg, idx, "alice@example.com", "ProjectA", "", "application/json")
	if rec.Code != http.StatusOK {
		t.Errorf("alice listing: status %d, want 200; body=%s", rec.Code, rec.Body.String())
	}
}

// Listing entries are filtered per-target by ACL: a caller denied at one
// transmittal subtree but allowed at others sees the unblocked entries
// (200 with the subset), not 403, because they have SOME read access
// in the project.
func TestServeArchive_ListingFiltersEntriesByPerTargetACL(t *testing.T) {
	root, idx := archiveTestRoot(t)
	writeZddc(t, root, ".", `acl:
  allow: ["*"]
`)
	// Deny alice on the transmittal folder where 100_~A+C1 lives, so her
	// listing of /ProjectA/.archive/ drops that entry — but other ProjectA
	// entries stay visible.
	writeZddc(t, root, "ProjectA/2025-02-01_T2 (RTN) - Comments", `acl:
  deny: ["alice@example.com"]
`)
	cfg := archiveCfg(root)

	rec := callArchiveAccept(t, cfg, idx, "alice@example.com", "ProjectA", "", "application/json")
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String())
	}
	gotNames := names(decodeListing(t, rec.Body.Bytes()))

	for _, want := range []string{"100.html", "100_A.html", "100_~A.html"} {
		if !contains(gotNames, want) {
			t.Errorf("alice missing accessible entry %q; got %v", want, gotNames)
		}
	}
	// 100_~A+C1.html maps to a denied target — must not appear.
	if contains(gotNames, "100_~A+C1.html") {
		t.Errorf("alice unexpectedly saw denied entry 100_~A+C1.html; got %v", gotNames)
	}
}

// Resolve: only the per-target ACL gates access. A caller denied on the
// resolved file's directory gets 404 (not 403) — never confirm the
// tracking number's existence.
func TestServeArchive_ResolvePerTargetACLOnly(t *testing.T) {
	root, idx := archiveTestRoot(t)
	// Both alice and mallory are root-allowed, but a deny on the
	// transmittal folder kicks mallory out at the per-target chain
	// ("first explicit match wins, bottom-up").
	writeZddc(t, root, ".", `acl:
  allow: ["alice@example.com", "mallory@example.com"]
`)
	writeZddc(t, root, "ProjectA/2025-01-01_T1 (IFR) - Title", `acl:
  deny: ["mallory@example.com"]
`)
	cfg := archiveCfg(root)

	// alice can resolve.
	rec := callArchive(t, cfg, idx, "alice@example.com", "ProjectA", "100.html")
	if rec.Code != http.StatusOK {
		t.Errorf("alice resolve: status %d, want 200; body=%s", rec.Code, rec.Body.String())
	}

	// mallory is denied at the file's directory → 404 (existence-leak guard).
	rec = callArchive(t, cfg, idx, "mallory@example.com", "ProjectA", "100.html")
	if rec.Code != http.StatusNotFound {
		t.Errorf("mallory resolve: status %d, want 404 (per-target deny); body=%s", rec.Code, rec.Body.String())
	}
}

// Resolve is decoupled from project-root ACL: a user explicitly allowed
// at one transmittal folder but denied at the project root (and not in
// any other allow list) can still fetch tracking numbers that resolve
// to that folder. .archive/ is a virtual surface — the file's own ACL
// chain decides.
func TestServeArchive_ResolveBypassesProjectRootDenyWhenPerTargetAllows(t *testing.T) {
	root, idx := archiveTestRoot(t)
	// Project root denies bob, but the transmittal folder under it
	// allows him. The cascade is "first explicit match wins, bottom-up"
	// — so the per-target chain at the file's directory hits the local
	// allow first.
	writeZddc(t, root, ".", `acl:
  allow: ["alice@example.com"]
`)
	writeZddc(t, root, "ProjectA/2025-01-01_T1 (IFR) - Title", `acl:
  allow: ["bob@example.com"]
`)
	cfg := archiveCfg(root)

	rec := callArchive(t, cfg, idx, "bob@example.com", "ProjectA", "100.html")
	if rec.Code != http.StatusOK {
		t.Errorf("bob resolve: status %d, want 200 (per-target allow rescues him); body=%s", rec.Code, rec.Body.String())
	}
	if loc := rec.Header().Get("Location"); loc != "" {
		t.Errorf("unexpected Location=%q (.archive must serve in place)", loc)
	}
}

// Cross-project: same tracking number issued under two projects. Each
// project's .archive/ resolves to its own copy, never the other's.
func TestServeArchive_CrossProjectSameTrackingNoLeak(t *testing.T) {
	root := t.TempDir()
	mk := func(rel string) {
		path := filepath.Join(root, filepath.FromSlash(rel))
		if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
			t.Fatalf("mkdir: %v", err)
		}
		if err := os.WriteFile(path, []byte(rel), 0o644); err != nil {
			t.Fatalf("write %s: %v", path, err)
		}
	}
	mk("ProjectA/2025-01-01_T1 (IFR) - Title/123_A (IFR) - Title.pdf")
	mk("ProjectB/2025-06-01_T9 (IFR) - Other Title/123_A (IFR) - Other Title.pdf")
	idx, err := archive.BuildIndex(root)
	if err != nil {
		t.Fatalf("BuildIndex: %v", err)
	}

	writeZddc(t, root, ".", `acl:
  allow: ["*"]
`)
	cfg := archiveCfg(root)
	const email = "alice@example.com"

	recA := callArchive(t, cfg, idx, email, "ProjectA", "123.html")
	if recA.Code != http.StatusOK {
		t.Fatalf("ProjectA 123.html status=%d body=%s", recA.Code, recA.Body.String())
	}
	bodyA := recA.Body.String()
	if !strings.HasPrefix(bodyA, "ProjectA/") {
		t.Errorf("ProjectA body=%q, want a ProjectA/ file's content", bodyA)
	}

	recB := callArchive(t, cfg, idx, email, "ProjectB", "123.html")
	if recB.Code != http.StatusOK {
		t.Fatalf("ProjectB 123.html status=%d body=%s", recB.Code, recB.Body.String())
	}
	bodyB := recB.Body.String()
	if !strings.HasPrefix(bodyB, "ProjectB/") {
		t.Errorf("ProjectB body=%q, want a ProjectB/ file's content", bodyB)
	}

	if bodyA == bodyB {
		t.Errorf("cross-project leak: same body served for both projects: %q", bodyA)
	}

	// URL must NOT have been rewritten — neither response carries a
	// Location header. Stable .archive/ links are the whole point.
	if loc := recA.Header().Get("Location"); loc != "" {
		t.Errorf("ProjectA: unexpected Location header %q (.archive must serve in place)", loc)
	}
	if loc := recB.Header().Get("Location"); loc != "" {
		t.Errorf("ProjectB: unexpected Location header %q (.archive must serve in place)", loc)
	}

	// Listing each project shows only its own.
	for _, c := range []struct{ project, mustHave string }{
		{"ProjectA", "ProjectA"},
		{"ProjectB", "ProjectB"},
	} {
		rec := callArchiveAccept(t, cfg, idx, email, c.project, "", "application/json")
		if rec.Code != http.StatusOK {
			t.Fatalf("listing %s: status %d", c.project, rec.Code)
		}
		got := decodeListing(t, rec.Body.Bytes())
		for _, e := range got {
			if !strings.Contains(e.URL, "/"+c.mustHave+"/") {
				t.Errorf("project=%s entry URL %q lacks /%s/ segment", c.project, e.URL, c.mustHave)
			}
		}
	}
}

// Empty email never matches — even an `allow: ["*"]` policy denies it,
// per the existing zddc package contract. .archive must honor it: the
// listing 403s (empty filtered set) and resolves return 404.
func TestServeArchive_EmptyEmailDeniedEvenWithStarAllow(t *testing.T) {
	root, idx := archiveTestRoot(t)
	writeZddc(t, root, ".", `acl:
  allow: ["*@example.com"]
`)
	cfg := archiveCfg(root)

	rec := callArchiveAccept(t, cfg, idx, "", "ProjectA", "", "application/json")
	if rec.Code != http.StatusForbidden {
		t.Errorf("anonymous listing: status %d, want 403", rec.Code)
	}

	rec = callArchive(t, cfg, idx, "", "ProjectA", "100.html")
	if rec.Code != http.StatusNotFound {
		t.Errorf("anonymous resolve: status %d, want 404", rec.Code)
	}
}

// Listing content negotiation: Accept: application/json returns the
// JSON entry array; Accept: text/html returns the embedded `browse` SPA
// bytes (tested by content-type and the embedded ETag header).
// The same URL must serve both, with Vary: Accept set.
func TestServeArchive_ListingContentNegotiation(t *testing.T) {
	root, idx := archiveTestRoot(t)
	writeZddc(t, root, ".", `acl:
  allow: ["*"]
`)
	cfg := archiveCfg(root)
	const email = "alice@example.com"

	// JSON branch.
	recJSON := callArchiveAccept(t, cfg, idx, email, "ProjectA", "", "application/json")
	if recJSON.Code != http.StatusOK {
		t.Fatalf("JSON listing: status %d, want 200; body=%s", recJSON.Code, recJSON.Body.String())
	}
	if ct := recJSON.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
		t.Errorf("JSON listing content-type=%q, want application/json", ct)
	}
	if vary := recJSON.Header().Get("Vary"); !strings.Contains(vary, "Accept") {
		t.Errorf("JSON listing missing Vary: Accept (got %q)", vary)
	}
	_ = decodeListing(t, recJSON.Body.Bytes())

	// HTML branch — falls back to JSON only if the embedded slot is
	// empty, which won't be the case in a normal test run (the embed is
	// populated at compile time). Verify either branch is sane.
	recHTML := callArchiveAccept(t, cfg, idx, email, "ProjectA", "", "text/html")
	if recHTML.Code != http.StatusOK {
		t.Fatalf("HTML listing: status %d, want 200; body=%s", recHTML.Code, recHTML.Body.String())
	}
	ct := recHTML.Header().Get("Content-Type")
	switch {
	case strings.Contains(ct, "text/html"):
		// Normal path: embedded browse bytes were served.
		if etag := recHTML.Header().Get("ETag"); etag == "" || etag != `"`+apps.EmbeddedETag("browse")+`"` {
			t.Errorf("HTML listing ETag=%q, want %q", etag, `"`+apps.EmbeddedETag("browse")+`"`)
		}
		if src := recHTML.Header().Get("X-ZDDC-Source"); src != "embedded:browse" {
			t.Errorf("HTML listing X-ZDDC-Source=%q, want embedded:browse", src)
		}
	case strings.Contains(ct, "application/json"):
		// Bootstrap path: embedded slot empty (e.g. fresh build before
		// browse.html has been populated). JSON fallback is acceptable
		// — confirm it parses as a listing.
		_ = decodeListing(t, recHTML.Body.Bytes())
	default:
		t.Errorf("HTML listing unexpected content-type=%q", ct)
	}

	// Conditional GET: re-fetching with If-None-Match for the JSON ETag
	// short-circuits to 304.
	etagJSON := recJSON.Header().Get("ETag")
	if etagJSON != "" {
		req := httptest.NewRequest(http.MethodGet, "/ProjectA/.archive/", nil)
		req.Header.Set("Accept", "application/json")
		req.Header.Set("If-None-Match", etagJSON)
		req = req.WithContext(context.WithValue(req.Context(), EmailKey, email))
		rec304 := httptest.NewRecorder()
		ServeArchive(cfg, idx, rec304, req, "ProjectA", "")
		if rec304.Code != http.StatusNotModified {
			t.Errorf("conditional JSON GET: status %d, want 304", rec304.Code)
		}
	}
}
