package handler

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

	"gopkg.in/yaml.v3"

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

// Plan Review — the doc-controller's "establish the canonical record"
// step. Right-click on archive/<party>/received/<tracking>/ in the
// browse app; the client POSTs X-ZDDC-Op: plan-review with the body
// below.
//
// Authorisation model — no ACL exception, only existing grants:
//
//   - Create authority on received/<tracking>/. The doc_controller
//     gets this from `worm: [document_controller]` on received/ in the
//     cascade defaults; the same `c` (write-once-create) verb that
//     lets them file canonical submittals lets them establish this
//     .zddc once.
//   - ActionAdmin on reviewing_root/.zddc + staging_root/.zddc. The
//     invoker must already administer those subtrees per the cascade
//     defaults.
//
// Operation:
//
//  1. Workflow folders converge first (idempotent — match by
//     .zddc.received_path; mkdir if missing; rewrite workflow .zddc
//     with received_path + ACL).
//  2. Write received/<tracking>/.zddc — but only if it doesn't exist.
//     The .zddc schema is server-constrained to {planned_review_date,
//     planned_response_date, created_by} — no ACL, admins, or other
//     fields, so this write cannot escalate the invoker's authority.
//     If the file already exists, the canonical record is sealed; the
//     dates in the request are ignored and the workflow folders are
//     converged on top.
//
// So Plan Review's first run establishes the canonical commitment;
// subsequent runs can only re-converge the workflow ACLs (e.g. swap
// review lead). The planned dates are write-once — to change them, an
// admin must edit received/<tracking>/.zddc directly via their admin
// authority (which under the cascade defaults is nobody beneath the
// root admin; deliberate).

const opPlanReview = "plan-review"

// planReviewRequest is the YAML body the browse client POSTs.
type planReviewRequest struct {
	ReviewLead              string `yaml:"review_lead"`
	Approver                string `yaml:"approver"`
	PlanReviewCompleteDate  string `yaml:"plan_review_complete_date"`
	PlanResponseDate        string `yaml:"plan_response_date"`
}

// planReviewResponse is the JSON returned to the client.
type planReviewResponse struct {
	Tracking  string             `json:"tracking"`
	Title     string             `json:"title"`
	Reviewing planReviewFolderOK `json:"reviewing"`
	Staging   planReviewFolderOK `json:"staging"`
	Received  planReviewFolderOK `json:"received"`
}

type planReviewFolderOK struct {
	Path        string `json:"path"`
	Created     bool   `json:"created"`
	ZddcWritten bool   `json:"zddc_written"`
}

// receivedURLPattern matches /<project>/archive/<party>/received/<tracking>/
// — Plan Review is only valid at that depth. Trailing slash required.
var receivedURLPattern = regexp.MustCompile(`^/([^/]+)/archive/([^/]+)/received/([^/]+)/?$`)

