package handler

import (
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"strings"
	"time"

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

// File API — authenticated CRUD over the served tree.
//
//	PUT    /<path>             write or overwrite. Body = file bytes (capped
//	                           by cfg.MaxWriteBytes). Auto-creates parent
//	                           directories. Optional If-Match for optimistic
//	                           concurrency. Returns 201 Created (new) or 200
//	                           OK (overwrite) with ETag.
//	DELETE /<path>             remove a file. Optional If-Match. Refuses to
//	                           delete directories or hidden paths.
//	POST   /<path>             control verb dispatched by X-ZDDC-Op header:
//	                             move:  X-ZDDC-Destination is the new path.
//	                                    Atomic os.Rename. Optional If-Match.
//	                             mkdir: create directory at /<path>/. Idempotent.
//
// All operations route through the same ACL chain as GET — but with
// policy action="write" so external Rego can split read from write.
// The internal decider treats both identically.
//
// Path posture matches the rest of the dispatch:
//   - hidden segments (./_-prefixed) are 404'd
//   - the apps cache directory _app is 404'd
//   - traversal that escapes Root is 404'd
//
// Audit: every successful write logs a structured `file_write` event
// (op, path, email, status, bytes) at INFO. Failed writes log at WARN.
const (
	headerOp          = "X-ZDDC-Op"
	headerDestination = "X-ZDDC-Destination"

	opMove  = "move"
	opMkdir = "mkdir"
)

// IsWriteMethod reports whether this method is handled by the file API.
// Used by the dispatcher to gate writes through ServeFileAPI before the
// read-path tree of static / app / directory handling.
func IsWriteMethod(method string) bool {
	switch method {
	case http.MethodPut, http.MethodDelete, http.MethodPost:
		return true
	}
	return false
}

// ServeFileAPI is the entry point for write methods. The dispatcher
// has already verified the path doesn't contain reserved segments.
// Caller must have already enforced the dot-prefix / _app guards
// (these match dispatch's existing ones, but we re-check defensively).
func ServeFileAPI(cfg config.Config, w http.ResponseWriter, r *http.Request) {
	switch r.Method {
	case http.MethodPut:
		serveFilePut(cfg, w, r)
	case http.MethodDelete:
		serveFileDelete(cfg, w, r)
	case http.MethodPost:
		serveFilePost(cfg, w, r)
	default:
		w.Header().Set("Allow", "GET, HEAD, PUT, DELETE, POST, OPTIONS")
		http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
	}
}

// resolveTargetPath validates urlPath, joins it onto cfg.Root, and
// rejects traversal/hidden segments. Returns absolute path + the
// cleaned URL path (with one leading "/").
func resolveTargetPath(cfg config.Config, urlPath string) (absPath, cleanURL string, ok bool, status int, msg string) {
	if urlPath == "" || urlPath == "/" {
		return "", "", false, http.StatusBadRequest, "empty path"
	}
	cleanURL = "/" + strings.Trim(urlPath, "/")
	if strings.HasSuffix(urlPath, "/") {
		cleanURL += "/"
	}

	// Reject hidden / reserved segments. Mirrors dispatch's guard,
	// applied here too because external callers reach ServeFileAPI
	// only via dispatch — but defense in depth costs nothing.
	for _, seg := range strings.Split(strings.Trim(cleanURL, "/"), "/") {
		if seg == "" {
			continue
		}
		if seg == "_app" || strings.HasPrefix(seg, ".") || strings.HasPrefix(seg, "_") {
			return "", "", false, http.StatusNotFound, "reserved path segment"
		}
	}

	rel := filepath.FromSlash(strings.TrimPrefix(strings.TrimSuffix(cleanURL, "/"), "/"))
	abs := filepath.Join(cfg.Root, rel)
	if !strings.HasPrefix(abs, cfg.Root+string(filepath.Separator)) && abs != cfg.Root {
		return "", "", false, http.StatusNotFound, "path traversal"
	}
	return abs, cleanURL, true, 0, ""
}

// authorizeAction runs the ACL chain for a verb-tagged write to absPath.
// The chain is computed from the closest existing ancestor (so writes
// that create a brand-new file inherit the parent directory's chain).
// Returns allowed=false with the response status already written on deny.
//
// Admin escape hatches: root admins (IsAdmin) and subtree admins
// (IsSubtreeAdmin) get unconditional access — the cascade evaluator
// and the WORM mask do not see their requests at all. This matches
// the existing admin-bypass semantics in /.profile/zddc and is the
// only way to mutate filed documents in Issued/Received.
//
// .zddc writes use the stricter CanEditZddc rule (strict-ancestor
// admin authority) regardless of the action verb, since the file
// being written is itself the source of the authority decision and
// the strict-ancestor rule is the existing defense against
// self-elevation.
func authorizeAction(cfg config.Config, w http.ResponseWriter, r *http.Request, absPath, urlPath, action string) bool {
	probe := filepath.Dir(absPath)
	for {
		info, err := os.Stat(probe)
		if err == nil && info.IsDir() {
			break
		}
		if probe == cfg.Root || !strings.HasPrefix(probe, cfg.Root+string(filepath.Separator)) {
			probe = cfg.Root
			break
		}
		probe = filepath.Dir(probe)
	}

	email := EmailFromContext(r)

	// Admin bypass — root and subtree.
	if zddc.IsAdmin(cfg.Root, email) {
		return true
	}
	if zddc.IsSubtreeAdmin(cfg.Root, probe, email) {
		return true
	}

	// .zddc writes: CanEditZddc enforces the strict-ancestor rule that
	// prevents a subtree admin from elevating themselves by editing the
	// .zddc that grants their authority. Non-admins fall through to the
	// regular decider — they will be denied unless an explicit `a` verb
	// is granted to a non-admin role at this path, which is unusual.
	if filepath.Base(absPath) == ".zddc" {
		zddcDir := filepath.Dir(absPath)
		if zddc.CanEditZddc(cfg.Root, zddcDir, email) {
			return true
		}
		// Non-admin .zddc writes go through the normal cascade with
		// action=admin. Most deployments will have no acl.permissions
		// entry granting `a`, so this denies; operators who want
		// non-admin .zddc edits can grant `a` explicitly.
	}

	chain, err := zddc.EffectivePolicy(cfg.Root, probe)
	if err != nil {
		slog.Warn("file API ACL chain error", "path", absPath, "err", err)
	}

	decider := DeciderFromContext(r)
	allowed, _ := policy.AllowActionFromChain(r.Context(), decider, chain, email, urlPath, action)
	if !allowed {
		http.Error(w, "Forbidden", http.StatusForbidden)
		return false
	}
	return true
}

// readBodyCapped consumes r.Body up to cfg.MaxWriteBytes. Returns 413
// on overflow. The body is read fully into memory — small / medium files
// are the dominant traffic and atomic write needs the whole payload before
// the rename. Streaming PUTs (chunked uploads, multi-part resumable)
// are out of scope for this iteration.
func readBodyCapped(cfg config.Config, w http.ResponseWriter, r *http.Request) ([]byte, bool) {
	limit := cfg.MaxWriteBytes
	if limit <= 0 {
		limit = 256 * 1024 * 1024
	}
	// http.MaxBytesReader writes a 413 itself when the limit is hit
	// during read, but its error message is not always recognizable —
	// we wrap it to surface a clean status code from the wrapped error.
	r.Body = http.MaxBytesReader(w, r.Body, limit)
	body, err := io.ReadAll(r.Body)
	if err != nil {
		var maxErr *http.MaxBytesError
		if errors.As(err, &maxErr) {
			http.Error(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
			return nil, false
		}
		http.Error(w, "Bad Request — could not read body: "+err.Error(), http.StatusBadRequest)
		return nil, false
	}
	return body, true
}

// fileETag returns the SHA-256 first-32-hex of bytes — the same scheme
// the static file serve handler uses, so PUT response ETags match what
// a subsequent GET would compute.
func fileETag(body []byte) string {
	sum := sha256.Sum256(body)
	return hex.EncodeToString(sum[:])[:32]
}

// fileETagOnDisk returns the ETag of the file at absPath (or "" if it
// doesn't exist). Used to evaluate If-Match on PUT/DELETE/MOVE.
func fileETagOnDisk(absPath string) (string, error) {
	body, err := os.ReadFile(absPath)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return "", nil
		}
		return "", err
	}
	return fileETag(body), nil
}

