package handler

import (
	"context"
	"encoding/json"
	"log/slog"
	"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/zddc"
)

// profileTestRoot creates a temp dir, writes a .zddc with the given admins
// list, and returns a Config pointing at it.
func profileTestRoot(t *testing.T, admins []string) (config.Config, *LogRing) {
	t.Helper()
	root := t.TempDir()
	if len(admins) > 0 {
		var b strings.Builder
		b.WriteString("admins:\n")
		for _, a := range admins {
			b.WriteString("  - \"")
			b.WriteString(a)
			b.WriteString("\"\n")
		}
		if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte(b.String()), 0o644); err != nil {
			t.Fatalf("write .zddc: %v", err)
		}
		zddc.InvalidateCache(root)
	}
	return config.Config{
		Root:        root,
		Addr:        ":8443",
		EmailHeader: "X-Auth-Request-Email",
	}, NewLogRing(50)
}

// requestWithEmail builds a request whose context already carries email (as
// the real ACLMiddleware would inject) and whose path is path.
func requestWithEmail(method, path, email string) *http.Request {
	r := httptest.NewRequest(method, path, nil)
	if email != "" {
		r.Header.Set("X-Auth-Request-Email", email)
		ctx := context.WithValue(r.Context(), EmailKey, email)
		r = r.WithContext(ctx)
	}
	return r
}

// TestServeProfileGateMatrix checks the authorization decisions for every
// sub-route. The page itself (/.profile/) is reachable to anyone (anonymous
// included); admin-only sub-resources stay 404 for non-eligible callers,
// preserving the existence-leakage policy on a per-resource basis.
func TestServeProfileGateMatrix(t *testing.T) {
	cfg, ring := profileTestRoot(t, []string{"alice@example.com"})

	cases := []struct {
		name       string
		path       string
		email      string
		wantStatus int
	}{
		// /.profile/ itself — public landing for everyone.
		{"anonymous /.profile/", "/.profile/", "", http.StatusOK},
		{"non-admin /.profile/", "/.profile/", "bob@example.com", http.StatusOK},
		{"admin /.profile/", "/.profile/", "alice@example.com", http.StatusOK},

		// /.profile/access — JSON, also public.
		{"anonymous /.profile/access", "/.profile/access", "", http.StatusOK},
		{"admin /.profile/access", "/.profile/access", "alice@example.com", http.StatusOK},

		// Admin-only sub-resources — 404 for non-eligible callers.
		{"anonymous /.profile/whoami", "/.profile/whoami", "", http.StatusNotFound},
		{"anonymous /.profile/config", "/.profile/config", "", http.StatusNotFound},
		{"anonymous /.profile/logs", "/.profile/logs", "", http.StatusNotFound},
		{"non-admin /.profile/whoami", "/.profile/whoami", "bob@example.com", http.StatusNotFound},
		{"non-admin /.profile/config", "/.profile/config", "bob@example.com", http.StatusNotFound},
		{"non-admin /.profile/logs", "/.profile/logs", "bob@example.com", http.StatusNotFound},
		{"admin /.profile/whoami", "/.profile/whoami", "alice@example.com", http.StatusOK},
		{"admin /.profile/config", "/.profile/config", "alice@example.com", http.StatusOK},
		{"admin /.profile/logs", "/.profile/logs", "alice@example.com", http.StatusOK},
		// effective-policy is admin-only too. With no params an admin
		// gets 400 (bad request), confirming the gate cleared. Same
		// 404 for non-admins as the other admin-only routes.
		{"anonymous /.profile/effective-policy", "/.profile/effective-policy", "", http.StatusNotFound},
		{"non-admin /.profile/effective-policy", "/.profile/effective-policy", "bob@example.com", http.StatusNotFound},
		{"admin /.profile/effective-policy without params", "/.profile/effective-policy", "alice@example.com", http.StatusBadRequest},

		// /.profile/reindex is admin-only too. GET (the matrix uses GET) is
		// rejected with 405 once the admin gate clears, confirming the gate
		// behaves the same way as the others. Real triggering is POST and is
		// covered separately by TestServeProfileReindexPOST.
		{"anonymous /.profile/reindex", "/.profile/reindex", "", http.StatusNotFound},
		{"non-admin /.profile/reindex", "/.profile/reindex", "bob@example.com", http.StatusNotFound},
		{"admin GET /.profile/reindex (POST-only)", "/.profile/reindex", "alice@example.com", http.StatusMethodNotAllowed},

		// Unknown sub-route still 404.
		{"admin unknown subroute", "/.profile/nope", "alice@example.com", http.StatusNotFound},
	}

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

func TestServeProfileWhoamiPayload(t *testing.T) {
	cfg, ring := profileTestRoot(t, []string{"alice@example.com"})
	rec := httptest.NewRecorder()
	r := requestWithEmail(http.MethodGet, "/.profile/whoami", "alice@example.com")
	r.Header.Set("X-Other-Header", "hi there")

	ServeProfile(cfg, ring, nil, rec, r)

	if rec.Code != 200 {
		t.Fatalf("status = %d", rec.Code)
	}
	if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
		t.Errorf("Content-Type = %q, want application/json", ct)
	}

	var got map[string]any
	if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
		t.Fatalf("invalid JSON: %v\n%s", err, rec.Body.String())
	}
	if got["configured_email_header"] != "X-Auth-Request-Email" {
		t.Errorf("configured_email_header = %v", got["configured_email_header"])
	}
	if got["observed_email"] != "alice@example.com" {
		t.Errorf("observed_email = %v", got["observed_email"])
	}
	if got["resolved_email"] != "alice@example.com" {
		t.Errorf("resolved_email = %v", got["resolved_email"])
	}
	headers, _ := got["headers"].(map[string]any)
	if _, ok := headers["X-Auth-Request-Email"]; !ok {
		t.Errorf("headers map missing X-Auth-Request-Email: %+v", headers)
	}
	if _, ok := headers["X-Other-Header"]; !ok {
		t.Errorf("headers map missing X-Other-Header: %+v", headers)
	}
}

