package handler

import (
	"bytes"
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
	"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
	"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)

// fileAPITestSetup writes a tree of directories and seed files under a
// temp root and returns a do() helper that builds and runs file API
// requests. The root .zddc grants caller@example.com read+write across
// the tree (single ACL allows both — the internal decider doesn't split
// read/write yet).
//
// seed: relative path → bytes (created as a regular file).
// dirs: relative paths to mkdir.
func fileAPITestSetup(t *testing.T, dirs []string, seed map[string]string) (cfg config.Config, do func(method, target, email string, body []byte, headers map[string]string) *httptest.ResponseRecorder, root string) {
	t.Helper()
	root = t.TempDir()

	// Root .zddc grants writer access to *@example.com.
	if err := os.WriteFile(filepath.Join(root, ".zddc"),
		[]byte("acl:\n  permissions:\n    \"*@example.com\": rwcd\n"), 0o644); err != nil {
		t.Fatalf("write root .zddc: %v", err)
	}

	for _, d := range dirs {
		if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil {
			t.Fatalf("mkdir %s: %v", d, err)
		}
		// Auto-register any party implied by a pre-created peer path
		// (<project>/<peer>/<party>/… or <project>/archive/<party>/…) so
		// the party_source gate doesn't 409 before the test's real
		// assertion. party_source: ssr → registry entry ssr/<party>.yaml.
		if segs := strings.Split(filepath.ToSlash(d), "/"); len(segs) >= 3 {
			ssrFile := filepath.Join(root, segs[0], "ssr", segs[2]+".yaml")
			_ = os.MkdirAll(filepath.Dir(ssrFile), 0o755)
			_ = os.WriteFile(ssrFile, []byte("kind: SSR\n"), 0o644)
		}
	}
	for rel, body := range seed {
		full := filepath.Join(root, rel)
		if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
			t.Fatalf("mkdir parent of %s: %v", rel, err)
		}
		if err := os.WriteFile(full, []byte(body), 0o644); err != nil {
			t.Fatalf("seed %s: %v", rel, err)
		}
	}
	zddc.InvalidateCache(root)

	cfg = config.Config{
		Root:          root,
		EmailHeader:   "X-Auth-Request-Email",
		MaxWriteBytes: 1024 * 1024,
	}

	do = func(method, target, email string, body []byte, headers map[string]string) *httptest.ResponseRecorder {
		var req *http.Request
		if body != nil {
			req = httptest.NewRequest(method, target, bytes.NewReader(body))
		} else {
			req = httptest.NewRequest(method, target, nil)
		}
		for k, v := range headers {
			req.Header.Set(k, v)
		}
		ctx := context.WithValue(req.Context(), EmailKey, email)
		ctx = context.WithValue(ctx, ElevatedKey, true)
		req = req.WithContext(ctx)
		rec := httptest.NewRecorder()
		ServeFileAPI(cfg, rec, req)
		return rec
	}
	return cfg, do, root
}

func sha32(data []byte) string {
	sum := sha256.Sum256(data)
	return hex.EncodeToString(sum[:])[:32]
}

func TestFileAPI_PutCreatesFile(t *testing.T) {
	_, do, root := fileAPITestSetup(t, []string{"Incoming"}, nil)

	body := []byte("hello world")
	rec := do(http.MethodPut, "/Incoming/note.txt", "alice@example.com", body, nil)
	if rec.Code != http.StatusCreated {
		t.Fatalf("want 201, got %d: %s", rec.Code, rec.Body.String())
	}
	got, err := os.ReadFile(filepath.Join(root, "Incoming/note.txt"))
	if err != nil {
		t.Fatalf("read back: %v", err)
	}
	if string(got) != "hello world" {
		t.Fatalf("body mismatch: %q", got)
	}
	wantTag := `"` + sha32(body) + `"`
	if got := rec.Header().Get("ETag"); got != wantTag {
		t.Fatalf("ETag: want %s, got %s", wantTag, got)
	}
}

func TestFileAPI_PutOverwritesExisting(t *testing.T) {
	_, do, root := fileAPITestSetup(t, nil, map[string]string{
		"Incoming/old.txt": "first",
	})

	body := []byte("second")
	rec := do(http.MethodPut, "/Incoming/old.txt", "alice@example.com", body, nil)
	if rec.Code != http.StatusOK {
		t.Fatalf("want 200 (overwrite), got %d: %s", rec.Code, rec.Body.String())
	}
	got, _ := os.ReadFile(filepath.Join(root, "Incoming/old.txt"))
	if string(got) != "second" {
		t.Fatalf("body: %q", got)
	}
}

func TestFileAPI_PutAutoCreatesParents(t *testing.T) {
	_, do, root := fileAPITestSetup(t, nil, nil)

	rec := do(http.MethodPut, "/Incoming/sub/deep/x.bin", "alice@example.com", []byte("data"), nil)
	if rec.Code != http.StatusCreated {
		t.Fatalf("want 201, got %d: %s", rec.Code, rec.Body.String())
	}
	if _, err := os.Stat(filepath.Join(root, "Incoming/sub/deep/x.bin")); err != nil {
		t.Fatalf("stat: %v", err)
	}
}

func TestFileAPI_PutDenyForbidden(t *testing.T) {
	cfg, do, _ := fileAPITestSetup(t, []string{"Working"}, nil)

	// Tighten ACL to a different domain — alice@example.com no longer
	// matches and writes must be 403.
	if err := os.WriteFile(filepath.Join(cfg.Root, ".zddc"),
		[]byte("acl:\n  permissions:\n    \"*@allowed.com\": rwcd\n"), 0o644); err != nil {
		t.Fatalf("rewrite .zddc: %v", err)
	}
	zddc.InvalidateCache(cfg.Root)

	rec := do(http.MethodPut, "/Working/note.md", "alice@example.com", []byte("nope"), nil)
	if rec.Code != http.StatusForbidden {
		t.Fatalf("want 403, got %d: %s", rec.Code, rec.Body.String())
	}

	// 403 body carries JSON with the missing verb so the client toast
	// can render "you need <verb> here" and offer elevation when the
	// path-scoped /.profile/access reports an elevation grant. PUT to
	// a path with no existing file is gated on `c` (create); a PUT
	// over an existing file would gate on `w` instead — covered by
	// the test below.
	if ct := rec.Header().Get("Content-Type"); ct != "application/json; charset=utf-8" {
		t.Errorf("Content-Type = %q, want application/json", ct)
	}
	var body struct {
		Error       string `json:"error"`
		MissingVerb string `json:"missing_verb"`
	}
	if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
		t.Fatalf("decode 403 body: %v (raw: %s)", err, rec.Body.String())
	}
	if body.MissingVerb != "c" {
		t.Errorf("missing_verb = %q, want c (PUT to non-existing file gates on create)", body.MissingVerb)
	}
}

