// Package handler — ssrhandler.go: Supplier/Subcontractor Status Report
// lifecycle endpoints that don't fit the generic form / file API shapes.
//
// Two endpoints live here:
//
//	POST /<project>/ssr/form.html              → "+ Add row" / SSR create
//	POST /<project>/ssr/<old>.yaml             with X-ZDDC-Op: ssr-rename
//	                                            X-ZDDC-Destination: /<project>/ssr/<new>.yaml
//
// Both target the project-level SSR aggregator, which is a virtual view
// (see zddc/virtualviews.go). Internally they materialize / rename real
// party folders under <project>/archive/.
//
// The generic file API only does file-level moves; renaming a party
// folder means renaming a directory and bringing every row inside with
// it. Rather than weaken serveFileMove's IsDir-source guard (which keeps
// callers from accidentally moving entire subtrees), this file
// implements ssr-rename as a tightly-scoped op that only fires for the
// SSR virtual URL shape.

package handler

import (
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"strings"
	"time"

	"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
	"codeberg.org/VARASYS/ZDDC/zddc/internal/jsonschema"
	"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
	"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
	"gopkg.in/yaml.v3"
)

// opSSRRename is the X-ZDDC-Op value that fires serveSSRRename. Dispatched
// from serveFilePost in fileapi.go.
const opSSRRename = "ssr-rename"