func TestServeProfileConfigPayload(t *testing.T) {
	cfg, ring := profileTestRoot(t, []string{"alice@example.com"})
	cfg.LogLevel = "info"
	cfg.IndexPath = ".archive"
	cfg.CORSOrigins = []string{"https://zddc.varasys.io"}

	rec := httptest.NewRecorder()
	ServeProfile(cfg, ring, nil, rec, requestWithEmail(http.MethodGet, "/.profile/config", "alice@example.com"))

	if rec.Code != 200 {
		t.Fatalf("status = %d", rec.Code)
	}
	var got map[string]any
	if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
		t.Fatalf("invalid JSON: %v", err)
	}
	for _, want := range []string{"root", "addr", "email_header", "log_level", "cors_origins"} {
		if _, ok := got[want]; !ok {
			t.Errorf("config payload missing key %q: %+v", want, got)
		}
	}
	if got["email_header"] != "X-Auth-Request-Email" {
		t.Errorf("email_header = %v", got["email_header"])
	}
}

func TestServeProfileLogsPayload(t *testing.T) {
	cfg, ring := profileTestRoot(t, []string{"alice@example.com"})
	rh := NewRingHandler(ring, slog.LevelDebug)
	logger := slog.New(rh)
	logger.Info("first")
	logger.Warn("second", "code", 42)

	rec := httptest.NewRecorder()
	ServeProfile(cfg, ring, nil, rec, requestWithEmail(http.MethodGet, "/.profile/logs", "alice@example.com"))

	if rec.Code != 200 {
		t.Fatalf("status = %d", rec.Code)
	}
	var got []map[string]any
	if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
		t.Fatalf("invalid JSON: %v\n%s", err, rec.Body.String())
	}
	if len(got) != 2 {
		t.Fatalf("entries = %d, want 2", len(got))
	}
	if got[0]["message"] != "first" || got[1]["message"] != "second" {
		t.Errorf("ordering wrong: %v / %v", got[0]["message"], got[1]["message"])
	}
}

func TestServeProfileLogsLevelFilter(t *testing.T) {
	cfg, ring := profileTestRoot(t, []string{"alice@example.com"})
	rh := NewRingHandler(ring, slog.LevelDebug)
	logger := slog.New(rh)
	logger.Debug("d")
	logger.Info("i")
	logger.Warn("w")

	rec := httptest.NewRecorder()
	ServeProfile(cfg, ring, nil, rec,
		requestWithEmail(http.MethodGet, "/.profile/logs?level=warn", "alice@example.com"))

	var got []map[string]any
	_ = json.Unmarshal(rec.Body.Bytes(), &got)
	if len(got) != 1 || got[0]["message"] != "w" {
		t.Errorf("level=warn filter failed: %+v", got)
	}
}

// stripTemplates removes every <template ...>...</template> block from the
// HTML body so substring assertions check only ACTIVE markup — i.e. live
// DOM content the user (and their browser) actually sees, as opposed to
// inert content that JS may clone in based on a later access fetch.
//
// Naive but sufficient for the controlled output of profileTemplate (the
// template tags are unnested and well-formed). If the page ever grows
// nested templates, swap this for an html.Tokenizer-based pass.
func stripTemplates(body string) string {
	var b strings.Builder
	for {
		i := strings.Index(body, "<template")
		if i < 0 {
			b.WriteString(body)
			return b.String()
		}
		b.WriteString(body[:i])
		j := strings.Index(body[i:], "</template>")
		if j < 0 {
			// Unterminated <template> — bail; whatever's left is suspect.
			return b.String()
		}
		body = body[i+j+len("</template>"):]
	}
}