// TestFileAPI_PutDenyForbiddenOverwriteVerb — PUT over an existing file
// gates on the write verb, so 403 reports missing_verb=w. Mirrors
// TestFileAPI_PutDenyForbidden but with a seeded file.
func TestFileAPI_PutDenyForbiddenOverwriteVerb(t *testing.T) {
	cfg, do, _ := fileAPITestSetup(t, nil, map[string]string{
		"Working/seeded.md": "before",
	})
	if err := os.WriteFile(filepath.Join(cfg.Root, ".zddc"),
		[]byte("acl:\n  permissions:\n    \"*@allowed.com\": rwcd\n"), 0o644); err != nil {
		t.Fatalf("rewrite .zddc: %v", err)
	}
	zddc.InvalidateCache(cfg.Root)

	rec := do(http.MethodPut, "/Working/seeded.md", "alice@example.com", []byte("after"), nil)
	if rec.Code != http.StatusForbidden {
		t.Fatalf("want 403, got %d: %s", rec.Code, rec.Body.String())
	}
	var body struct {
		MissingVerb string `json:"missing_verb"`
	}
	if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
		t.Fatalf("decode 403 body: %v", err)
	}
	if body.MissingVerb != "w" {
		t.Errorf("missing_verb = %q, want w (PUT over existing file gates on write)", body.MissingVerb)
	}
}

func TestFileAPI_DotContentAllowedButZddcDReserved(t *testing.T) {
	_, do, _ := fileAPITestSetup(t, nil, nil)

	// Dot-/underscore-prefixed paths are ordinary ACL-governed content now:
	// alice has a broad rwcd grant via *@example.com, so these writes succeed.
	// (A leading dot only hides an entry from listings, not from the ACL.)
	for _, p := range []string{"/foo/.hidden", "/_app/spoof.html", "/_template/x"} {
		rec := do(http.MethodPut, p, "alice@example.com", []byte("x"), nil)
		if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
			t.Fatalf("want 200/201 for content path %s, got %d: %s", p, rec.Code, rec.Body.String())
		}
	}

	// The one reserved namespace, .zddc.d/, is admin-only and the gate
	// OVERRIDES the broad operator grant: alice is elevated but not an admin,
	// so she is hard-denied at every depth — this is what keeps the token
	// store un-forgeable even under a permissive ACL. Case variants
	// (.ZDDC.D, .Zddc.D) MUST be denied too: on a case-insensitive root they
	// resolve to the same dir, so a write to a case-varied path — e.g. a MOVE
	// destination header that skips dispatch's canonical case-folding — would
	// otherwise plant a forged bearer token. HasReservedSidecar folds case.
	for _, p := range []string{
		"/.zddc.d/tokens/forged", "/Project/.zddc.d/history/x",
		"/.ZDDC.D/tokens/forged", "/Project/.Zddc.D/history/x",
	} {
		rec := do(http.MethodPut, p, "alice@example.com", []byte("x"), nil)
		if rec.Code != http.StatusForbidden {
			t.Fatalf("want 403 for reserved %s, got %d: %s", p, rec.Code, rec.Body.String())
		}
	}
}

func TestFileAPI_PutOversizeRejected(t *testing.T) {
	cfg, _, _ := fileAPITestSetup(t, []string{"Incoming"}, nil)
	cfg.MaxWriteBytes = 16

	body := bytes.Repeat([]byte("A"), 32)
	req := httptest.NewRequest(http.MethodPut, "/Incoming/big.bin", bytes.NewReader(body))
	ctx := context.WithValue(req.Context(), EmailKey, "alice@example.com")
	ctx = context.WithValue(ctx, ElevatedKey, true)
	req = req.WithContext(ctx)
	rec := httptest.NewRecorder()
	ServeFileAPI(cfg, rec, req)
	if rec.Code != http.StatusRequestEntityTooLarge {
		t.Fatalf("want 413, got %d: %s", rec.Code, rec.Body.String())
	}
}

func TestFileAPI_PutTrailingSlashRejected(t *testing.T) {
	_, do, _ := fileAPITestSetup(t, nil, nil)
	rec := do(http.MethodPut, "/Incoming/", "alice@example.com", []byte("x"), nil)
	if rec.Code != http.StatusBadRequest {
		t.Fatalf("want 400, got %d", rec.Code)
	}
}

func TestFileAPI_DeleteRemovesFile(t *testing.T) {
	_, do, root := fileAPITestSetup(t, nil, map[string]string{
		"Incoming/old.txt": "garbage",
	})

	rec := do(http.MethodDelete, "/Incoming/old.txt", "alice@example.com", nil, nil)
	if rec.Code != http.StatusNoContent {
		t.Fatalf("want 204, got %d: %s", rec.Code, rec.Body.String())
	}
	if _, err := os.Stat(filepath.Join(root, "Incoming/old.txt")); !os.IsNotExist(err) {
		t.Fatalf("file should be gone, err=%v", err)
	}
}

func TestFileAPI_DeleteMissing404(t *testing.T) {
	_, do, _ := fileAPITestSetup(t, nil, nil)
	rec := do(http.MethodDelete, "/Incoming/never-existed.txt", "alice@example.com", nil, nil)
	if rec.Code != http.StatusNotFound {
		t.Fatalf("want 404, got %d", rec.Code)
	}
}

// Directory delete is admin-only and recursive. A non-admin (elevated but not
// named in admins:) is forbidden; an admin recursively removes the subtree.
func TestFileAPI_DeleteDirectoryNonAdminForbidden(t *testing.T) {
	_, do, _ := fileAPITestSetup(t, []string{"Incoming/sub"}, nil)
	rec := do(http.MethodDelete, "/Incoming/sub/", "alice@example.com", nil, nil)
	if rec.Code != http.StatusForbidden {
		t.Fatalf("want 403, got %d: %s", rec.Code, rec.Body.String())
	}
}