func servePlanReview(cfg config.Config, w http.ResponseWriter, r *http.Request) {
	// 1. URL must be a received-tracking folder.
	cleanURL := "/" + strings.Trim(r.URL.Path, "/") + "/"
	m := receivedURLPattern.FindStringSubmatch(cleanURL)
	if m == nil {
		http.Error(w, "Bad Request — plan-review must POST to /<project>/archive/<party>/received/<tracking>/", http.StatusBadRequest)
		return
	}
	project, party, tracking := m[1], m[2], m[3]

	// 2. Body parse.
	body, ok := readBodyCapped(cfg, w, r)
	if !ok {
		return
	}
	var req planReviewRequest
	if err := yaml.Unmarshal(body, &req); err != nil {
		http.Error(w, "Bad Request — could not parse YAML body: "+err.Error(), http.StatusBadRequest)
		return
	}
	if req.ReviewLead == "" || req.Approver == "" ||
		req.PlanReviewCompleteDate == "" || req.PlanResponseDate == "" {
		http.Error(w, "Bad Request — body must include review_lead, approver, plan_review_complete_date, plan_response_date", http.StatusBadRequest)
		return
	}

	resp, status, msg := executePlanReview(cfg, r, project, party, tracking, req)
	if status != http.StatusOK {
		auditFile(r, "plan-review", cleanURL, status, 0, nil)
		http.Error(w, msg, status)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	w.Header().Set("X-ZDDC-Source", "fileapi:plan-review")
	w.WriteHeader(http.StatusOK)
	_ = json.NewEncoder(w).Encode(resp)
	auditFile(r, "plan-review", cleanURL, http.StatusOK, 0, nil)
}

// executePlanReview runs the Plan Review three-stage flow against an
// already-resolved received/<tracking>/ path. URL and body parsing
// happen in the caller. Returns the response struct on success;
// non-200 (status, message) on auth or execution failure. The caller
// is responsible for writing the HTTP response.
//
// Exposed so accept-transmittal can chain Plan Review in the same
// request without round-tripping through HTTP.
func executePlanReview(cfg config.Config, r *http.Request, project, party, tracking string, req planReviewRequest) (*planReviewResponse, int, string) {
	receivedRel := filepath.ToSlash(filepath.Join("archive", party, "received", tracking))
	receivedAbs := filepath.Join(cfg.Root, project, filepath.FromSlash(receivedRel))
	cleanURL := "/" + project + "/archive/" + party + "/received/" + tracking + "/"

	prCfg := zddc.OnPlanReviewAt(cfg.Root, receivedAbs)
	if prCfg == nil || prCfg.ReviewingRoot == "" || prCfg.StagingRoot == "" {
		return nil, http.StatusConflict, "Conflict — on_plan_review is not configured in the cascade for this subtree"
	}
	reviewingRoot := filepath.Join(cfg.Root, project, filepath.FromSlash(strings.Trim(prCfg.ReviewingRoot, "/")))
	stagingRoot := filepath.Join(cfg.Root, project, filepath.FromSlash(strings.Trim(prCfg.StagingRoot, "/")))

	// Pre-flight authorisation. No ACL exception — we use existing
	// cascade grants:
	//   (a) ActionAdmin on reviewing_root and staging_root proves the
	//       invoker is subtree-admin of the workflow roots and can
	//       write the workflow .zddc files.
	//   (b) The invoker has `c` (write-once-create) authority on
	//       received/<tracking>/. For the doc_controller this comes
	//       from `worm: [document_controller]` on received/ in the
	//       cascade defaults — the same authority that lets them file
	//       canonical submittals lets them establish this .zddc once.
	p := PrincipalFromContext(r)
	email := EmailFromContext(r)
	if email == "" {
		return nil, http.StatusForbidden, "Forbidden — no authenticated principal"
	}
	// All three pre-flight checks go through the consolidated decider.
	// AllowActionFromChainP routes ActionAdmin .zddc edits and the
	// single admin-bypass branch for elevated admins. No manual
	// IsAdmin / IsSubtreeAdmin branching here.
	decider := DeciderFromContext(r)
	for _, root := range []string{reviewingRoot, stagingRoot} {
		chain, perr := zddc.EffectivePolicy(cfg.Root, root)
		if perr != nil {
			return nil, http.StatusInternalServerError, "Internal Server Error — cascade lookup: " + perr.Error()
		}
		rel, _ := filepath.Rel(cfg.Root, root)
		rootURL := "/" + filepath.ToSlash(rel) + "/.zddc"
		allowed, _ := policy.AllowActionFromChainP(r.Context(), decider, chain, p, rootURL, policy.ActionAdmin)
		if !allowed {
			return nil, http.StatusForbidden, fmt.Sprintf("Forbidden — %s lacks subtree-admin authority for %s",
				email, strings.TrimPrefix(root, cfg.Root+string(filepath.Separator)))
		}
	}
	// Verify `c` (create) authority on received/<tracking>/. Elevated
	// admins short-circuit inside the decider; non-admin doc_controllers
	// come through the WORM-list grant. One code path either way.
	{
		chain, perr := zddc.EffectivePolicy(cfg.Root, receivedAbs)
		if perr != nil {
			return nil, http.StatusInternalServerError, "Internal Server Error — cascade lookup: " + perr.Error()
		}
		allowed, _ := policy.AllowActionFromChainP(r.Context(), decider, chain, p, cleanURL, policy.ActionCreate)
		if !allowed {
			return nil, http.StatusForbidden, fmt.Sprintf("Forbidden — %s lacks create authority on %s (filing this submittal requires the doc_controller WORM grant)",
				email, strings.TrimPrefix(receivedAbs, cfg.Root+string(filepath.Separator)))
		}
	}

	// Derive a title from received/<tracking>/'s contents — first
	// ZDDC-parseable filename's title field wins. Fallback to the
	// tracking number itself so the folder name always has a tail.
	title := deriveTitleFromReceived(receivedAbs)
	if title == "" {
		title = tracking
	}

	// Materialise roots + received/<tracking>/ ancestors (the received
	// folder itself was created when the doc controller moved the
	// submittal in; defensive ensure here for tests).
	for _, root := range []string{reviewingRoot, stagingRoot, receivedAbs} {
		if err := os.MkdirAll(root, 0o755); err != nil {
			return nil, http.StatusInternalServerError, "Internal Server Error — ensure dirs: " + err.Error()
		}
	}

	// received/<tracking>/.zddc is WRITE-ONCE — the canonical commitment.
	// First-run creates it under the invoker's WORM-`c` authority
	// (verified above); subsequent runs leave it alone and the request's
	// date fields are ignored. The schema is server-constrained: only
	// planned_review_date + planned_response_date + created_by are written.
	// No ACL, admins, or other content — so this write cannot escalate
	// the invoker's authority.
	receivedResult, err := establishReceivedPlanDates(receivedAbs, req.PlanReviewCompleteDate, req.PlanResponseDate, email, cfg.Root)
	if err != nil {
		return nil, http.StatusInternalServerError, "Internal Server Error — received .zddc: " + err.Error()
	}

	// Converge the workflow folders.
	reviewingResult, err := convergeWorkflowFolder(workflowConverge{
		fsRoot:       cfg.Root,
		root:         reviewingRoot,
		forecast:     req.PlanReviewCompleteDate,
		tracking:     tracking,
		title:        title,
		receivedRel:  receivedRel,
		acl:          map[string]string{req.ReviewLead: "rwcda"},
		creatorEmail: email,
	})
	if err != nil {
		return nil, http.StatusInternalServerError, "Internal Server Error — reviewing convergence: " + err.Error()
	}

	stagingResult, err := convergeWorkflowFolder(workflowConverge{
		fsRoot:       cfg.Root,
		root:         stagingRoot,
		forecast:     req.PlanResponseDate,
		tracking:     tracking,
		title:        title,
		receivedRel:  receivedRel,
		acl:          map[string]string{req.Approver: "rwcda"},
		creatorEmail: email,
	})
	if err != nil {
		return nil, http.StatusInternalServerError, "Internal Server Error — staging convergence: " + err.Error()
	}

	return &planReviewResponse{
		Tracking: tracking,
		Title:    title,
		Reviewing: planReviewFolderOK{
			Path:        "/" + filepath.ToSlash(reviewingResult.relPath) + "/",
			Created:     reviewingResult.created,
			ZddcWritten: reviewingResult.zddcWritten,
		},
		Staging: planReviewFolderOK{
			Path:        "/" + filepath.ToSlash(stagingResult.relPath) + "/",
			Created:     stagingResult.created,
			ZddcWritten: stagingResult.zddcWritten,
		},
		Received: planReviewFolderOK{
			Path:        "/" + filepath.ToSlash(receivedResult.relPath) + "/",
			Created:     receivedResult.created,
			ZddcWritten: receivedResult.zddcWritten,
		},
	}, http.StatusOK, ""
}

// establishReceivedPlanDates writes received/<tracking>/.zddc with the
// committed planned dates iff the file doesn't yet exist. If it does,
// the canonical record is already sealed and the call is a no-op
// (zddcWritten=false in the result); the request's date fields are
// silently ignored on subsequent runs. The schema is server-constrained
// to just the two date fields + created_by — no ACL or admin grants.
func establishReceivedPlanDates(receivedAbs, planReview, planResponse, creatorEmail, fsRoot string) (workflowResult, error) {
	var res workflowResult
	res.absPath = receivedAbs
	if rel, err := filepath.Rel(fsRoot, receivedAbs); err == nil {
		res.relPath = filepath.ToSlash(rel)
	} else {
		res.relPath = receivedAbs
	}
	zddcPath := filepath.Join(receivedAbs, ".zddc")
	if _, err := os.Stat(zddcPath); err == nil {
		// Sealed — leave alone. zddcWritten stays false.
		return res, nil
	} else if !errors.Is(err, os.ErrNotExist) {
		return res, err
	}
	zf := zddc.ZddcFile{
		PlannedReviewDate:   planReview,
		PlannedResponseDate: planResponse,
		CreatedBy:           creatorEmail,
	}
	if err := zddc.WriteFile(receivedAbs, zf); err != nil {
		return res, err
	}
	res.zddcWritten = true
	res.created = true // first-time establishment
	return res, nil
}

// deriveTitleFromReceived scans received/<tracking>/ for ZDDC-parseable
// filenames and returns the first one's title field. Empty if no
// parseable file is found.
func deriveTitleFromReceived(receivedAbs string) string {
	entries, err := os.ReadDir(receivedAbs)
	if err != nil {
		return ""
	}
	// Sort for deterministic title selection (first alphabetical wins).
	names := make([]string, 0, len(entries))
	for _, e := range entries {
		if e.IsDir() {
			continue
		}
		names = append(names, e.Name())
	}
	sort.Strings(names)
	for _, name := range names {
		parsed := zddc.ParseFilename(name)
		if parsed.Valid && parsed.Title != "" {
			return parsed.Title
		}
	}
	return ""
}

// workflowConverge captures the parameters for converging a single
// reviewing/ or staging/ workflow folder.
type workflowConverge struct {
	fsRoot       string            // master root (cfg.Root) — used to compute response paths
	root         string            // absolute path of reviewing_root or staging_root
	forecast     string            // initial forecast date for the folder name (YYYY-MM-DD)
	tracking     string            // tracking number
	title        string            // derived title
	receivedRel  string            // relative path to canonical submittal, e.g. archive/Acme/received/Acme-0042
	acl          map[string]string // per-folder ACL grants (principal → verb-set)
	creatorEmail string            // creator/audit email
}

// workflowResult is the post-convergence summary for one folder.
type workflowResult struct {
	relPath     string // server-relative path (no leading slash, no trailing slash)
	absPath     string
	created     bool // true iff this convergence run mkdir'd the folder
	zddcWritten bool // true iff a .zddc was written (always true on success)
}

// convergeWorkflowFolder converges one of the workflow folders (reviewing
// or staging) toward the desired state. Idempotent on re-run.
func convergeWorkflowFolder(c workflowConverge) (workflowResult, error) {
	var res workflowResult

	// Search the root for an existing folder whose .zddc.received_path
	// matches. If found, use it — the user controls the folder name via
	// direct rename, so we don't fight their date.
	existing, err := findWorkflowFolderByReceivedPath(c.root, c.receivedRel)
	if err != nil {
		return res, err
	}

	target := existing
	if target == "" {
		// No match — mkdir at <root>/<forecast>_<tracking> (TBD) - <title>/.
		// Append _2, _3 to disambiguate exact-name collisions with a
		// folder belonging to a DIFFERENT submittal.
		baseName := sanitiseFolderName(fmt.Sprintf("%s_%s (TBD) - %s", c.forecast, c.tracking, c.title))
		candidate := filepath.Join(c.root, baseName)
		for n := 2; n <= 100; n++ {
			if _, statErr := os.Stat(candidate); errors.Is(statErr, os.ErrNotExist) {
				break
			} else if statErr != nil {
				return res, statErr
			}
			candidate = filepath.Join(c.root, fmt.Sprintf("%s_%d", baseName, n))
			if n == 100 {
				return res, fmt.Errorf("convergence: exhausted suffix attempts for %s", baseName)
			}
		}
		if err := os.MkdirAll(candidate, 0o755); err != nil {
			return res, fmt.Errorf("mkdir workflow folder: %w", err)
		}
		target = candidate
		res.created = true
	}

	// Write .zddc with desired content. Overwrites if present. Workflow
	// .zddc carries received_path + acl ONLY — no planned dates (those
	// live in the canonical received/.zddc, which the sub-admins
	// cannot modify).
	zf := zddc.ZddcFile{
		ReceivedPath: c.receivedRel,
		CreatedBy:    c.creatorEmail,
	}
	if len(c.acl) > 0 {
		zf.ACL = zddc.ACLRules{Permissions: c.acl}
	}
	if err := zddc.WriteFile(target, zf); err != nil {
		return res, fmt.Errorf("write workflow .zddc: %w", err)
	}
	res.zddcWritten = true

	res.absPath = target
	if rel, err := filepath.Rel(c.fsRoot, target); err == nil {
		res.relPath = filepath.ToSlash(rel)
	} else {
		res.relPath = target
	}
	return res, nil
}

// findWorkflowFolderByReceivedPath scans root for direct children
// whose .zddc has received_path matching the given relative path.
// Returns the matching absolute path, or "" if none.
func findWorkflowFolderByReceivedPath(root, receivedRel string) (string, error) {
	entries, err := os.ReadDir(root)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return "", nil
		}
		return "", err
	}
	want := filepath.ToSlash(filepath.Clean(receivedRel))
	for _, e := range entries {
		if !e.IsDir() {
			continue
		}
		zddcPath := filepath.Join(root, e.Name(), ".zddc")
		zf, perr := zddc.ParseFile(zddcPath)
		if perr != nil {
			slog.Warn("plan-review: parse workflow .zddc", "path", zddcPath, "err", perr)
			continue
		}
		if zf.ReceivedPath == "" {
			continue
		}
		got := filepath.ToSlash(filepath.Clean(zf.ReceivedPath))
		if got == want {
			return filepath.Join(root, e.Name()), nil
		}
	}
	return "", nil
}

// sanitiseFolderName replaces filesystem-troublesome characters in a
// title with safe substitutes. Conservative — keeps the ZDDC folder
// grammar (the parens and the " - " separator) intact while taming
// arbitrary user input in the title segment.
func sanitiseFolderName(name string) string {
	repl := strings.NewReplacer(
		"/", "-",
		"\\", "-",
		":", "-",
		"\x00", "",
	)
	return strings.TrimSpace(repl.Replace(name))
}