// TestServeProfileHTMLLayered pins the page-render contract after the
// lazy-load refactor:
//
//   - The shell is the same byte-stream for every caller modulo the
//     identity card and the super-admin diagnostics scaffold (gated by the
//     cheap IsSuperAdmin check on the root .zddc).
//   - Subtree-admin scaffolds (Editable .zddc files / Create new project)
//     live ONLY inside <template id="tmpl-subtree-admin">. Pure non-admins
//     receive the inert template but no live form, button handler, or
//     event-bound markup.
//   - Subtree-admin discovery moved to /.profile/access; the server-side
//     render no longer needs to walk the .zddc tree.
//
// Subtree-admin and super-admin behaviour beyond identity + diagnostics is
// covered by TestServeProfileAccessJSON since it now flows through the
// JSON endpoint, not the shell.
func TestServeProfileHTMLLayered(t *testing.T) {
	root := t.TempDir()
	zf := "admins:\n  - alice@example.com\n"
	if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte(zf), 0o644); err != nil {
		t.Fatalf("write root .zddc: %v", err)
	}
	if err := os.MkdirAll(filepath.Join(root, "projects"), 0o755); err != nil {
		t.Fatalf("mkdir: %v", err)
	}
	if err := os.WriteFile(filepath.Join(root, "projects", ".zddc"), []byte("admins:\n  - bob@example.com\n"), 0o644); err != nil {
		t.Fatalf("write subtree .zddc: %v", err)
	}
	zddc.InvalidateCache(root)
	zddc.InvalidateCache(filepath.Join(root, "projects"))
	cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
	ring := NewLogRing(50)

	render := func(email string) string {
		rec := httptest.NewRecorder()
		ServeProfile(cfg, ring, nil, rec, requestWithEmail(http.MethodGet, "/.profile/", email))
		if rec.Code != http.StatusOK {
			t.Fatalf("email=%q status=%d body=%s", email, rec.Code, rec.Body.String())
		}
		if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
			t.Errorf("email=%q Content-Type = %q, want text/html", email, ct)
		}
		return rec.Body.String()
	}

	// Anonymous: identity says "Not signed in", no live admin markup, no
	// diagnostics. The <template> still ships inertly so any caller could
	// hydrate it after a successful /access fetch — but a non-admin's
	// /access response carries empty AdminSubtrees and the JS skips
	// instantiation. The active-markup check below proves the live DOM is
	// admin-clean regardless.
	anon := render("")
	if !strings.Contains(anon, "Not signed in") {
		t.Errorf("anonymous body missing 'Not signed in'")
	}
	anonActive := stripTemplates(anon)
	for _, marker := range []string{
		`<form id="cp-form"`,
		`id="diag-config"`,
		`id="diag-logs"`,
		`id="diag-whoami"`,
		"Server config",
	} {
		if strings.Contains(anonActive, marker) {
			t.Errorf("anonymous active markup unexpectedly contains admin marker %q", marker)
		}
	}
	// Inert <template> SHOULD ship — admins (and only admins) hydrate it.
	if !strings.Contains(anon, `<template id="tmpl-subtree-admin">`) {
		t.Errorf("anonymous body missing inert subtree-admin <template>")
	}

	nonAdmin := render("carol@example.com")
	if !strings.Contains(nonAdmin, "carol@example.com") {
		t.Errorf("non-admin body missing email")
	}
	nonAdminActive := stripTemplates(nonAdmin)
	for _, marker := range []string{
		`<form id="cp-form"`,
		`id="diag-config"`,
		"Server config",
	} {
		if strings.Contains(nonAdminActive, marker) {
			t.Errorf("non-admin active markup unexpectedly contains admin marker %q", marker)
		}
	}

	// Subtree-admin (bob) gets the same shell as a non-admin — the
	// scaffold lives in the <template> and JS hydrates it after fetching
	// /.profile/access. The server-side render no longer differentiates
	// these two roles, so its byte-output should match a non-admin's.
	subtree := render("bob@example.com")
	subtreeActive := stripTemplates(subtree)
	for _, marker := range []string{
		`<form id="cp-form"`,
		`id="diag-config"`,
		"Server config",
	} {
		if strings.Contains(subtreeActive, marker) {
			t.Errorf("subtree-admin active markup unexpectedly contains admin marker %q (these are JS-hydrated)", marker)
		}
	}
	if !strings.Contains(subtree, `<template id="tmpl-subtree-admin">`) {
		t.Errorf("subtree-admin body missing the <template> the IIFE will hydrate")
	}

	// Super-admin: diagnostics scaffold is rendered inline (cheap to
	// gate), AND the subtree-admin <template> still ships for the IIFE to
	// hydrate Editable + Create sections.
	super := render("alice@example.com")
	superActive := stripTemplates(super)
	for _, marker := range []string{
		"Server config",
		`id="diag-config"`,
		`id="diag-logs"`,
		`id="diag-whoami"`,
	} {
		if !strings.Contains(superActive, marker) {
			t.Errorf("super-admin active markup missing %q", marker)
		}
	}
	if !strings.Contains(super, `<template id="tmpl-subtree-admin">`) {
		t.Errorf("super-admin body missing subtree-admin <template> (still needs to hydrate Editable + Create)")
	}
}