func TestFileAPI_DeleteDirectoryAdminRecursive(t *testing.T) {
	_, do, root := fileAPITestSetup(t, []string{"Incoming/sub"}, map[string]string{
		"Incoming/sub/a.txt":      "one",
		"Incoming/sub/deep/b.txt": "two",
	})
	// Promote alice to root admin.
	if err := os.WriteFile(filepath.Join(root, ".zddc"),
		[]byte("acl:\n  permissions:\n    \"*@example.com\": rwcd\nadmins:\n  - alice@example.com\n"), 0o644); err != nil {
		t.Fatalf("rewrite root .zddc: %v", err)
	}
	zddc.InvalidateCache(root)

	rec := do(http.MethodDelete, "/Incoming/sub/", "alice@example.com", nil, nil)
	if rec.Code != http.StatusNoContent {
		t.Fatalf("want 204, got %d: %s", rec.Code, rec.Body.String())
	}
	if _, err := os.Stat(filepath.Join(root, "Incoming/sub")); !os.IsNotExist(err) {
		t.Fatalf("dir should be gone recursively, err=%v", err)
	}
}

func TestFileAPI_MoveRenames(t *testing.T) {
	_, do, root := fileAPITestSetup(t, nil, map[string]string{
		"Incoming/old.pdf": "PDF body",
	})

	rec := do(http.MethodPost, "/Incoming/old.pdf", "alice@example.com", nil, map[string]string{
		"X-ZDDC-Op":          "move",
		"X-ZDDC-Destination": "/Incoming/new.pdf",
	})
	if rec.Code != http.StatusOK {
		t.Fatalf("want 200, got %d: %s", rec.Code, rec.Body.String())
	}
	if _, err := os.Stat(filepath.Join(root, "Incoming/old.pdf")); !os.IsNotExist(err) {
		t.Fatalf("source still exists")
	}
	got, err := os.ReadFile(filepath.Join(root, "Incoming/new.pdf"))
	if err != nil {
		t.Fatalf("read dest: %v", err)
	}
	if string(got) != "PDF body" {
		t.Fatalf("dest bytes: %q", got)
	}
	if dst := rec.Header().Get("X-ZDDC-Destination"); dst != "/Incoming/new.pdf" {
		t.Fatalf("destination header: %s", dst)
	}
}

// Directory move is admin-only and relocates the whole subtree. A non-admin
// (elevated but not in admins:) is forbidden; an admin renames/relocates it.
func TestFileAPI_MoveDirectoryNonAdminForbidden(t *testing.T) {
	_, do, _ := fileAPITestSetup(t, []string{"Docs/sub"}, nil)
	rec := do(http.MethodPost, "/Docs/sub/", "alice@example.com", nil, map[string]string{
		"X-ZDDC-Op":          "move",
		"X-ZDDC-Destination": "/Docs/renamed/",
	})
	if rec.Code != http.StatusForbidden {
		t.Fatalf("want 403, got %d: %s", rec.Code, rec.Body.String())
	}
}

func TestFileAPI_MoveDirectoryAdmin(t *testing.T) {
	_, do, root := fileAPITestSetup(t, []string{"Docs/sub"}, map[string]string{
		"Docs/sub/a.txt":      "x",
		"Docs/sub/deep/b.txt": "y",
	})
	if err := os.WriteFile(filepath.Join(root, ".zddc"),
		[]byte("acl:\n  permissions:\n    \"*@example.com\": rwcd\nadmins:\n  - alice@example.com\n"), 0o644); err != nil {
		t.Fatalf("rewrite root .zddc: %v", err)
	}
	zddc.InvalidateCache(root)

	rec := do(http.MethodPost, "/Docs/sub/", "alice@example.com", nil, map[string]string{
		"X-ZDDC-Op":          "move",
		"X-ZDDC-Destination": "/Docs/renamed/",
	})
	if rec.Code != http.StatusOK {
		t.Fatalf("want 200, got %d: %s", rec.Code, rec.Body.String())
	}
	if _, err := os.Stat(filepath.Join(root, "Docs/sub")); !os.IsNotExist(err) {
		t.Fatalf("source dir should be gone, err=%v", err)
	}
	if b, err := os.ReadFile(filepath.Join(root, "Docs/renamed/deep/b.txt")); err != nil || string(b) != "y" {
		t.Fatalf("moved subtree content missing: b=%q err=%v", b, err)
	}
}

// Refuse moving a directory into itself or a descendant (would orphan the tree).
func TestFileAPI_MoveDirectoryIntoItself(t *testing.T) {
	_, do, root := fileAPITestSetup(t, []string{"Docs/sub"}, nil)
	if err := os.WriteFile(filepath.Join(root, ".zddc"),
		[]byte("acl:\n  permissions:\n    \"*@example.com\": rwcd\nadmins:\n  - alice@example.com\n"), 0o644); err != nil {
		t.Fatalf("rewrite root .zddc: %v", err)
	}
	zddc.InvalidateCache(root)

	rec := do(http.MethodPost, "/Docs/sub/", "alice@example.com", nil, map[string]string{
		"X-ZDDC-Op":          "move",
		"X-ZDDC-Destination": "/Docs/sub/inner/",
	})
	if rec.Code != http.StatusConflict {
		t.Fatalf("want 409, got %d: %s", rec.Code, rec.Body.String())
	}
}

func TestFileAPI_MoveDestinationExistsConflict(t *testing.T) {
	_, do, _ := fileAPITestSetup(t, nil, map[string]string{
		"Incoming/a.txt": "a",
		"Incoming/b.txt": "b",
	})

	rec := do(http.MethodPost, "/Incoming/a.txt", "alice@example.com", nil, map[string]string{
		"X-ZDDC-Op":          "move",
		"X-ZDDC-Destination": "/Incoming/b.txt",
	})
	if rec.Code != http.StatusConflict {
		t.Fatalf("want 409, got %d: %s", rec.Code, rec.Body.String())
	}
}