// checkIfMatch returns true if the request's If-Match header (when
// present) matches the current ETag for absPath. Empty header always
// passes. Wildcard ("*") passes iff the file exists. On precondition
// failure the response is written as 412 and false is returned.
//
// Special case for PUT: when allowMissing is true and the file doesn't
// exist, the wildcard "*" form fails (per RFC) but a specific ETag is
// treated as a no-current-file hit (412). This distinguishes
// create-new from update-existing semantically.
//
// Also honors If-Unmodified-Since (RFC 7232 §3.4): the request fails
// with 412 if the current file's mtime is strictly later than the
// header value. Used by the cache layer's offline-write outbox to
// detect concurrent modifications without ETag round-trips — the
// cached file's mtime (set from upstream's Last-Modified) becomes the
// base for the precondition. Either header (or both) can be present;
// both must pass.
func checkIfMatch(w http.ResponseWriter, r *http.Request, absPath string) bool {
	if !checkIfUnmodifiedSince(w, r, absPath) {
		return false
	}
	header := strings.TrimSpace(r.Header.Get("If-Match"))
	if header == "" {
		return true
	}
	current, err := fileETagOnDisk(absPath)
	if err != nil {
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		return false
	}
	if header == "*" {
		if current == "" {
			http.Error(w, "Precondition Failed — target does not exist", http.StatusPreconditionFailed)
			return false
		}
		return true
	}
	want := strings.Trim(header, `"`)
	if want != current {
		http.Error(w, "Precondition Failed — ETag mismatch", http.StatusPreconditionFailed)
		return false
	}
	return true
}