func TestServeProfileAccessJSON(t *testing.T) {
	cfg, ring := profileTestRoot(t, []string{"alice@example.com"})
	rec := httptest.NewRecorder()
	ServeProfile(cfg, ring, nil, rec, requestWithEmail(http.MethodGet, "/.profile/access", "alice@example.com"))
	if rec.Code != http.StatusOK {
		t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
	}
	var v AccessView
	if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil {
		t.Fatalf("decode: %v", err)
	}
	if v.Email != "alice@example.com" || !v.IsSuperAdmin {
		t.Errorf("expected super-admin alice; got %+v", v)
	}
	if v.EmailHeader != "X-Auth-Request-Email" {
		t.Errorf("EmailHeader = %q", v.EmailHeader)
	}
}

// Subtree-admin discovery used to live in the HTML render; now it flows
// through /.profile/access. Verify the JSON endpoint exposes everything
// the IIFE needs to hydrate the Editable + Create scaffolds: AdminSubtrees
// for the read-only list, EditableParentChoices for the parent-selector
// options, and HasAnyAdminScope so the IIFE knows whether to clone the
// <template>. Pure non-admins get an empty access view and no scaffold.
func TestServeProfileAccessJSON_SubtreeAdminScopes(t *testing.T) {
	root := t.TempDir()
	if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("admins:\n  - alice@example.com\n"), 0o644); err != nil {
		t.Fatalf("write root .zddc: %v", err)
	}
	if err := os.MkdirAll(filepath.Join(root, "projects"), 0o755); err != nil {
		t.Fatalf("mkdir: %v", err)
	}
	if err := os.WriteFile(filepath.Join(root, "projects", ".zddc"), []byte("admins:\n  - bob@example.com\n"), 0o644); err != nil {
		t.Fatalf("write subtree .zddc: %v", err)
	}
	zddc.InvalidateCache(root)
	zddc.InvalidateCache(filepath.Join(root, "projects"))
	zddc.InvalidateScanCache()
	cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
	ring := NewLogRing(50)

	fetchAccess := func(email string) AccessView {
		t.Helper()
		rec := httptest.NewRecorder()
		ServeProfile(cfg, ring, nil, rec, requestWithEmail(http.MethodGet, "/.profile/access", email))
		if rec.Code != http.StatusOK {
			t.Fatalf("email=%q status=%d body=%s", email, rec.Code, rec.Body.String())
		}
		var v AccessView
		if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil {
			t.Fatalf("decode email=%q: %v", email, err)
		}
		return v
	}

	// Pure non-admin: no admin scope, IIFE skips the template hydration.
	carol := fetchAccess("carol@example.com")
	if carol.HasAnyAdminScope {
		t.Errorf("carol HasAnyAdminScope = true, want false")
	}
	if len(carol.AdminSubtrees) != 0 {
		t.Errorf("carol AdminSubtrees = %v, want empty", carol.AdminSubtrees)
	}
	if len(carol.EditableParentChoices) != 0 {
		t.Errorf("carol EditableParentChoices = %v, want empty", carol.EditableParentChoices)
	}

	// Subtree-admin: AdminSubtrees lists projects/ so the create-project
	// parent dropdown can offer it; HasAnyAdminScope triggers template
	// hydration. The projects/.zddc is NOT editable by bob — he cannot
	// edit the file that grants him his own authority — so
	// EditableParentChoices is empty and the Editable-files list will
	// render its "None" placeholder.
	bob := fetchAccess("bob@example.com")
	if bob.IsSuperAdmin {
		t.Errorf("bob IsSuperAdmin = true, want false")
	}
	if !bob.HasAnyAdminScope {
		t.Errorf("bob HasAnyAdminScope = false, want true")
	}
	if len(bob.AdminSubtrees) == 0 {
		t.Fatalf("bob AdminSubtrees empty; want projects/")
	}
	gotProjects := false
	for _, s := range bob.AdminSubtrees {
		if strings.HasSuffix(s.Path, "/projects") {
			gotProjects = true
			if s.CanEdit {
				t.Errorf("bob's projects/ entry CanEdit = true; he should not be able to edit the .zddc granting his own authority")
			}
		}
	}
	if !gotProjects {
		t.Errorf("bob AdminSubtrees missing projects/: %+v", bob.AdminSubtrees)
	}
	if len(bob.EditableParentChoices) != 0 {
		t.Errorf("bob EditableParentChoices = %+v, want empty (his only subtree is one he can't edit)", bob.EditableParentChoices)
	}

	// Super-admin: AdminSubtrees enumerates every .zddc directory.
	alice := fetchAccess("alice@example.com")
	if !alice.IsSuperAdmin || !alice.HasAnyAdminScope {
		t.Errorf("alice IsSuperAdmin=%v HasAnyAdminScope=%v, want both true", alice.IsSuperAdmin, alice.HasAnyAdminScope)
	}
	if len(alice.AdminSubtrees) < 2 {
		t.Errorf("alice should see at least the root + projects/ subtrees; got %+v", alice.AdminSubtrees)
	}
}