func TestFileAPI_MoveMissingDestinationHeader(t *testing.T) {
	_, do, _ := fileAPITestSetup(t, nil, map[string]string{
		"Incoming/a.txt": "a",
	})
	rec := do(http.MethodPost, "/Incoming/a.txt", "alice@example.com", nil, map[string]string{
		"X-ZDDC-Op": "move",
	})
	if rec.Code != http.StatusBadRequest {
		t.Fatalf("want 400, got %d: %s", rec.Code, rec.Body.String())
	}
}

func TestFileAPI_MoveCreatesParentDirs(t *testing.T) {
	_, do, root := fileAPITestSetup(t, nil, map[string]string{
		"Incoming/a.txt": "hi",
	})
	rec := do(http.MethodPost, "/Incoming/a.txt", "alice@example.com", nil, map[string]string{
		"X-ZDDC-Op":          "move",
		"X-ZDDC-Destination": "/Working/sub/a.txt",
	})
	if rec.Code != http.StatusOK {
		t.Fatalf("want 200, got %d: %s", rec.Code, rec.Body.String())
	}
	if _, err := os.Stat(filepath.Join(root, "Working/sub/a.txt")); err != nil {
		t.Fatalf("dest not present: %v", err)
	}
}

func TestFileAPI_PostUnknownOp(t *testing.T) {
	_, do, _ := fileAPITestSetup(t, []string{"Incoming"}, nil)
	rec := do(http.MethodPost, "/Incoming/x.txt", "alice@example.com", nil, map[string]string{
		"X-ZDDC-Op": "weld",
	})
	if rec.Code != http.StatusBadRequest {
		t.Fatalf("want 400, got %d", rec.Code)
	}
}

func TestFileAPI_PostMissingOp(t *testing.T) {
	_, do, _ := fileAPITestSetup(t, []string{"Incoming"}, nil)
	rec := do(http.MethodPost, "/Incoming/x.txt", "alice@example.com", nil, nil)
	if rec.Code != http.StatusBadRequest {
		t.Fatalf("want 400, got %d", rec.Code)
	}
}

func TestFileAPI_MkdirCreates(t *testing.T) {
	// Project-root mkdir is restricted to archive/ + system names
	// after the layout reshape; test mkdir at a depth where the
	// guard doesn't fire (under archive/<party>/incoming/).
	_, do, root := fileAPITestSetup(t, []string{"Proj/incoming/Acme"}, nil)

	rec := do(http.MethodPost, "/Proj/incoming/Acme/newfolder/", "alice@example.com", nil, map[string]string{
		"X-ZDDC-Op": "mkdir",
	})
	if rec.Code != http.StatusCreated {
		t.Fatalf("want 201, got %d: %s", rec.Code, rec.Body.String())
	}
	info, err := os.Stat(filepath.Join(root, "Proj/incoming/Acme/newfolder"))
	if err != nil {
		t.Fatalf("stat: %v", err)
	}
	if !info.IsDir() {
		t.Fatalf("not a dir")
	}
}

func TestFileAPI_MkdirIdempotent(t *testing.T) {
	_, do, _ := fileAPITestSetup(t, []string{"Proj/incoming/Acme/exists"}, nil)
	rec := do(http.MethodPost, "/Proj/incoming/Acme/exists/", "alice@example.com", nil, map[string]string{
		"X-ZDDC-Op": "mkdir",
	})
	if rec.Code != http.StatusOK {
		t.Fatalf("want 200, got %d", rec.Code)
	}
}

// TestFileAPI_MkdirProjectRootGuard — direct mkdir at <project>/<name>/
// is restricted to the canonical peers + system names (_/.-prefix); any
// other name is rejected with 409.
func TestFileAPI_MkdirProjectRootGuard(t *testing.T) {
	_, do, _ := fileAPITestSetup(t, []string{"Proj"}, nil)
	// Reject ad-hoc name.
	rec := do(http.MethodPost, "/Proj/notes/", "alice@example.com", nil, map[string]string{
		"X-ZDDC-Op": "mkdir",
	})
	if rec.Code != http.StatusConflict {
		t.Fatalf("want 409 for /Proj/notes/, got %d: %s", rec.Code, rec.Body.String())
	}
	// Allow each canonical peer name.
	for _, name := range []string{"archive", "ssr", "mdl", "rsk", "working", "staging", "reviewing", "incoming"} {
		rec := do(http.MethodPost, "/Proj/"+name+"/", "alice@example.com", nil, map[string]string{
			"X-ZDDC-Op": "mkdir",
		})
		if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
			t.Fatalf("%s: want 201/200 (canonical peer), got %d: %s", name, rec.Code, rec.Body.String())
		}
	}
	// `_`/`.`-prefixed system names are caught earlier (resolveTargetPath
	// rejects them as reserved path segments with 404 — see fileapi.go
	// resolveTargetPath); the mkdir guard would also allow them, so the
	// composite end-state is reserved + 404. Tested elsewhere.
}

func TestFileAPI_IfMatchEnforced(t *testing.T) {
	_, do, _ := fileAPITestSetup(t, nil, map[string]string{
		"Incoming/x.txt": "v1",
	})

	// Wrong ETag → 412.
	rec := do(http.MethodPut, "/Incoming/x.txt", "alice@example.com", []byte("v2"), map[string]string{
		"If-Match": `"` + strings.Repeat("0", 32) + `"`,
	})
	if rec.Code != http.StatusPreconditionFailed {
		t.Fatalf("want 412, got %d", rec.Code)
	}

	// Correct ETag → 200.
	correctTag := sha32([]byte("v1"))
	rec = do(http.MethodPut, "/Incoming/x.txt", "alice@example.com", []byte("v2"), map[string]string{
		"If-Match": `"` + correctTag + `"`,
	})
	if rec.Code != http.StatusOK {
		t.Fatalf("want 200, got %d: %s", rec.Code, rec.Body.String())
	}
}

func TestFileAPI_IfMatchWildcardOnMissing(t *testing.T) {
	_, do, _ := fileAPITestSetup(t, []string{"Incoming"}, nil)
	rec := do(http.MethodPut, "/Incoming/new.txt", "alice@example.com", []byte("data"), map[string]string{
		"If-Match": `*`,
	})
	if rec.Code != http.StatusPreconditionFailed {
		t.Fatalf("want 412 (wildcard expects existing), got %d", rec.Code)
	}
}