// checkIfUnmodifiedSince evaluates the RFC 7232 §3.4 precondition.
// Returns true (pass) when the header is absent or unparseable, when
// the target file does not exist, or when the file's current mtime
// is at or before the header value. Returns false (fail) and writes a
// 412 response when the file has been modified after the header time.
//
// mtime comparison uses the file's mod time truncated to whole
// seconds — HTTP-Date format has 1-second resolution, so a finer
// comparison would spuriously fail on filesystems that retain ns
// precision. "After" therefore means strictly greater than the
// header value at second resolution.
func checkIfUnmodifiedSince(w http.ResponseWriter, r *http.Request, absPath string) bool {
	header := strings.TrimSpace(r.Header.Get("If-Unmodified-Since"))
	if header == "" {
		return true
	}
	since, err := http.ParseTime(header)
	if err != nil {
		// Per RFC 7232: if the header value is unparseable, ignore.
		return true
	}
	info, err := os.Stat(absPath)
	if err != nil {
		// Missing file → no resource to compare against. Pass.
		return true
	}
	current := info.ModTime().Truncate(time.Second)
	if current.After(since.Truncate(time.Second)) {
		http.Error(w, "Precondition Failed — If-Unmodified-Since: file modified at "+current.UTC().Format(http.TimeFormat), http.StatusPreconditionFailed)
		return false
	}
	return true
}

func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) {
	abs, cleanURL, ok, status, msg := resolveTargetPath(cfg, r.URL.Path)
	if !ok {
		http.Error(w, msg, status)
		return
	}
	if strings.HasSuffix(cleanURL, "/") {
		http.Error(w, "PUT must target a file, not a directory", http.StatusBadRequest)
		return
	}

	// Resolve canonical-folder casing on the way in (no side effects): a
	// request for /Project/working/foo.md when the on-disk folder is
	// Working/ should land in Working/, not create a duplicate sibling.
	// The actual MkdirAll for missing canonical ancestors and the
	// auto-own .zddc seeding happen after authorisation, below.
	if r2, err := zddc.ResolveCanonicalPath(cfg.Root, abs); err == nil {
		abs = r2
	}

	// Stat first so we can choose action=create vs action=write before the
	// ACL gate runs — this matters because role grants may include `c` but
	// not `w` (or vice versa), and the gate must check the right verb.
	existed := false
	if info, err := os.Stat(abs); err == nil {
		if info.IsDir() {
			http.Error(w, "Conflict — a directory exists at this path", http.StatusConflict)
			return
		}
		existed = true
	}

	action := policy.ActionCreate
	if existed {
		action = policy.ActionWrite
	}
	// .zddc writes always require `a` (admin) regardless of create/overwrite.
	if filepath.Base(abs) == ".zddc" {
		action = policy.ActionAdmin
	}

	if !authorizeAction(cfg, w, r, abs, cleanURL, action) {
		return
	}
	if !checkIfMatch(w, r, abs) {
		return
	}

	body, ok := readBodyCapped(cfg, w, r)
	if !ok {
		return
	}

	// Now that the write is authorized, materialise any missing canonical
	// ancestors and seed auto-own .zddc files for them.
	if email := EmailFromContext(r); email != "" {
		if _, err := zddc.EnsureCanonicalAncestors(cfg.Root, abs, email, 0o755); err != nil {
			slog.Warn("ensure canonical ancestors", "path", abs, "err", err)
		}
	}

	if err := zddc.WriteAtomic(abs, body); err != nil {
		auditFile(r, "put", cleanURL, http.StatusInternalServerError, len(body), err)
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		return
	}
	// Invalidate ETag cache (static.go memoizes by mtime; rename produces
	// a fresh mtime so a stale entry is harmless, but clearing is cheap).
	etagCacheM.Delete(abs)

	etag := fileETag(body)
	w.Header().Set("ETag", `"`+etag+`"`)
	w.Header().Set("X-ZDDC-Source", "fileapi:put")
	respStatus := http.StatusCreated
	if existed {
		respStatus = http.StatusOK
	}
	w.WriteHeader(respStatus)
	auditFile(r, "put", cleanURL, respStatus, len(body), nil)
}