// TestServeProfileEffectivePolicy: admin queries the cascade tracer for a
// (path, email) tuple and gets back the resolved chain plus the decision.
// The fixture mirrors the worked-example layout from zddc/README.md (a
// closed project where alice is allow-listed but bob is not, even though
// /Archive/ would let *@mycompany.com in).
func TestServeProfileEffectivePolicy(t *testing.T) {
	cfg, ring := profileTestRoot(t, []string{"super@admin.com"})
	if err := os.MkdirAll(filepath.Join(cfg.Root, "Closed-Project"), 0o755); err != nil {
		t.Fatalf("mkdir: %v", err)
	}
	if err := os.WriteFile(filepath.Join(cfg.Root, "Closed-Project", ".zddc"),
		[]byte("acl:\n  allow:\n    - alice@mycompany.com\n"), 0o644); err != nil {
		t.Fatalf("write child .zddc: %v", err)
	}
	zddc.InvalidateCache(cfg.Root)

	// Trace alice (allowed at the leaf).
	rec := httptest.NewRecorder()
	r := requestWithEmail(http.MethodGet,
		"/.profile/effective-policy?path=/Closed-Project/&email=alice@mycompany.com",
		"super@admin.com")
	ServeProfile(cfg, ring, nil, rec, r)
	if rec.Code != http.StatusOK {
		t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
	}
	var resp struct {
		Path     string `json:"path"`
		Email    string `json:"email"`
		Decision bool   `json:"decision"`
		Chain    struct {
			HasAnyFile bool `json:"has_any_file"`
			Levels     []struct {
				Index           int    `json:"index"`
				Exists          bool   `json:"exists"`
				MatchesEmail    bool   `json:"matches_email"`
				DecisionAtLevel string `json:"decision_at_level"`
			} `json:"levels"`
		} `json:"chain"`
	}
	if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
		t.Fatalf("unmarshal: %v", err)
	}
	if resp.Path != "/Closed-Project/" || resp.Email != "alice@mycompany.com" {
		t.Errorf("path/email round-trip mismatch: %+v", resp)
	}
	if !resp.Decision {
		t.Errorf("decision = false, want true (alice is allow-listed at /Closed-Project/)")
	}
	if !resp.Chain.HasAnyFile {
		t.Error("HasAnyFile = false, want true (.zddc files exist)")
	}
	if len(resp.Chain.Levels) != 2 {
		t.Fatalf("levels count = %d, want 2 (root + Closed-Project/)", len(resp.Chain.Levels))
	}
	// Leaf level should have matched alice with allow.
	leaf := resp.Chain.Levels[1]
	if !leaf.MatchesEmail || leaf.DecisionAtLevel != "allow" {
		t.Errorf("leaf decision = %q (matches=%v), want allow (matches=true)", leaf.DecisionAtLevel, leaf.MatchesEmail)
	}

	// Trace bob (not allow-listed; root has no broad allow either).
	rec2 := httptest.NewRecorder()
	r2 := requestWithEmail(http.MethodGet,
		"/.profile/effective-policy?path=/Closed-Project/&email=bob@mycompany.com",
		"super@admin.com")
	ServeProfile(cfg, ring, nil, rec2, r2)
	if rec2.Code != http.StatusOK {
		t.Fatalf("status=%d body=%s", rec2.Code, rec2.Body.String())
	}
	var resp2 struct {
		Decision bool `json:"decision"`
	}
	if err := json.Unmarshal(rec2.Body.Bytes(), &resp2); err != nil {
		t.Fatalf("unmarshal: %v", err)
	}
	if resp2.Decision {
		t.Error("decision = true for bob, want false (no .zddc match anywhere; HasAnyFile=true → default-deny)")
	}
}