func TestFileAPI_PathTraversalBlocked(t *testing.T) {
	_, do, _ := fileAPITestSetup(t, nil, nil)

	rec := do(http.MethodPut, "/../escaped.txt", "alice@example.com", []byte("x"), nil)
	if rec.Code != http.StatusNotFound && rec.Code != http.StatusBadRequest {
		t.Fatalf("traversal not blocked: %d", rec.Code)
	}
}

func TestFileAPI_AnonymousDenied(t *testing.T) {
	_, do, _ := fileAPITestSetup(t, []string{"Incoming"}, nil)

	rec := do(http.MethodPut, "/Incoming/note.txt", "", []byte("x"), nil)
	if rec.Code != http.StatusForbidden {
		t.Fatalf("want 403 for anon, got %d", rec.Code)
	}
}

// rolePermissionsTestSetup creates a project + per-party exchange shape:
//
//	root .zddc:                                  _company:r, _doc_controller:rwcda
//	<project>/archive/Acme/.zddc:                vendor_acme:rwcd, _doc_controller:rwcda, _company:""
//	roles defined at root.
//
// The project is "Project-X"; the counterparty is "Acme". URLs target
// paths like /Project-X/incoming/Acme/<file>.
//
// Returns the same do() helper as fileAPITestSetup.
func rolePermissionsTestSetup(t *testing.T) (cfg config.Config, do func(method, target, email string, body []byte, headers map[string]string) *httptest.ResponseRecorder, root string) {
	t.Helper()
	root = t.TempDir()

	// Root .zddc — company gets r, doc_controller gets rwcda. Roles
	// defined here so the per-party subtree's permissions can reference
	// them by name.
	rootZ := []byte(`roles:
  _company:
    members: ["*@mycompany.com"]
  _doc_controller:
    members: [dc@mycompany.com]
  vendor_acme:
    members: ["*@acme.com"]
acl:
  permissions:
    _company: r
    _doc_controller: rwcda
`)
	if err := os.WriteFile(filepath.Join(root, ".zddc"), rootZ, 0o644); err != nil {
		t.Fatalf("root .zddc: %v", err)
	}

	// Register the party (party_source: ssr) — its existence gates the peers.
	if err := os.MkdirAll(filepath.Join(root, "Project-X", "ssr"), 0o755); err != nil {
		t.Fatalf("mkdir ssr: %v", err)
	}
	if err := os.WriteFile(filepath.Join(root, "Project-X", "ssr", "Acme.yaml"), []byte("kind: SSR\n"), 0o644); err != nil {
		t.Fatalf("register party: %v", err)
	}
	// The counterparty's inbound drop zone is a top-level peer now; grant
	// the vendor rwcd there (the DC would set this on incoming/<party>/.zddc).
	incomingDir := filepath.Join(root, "Project-X", "incoming", "Acme")
	if err := os.MkdirAll(incomingDir, 0o755); err != nil {
		t.Fatalf("mkdir incoming/Acme: %v", err)
	}
	if err := os.WriteFile(filepath.Join(incomingDir, ".zddc"),
		[]byte("acl:\n  permissions:\n    vendor_acme: rwcd\n    _doc_controller: rwcda\n"), 0o644); err != nil {
		t.Fatalf("incoming .zddc: %v", err)
	}
	// The committed record (WORM) stays under archive/<party>/.
	for _, sub := range []string{"issued", "received"} {
		if err := os.MkdirAll(filepath.Join(root, "Project-X", "archive", "Acme", sub), 0o755); err != nil {
			t.Fatalf("mkdir archive/Acme/%s: %v", sub, err)
		}
	}

	zddc.InvalidateCache(root)

	cfg = config.Config{
		Root:          root,
		EmailHeader:   "X-Auth-Request-Email",
		MaxWriteBytes: 1024 * 1024,
	}
	decider := &policy.InternalDecider{}

	do = func(method, target, email string, body []byte, headers map[string]string) *httptest.ResponseRecorder {
		var req *http.Request
		if body != nil {
			req = httptest.NewRequest(method, target, bytes.NewReader(body))
		} else {
			req = httptest.NewRequest(method, target, nil)
		}
		for k, v := range headers {
			req.Header.Set(k, v)
		}
		ctx := context.WithValue(req.Context(), EmailKey, email)
		ctx = context.WithValue(ctx, ElevatedKey, true)
		ctx = context.WithValue(ctx, DeciderKey, decider)
		req = req.WithContext(ctx)
		rec := httptest.NewRecorder()
		ServeFileAPI(cfg, rec, req)
		return rec
	}
	return cfg, do, root
}

func TestFileAPI_RoleBasedVendorIncoming(t *testing.T) {
	_, do, _ := rolePermissionsTestSetup(t)

	// Vendor PUTs into their incoming → 201.
	rec := do(http.MethodPut, "/Project-X/incoming/Acme/submission.pdf", "rep@acme.com", []byte("data"), nil)
	if rec.Code != http.StatusCreated {
		t.Fatalf("PUT vendor → incoming: want 201, got %d: %s", rec.Code, rec.Body.String())
	}
	// Vendor overwrites the same file → 200 (rwcd has w).
	rec = do(http.MethodPut, "/Project-X/incoming/Acme/submission.pdf", "rep@acme.com", []byte("data2"), nil)
	if rec.Code != http.StatusOK {
		t.Fatalf("PUT vendor → incoming overwrite: want 200, got %d", rec.Code)
	}
}