func serveFileDelete(cfg config.Config, w http.ResponseWriter, r *http.Request) {
	abs, cleanURL, ok, status, msg := resolveTargetPath(cfg, r.URL.Path)
	if !ok {
		http.Error(w, msg, status)
		return
	}
	if strings.HasSuffix(cleanURL, "/") {
		http.Error(w, "DELETE must target a file, not a directory", http.StatusBadRequest)
		return
	}

	info, err := os.Stat(abs)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			http.Error(w, "Not Found", http.StatusNotFound)
			return
		}
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		return
	}
	if info.IsDir() {
		http.Error(w, "Conflict — DELETE of directories is not supported", http.StatusConflict)
		return
	}

	action := policy.ActionDelete
	if filepath.Base(abs) == ".zddc" {
		action = policy.ActionAdmin
	}
	if !authorizeAction(cfg, w, r, abs, cleanURL, action) {
		return
	}
	if !checkIfMatch(w, r, abs) {
		return
	}

	if err := os.Remove(abs); err != nil {
		auditFile(r, "delete", cleanURL, http.StatusInternalServerError, 0, err)
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		return
	}
	etagCacheM.Delete(abs)

	w.Header().Set("X-ZDDC-Source", "fileapi:delete")
	w.WriteHeader(http.StatusNoContent)
	auditFile(r, "delete", cleanURL, http.StatusNoContent, 0, nil)
}

func serveFilePost(cfg config.Config, w http.ResponseWriter, r *http.Request) {
	op := strings.ToLower(strings.TrimSpace(r.Header.Get(headerOp)))
	switch op {
	case opMove:
		serveFileMove(cfg, w, r)
	case opMkdir:
		serveFileMkdir(cfg, w, r)
	case "":
		http.Error(w, "Bad Request — missing "+headerOp+" header", http.StatusBadRequest)
	default:
		http.Error(w, "Bad Request — unknown "+headerOp+" value: "+op, http.StatusBadRequest)
	}
}