// TestServeProfileEffectivePolicy_InheritFence: a child .zddc with
// inherit:false fences ancestor grants. The tracer surfaces both the
// per-level inherit flag and the chain-level visible_start so an
// operator can see why ancestor grants don't apply at the leaf.
func TestServeProfileEffectivePolicy_InheritFence(t *testing.T) {
	cfg, ring := profileTestRoot(t, []string{"super@admin.com"})

	// Vendor-folder pattern: root grants everyone-at-mycompany rwcd;
	// the vendor folder fences and only allows the vendor. Preserve
	// the admins: list so the test admin can hit the tracer.
	if err := os.WriteFile(filepath.Join(cfg.Root, ".zddc"),
		[]byte("admins:\n  - super@admin.com\nacl:\n  permissions:\n    \"*@mycompany.com\": rwcd\n"),
		0o644); err != nil {
		t.Fatalf("write root .zddc: %v", err)
	}
	if err := os.MkdirAll(filepath.Join(cfg.Root, "Vendor"), 0o755); err != nil {
		t.Fatalf("mkdir: %v", err)
	}
	if err := os.WriteFile(filepath.Join(cfg.Root, "Vendor", ".zddc"),
		[]byte("acl:\n  inherit: false\n  permissions:\n    \"*@vendor.com\": rwcd\n"),
		0o644); err != nil {
		t.Fatalf("write vendor .zddc: %v", err)
	}
	zddc.InvalidateCache(cfg.Root)

	type respShape struct {
		Decision    bool   `json:"decision"`
		CascadeMode string `json:"cascade_mode"`
		Chain       struct {
			VisibleStart int `json:"visible_start"`
			Levels       []struct {
				Index   int   `json:"index"`
				Inherit *bool `json:"inherit,omitempty"`
			} `json:"levels"`
		} `json:"chain"`
	}

	// Trace a my-company user — fenced out at the leaf, despite root grant.
	rec := httptest.NewRecorder()
	ServeProfile(cfg, ring, nil, rec,
		requestWithEmail(http.MethodGet,
			"/.profile/effective-policy?path=/Vendor/&email=alice@mycompany.com",
			"super@admin.com"))
	if rec.Code != http.StatusOK {
		t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
	}
	var got respShape
	if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
		t.Fatalf("unmarshal: %v", err)
	}
	if got.Decision {
		t.Errorf("alice should be fenced out; decision = true")
	}
	if got.Chain.VisibleStart != 1 {
		t.Errorf("VisibleStart = %d, want 1 (fence at /Vendor/)", got.Chain.VisibleStart)
	}
	if len(got.Chain.Levels) != 2 {
		t.Fatalf("expected 2 levels; got %d", len(got.Chain.Levels))
	}
	leaf := got.Chain.Levels[1]
	if leaf.Inherit == nil || *leaf.Inherit != false {
		t.Errorf("leaf.Inherit should be explicit false; got %v", leaf.Inherit)
	}
	root := got.Chain.Levels[0]
	if root.Inherit != nil {
		t.Errorf("root.Inherit should be unset; got %v", root.Inherit)
	}
}

func TestServeProfileNoAdminsConfiguredStillRendersPage(t *testing.T) {
	// .zddc exists but has no admins list — page is still reachable,
	// but the admin/super-admin sections are absent.
	cfg, ring := profileTestRoot(t, nil)
	if err := os.WriteFile(filepath.Join(cfg.Root, ".zddc"), []byte("acl:\n  allow: [\"*\"]\n"), 0o644); err != nil {
		t.Fatalf("write .zddc: %v", err)
	}
	zddc.InvalidateCache(cfg.Root)

	rec := httptest.NewRecorder()
	ServeProfile(cfg, ring, nil, rec, requestWithEmail(http.MethodGet, "/.profile/", "alice@example.com"))
	if rec.Code != http.StatusOK {
		t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
	}
	body := rec.Body.String()
	if strings.Contains(body, "Server config") {
		t.Errorf("alice should not see super-admin section when no admins configured")
	}

	// Per-resource gates remain.
	rec = httptest.NewRecorder()
	ServeProfile(cfg, ring, nil, rec, requestWithEmail(http.MethodGet, "/.profile/whoami", "alice@example.com"))
	if rec.Code != http.StatusNotFound {
		t.Errorf("/.profile/whoami status = %d, want 404 (no admins configured)", rec.Code)
	}
}