func TestFileAPI_WORM_VendorReadOnlyInIssued(t *testing.T) {
	_, do, root := rolePermissionsTestSetup(t)

	// Seed an existing issued file.
	if err := os.WriteFile(filepath.Join(root, "Project-X/archive/Acme/issued/spec.pdf"), []byte("FILED"), 0o644); err != nil {
		t.Fatalf("seed: %v", err)
	}

	// Vendor cannot overwrite — ancestor grant masked to r in issued.
	rec := do(http.MethodPut, "/Project-X/archive/Acme/issued/spec.pdf", "rep@acme.com", []byte("tamper"), nil)
	if rec.Code != http.StatusForbidden {
		t.Fatalf("PUT vendor → issued (overwrite): want 403, got %d: %s", rec.Code, rec.Body.String())
	}
	// Vendor cannot delete.
	rec = do(http.MethodDelete, "/Project-X/archive/Acme/issued/spec.pdf", "rep@acme.com", nil, nil)
	if rec.Code != http.StatusForbidden {
		t.Fatalf("DELETE vendor → issued: want 403, got %d", rec.Code)
	}
	// Vendor cannot create new files — they have no explicit .zddc grant
	// at the issued folder, so the WORM split reduces their inherited
	// rwcd to r-only.
	rec = do(http.MethodPut, "/Project-X/archive/Acme/issued/new.pdf", "rep@acme.com", []byte("x"), nil)
	if rec.Code != http.StatusForbidden {
		t.Fatalf("PUT vendor → issued (create): want 403 (no explicit grant at issued), got %d", rec.Code)
	}
}

func TestFileAPI_WORM_DocControllerNeedsExplicitGrant(t *testing.T) {
	_, do, root := rolePermissionsTestSetup(t)

	// Without a .zddc at archive/Acme/issued/ explicitly granting cr,
	// the dc's inherited rwcda is masked to r. They cannot create.
	rec := do(http.MethodPut, "/Project-X/archive/Acme/issued/no-grant.pdf", "dc@mycompany.com", []byte("x"), nil)
	if rec.Code != http.StatusForbidden {
		t.Fatalf("dc without explicit grant → issued: want 403, got %d: %s", rec.Code, rec.Body.String())
	}

	// Operator names the document-controller role in the issued/ WORM
	// zone. That role's members then get {r, c} there — the embedded
	// `worm: []` (no controllers) is unioned with this deeper grant.
	issuedZ := []byte("worm:\n  - _doc_controller\n")
	if err := os.WriteFile(filepath.Join(root, "Project-X/archive/Acme/issued/.zddc"), issuedZ, 0o644); err != nil {
		t.Fatalf("write issued .zddc: %v", err)
	}
	zddc.InvalidateCache(root)

	rec = do(http.MethodPut, "/Project-X/archive/Acme/issued/2026-Q2-spec.pdf", "dc@mycompany.com", []byte("CONTROLLED"), nil)
	if rec.Code != http.StatusCreated {
		t.Fatalf("dc with explicit grant → issued: want 201, got %d: %s", rec.Code, rec.Body.String())
	}
	got, _ := os.ReadFile(filepath.Join(root, "Project-X/archive/Acme/issued/2026-Q2-spec.pdf"))
	if string(got) != "CONTROLLED" {
		t.Fatalf("body: %q", got)
	}

	// dc still cannot overwrite — explicit grant is cr, no w.
	rec = do(http.MethodPut, "/Project-X/archive/Acme/issued/2026-Q2-spec.pdf", "dc@mycompany.com", []byte("REVISION"), nil)
	if rec.Code != http.StatusForbidden {
		t.Fatalf("dc PUT overwrite → issued: want 403, got %d", rec.Code)
	}
	// dc still cannot delete.
	rec = do(http.MethodDelete, "/Project-X/archive/Acme/issued/2026-Q2-spec.pdf", "dc@mycompany.com", nil, nil)
	if rec.Code != http.StatusForbidden {
		t.Fatalf("dc DELETE → issued: want 403, got %d", rec.Code)
	}
}

func TestFileAPI_WORM_AdminBypass(t *testing.T) {
	cfg, do, root := rolePermissionsTestSetup(t)

	// Promote root@example.com to root admin.
	rootZ, _ := os.ReadFile(filepath.Join(cfg.Root, ".zddc"))
	updated := string(rootZ) + "\nadmins:\n  - root@example.com\n"
	if err := os.WriteFile(filepath.Join(cfg.Root, ".zddc"), []byte(updated), 0o644); err != nil {
		t.Fatalf("rewrite root .zddc: %v", err)
	}
	zddc.InvalidateCache(cfg.Root)

	// Seed an issued file and have root@ delete it (escape hatch).
	if err := os.WriteFile(filepath.Join(root, "Project-X/archive/Acme/issued/mistake.pdf"), []byte("oops"), 0o644); err != nil {
		t.Fatalf("seed: %v", err)
	}
	rec := do(http.MethodDelete, "/Project-X/archive/Acme/issued/mistake.pdf", "root@example.com", nil, nil)
	if rec.Code != http.StatusNoContent {
		t.Fatalf("admin DELETE → issued: want 204, got %d: %s", rec.Code, rec.Body.String())
	}
}

func TestFileAPI_AutoMkdirOwnership(t *testing.T) {
	_, do, root := rolePermissionsTestSetup(t)

	// Vendor creates a folder under their incoming. Server should
	// auto-write a .zddc granting them rwcda on the new subtree.
	rec := do(http.MethodPost, "/Project-X/incoming/Acme/2026-05-15-issue/", "rep@acme.com", nil, map[string]string{
		"X-ZDDC-Op": "mkdir",
	})
	if rec.Code != http.StatusCreated {
		t.Fatalf("mkdir: want 201, got %d: %s", rec.Code, rec.Body.String())
	}

	autoZ := filepath.Join(root, "Project-X/incoming/Acme/2026-05-15-issue/.zddc")
	data, err := os.ReadFile(autoZ)
	if err != nil {
		t.Fatalf("auto .zddc not written: %v", err)
	}
	body := string(data)
	if !strings.Contains(body, "created_by: rep@acme.com") {
		t.Errorf("auto .zddc missing created_by: %s", body)
	}
	if !strings.Contains(body, "rep@acme.com: rwcda") {
		t.Errorf("auto .zddc missing email→rwcda grant: %s", body)
	}

	// Now the cascade caches are stale because we didn't go through
	// WriteFile here; the server's writeAutoOwnZddc DID call WriteFile
	// (via zddc.WriteFile → InvalidateCache). Confirm the vendor can
	// now PUT a brand-new file inside their owned folder where they
	// otherwise wouldn't have ACL admin rights.
	zddc.InvalidateCache(root)
	rec = do(http.MethodPut, "/Project-X/incoming/Acme/2026-05-15-issue/note.txt", "rep@acme.com", []byte("x"), nil)
	if rec.Code != http.StatusCreated {
		t.Fatalf("vendor PUT in own subtree: want 201, got %d: %s", rec.Code, rec.Body.String())
	}
}