// serveFormCreateSSR materializes a new party folder under
// <project>/archive/<name>/ and writes archive/<name>/ssr.yaml from the
// submitted form body. The `name` field doubles as the party folder
// name and is stripped from the YAML before write (path-derived).
//
// ACL gate: ActionCreate at <project>/archive/<name>/ — typically
// satisfied by document_controller's rwc grant on archive/ in the
// project-level cascade, OR by a deployment that grants `c` to a wider
// audience.
//
// Auto-own .zddc is seeded for the new party folder via
// WriteAutoOwnZddc, the same machinery the generic mkdir uses.
// Authenticated email is required (401 otherwise) so the auto-own
// grant always names a real principal.
func serveFormCreateSSR(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *http.Request) {
	email := EmailFromContext(r)
	if email == "" {
		http.Error(w, "authentication required", http.StatusUnauthorized)
		return
	}
	if req.Project == "" {
		http.Error(w, "internal: SSR create missing project", http.StatusInternalServerError)
		return
	}

	data, err := decodeRequestData(r)
	if err != nil {
		http.Error(w, "request body: "+err.Error(), http.StatusBadRequest)
		return
	}
	spec, err := loadFormSpec(cfg.Root, req.SpecPath)
	if err != nil {
		http.Error(w, "form spec error: "+err.Error(), http.StatusInternalServerError)
		return
	}
	if errs := jsonschema.Validate(spec.Schema, data); len(errs) > 0 {
		writeValidationErrors(w, errs)
		return
	}

	dataMap, ok := data.(map[string]interface{})
	if !ok {
		http.Error(w, "request body must be a YAML/JSON object", http.StatusBadRequest)
		return
	}
	nameRaw, _ := dataMap["name"].(string)
	name := strings.TrimSpace(nameRaw)
	if !zddc.ValidPartyName(name) {
		writeValidationErrors(w, []jsonschema.Error{{
			Path:    "/name",
			Message: "must match " + `^[A-Za-z0-9][A-Za-z0-9.-]*$`,
		}})
		return
	}

	partyAbs := filepath.Join(cfg.Root, req.Project, "archive", name)
	if !strings.HasPrefix(partyAbs, cfg.Root+string(filepath.Separator)) {
		http.Error(w, "Not Found", http.StatusNotFound)
		return
	}
	partyURL := "/" + req.Project + "/archive/" + name + "/"
	rowURL := "/" + req.Project + "/ssr/" + name + ".yaml"

	// ACL gate: create at <project>/archive/<name>/. authorizeAction walks
	// up to the closest existing ancestor for the chain — typically
	// <project>/archive/, where document_controller carries rwc per the
	// project-level cascade.
	if !authorizeAction(cfg, w, r, partyAbs, partyURL, policy.ActionCreate) {
		return
	}

	// Refuse to clobber an existing party folder. The SSR view shows
	// any folder under archive/*/; if one with this name exists, the
	// user should edit that row instead of creating a duplicate.
	if info, err := os.Stat(partyAbs); err == nil {
		if info.IsDir() {
			http.Error(w, "Conflict — a party folder with that name already exists", http.StatusConflict)
			return
		}
		http.Error(w, "Conflict — a file exists at this path", http.StatusConflict)
		return
	} else if !errors.Is(err, os.ErrNotExist) {
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		return
	}

	// Materialize canonical ancestors (<project>/archive/) with auto-own
	// seeding before creating the party folder itself. Mirrors what the
	// generic file-API mkdir does at fileapi.go:629-634.
	yamlAbs := filepath.Join(partyAbs, "ssr.yaml")
	if _, err := zddc.EnsureCanonicalAncestors(cfg.Root, yamlAbs, email, 0o755); err != nil {
		slog.Warn("ssr-create: ensure canonical ancestors", "path", yamlAbs, "err", err)
	}
	if err := os.MkdirAll(partyAbs, 0o755); err != nil {
		auditFile(r, "ssr-create", rowURL, http.StatusInternalServerError, 0, err)
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		return
	}
	// Auto-own .zddc on the new party folder. archive/*/ is declared
	// auto_own in defaults.zddc.yaml, so the unfenced creator grant
	// fires here exactly as it would for a manual mkdir.
	if zddc.AutoOwnAt(cfg.Root, partyAbs) || zddc.AutoOwnAt(cfg.Root, filepath.Dir(partyAbs)) {
		var werr error
		if zddc.AutoOwnFencedAt(cfg.Root, partyAbs) {
			werr = zddc.WriteAutoOwnZddcFenced(partyAbs, email)
		} else {
			werr = zddc.WriteAutoOwnZddc(partyAbs, email)
		}
		if werr != nil {
			slog.Warn("ssr-create: auto-own .zddc write failed", "path", partyAbs, "err", werr)
		}
	}

	// Drop the path-derived `name` field — it's the folder name, not
	// row data. The dispatcher re-injects it on read.
	delete(dataMap, "name")

	yamlBytes, err := yaml.Marshal(dataMap)
	if err != nil {
		auditFile(r, "ssr-create", rowURL, http.StatusInternalServerError, 0, err)
		http.Error(w, "marshal yaml: "+err.Error(), http.StatusInternalServerError)
		return
	}
	// Route through WriteWithHistory so audit fields (created_*,
	// updated_*, revision=1) are stamped uniformly with the PUT
	// path. No prior file exists, so the history-write branch is a
	// no-op — only the stamping + live write fire.
	res, verrs, herr := WriteWithHistory(cfg, yamlAbs, rowURL, yamlBytes, email)
	if herr != nil {
		auditFile(r, "ssr-create", rowURL, http.StatusInternalServerError, len(yamlBytes), herr)
		http.Error(w, "write: "+herr.Error(), http.StatusInternalServerError)
		return
	}
	if len(verrs) > 0 {
		writeValidationErrors(w, verrs)
		return
	}
	finalBody := res.FinalBody

	w.Header().Set("Location", rowURL)
	w.Header().Set("Content-Type", "application/json")
	w.Header().Set("X-ZDDC-Source", "ssr-create")
	w.WriteHeader(http.StatusCreated)
	_, _ = w.Write([]byte(fmt.Sprintf(`{"ok":true,"location":%q}`, rowURL)))
	auditFile(r, "ssr-create", rowURL, http.StatusCreated, len(finalBody), nil)
}