// TestServeProfileProjectsCreate covers the happy path and the most
// common rejection modes for POST /.profile/projects.
func TestServeProfileProjectsCreate(t *testing.T) {
	root := t.TempDir()
	zf := "admins:\n  - root@example.com\n"
	if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte(zf), 0o644); err != nil {
		t.Fatalf("write root .zddc: %v", err)
	}
	zddc.InvalidateCache(root)
	cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}
	ring := NewLogRing(50)

	post := func(email, body string) *httptest.ResponseRecorder {
		req := httptest.NewRequest(http.MethodPost, "/.profile/projects", strings.NewReader(body))
		req.Header.Set("Content-Type", "application/json")
		if email != "" {
			req = req.WithContext(context.WithValue(req.Context(), EmailKey, email))
		}
		rec := httptest.NewRecorder()
		ServeProfile(cfg, ring, nil, rec, req)
		return rec
	}

	// Happy path: super-admin creates /alpha with no .zddc body.
	rec := post("root@example.com", `{"parent":"/", "name":"alpha"}`)
	if rec.Code != http.StatusCreated {
		t.Fatalf("happy path status=%d body=%s", rec.Code, rec.Body.String())
	}
	if _, err := os.Stat(filepath.Join(root, "alpha")); err != nil {
		t.Errorf("alpha dir not created on disk: %v", err)
	}
	if _, err := os.Stat(filepath.Join(root, "alpha", ".zddc")); err == nil {
		t.Errorf(".zddc should NOT be auto-written when no fields supplied")
	}

	// Body with a title also writes a .zddc.
	rec = post("root@example.com", `{"parent":"/", "name":"beta", "title":"Beta site"}`)
	if rec.Code != http.StatusCreated {
		t.Fatalf("create-with-title status=%d body=%s", rec.Code, rec.Body.String())
	}
	if _, err := os.Stat(filepath.Join(root, "beta", ".zddc")); err != nil {
		t.Errorf(".zddc should be written when title supplied: %v", err)
	}

	// Conflict on existing dir.
	rec = post("root@example.com", `{"parent":"/", "name":"alpha"}`)
	if rec.Code != http.StatusConflict {
		t.Errorf("duplicate create status=%d, want 409 (body=%s)", rec.Code, rec.Body.String())
	}

	// Bad name.
	rec = post("root@example.com", `{"parent":"/", "name":".hidden"}`)
	if rec.Code != http.StatusBadRequest {
		t.Errorf("bad name status=%d, want 400 (body=%s)", rec.Code, rec.Body.String())
	}
	rec = post("root@example.com", `{"parent":"/", "name":"a/b"}`)
	if rec.Code != http.StatusBadRequest {
		t.Errorf("path-separator name status=%d, want 400", rec.Code)
	}

	// Reserved-prefix parent.
	rec = post("root@example.com", `{"parent":"/.foo", "name":"x"}`)
	if rec.Code != http.StatusNotFound {
		t.Errorf("reserved-prefix parent status=%d, want 404", rec.Code)
	}

	// Non-existent parent.
	rec = post("root@example.com", `{"parent":"/does-not-exist", "name":"x"}`)
	if rec.Code != http.StatusBadRequest {
		t.Errorf("missing-parent status=%d, want 400", rec.Code)
	}

	// Anonymous and non-admin: 404 (no admin scope anywhere).
	rec = post("", `{"parent":"/", "name":"gamma"}`)
	if rec.Code != http.StatusNotFound {
		t.Errorf("anonymous status=%d, want 404", rec.Code)
	}
	rec = post("mallory@example.com", `{"parent":"/", "name":"gamma"}`)
	if rec.Code != http.StatusNotFound {
		t.Errorf("non-admin status=%d, want 404", rec.Code)
	}

	// Method other than POST is 405.
	req := httptest.NewRequest(http.MethodGet, "/.profile/projects", nil)
	req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com"))
	rec = httptest.NewRecorder()
	ServeProfile(cfg, ring, nil, rec, req)
	if rec.Code != http.StatusMethodNotAllowed {
		t.Errorf("GET /.profile/projects status=%d, want 405", rec.Code)
	}
}

// TestServeProfileProjectsCreateValidatesZddc covers ACL/admin pattern
// validation: an invalid glob in the body must roll the directory back.
func TestServeProfileProjectsCreateValidatesZddc(t *testing.T) {
	root := t.TempDir()
	if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("admins:\n  - root@example.com\n"), 0o644); err != nil {
		t.Fatalf("write root .zddc: %v", err)
	}
	zddc.InvalidateCache(root)
	cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}

	body := `{"parent":"/", "name":"badproject", "acl":{"allow":["bad@@glob"], "deny":[]}}`
	req := httptest.NewRequest(http.MethodPost, "/.profile/projects", strings.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	req = req.WithContext(context.WithValue(req.Context(), EmailKey, "root@example.com"))
	rec := httptest.NewRecorder()
	ServeProfile(cfg, NewLogRing(50), nil, rec, req)

	if rec.Code != http.StatusBadRequest {
		t.Fatalf("status=%d, want 400; body=%s", rec.Code, rec.Body.String())
	}
	if _, err := os.Stat(filepath.Join(root, "badproject")); err == nil {
		t.Errorf("dir should not exist after validation rejection")
	}
}