func TestFileAPI_AutoMkdirNotInIssued(t *testing.T) {
	_, do, root := rolePermissionsTestSetup(t)

	// Name the document-controller role in the issued/ WORM zone so its
	// members get cr there.
	issuedZ := []byte("worm:\n  - _doc_controller\n")
	if err := os.WriteFile(filepath.Join(root, "Project-X/archive/Acme/issued/.zddc"), issuedZ, 0o644); err != nil {
		t.Fatalf("seed issued .zddc: %v", err)
	}
	zddc.InvalidateCache(root)

	// Doc controller mkdir under issued — should succeed (cr survives the
	// WORM mask) but should NOT auto-write an ownership .zddc (issued is
	// not declared auto_own in the cascade).
	rec := do(http.MethodPost, "/Project-X/archive/Acme/issued/2026-Q2/", "dc@mycompany.com", nil, map[string]string{
		"X-ZDDC-Op": "mkdir",
	})
	if rec.Code != http.StatusCreated {
		t.Fatalf("mkdir: want 201, got %d: %s", rec.Code, rec.Body.String())
	}
	autoZ := filepath.Join(root, "Project-X/archive/Acme/issued/2026-Q2/.zddc")
	if _, err := os.Stat(autoZ); !os.IsNotExist(err) {
		t.Errorf("auto .zddc should NOT be written under issued; got err=%v", err)
	}
}

// party_source gating: creating a party folder under a workspace peer
// 409s until the party is registered (ssr/<party>.yaml exists), then
// succeeds.
func TestFileAPI_PartySourceGate(t *testing.T) {
	_, do, root := fileAPITestSetup(t, nil, nil)

	// Unregistered party → 409 under each party_source peer.
	for _, peer := range []string{"working", "staging", "reviewing", "incoming", "mdl", "rsk"} {
		rec := do(http.MethodPost, "/Proj/"+peer+"/Acme/", "alice@example.com", nil, map[string]string{
			"X-ZDDC-Op": "mkdir",
		})
		if rec.Code != http.StatusConflict {
			t.Errorf("%s: mkdir for unregistered party: want 409, got %d: %s", peer, rec.Code, rec.Body.String())
		}
	}

	// Register the party (ssr/ has no party_source — a plain create).
	rec := do(http.MethodPut, "/Proj/ssr/Acme.yaml", "alice@example.com", []byte("kind: SSR\n"), nil)
	if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
		t.Fatalf("register party via ssr/: want 201/200, got %d: %s", rec.Code, rec.Body.String())
	}

	// Now the workspace folder can be created.
	rec = do(http.MethodPost, "/Proj/working/Acme/drafts/", "alice@example.com", nil, map[string]string{
		"X-ZDDC-Op": "mkdir",
	})
	if rec.Code != http.StatusCreated {
		t.Fatalf("registered-party mkdir: want 201, got %d: %s", rec.Code, rec.Body.String())
	}
	if _, err := os.Stat(filepath.Join(root, "Proj", "working", "Acme", "drafts")); err != nil {
		t.Errorf("workspace folder not created: %v", err)
	}

	// Regression: a party folder that already exists on disk but whose
	// registry row is missing (deleted, migrated, or differently-cased)
	// must still accept writes. The gate guards INTRODUCING a party, not
	// editing one that's established — without this, opening any
	// pre-existing file under such a folder and saving it 409s. Create
	// the folder out-of-band (no ssr/ row) and PUT into it.
	if err := os.MkdirAll(filepath.Join(root, "Proj", "working", "Ghost"), 0o755); err != nil {
		t.Fatal(err)
	}
	rec = do(http.MethodPut, "/Proj/working/Ghost/note.md", "alice@example.com", []byte("# hi\n"), nil)
	if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
		t.Errorf("PUT into existing unregistered party folder: want 201/200, got %d: %s", rec.Code, rec.Body.String())
	}
}

// An in-place rename of a markdown file carries its .history/<stem>/ folder
// to the new name; a cross-directory move leaves history behind.
func TestFileAPI_MoveRenamesHistoryDir(t *testing.T) {
	_, do, root := fileAPITestSetup(t, nil, map[string]string{
		"Docs/notes.md": "# notes\n",
	})
	// Pre-seed a history snapshot for notes.md.
	histOld := filepath.Join(root, "Docs", ".zddc.d", "history", "notes")
	if err := os.MkdirAll(histOld, 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(histOld, "20260101T000000.000Z-a@example.com.md"), []byte("# notes\n"), 0o644); err != nil {
		t.Fatal(err)
	}

	// In-place rename → history dir follows.
	rec := do(http.MethodPost, "/Docs/notes.md", "alice@example.com", nil, map[string]string{
		"X-ZDDC-Op":          "move",
		"X-ZDDC-Destination": "/Docs/renamed.md",
	})
	if rec.Code != http.StatusOK {
		t.Fatalf("rename: want 200, got %d: %s", rec.Code, rec.Body.String())
	}
	if _, err := os.Stat(filepath.Join(root, "Docs", ".zddc.d", "history", "renamed")); err != nil {
		t.Errorf("history dir not renamed to <newstem>: %v", err)
	}
	if _, err := os.Stat(histOld); !os.IsNotExist(err) {
		t.Errorf("old history dir should be gone after rename; err=%v", err)
	}

	// Cross-directory move → history stays behind in the source dir.
	if err := os.MkdirAll(filepath.Join(root, "Other"), 0o755); err != nil {
		t.Fatal(err)
	}
	rec = do(http.MethodPost, "/Docs/renamed.md", "alice@example.com", nil, map[string]string{
		"X-ZDDC-Op":          "move",
		"X-ZDDC-Destination": "/Other/renamed.md",
	})
	if rec.Code != http.StatusOK {
		t.Fatalf("cross-dir move: want 200, got %d: %s", rec.Code, rec.Body.String())
	}
	if _, err := os.Stat(filepath.Join(root, "Docs", ".zddc.d", "history", "renamed")); err != nil {
		t.Errorf("history should stay behind on a cross-dir move: %v", err)
	}
	if _, err := os.Stat(filepath.Join(root, "Other", ".zddc.d", "history", "renamed")); !os.IsNotExist(err) {
		t.Errorf("history should NOT follow a cross-dir move; err=%v", err)
	}
}