// serveFormCreateRollup adds a row to a project-level MDL or RSK
// rollup view by writing it inside the per-party folder named by the
// submitted `party` field.
//
// Wire-form: POST /<project>/(mdl|rsk)/form.html
//
//	Content-Type: application/yaml | application/json
//	body: { party: "<name>", ...row fields... }
//
// The rollup form schema (default-project-{mdl,rsk}.form.yaml) makes
// `party` a required field; the rollup view's `Package` column maps
// to it. The party folder must already exist — create it via the
// SSR view first. ACL gate: ActionCreate at
// <project>/archive/<party>/<slot>/, same chain the generic
// serveFormCreate would gate against if the user were on the
// per-party path directly.
//
// On success: 201 + Location: /<project>/<slot>/<party>__<filename>.yaml,
// the virtual row URL that the rollup table client uses to address
// the new row.
func serveFormCreateRollup(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *http.Request) {
	email := EmailFromContext(r)
	if email == "" {
		http.Error(w, "authentication required", http.StatusUnauthorized)
		return
	}
	if req.Project == "" || (req.Slot != "mdl" && req.Slot != "rsk") {
		http.Error(w, "internal: rollup create missing project/slot", http.StatusInternalServerError)
		return
	}

	data, err := decodeRequestData(r)
	if err != nil {
		http.Error(w, "request body: "+err.Error(), http.StatusBadRequest)
		return
	}
	spec, err := loadFormSpec(cfg.Root, req.SpecPath)
	if err != nil {
		http.Error(w, "form spec error: "+err.Error(), http.StatusInternalServerError)
		return
	}
	if errs := jsonschema.Validate(spec.Schema, data); len(errs) > 0 {
		writeValidationErrors(w, errs)
		return
	}

	dataMap, ok := data.(map[string]interface{})
	if !ok {
		http.Error(w, "request body must be a YAML/JSON object", http.StatusBadRequest)
		return
	}
	partyRaw, _ := dataMap["party"].(string)
	party := strings.TrimSpace(partyRaw)
	if !zddc.ValidPartyName(party) {
		writeValidationErrors(w, []jsonschema.Error{{
			Path:    "/party",
			Message: "must match " + `^[A-Za-z0-9][A-Za-z0-9.-]*$`,
		}})
		return
	}

	partyAbs := filepath.Join(cfg.Root, req.Project, "archive", party)
	if !strings.HasPrefix(partyAbs, cfg.Root+string(filepath.Separator)) {
		http.Error(w, "Not Found", http.StatusNotFound)
		return
	}
	if info, err := os.Stat(partyAbs); err != nil || !info.IsDir() {
		writeValidationErrors(w, []jsonschema.Error{{
			Path:    "/party",
			Message: "party folder does not exist — create it via the SSR view first",
		}})
		return
	}

	slotAbs := filepath.Join(partyAbs, req.Slot)
	slotURL := "/" + req.Project + "/archive/" + party + "/" + req.Slot + "/"
	rowDirURL := slotURL // The slot folder where the new row lands.
	_ = rowDirURL        // kept for clarity; ACL chain is gated below.

	// ACL gate: create at <project>/archive/<party>/<slot>/. authorizeAction
	// walks up to the closest existing ancestor (typically <party>/), where
	// the auto-own .zddc grants the party owner rwcd.
	if !authorizeAction(cfg, w, r, slotAbs, slotURL, policy.ActionCreate) {
		return
	}

	// Strip the routing key from the data before write — the folder
	// name IS the identity and the per-party MDL/RSK schemas forbid
	// `additionalProperties` other than the listed ones.
	delete(dataMap, "party")

	if err := os.MkdirAll(slotAbs, 0o755); err != nil {
		auditFile(r, "rollup-create", req.SubmitURL, http.StatusInternalServerError, 0, err)
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		return
	}

	// Resolve the cascade rule at slotAbs to pick a composed filename.
	// The defaults.zddc.yaml records: entries declare a "*.yaml" rule
	// for both mdl/ and rsk/ folders with filename_format pointing at
	// body fields; for RSK, the rule also carries row_field +
	// row_scope_fields so the server can assign the next row sequence
	// within the table-tracking group.
	chain, err := zddc.EffectivePolicy(cfg.Root, slotAbs)
	if err != nil {
		auditFile(r, "rollup-create", req.SubmitURL, http.StatusInternalServerError, 0, err)
		http.Error(w, "cascade resolve: "+err.Error(), http.StatusInternalServerError)
		return
	}
	// Probe with the wildcard placeholder; the embedded defaults
	// declare a "*.yaml" entry for both slots.
	_, rule, hasRule := chain.EffectiveRecordRule("placeholder.yaml")

	var target, fname string
	if hasRule && rule.FilenameFormat != "" {
		// Apply field_defaults (e.g. type=RSK for rsk/).
		for k, want := range rule.FieldDefaults {
			if _, present := dataMap[k]; !present {
				dataMap[k] = want
			}
		}
		// Auto-assign the per-row sequence for RSK-style rules.
		if rule.RowField != "" {
			rowVal, rerr := AssignNextRow(slotAbs, rule.RowField, rule.RowScopeFields, dataMap)
			if rerr != nil {
				auditFile(r, "rollup-create", req.SubmitURL, http.StatusInternalServerError, 0, rerr)
				http.Error(w, "row assign: "+rerr.Error(), http.StatusInternalServerError)
				return
			}
			dataMap[rule.RowField] = rowVal
		}
		composed, cerr := composeFilename(rule.FilenameFormat, dataMap)
		if cerr != nil {
			writeValidationErrors(w, []jsonschema.Error{{Path: "/", Message: cerr.Error()}})
			return
		}
		fname = composed + ".yaml"
		target = filepath.Join(slotAbs, fname)
		if _, err := os.Stat(target); err == nil {
			http.Error(w, "Conflict — a row with this composed tracking number already exists", http.StatusConflict)
			return
		}
	} else {
		// Fallback for deployments that override the embedded
		// defaults without providing records: entries — keep the
		// historical date+email naming so they aren't broken by
		// this upgrade.
		dateStr := time.Now().UTC().Format("2006-01-02")
		emailSan := sanitizeEmail(email)
		base := dateStr + "-" + emailSan
		var ok bool
		target, fname, ok = pickAvailableFilename(slotAbs, base, ".yaml")
		if !ok {
			http.Error(w, "could not pick a free filename (>100 collisions)", http.StatusConflict)
			return
		}
	}

	yamlBytes, err := yaml.Marshal(dataMap)
	if err != nil {
		auditFile(r, "rollup-create", req.SubmitURL, http.StatusInternalServerError, 0, err)
		http.Error(w, "marshal yaml: "+err.Error(), http.StatusInternalServerError)
		return
	}
	// Route through WriteWithHistory for audit stamping. The
	// filename_format check inside WriteWithHistory passes because
	// the path we constructed above used the same composition.
	res, verrs, herr := WriteWithHistory(cfg, target, "/"+req.Project+"/"+req.Slot+"/"+party+"__"+fname, yamlBytes, email)
	if herr != nil {
		auditFile(r, "rollup-create", req.SubmitURL, http.StatusInternalServerError, len(yamlBytes), herr)
		http.Error(w, "write: "+herr.Error(), http.StatusInternalServerError)
		return
	}
	if len(verrs) > 0 {
		writeValidationErrors(w, verrs)
		return
	}
	finalBody := res.FinalBody

	rowURL := "/" + req.Project + "/" + req.Slot + "/" + party + "__" + fname
	w.Header().Set("Location", rowURL)
	w.Header().Set("Content-Type", "application/json")
	w.Header().Set("X-ZDDC-Source", "rollup-create")
	w.WriteHeader(http.StatusCreated)
	_, _ = w.Write([]byte(fmt.Sprintf(`{"ok":true,"location":%q}`, rowURL)))
	auditFile(r, "rollup-create", rowURL, http.StatusCreated, len(finalBody), nil)
}

