// Package handler — formhandler.go: the form-data system endpoints.
//
// URL conventions (the form always POSTs to the same URL it was GET'd from;
// the server strips ".html" and routes by what's underneath):
//
//	GET  /<path>/<name>.form.html         → render empty form
//	POST /<path>/<name>.form.html         → create new submission → 201 + Location
//	GET  /<path>/<name>/<id>.yaml.html    → render form pre-filled from <id>.yaml
//	POST /<path>/<name>/<id>.yaml.html    → validate + overwrite that submission → 200
//
// Direct GET of the raw .yaml (data) and .form.yaml (spec) continues through
// the existing static-file path; only the .html suffix is hijacked here.
//
// Storage layout: a form named "safety" lives at <dir>/safety.form.yaml;
// submissions go to <dir>/safety/<YYYY-MM-DD>-<email-sanitized>.yaml. The
// submissions folder is created lazily on first POST. ACL via the existing
// .zddc cascade — submit-rights = path-write-rights at the submissions
// directory.
package handler

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"os"
	"path/filepath"
	"strconv"
	"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"
)

// Form-mode rendering shares the unified `tables.html` bundle: one HTML
// hosts both apps (tablesApp, formApp) so the table view's "+ Add row"
// link, the empty-form / create URL, and the row-edit URL all return
// the same bytes. A small mode dispatcher in the bundle picks which
// app paints based on the URL pattern. This eliminates a second
// embedded HTML and lets future editable-cell mode reuse the form
// validator + write path without IPC across two SPAs.
//
// formRenderHTML is the source of bytes for serveFormRender; it's the
// same package var as embeddedTablesHTML (declared in tablehandler.go).
func formRenderHTML() []byte { return embeddedTablesHTML }

// FormSpec is the YAML envelope of a <name>.form.yaml file.
//
// v0 fields: Title, Description, Schema, UI. Mode is reserved for the v1
// file-as-truth introduction (default form-as-truth = empty / "form-as-truth").
// Unknown YAML keys are ignored — this struct is the source of truth for the
// supported form-spec vocabulary.
type FormSpec struct {
	Title       string                 `yaml:"title"`
	Description string                 `yaml:"description"`
	Schema      *jsonschema.Schema     `yaml:"schema"`
	UI          map[string]interface{} `yaml:"ui"`
	Mode        string                 `yaml:"mode"`
}

// formContext is the JSON object the server injects into the form HTML.
// The renderer (form/js/context.js) reads this from #form-context.
type formContext struct {
	Title     string                 `json:"title,omitempty"`
	Schema    *jsonschema.Schema     `json:"schema"`
	UI        map[string]interface{} `json:"ui,omitempty"`
	Data      interface{}            `json:"data,omitempty"`
	SubmitURL string                 `json:"submitUrl"`
	Errors    []jsonschema.Error     `json:"errors,omitempty"`
}

// FormRequest describes a recognized form-system request.
type FormRequest struct {
	// Kind is one of: "render-empty", "create", "render-edit", "update".
	Kind string
	// SpecPath is the absolute filesystem path to the <name>.form.yaml.
	SpecPath string
	// DataPath is the absolute filesystem path to the data .yaml; empty for
	// render-empty / create.
	DataPath string
	// SubmitURL is the URL the form should POST back to (the server-injected
	// "submit to my own URL" value).
	SubmitURL string
}