// TestFileAPI_PreservesCase — mkdir + PUT must keep the basename's case
// verbatim (tracking numbers are uppercase; the server must not normalize).
func TestFileAPI_PreservesCase(t *testing.T) {
	// working/<party>/… is the realistic create surface; the harness
	// auto-registers Acme so the party gate passes. Subfolder + file names
	// under it are arbitrary — isolates the basename-case question.
	_, do, root := fileAPITestSetup(t, []string{"Proj/working/Acme"}, nil)
	base := filepath.Join(root, "Proj", "working", "Acme")

	rec := do(http.MethodPost, "/Proj/working/Acme/MixedCaseDir", "alice@example.com", nil, map[string]string{"X-ZDDC-Op": "mkdir"})
	if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
		t.Fatalf("mkdir: %d %s", rec.Code, rec.Body.String())
	}
	rec = do(http.MethodPut, "/Proj/working/Acme/UPPER-Name.MD", "alice@example.com", []byte("# hi\n"), map[string]string{"Content-Type": "text/markdown"})
	if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
		t.Fatalf("put: %d %s", rec.Code, rec.Body.String())
	}
	ents, _ := os.ReadDir(base)
	var names []string
	for _, e := range ents {
		names = append(names, e.Name())
	}
	t.Logf("on-disk under working/Acme: %v", names)
	if _, err := os.Stat(filepath.Join(base, "MixedCaseDir")); err != nil {
		t.Errorf("mkdir case NOT preserved (%v)", names)
	}
	if _, err := os.Stat(filepath.Join(base, "UPPER-Name.MD")); err != nil {
		t.Errorf("PUT case NOT preserved (%v)", names)
	}
}

// TestFileAPI_MoveOntoConfigRequiresConfigEdit pins the authorization parity
// between MOVE and PUT/DELETE for config files. alice@example.com holds rwcd
// (create+write) via the default *@example.com grant and is elevated — but is
// named in no admins: and holds no `a` verb, so she is NOT a config-editor.
// Moving a file ONTO a .zddc/.zddc.zip, or moving a .zddc AWAY, mutates policy
// and must require config-edit (VerbA), not mere create/write. Pre-fix, MOVE
// authorized the destination as ActionCreate and the source as ActionWrite, so
// each of these succeeded — letting a non-admin plant an attacker-controlled
// cascade. See configWriteAction / serveFileMove.
func TestFileAPI_MoveOntoConfigRequiresConfigEdit(t *testing.T) {
	t.Run("move onto .zddc is forbidden", func(t *testing.T) {
		_, do, root := fileAPITestSetup(t, []string{"Incoming"}, map[string]string{
			"Incoming/payload.txt": "x",
		})
		rec := do(http.MethodPost, "/Incoming/payload.txt", "alice@example.com", nil, map[string]string{
			headerOp:          opMove,
			headerDestination: "/Incoming/.zddc",
		})
		if rec.Code != http.StatusForbidden {
			t.Fatalf("want 403, got %d: %s", rec.Code, rec.Body.String())
		}
		if _, err := os.Stat(filepath.Join(root, "Incoming/.zddc")); err == nil {
			t.Fatal("a .zddc was planted despite the 403")
		}
	})

	t.Run("move onto .zddc.zip is forbidden", func(t *testing.T) {
		_, do, root := fileAPITestSetup(t, []string{"Incoming"}, map[string]string{
			"Incoming/payload.txt": "x",
		})
		rec := do(http.MethodPost, "/Incoming/payload.txt", "alice@example.com", nil, map[string]string{
			headerOp:          opMove,
			headerDestination: "/Incoming/.zddc.zip",
		})
		if rec.Code != http.StatusForbidden {
			t.Fatalf("want 403, got %d: %s", rec.Code, rec.Body.String())
		}
		if _, err := os.Stat(filepath.Join(root, "Incoming/.zddc.zip")); err == nil {
			t.Fatal("a .zddc.zip bundle was planted despite the 403")
		}
	})

	t.Run("moving a .zddc away is forbidden", func(t *testing.T) {
		_, do, root := fileAPITestSetup(t, []string{"Incoming", "Other"}, map[string]string{
			"Incoming/.zddc": "acl:\n  permissions:\n    \"*@example.com\": rwcd\n",
		})
		rec := do(http.MethodPost, "/Incoming/.zddc", "alice@example.com", nil, map[string]string{
			headerOp:          opMove,
			headerDestination: "/Other/.zddc",
		})
		if rec.Code != http.StatusForbidden {
			t.Fatalf("want 403, got %d: %s", rec.Code, rec.Body.String())
		}
		if _, err := os.Stat(filepath.Join(root, "Incoming/.zddc")); err != nil {
			t.Fatalf("source .zddc was removed despite the 403: %v", err)
		}
	})

	t.Run("config-editor may move config (positive control)", func(t *testing.T) {
		_, do, root := fileAPITestSetup(t, []string{"Incoming"}, map[string]string{
			"Incoming/payload.txt": "x",
		})
		// Grant admin@example.com the `a` verb (standing config-edit) on top
		// of rwcd. The same MOVE that alice is denied must still succeed for a
		// config-editor — proving the fix gates on authority, not on the verb.
		if err := os.WriteFile(filepath.Join(root, ".zddc"),
			[]byte("acl:\n  permissions:\n    \"admin@example.com\": rwcda\n    \"*@example.com\": rwcd\n"), 0o644); err != nil {
			t.Fatalf("rewrite root .zddc: %v", err)
		}
		zddc.InvalidateCache(root)
		rec := do(http.MethodPost, "/Incoming/payload.txt", "admin@example.com", nil, map[string]string{
			headerOp:          opMove,
			headerDestination: "/Incoming/.zddc",
		})
		if rec.Code != http.StatusOK {
			t.Fatalf("config-editor move: want 200, got %d: %s", rec.Code, rec.Body.String())
		}
		if _, err := os.Stat(filepath.Join(root, "Incoming/.zddc")); err != nil {
			t.Fatalf("authorized editor's move did not land the file: %v", err)
		}
	})
}