// serveSSRRename renames a party folder by rewriting an SSR row URL.
//
// Wire-form: POST /<project>/ssr/<old>.yaml
//
//	X-ZDDC-Op: ssr-rename
//	X-ZDDC-Destination: /<project>/ssr/<new>.yaml
//
// Both source and destination must resolve as SSR-row virtual URLs in
// the same project. The destination party name must be a valid party
// folder name, must differ from the source, and must not already exist.
//
// ACL: ActionWrite at <project>/archive/<old>/ AND ActionCreate at
// <project>/archive/<new>/. Both gates evaluate against their canonical
// archive paths so non-owners can't rename someone else's party.
//
// On success: os.Rename moves archive/<old>/ → archive/<new>/ (an
// atomic directory rename on the same filesystem); every row inside
// follows. No denormalized `party:` field rewrite is needed — the
// MDL/RSK schemas don't carry one.
func serveSSRRename(cfg config.Config, w http.ResponseWriter, r *http.Request) {
	email := EmailFromContext(r)
	if email == "" {
		http.Error(w, "authentication required", http.StatusUnauthorized)
		return
	}

	src := zddc.ResolveVirtualView(cfg.Root, r.URL.Path)
	if !src.Resolved || src.Kind != zddc.VirtualViewSSRRow {
		http.Error(w, "Bad Request — ssr-rename source must be /<project>/ssr/<party>.yaml", http.StatusBadRequest)
		return
	}

	dstHeader := r.Header.Get(headerDestination)
	if dstHeader == "" {
		http.Error(w, "Bad Request — missing "+headerDestination+" header", http.StatusBadRequest)
		return
	}
	if dec, err := url.PathUnescape(dstHeader); err == nil {
		dstHeader = dec
	}
	dst := zddc.ResolveVirtualView(cfg.Root, dstHeader)
	if !dst.Resolved || dst.Kind != zddc.VirtualViewSSRRow {
		http.Error(w, "Bad Request — ssr-rename destination must be /<project>/ssr/<party>.yaml", http.StatusBadRequest)
		return
	}
	if dst.Project != src.Project {
		http.Error(w, "Bad Request — ssr-rename cannot cross projects", http.StatusBadRequest)
		return
	}
	if dst.Party == src.Party {
		http.Error(w, "Bad Request — destination is the same as source", http.StatusBadRequest)
		return
	}

	// Source party folder must exist.
	srcArchive := src.PartyArchive
	if info, err := os.Stat(srcArchive); err != nil || !info.IsDir() {
		if err != nil && !errors.Is(err, os.ErrNotExist) {
			http.Error(w, "Internal Server Error", http.StatusInternalServerError)
			return
		}
		http.Error(w, "Not Found", http.StatusNotFound)
		return
	}
	// Destination must not exist.
	dstArchive := dst.PartyArchive
	if _, err := os.Stat(dstArchive); err == nil {
		http.Error(w, "Conflict — destination party folder already exists", http.StatusConflict)
		return
	} else if !errors.Is(err, os.ErrNotExist) {
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		return
	}

	// ACL: write on src archive, create on dst archive. URLs include
	// the trailing slash convention used elsewhere for directory ops.
	srcArchiveURL := "/" + src.Project + "/archive/" + src.Party + "/"
	dstArchiveURL := "/" + dst.Project + "/archive/" + dst.Party + "/"
	if !authorizeAction(cfg, w, r, srcArchive, srcArchiveURL, policy.ActionWrite) {
		return
	}
	if !authorizeAction(cfg, w, r, dstArchive, dstArchiveURL, policy.ActionCreate) {
		return
	}

	// Optional If-Match against the source ssr.yaml etag.
	if !checkIfMatch(w, r, src.CanonicalAbs) {
		return
	}

	if err := os.Rename(srcArchive, dstArchive); err != nil {
		auditFile(r, "ssr-rename", r.URL.Path, http.StatusInternalServerError, 0, err)
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		return
	}

	newURL := "/" + dst.Project + "/ssr/" + dst.Party + ".yaml"
	w.Header().Set("Location", newURL)
	w.Header().Set("X-ZDDC-Destination", newURL)
	w.Header().Set("X-ZDDC-Source", "ssr-rename")
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	resp, _ := json.Marshal(map[string]string{"location": newURL})
	_, _ = w.Write(resp)
	auditFile(r, "ssr-rename", r.URL.Path, http.StatusOK, 0, nil)
}