func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) {
	srcAbs, srcURL, ok, status, msg := resolveTargetPath(cfg, r.URL.Path)
	if !ok {
		http.Error(w, msg, status)
		return
	}
	if strings.HasSuffix(srcURL, "/") {
		http.Error(w, "MOVE source must be a file path", http.StatusBadRequest)
		return
	}

	dstHeader := r.Header.Get(headerDestination)
	if dstHeader == "" {
		http.Error(w, "Bad Request — missing "+headerDestination+" header", http.StatusBadRequest)
		return
	}
	// Destination is sent as a URL path; decode percent-encoding.
	if dec, err := url.PathUnescape(dstHeader); err == nil {
		dstHeader = dec
	}
	dstAbs, dstURL, ok, status, msg := resolveTargetPath(cfg, dstHeader)
	if !ok {
		http.Error(w, "destination: "+msg, status)
		return
	}
	if strings.HasSuffix(dstURL, "/") {
		http.Error(w, "MOVE destination must be a file path", http.StatusBadRequest)
		return
	}

	// Resolve canonical-folder casing on src + dst (no side effects).
	if r2, err := zddc.ResolveCanonicalPath(cfg.Root, srcAbs); err == nil {
		srcAbs = r2
	}
	if r2, err := zddc.ResolveCanonicalPath(cfg.Root, dstAbs); err == nil {
		dstAbs = r2
	}

	// Source must exist as a regular file.
	srcInfo, err := os.Stat(srcAbs)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			http.Error(w, "Not Found", http.StatusNotFound)
		} else {
			http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		}
		return
	}
	if srcInfo.IsDir() {
		http.Error(w, "Conflict — MOVE of directories is not supported", http.StatusConflict)
		return
	}

	// Destination must not exist (no implicit overwrite). If-Match on the
	// SOURCE is still respected for concurrency on the source bytes.
	if _, err := os.Stat(dstAbs); err == nil {
		http.Error(w, "Conflict — destination already exists", http.StatusConflict)
		return
	} else if !errors.Is(err, os.ErrNotExist) {
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		return
	}

	// ACL: source side requires `w` (rename mutates the source); dest
	// side requires `c` (creates a new path). Cross-folder moves run
	// both gates against potentially different chains.
	if !authorizeAction(cfg, w, r, srcAbs, srcURL, policy.ActionWrite) {
		return
	}
	if !authorizeAction(cfg, w, r, dstAbs, dstURL, policy.ActionCreate) {
		return
	}
	if !checkIfMatch(w, r, srcAbs) {
		return
	}

	// Ensure destination's canonical ancestors are created (with auto-own
	// .zddc seeding) before the rename. This lets a MOVE from working/foo
	// → archive/<party>/issued/foo materialise the per-party folders on
	// the way in.
	if email := EmailFromContext(r); email != "" {
		if _, err := zddc.EnsureCanonicalAncestors(cfg.Root, dstAbs, email, 0o755); err != nil {
			slog.Warn("ensure canonical ancestors (move dst)", "path", dstAbs, "err", err)
		}
	}
	if err := os.MkdirAll(filepath.Dir(dstAbs), 0o755); err != nil {
		auditFile(r, "move", srcURL, http.StatusInternalServerError, 0, err)
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		return
	}

	if err := os.Rename(srcAbs, dstAbs); err != nil {
		// Cross-device or permission errors: report 500 — the client
		// will retry or surface the failure to the user.
		auditFile(r, "move", srcURL, http.StatusInternalServerError, 0, err)
		http.Error(w, "Internal Server Error — rename failed: "+err.Error(), http.StatusInternalServerError)
		return
	}
	etagCacheM.Delete(srcAbs)
	etagCacheM.Delete(dstAbs)

	// Compute new ETag from the moved bytes for the response — clients
	// that want to keep tracking should pin to this ETag.
	if etag, err := fileETagOnDisk(dstAbs); err == nil && etag != "" {
		w.Header().Set("ETag", `"`+etag+`"`)
	}
	w.Header().Set("X-ZDDC-Source", "fileapi:move")
	w.Header().Set("X-ZDDC-Destination", dstURL)
	w.WriteHeader(http.StatusOK)
	auditFile(r, "move", srcURL+" -> "+dstURL, http.StatusOK, 0, nil)
}

func serveFileMkdir(cfg config.Config, w http.ResponseWriter, r *http.Request) {
	abs, cleanURL, ok, status, msg := resolveTargetPath(cfg, r.URL.Path)
	if !ok {
		http.Error(w, msg, status)
		return
	}

	// Resolve canonical-folder casing on the way in (no side effects).
	if r2, err := zddc.ResolveCanonicalPath(cfg.Root, abs); err == nil {
		abs = r2
	}

	if !authorizeAction(cfg, w, r, abs, cleanURL, policy.ActionCreate) {
		return
	}

	// Idempotent: if the dir already exists, treat it as success;
	// if a file is at the path, conflict.
	if info, err := os.Stat(abs); err == nil {
		if info.IsDir() {
			w.Header().Set("X-ZDDC-Source", "fileapi:mkdir")
			w.WriteHeader(http.StatusOK)
			auditFile(r, "mkdir", cleanURL, http.StatusOK, 0, nil)
			return
		}
		http.Error(w, "Conflict — a file exists at this path", http.StatusConflict)
		return
	}

	// Materialise any missing canonical ancestors (working/, staging/,
	// archive/<party>/incoming/) before creating the target itself. This
	// also seeds auto-own .zddc on each newly-created canonical ancestor.
	email := EmailFromContext(r)
	if email != "" {
		if _, err := zddc.EnsureCanonicalAncestors(cfg.Root, abs, email, 0o755); err != nil {
			slog.Warn("ensure canonical ancestors", "path", abs, "err", err)
		}
	}

	if err := os.MkdirAll(abs, 0o755); err != nil {
		auditFile(r, "mkdir", cleanURL, http.StatusInternalServerError, 0, err)
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		return
	}

	// Auto-ownership for the newly-created directory itself.
	//
	// Two cases yield an auto-own .zddc inside abs:
	//   - The new directory is itself a canonical auto-own position
	//     (e.g. an explicit MKCOL of /Project/working). In this case
	//     IsAutoOwnPath(abs, cfg.Root) is true.
	//   - The new directory's parent is canonical auto-own — every child
	//     mkdir under working/, staging/, or archive/<party>/incoming/
	//     gets the creator's grant.
	if email != "" {
		if zddc.IsAutoOwnPath(abs, cfg.Root) || zddc.IsAutoOwnPath(filepath.Dir(abs), cfg.Root) {
			if err := zddc.WriteAutoOwnZddc(abs, email); err != nil {
				slog.Warn("auto-own .zddc write failed", "path", abs, "err", err)
			}
		}
	}

	// Staging↔working mirror: when a folder created under staging/ matches
	// the ZDDC transmittal-folder grammar AND its tracking number contains
	// -SUB- or -TRN-, also create the same-named folder under working/ as
	// a drafting space for staff. The mirror is one-way and one-shot —
	// renames or deletions of either side are not propagated.
	if email != "" {
		mirrorStagingToWorking(cfg, abs, email)
	}

	w.Header().Set("X-ZDDC-Source", "fileapi:mkdir")
	w.WriteHeader(http.StatusCreated)
	auditFile(r, "mkdir", cleanURL, http.StatusCreated, 0, nil)
}