// RecognizeFormRequest classifies r as a form-system request, or returns nil
// if it falls through to static file serving. Form-spec existence on disk is
// required: a *.form.html URL with no corresponding *.form.yaml is not a
// form request.
//
// Methods other than GET / POST return nil (HEAD / OPTIONS pass through to
// the catch-all so the standard handlers respond).
func RecognizeFormRequest(fsRoot, method, urlPath string) *FormRequest {
	if method != http.MethodGet && method != http.MethodPost {
		return nil
	}
	if !strings.HasSuffix(urlPath, ".html") {
		return nil
	}
	underlying := strings.TrimSuffix(urlPath, ".html")

	// specEligible accepts a spec path that exists on disk OR matches
	// the default-MDL virtual-fallback shape at archive/<party>/mdl/.
	// Without this, the default-MDL row form would 404 on a fresh
	// archive even though the table view renders.
	specEligible := func(specAbs string) bool {
		if fileExists(specAbs) {
			return true
		}
		if _, ok := IsDefaultMdlSpecAbs(fsRoot, specAbs); ok {
			return true
		}
		return false
	}

	// In-dir convention: spec, form, and rows all live in one
	// directory. URLs:
	//   /<dir>/form.html             — empty form / create
	//   /<dir>/<id>.yaml.html        — re-edit / update one row
	// Spec is always <dir>/form.yaml relative to the URL.
	if strings.HasSuffix(underlying, "/form") || underlying == "/form" {
		// /<dir>/form.html — empty form / create.
		dirRel := strings.TrimSuffix(strings.TrimPrefix(underlying, "/"), "/form")
		dirRel = strings.TrimSuffix(dirRel, "form") // root case "/form" → ""
		dirRel = strings.Trim(dirRel, "/")
		if dirRel == "" {
			// /form.html at root has no rows-dir to bind a spec to.
			return nil
		}
		dirAbs := filepath.Join(fsRoot, filepath.FromSlash(dirRel))
		if !strings.HasPrefix(dirAbs, fsRoot+string(filepath.Separator)) && dirAbs != fsRoot {
			return nil
		}
		specAbs := filepath.Join(dirAbs, "form.yaml")
		if !specEligible(specAbs) {
			return nil
		}
		kind := "render-empty"
		if method == http.MethodPost {
			kind = "create"
		}
		return &FormRequest{
			Kind:      kind,
			SpecPath:  specAbs,
			SubmitURL: urlPath,
		}
	}

	if strings.HasSuffix(underlying, ".yaml") {
		// /<dir>/<id>.yaml.html — re-edit / update. Spec lives in the
		// SAME directory as the row file (<dir>/form.yaml).
		dataRel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(underlying, "/")))
		dataAbs := filepath.Join(fsRoot, dataRel)
		if !strings.HasPrefix(dataAbs, fsRoot+string(filepath.Separator)) && dataAbs != fsRoot {
			return nil
		}
		specPath := filepath.Join(filepath.Dir(dataAbs), "form.yaml")
		if !specEligible(specPath) {
			return nil
		}
		kind := "render-edit"
		if method == http.MethodPost {
			kind = "update"
		}
		return &FormRequest{
			Kind:      kind,
			SpecPath:  specPath,
			DataPath:  dataAbs,
			SubmitURL: urlPath,
		}
	}

	return nil
}

// ServeForm dispatches a recognized form request to render or write logic.
// The catch-all dispatch in zddc-server/main.go calls this whenever
// RecognizeFormRequest returns non-nil.
func ServeForm(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *http.Request) {
	switch req.Kind {
	case "render-empty":
		serveFormRender(cfg, req, w, r, nil)
	case "render-edit":
		serveFormRender(cfg, req, w, r, nil)
	case "create":
		serveFormCreate(cfg, req, w, r)
	case "update":
		serveFormUpdate(cfg, req, w, r)
	default:
		http.Error(w, "unknown form request kind", http.StatusInternalServerError)
	}
}