// TestSubtreeAdminCanCreateInScope: a subtree admin (alice on /projects)
// can create /projects/sub but not /other.
func TestSubtreeAdminCanCreateInScope(t *testing.T) {
	root := t.TempDir()
	if err := os.WriteFile(filepath.Join(root, ".zddc"), []byte("admins:\n  - root@example.com\n"), 0o644); err != nil {
		t.Fatalf("write root .zddc: %v", err)
	}
	if err := os.MkdirAll(filepath.Join(root, "projects"), 0o755); err != nil {
		t.Fatalf("mkdir: %v", err)
	}
	if err := os.WriteFile(filepath.Join(root, "projects", ".zddc"), []byte("admins:\n  - alice@example.com\n"), 0o644); err != nil {
		t.Fatalf("write subtree .zddc: %v", err)
	}
	if err := os.MkdirAll(filepath.Join(root, "other"), 0o755); err != nil {
		t.Fatalf("mkdir other: %v", err)
	}
	zddc.InvalidateCache(root)
	zddc.InvalidateCache(filepath.Join(root, "projects"))
	cfg := config.Config{Root: root, EmailHeader: "X-Auth-Request-Email"}

	post := func(parent, name string) int {
		body := `{"parent":"` + parent + `", "name":"` + name + `"}`
		req := httptest.NewRequest(http.MethodPost, "/.profile/projects", strings.NewReader(body))
		req.Header.Set("Content-Type", "application/json")
		req = req.WithContext(context.WithValue(req.Context(), EmailKey, "alice@example.com"))
		rec := httptest.NewRecorder()
		ServeProfile(cfg, NewLogRing(50), nil, rec, req)
		return rec.Code
	}

	if code := post("/projects", "sub"); code != http.StatusCreated {
		t.Errorf("alice creating /projects/sub: status=%d, want 201", code)
	}
	if code := post("/other", "sub"); code != http.StatusNotFound {
		t.Errorf("alice creating /other/sub: status=%d, want 404 (no scope)", code)
	}
}

// TestServeProfileReindexPOST exercises the happy path: admin POSTs to
// /.profile/reindex, the index rebuilds against the temp root, and the
// response carries non-zero counts plus a duration_ms field.
//
// Setup writes a transmittal folder under cfg.Root AFTER the initial empty
// index is created, so a successful Rebuild has to discover it. This mirrors
// the real-world reason the endpoint exists (an SMB-side write the watcher
// missed).
func TestServeProfileReindexPOST(t *testing.T) {
	cfg, ring := profileTestRoot(t, []string{"alice@example.com"})

	// Empty initial index — the directory hasn't been populated yet.
	idx, err := archive.BuildIndex(cfg.Root)
	if err != nil {
		t.Fatalf("BuildIndex: %v", err)
	}

	// Drop a transmittal folder in after the index is built. Without
	// rebuild this would be invisible to the .archive resolver.
	dir := filepath.Join(cfg.Root, "ProjectA", "2025-01-01_T1 (IFR) - Title")
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatalf("mkdir: %v", err)
	}
	if err := os.WriteFile(filepath.Join(dir, "100_A (IFR) - Title.pdf"), []byte("x"), 0o644); err != nil {
		t.Fatalf("write file: %v", err)
	}

	rec := httptest.NewRecorder()
	req := requestWithEmail(http.MethodPost, "/.profile/reindex", "alice@example.com")
	ServeProfile(cfg, ring, idx, rec, req)

	if rec.Code != http.StatusOK {
		t.Fatalf("status=%d, want 200; body=%s", rec.Code, rec.Body.String())
	}

	var resp struct {
		DurationMs    int64 `json:"duration_ms"`
		ProjectCount  int   `json:"project_count"`
		TrackingCount int   `json:"tracking_count"`
	}
	if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
		t.Fatalf("decode: %v; body=%s", err, rec.Body.String())
	}
	if resp.ProjectCount != 1 {
		t.Errorf("project_count=%d, want 1", resp.ProjectCount)
	}
	if resp.TrackingCount != 1 {
		t.Errorf("tracking_count=%d, want 1", resp.TrackingCount)
	}
	if resp.DurationMs < 0 {
		t.Errorf("duration_ms=%d, want >= 0", resp.DurationMs)
	}

	// The post-rebuild index must now resolve the new tracking number.
	if _, ok := archive.Resolve(idx, "ProjectA", "100.html"); !ok {
		t.Errorf("post-reindex: 100 should resolve")
	}
}

// TestAdminPathHardCut verifies the legacy /.admin prefix is not handled
// by the server — every /.admin/* falls through to the dispatcher's normal
// path resolution which 404s on the dot-prefix guard.
func TestAdminPathHardCut(t *testing.T) {
	cfg, ring := profileTestRoot(t, []string{"alice@example.com"})
	for _, p := range []string{"/.admin/", "/.admin/whoami", "/.admin/zddc/edit?path=/"} {
		rec := httptest.NewRecorder()
		req := requestWithEmail(http.MethodGet, p, "alice@example.com")
		// Calling ServeProfile directly with /.admin path: it should not match
		// the /.profile prefix and so return 404. (The real-world path is
		// dispatch() routing — covered in main_test.go.)
		ServeProfile(cfg, ring, nil, rec, req)
		if rec.Code != http.StatusNotFound {
			t.Errorf("path=%q status=%d, want 404 (hard-cut)", p, rec.Code)
		}
	}
}