// mirrorStagingToWorking creates a paired drafting folder under working/
// when newAbs is a transmittal-named folder under <project>/staging/. Best
// effort — failures are logged but do not affect the staging mkdir result.
//
// Eligibility:
//   - newAbs's parent is exactly <project>/staging/ (case-fold)
//   - filepath.Base(newAbs) parses as a transmittal folder
//     (YYYY-MM-DD_<tracking> (<status>) - <title>)
//   - tracking contains -SUB- or -TRN- (case-fold)
//
// Side effects on success:
//   - <project>/working/ created if missing, with auto-own .zddc seeded
//     (via EnsureCanonicalAncestors)
//   - <project>/working/<sameName>/ created if missing, with its own
//     auto-own .zddc (it's a child of the working/ canonical folder)
func mirrorStagingToWorking(cfg config.Config, newAbs, email string) {
	rel, err := filepath.Rel(cfg.Root, newAbs)
	if err != nil {
		return
	}
	rel = filepath.ToSlash(rel)
	parts := strings.Split(rel, "/")
	if len(parts) != 3 {
		// Mirror only fires for direct children of staging/. Deeper paths
		// (staging/<name>/sub/) are user-managed.
		return
	}
	if !strings.EqualFold(parts[1], "staging") {
		return
	}

	name := parts[2]
	_, tracking, _, _, ok := zddc.ParseTransmittalFolder(name)
	if !ok || !zddc.IsTrnOrSubTracking(tracking) {
		return
	}

	mirrorPath := filepath.Join(cfg.Root, parts[0], "working", name)
	// Idempotent: skip if the working sibling already exists.
	if info, err := os.Stat(mirrorPath); err == nil && info.IsDir() {
		return
	}

	// EnsureCanonicalAncestors creates working/ (with its own auto-own .zddc)
	// if missing; we then MkdirAll the mirror folder itself and seed its
	// auto-own grant.
	if _, err := zddc.EnsureCanonicalAncestors(cfg.Root, mirrorPath, email, 0o755); err != nil {
		slog.Warn("mirror: ensure ancestors", "path", mirrorPath, "err", err)
		return
	}
	if err := os.MkdirAll(mirrorPath, 0o755); err != nil {
		slog.Warn("mirror: mkdir", "path", mirrorPath, "err", err)
		return
	}
	if err := zddc.WriteAutoOwnZddc(mirrorPath, email); err != nil {
		slog.Warn("mirror: auto-own .zddc", "path", mirrorPath, "err", err)
	}
}

// auditFile emits a structured log line for each file API operation.
// AccessLogMiddleware already logs every request — this adds an
// op-tagged line so audit consumers can filter by operation without
// pattern-matching on method + path.
func auditFile(r *http.Request, op, path string, status any, bytes int, err error) {
	email := EmailFromContext(r)
	if email == "" {
		email = "anonymous"
	}
	args := []any{
		"op", op,
		"path", path,
		"email", email,
		"status", fmt.Sprint(status),
		"bytes", bytes,
	}
	if err != nil {
		args = append(args, "err", err.Error())
		slog.Warn("file_write", args...)
		return
	}
	slog.Info("file_write", args...)
}