// serveFormRender handles GET requests for both empty and pre-filled forms.
// validationErrs is non-nil only when re-rendering after a POST→422 (not used
// in v0 — POST returns JSON 422 and the client patches errors into the live
// form via JS).
func serveFormRender(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *http.Request, validationErrs []jsonschema.Error) {
	email := EmailFromContext(r)

	// ACL: read-rights at the directory holding the spec (and, for edits, at
	// the directory holding the data file). Cascade chain is the same for
	// every entity in the same directory — a single check covers both.
	gateDir := filepath.Dir(req.SpecPath)
	if req.DataPath != "" {
		gateDir = filepath.Dir(req.DataPath)
	}
	chain, err := zddc.EffectivePolicy(cfg.Root, gateDir)
	if err != nil {
		slog.Warn("form: policy error", "path", gateDir, "err", err)
	}
	if allowed, _ := policy.AllowFromChain(r.Context(), DeciderFromContext(r), chain, email, r.URL.Path); !allowed {
		http.Error(w, "Forbidden", http.StatusForbidden)
		return
	}

	if len(formRenderHTML()) == 0 {
		http.Error(w, "form renderer not built into this binary", http.StatusServiceUnavailable)
		return
	}

	spec, err := loadFormSpec(cfg.Root, req.SpecPath)
	if err != nil {
		slog.Warn("form: spec parse error", "path", req.SpecPath, "err", err)
		http.Error(w, "form spec error: "+err.Error(), http.StatusInternalServerError)
		return
	}

	var data interface{}
	if req.DataPath != "" {
		if !fileExists(req.DataPath) {
			http.NotFound(w, r)
			return
		}
		raw, err := os.ReadFile(req.DataPath)
		if err != nil {
			http.Error(w, "read submission: "+err.Error(), http.StatusInternalServerError)
			return
		}
		if err := yaml.Unmarshal(raw, &data); err != nil {
			http.Error(w, "parse submission: "+err.Error(), http.StatusInternalServerError)
			return
		}
		data = normalizeYAMLForJSON(data)
	}

	ctx := formContext{
		Title:     spec.Title,
		Schema:    spec.Schema,
		UI:        spec.UI,
		Data:      data,
		SubmitURL: req.SubmitURL,
		Errors:    validationErrs,
	}

	html, err := injectFormContext(formRenderHTML(), ctx)
	if err != nil {
		http.Error(w, "render: "+err.Error(), http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.Header().Set("Cache-Control", "no-store")
	_, _ = w.Write(html)
}

// serveFormCreate handles POST to <name>.form.html — creates a new submission.
func serveFormCreate(cfg config.Config, req *FormRequest, w http.ResponseWriter, r *http.Request) {
	email := EmailFromContext(r)
	if email == "" {
		http.Error(w, "authentication required", http.StatusUnauthorized)
		return
	}

	// In-dir convention: spec, form, and rows live in one directory.
	// New submissions land alongside the spec; submissionsDir IS the
	// directory holding form.yaml.
	submissionsDir := filepath.Dir(req.SpecPath)

	// ACL: write-rights at the directory where the row YAML will land.
	// In the default-MDL fallback case the directory may not exist
	// yet; cascade up to the closest existing ancestor for the policy
	// chain.
	gateDir := submissionsDir
	if !fileExists(submissionsDir) {
		gateDir = filepath.Dir(submissionsDir)
	}
	chain, err := zddc.EffectivePolicy(cfg.Root, gateDir)
	if err != nil {
		slog.Warn("form: policy error", "path", gateDir, "err", err)
	}
	if allowed, _ := policy.AllowFromChain(r.Context(), DeciderFromContext(r), chain, email, r.URL.Path); !allowed {
		http.Error(w, "Forbidden", http.StatusForbidden)
		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
	}

	if err := os.MkdirAll(submissionsDir, 0o755); err != nil {
		http.Error(w, "ensure submissions dir: "+err.Error(), http.StatusInternalServerError)
		return
	}

	dateStr := time.Now().UTC().Format("2006-01-02")
	emailSan := sanitizeEmail(email)
	base := dateStr + "-" + emailSan
	target, fname, ok := pickAvailableFilename(submissionsDir, base, ".yaml")
	if !ok {
		http.Error(w, "could not pick a free filename (>100 collisions)", http.StatusConflict)
		return
	}

	yamlBytes, err := yaml.Marshal(data)
	if err != nil {
		http.Error(w, "marshal yaml: "+err.Error(), http.StatusInternalServerError)
		return
	}
	if err := zddc.WriteAtomic(target, yamlBytes); err != nil {
		http.Error(w, "write: "+err.Error(), http.StatusInternalServerError)
		return
	}

	// Capability URL: the path to the new submission file. The renderer
	// appends ".html" to navigate back to the form-rendered view of the just-
	// saved data.
	relPath, err := filepath.Rel(cfg.Root, target)
	if err != nil {
		slog.Warn("form: rel path error", "root", cfg.Root, "target", target, "err", err)
		http.Error(w, "post-write: "+err.Error(), http.StatusInternalServerError)
		return
	}
	capURL := "/" + filepath.ToSlash(relPath)

	w.Header().Set("Content-Type", "application/json")
	w.Header().Set("Location", capURL)
	w.WriteHeader(http.StatusCreated)
	_ = json.NewEncoder(w).Encode(map[string]string{
		"location": capURL,
		"filename": fname,
	})
}

// serveFormUpdate handles POST to <name>/<id>.yaml.html — overwrites an
// existing submission after re-validating against the form spec.
func serveFormUpdate(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 !fileExists(req.DataPath) {
		http.NotFound(w, r)
		return
	}

	chain, err := zddc.EffectivePolicy(cfg.Root, filepath.Dir(req.DataPath))
	if err != nil {
		slog.Warn("form: policy error", "path", req.DataPath, "err", err)
	}
	if allowed, _ := policy.AllowFromChain(r.Context(), DeciderFromContext(r), chain, email, r.URL.Path); !allowed {
		http.Error(w, "Forbidden", http.StatusForbidden)
		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
	}

	yamlBytes, err := yaml.Marshal(data)
	if err != nil {
		http.Error(w, "marshal yaml: "+err.Error(), http.StatusInternalServerError)
		return
	}
	if err := zddc.WriteAtomic(req.DataPath, yamlBytes); err != nil {
		http.Error(w, "write: "+err.Error(), http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	_, _ = w.Write([]byte(`{"ok":true}`))
}

// --- Helpers -----------------------------------------------------------------

func loadFormSpec(fsRoot, path string) (*FormSpec, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		// Default-MDL virtual fallback: when the operator hasn't placed
		// an mdl.form.yaml under archive/<party>/, serve the embedded
		// default. Mirrors the static-handler fallback for direct YAML
		// fetches so the form recognizer and the loader agree on what
		// "this spec exists" means.
		if os.IsNotExist(err) {
			if bytes, ok := IsDefaultMdlSpecAbs(fsRoot, path); ok {
				data = bytes
			} else {
				return nil, err
			}
		} else {
			return nil, err
		}
	}
	var spec FormSpec
	if err := yaml.Unmarshal(data, &spec); err != nil {
		return nil, fmt.Errorf("parse: %w", err)
	}
	if spec.Schema == nil {
		return nil, errors.New("form spec has no schema")
	}
	return &spec, nil
}

// decodeRequestData reads the request body as JSON (preferred) or YAML,
// returning the decoded value as the same `any` shape jsonschema.Validate
// expects. Body size is capped at 1 MiB.
func decodeRequestData(r *http.Request) (interface{}, error) {
	body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
	if err != nil {
		return nil, err
	}
	if len(body) == 0 {
		return nil, errors.New("empty body")
	}
	ct := strings.ToLower(strings.TrimSpace(strings.Split(r.Header.Get("Content-Type"), ";")[0]))
	if ct == "application/yaml" || ct == "application/x-yaml" || ct == "text/yaml" {
		var v interface{}
		if err := yaml.Unmarshal(body, &v); err != nil {
			return nil, err
		}
		return normalizeYAMLForJSON(v), nil
	}
	var v interface{}
	dec := json.NewDecoder(strings.NewReader(string(body)))
	dec.UseNumber()
	if err := dec.Decode(&v); err != nil {
		return nil, err
	}
	return normalizeJSONNumbers(v), nil
}

// normalizeYAMLForJSON converts yaml.v3's `map[interface{}]interface{}` (which
// it produces for mappings under a generic `interface{}` target) into
// `map[string]interface{}` so the rest of the pipeline can assume JSON shape.
// Also recurses through slices.
func normalizeYAMLForJSON(v interface{}) interface{} {
	switch x := v.(type) {
	case map[interface{}]interface{}:
		out := make(map[string]interface{}, len(x))
		for k, val := range x {
			out[fmt.Sprintf("%v", k)] = normalizeYAMLForJSON(val)
		}
		return out
	case map[string]interface{}:
		out := make(map[string]interface{}, len(x))
		for k, val := range x {
			out[k] = normalizeYAMLForJSON(val)
		}
		return out
	case []interface{}:
		out := make([]interface{}, len(x))
		for i, item := range x {
			out[i] = normalizeYAMLForJSON(item)
		}
		return out
	}
	return v
}

// normalizeJSONNumbers converts json.Number values into int64 (when integral)
// or float64. Without this, the validator would have to know about
// json.Number, which would couple the focused jsonschema package to the
// json decoder we happen to use here.
func normalizeJSONNumbers(v interface{}) interface{} {
	switch x := v.(type) {
	case json.Number:
		if i, err := x.Int64(); err == nil {
			return i
		}
		if f, err := x.Float64(); err == nil {
			return f
		}
		return x.String()
	case map[string]interface{}:
		for k, val := range x {
			x[k] = normalizeJSONNumbers(val)
		}
		return x
	case []interface{}:
		for i, item := range x {
			x[i] = normalizeJSONNumbers(item)
		}
		return x
	}
	return v
}

func writeValidationErrors(w http.ResponseWriter, errs []jsonschema.Error) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusUnprocessableEntity)
	_ = json.NewEncoder(w).Encode(map[string]interface{}{
		"errors": errs,
	})
}

// sanitizeEmail produces a safe filename component from an email address.
// "casey@proton.me" → "casey-at-proton-me". Conservative: also flattens any
// path-meaningful characters so a malicious email can't escape its directory,
// then collapses runs of '-' and trims leading/trailing '-' so the resulting
// filename is well-formed.
func sanitizeEmail(s string) string {
	s = strings.ReplaceAll(s, "@", "-at-")
	s = strings.ReplaceAll(s, ".", "-")
	var b strings.Builder
	for _, r := range s {
		switch {
		case r >= 'a' && r <= 'z',
			r >= 'A' && r <= 'Z',
			r >= '0' && r <= '9',
			r == '-', r == '_':
			b.WriteRune(r)
		}
	}
	out := b.String()
	for strings.Contains(out, "--") {
		out = strings.ReplaceAll(out, "--", "-")
	}
	out = strings.Trim(out, "-")
	if out == "" {
		out = "anonymous"
	}
	return out
}

// pickAvailableFilename tries `<base><ext>`, then `<base>-2<ext>`, ...,
// `<base>-100<ext>`, returning the first that does not yet exist on disk.
func pickAvailableFilename(dir, base, ext string) (path, name string, ok bool) {
	name = base + ext
	path = filepath.Join(dir, name)
	if !fileExists(path) {
		return path, name, true
	}
	for i := 2; i < 100; i++ {
		name = base + "-" + strconv.Itoa(i) + ext
		path = filepath.Join(dir, name)
		if !fileExists(path) {
			return path, name, true
		}
	}
	return "", "", false
}

func fileExists(path string) bool {
	_, err := os.Stat(path)
	return err == nil
}

// injectFormContext rewrites the embedded form HTML's #form-context placeholder
// with a serialized form context. Defends against script-tag breakouts in the
// JSON values by escaping any "</" sequences as "<\\/" — the JS engine treats
// a backslash-escaped slash identically inside a string literal, so behavior
// is preserved while the HTML parser cannot mistake content for a closing
// </script>.
func injectFormContext(template []byte, ctx formContext) ([]byte, error) {
	js, err := json.Marshal(ctx)
	if err != nil {
		return nil, err
	}
	js = []byte(strings.ReplaceAll(string(js), "</", "<\\/"))

	needle := []byte(`<script id="form-context" type="application/json">{}</script>`)
	if !bytesContains(template, needle) {
		return nil, errors.New("#form-context placeholder not found in template")
	}
	replacement := append([]byte(`<script id="form-context" type="application/json">`), js...)
	replacement = append(replacement, []byte(`</script>`)...)

	out := bytesReplace(template, needle, replacement)
	return out, nil
}

// Tiny bytes helpers — scoped here so we don't pull in "bytes" for two calls.
func bytesContains(haystack, needle []byte) bool {
	return strings.Contains(string(haystack), string(needle))
}

func bytesReplace(haystack, needle, replacement []byte) []byte {
	return []byte(strings.Replace(string(haystack), string(needle), string(replacement), 1))
}
