package handler

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

// Accept Transmittal — the doc-controller's "file a counterparty
// upload into the immutable received archive" step. Right-click on a
// single transmittal folder under archive/<party>/incoming/ in the
// browse app; the client POSTs X-ZDDC-Op: accept-transmittal with the
// body below.
//
// Authorisation model — same primitives as Plan Review, no exceptions:
//
//   - ActionWrite on incoming/<transmittal>/ (move source).
//     document_controller has rwcd on incoming/ via the cascade defaults.
//   - ActionCreate on received/<tracking>/ (move destination, WORM zone).
//     document_controller has `cr` here via worm: [document_controller].
//
// Operation:
//
//  1. Parse URL — must be a direct child of archive/<party>/incoming/.
//  2. Validate the transmittal folder name via ParseTransmittalFolder
//     (date, tracking, status, title). Reject if not well-formed.
//  3. Validate every file in the folder via ParseFilename. Each file's
//     parsed tracking must match the folder's tracking. Reject on any
//     non-conformance — client should cancel and tell sender to fix.
//  4. ACL pre-flight (source write, destination create).
//  5. mkdir received/ (parent of the destination) if missing.
//  6. If received/<tracking>/ does NOT exist → os.Rename the whole
//     folder (atomic, fast).
//     If received/<tracking>/ DOES exist (re-submission of the same
//     tracking) → per-file move. Refuse if any child filename already
//     exists at the destination — WORM forbids overwrite.
//  7. Optional Plan Review chain: when the body's setup_plan_review
//     flag is true, the same handler dispatches through Plan Review's
//     three-stage flow against the new received/<tracking>/ URL. The
//     ACL gates re-run there (idempotent against the same principal),
//     which is correct: both authorities are required by design.
//
// The accept itself does NOT write received/<tracking>/.zddc — the
// cascade's worm: [document_controller] inheritance is enough. If
// Plan Review is chained, IT writes the .zddc with planned dates.
// Filesystem mtime on the moved folder records when the accept
// happened; the audit log records who.

const opAcceptTransmittal = "accept-transmittal"

// incomingURLPattern matches /<project>/archive/<party>/incoming/<transmittal>/.
var incomingURLPattern = regexp.MustCompile(`^/([^/]+)/archive/([^/]+)/incoming/([^/]+)/?$`)

type acceptRequest struct {
	ReceivedDate           string `yaml:"received_date"`
	SetupPlanReview        bool   `yaml:"setup_plan_review"`
	ReviewLead             string `yaml:"review_lead"`
	Approver               string `yaml:"approver"`
	PlanReviewCompleteDate string `yaml:"plan_review_complete_date"`
	PlanResponseDate       string `yaml:"plan_response_date"`
}

type acceptResponse struct {
	Tracking     string              `json:"tracking"`
	IncomingPath string              `json:"incoming_path"`
	ReceivedPath string              `json:"received_path"`
	MovedFiles   int                 `json:"moved_files"`
	Merged       bool                `json:"merged"`
	PlanReview   *planReviewResponse `json:"plan_review,omitempty"`
}

func serveAcceptTransmittal(cfg config.Config, w http.ResponseWriter, r *http.Request) {
	cleanURL := "/" + strings.Trim(r.URL.Path, "/") + "/"
	m := incomingURLPattern.FindStringSubmatch(cleanURL)
	if m == nil {
		http.Error(w, "Bad Request — accept-transmittal must POST to /<project>/archive/<party>/incoming/<transmittal>/", http.StatusBadRequest)
		return
	}
	project, party, transmittalFolder := m[1], m[2], m[3]

	date, tracking, _, _, ok := zddc.ParseTransmittalFolder(transmittalFolder)
	if !ok {
		http.Error(w, "Bad Request — folder name does not conform to ZDDC transmittal grammar (expected YYYY-MM-DD_<tracking> (<status>) - <title>)", http.StatusBadRequest)
		return
	}
	_ = date // available for audit; mtime carries the actual accept time

	body, ok2 := readBodyCapped(cfg, w, r)
	if !ok2 {
		return
	}
	var req acceptRequest
	if len(body) > 0 {
		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.SetupPlanReview {
		if req.ReviewLead == "" || req.Approver == "" ||
			req.PlanReviewCompleteDate == "" || req.PlanResponseDate == "" {
			http.Error(w, "Bad Request — setup_plan_review requires review_lead, approver, plan_review_complete_date, plan_response_date", http.StatusBadRequest)
			return
		}
	}

	incomingAbs := filepath.Join(cfg.Root, project, "archive", party, "incoming", transmittalFolder)
	receivedAbs := filepath.Join(cfg.Root, project, "archive", party, "received", tracking)
	receivedURL := "/" + project + "/archive/" + party + "/received/" + tracking + "/"

	// Source must exist as a directory.
	srcInfo, err := os.Stat(incomingAbs)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			http.Error(w, "Not Found", http.StatusNotFound)
		} else {
			http.Error(w, "Internal Server Error — stat source: "+err.Error(), http.StatusInternalServerError)
		}
		return
	}
	if !srcInfo.IsDir() {
		http.Error(w, "Bad Request — accept-transmittal target is not a directory", http.StatusBadRequest)
		return
	}

	// Validate every file in the folder before any side-effect.
	entries, err := os.ReadDir(incomingAbs)
	if err != nil {
		http.Error(w, "Internal Server Error — read source: "+err.Error(), http.StatusInternalServerError)
		return
	}
	var fileNames []string
	var violations []string
	for _, e := range entries {
		name := e.Name()
		if strings.HasPrefix(name, ".") {
			continue // skip dotfiles silently (e.g. .zddc dropped by counterparty)
		}
		if e.IsDir() {
			violations = append(violations, name+": nested directories are not permitted in a transmittal folder")
			continue
		}
		parsed := zddc.ParseFilename(name)
		if !parsed.Valid {
			violations = append(violations, name+": does not conform to ZDDC filename grammar")
			continue
		}
		if parsed.TrackingNumber != tracking {
			violations = append(violations, fmt.Sprintf("%s: tracking %q does not match folder tracking %q", name, parsed.TrackingNumber, tracking))
			continue
		}
		fileNames = append(fileNames, name)
	}
	if len(violations) > 0 {
		http.Error(w, "Conflict — transmittal folder contents do not conform:\n"+strings.Join(violations, "\n"), http.StatusConflict)
		return
	}
	if len(fileNames) == 0 {
		http.Error(w, "Conflict — transmittal folder is empty", http.StatusConflict)
		return
	}

	// ACL pre-flight: source needs Write (rename out), destination needs Create.
	if !authorizeAction(cfg, w, r, incomingAbs, cleanURL, policy.ActionWrite) {
		return
	}
	if !authorizeAction(cfg, w, r, receivedAbs, receivedURL, policy.ActionCreate) {
		return
	}

	email := EmailFromContext(r)
	if email == "" {
		http.Error(w, "Forbidden — no authenticated principal", http.StatusForbidden)
		return
	}

	// Ensure received/'s parent exists (received/ itself materialises via
	// the rename or the per-file moves below).
	receivedParent := filepath.Dir(receivedAbs)
	if err := os.MkdirAll(receivedParent, 0o755); err != nil {
		auditFile(r, "accept-transmittal", cleanURL, http.StatusInternalServerError, 0, err)
		http.Error(w, "Internal Server Error — mkdir received/: "+err.Error(), http.StatusInternalServerError)
		return
	}

	merged := false
	if _, err := os.Stat(receivedAbs); err == nil {
		// Re-submission of an already-accepted tracking → merge per-file.
		// Refuse any filename collision; WORM forbids overwriting.
		merged = true
		for _, name := range fileNames {
			dst := filepath.Join(receivedAbs, name)
			if _, statErr := os.Stat(dst); statErr == nil {
				http.Error(w, "Conflict — "+name+" already exists in received/"+tracking+"/ (WORM forbids overwrite)", http.StatusConflict)
				return
			} else if !errors.Is(statErr, os.ErrNotExist) {
				http.Error(w, "Internal Server Error — stat destination: "+statErr.Error(), http.StatusInternalServerError)
				return
			}
		}
		for _, name := range fileNames {
			src := filepath.Join(incomingAbs, name)
			dst := filepath.Join(receivedAbs, name)
			if err := os.Rename(src, dst); err != nil {
				auditFile(r, "accept-transmittal", cleanURL, http.StatusInternalServerError, 0, err)
				http.Error(w, "Internal Server Error — rename "+name+": "+err.Error(), http.StatusInternalServerError)
				return
			}
		}
		// Best-effort: remove the now-empty incoming folder. Leaves it in
		// place if non-empty (e.g. operator left ad-hoc notes alongside
		// the conformant files); audit log captures the success either way.
		_ = os.Remove(incomingAbs)
	} else if errors.Is(err, os.ErrNotExist) {
		// Fresh acceptance → atomic folder rename.
		if err := os.Rename(incomingAbs, receivedAbs); err != nil {
			auditFile(r, "accept-transmittal", cleanURL, http.StatusInternalServerError, 0, err)
			http.Error(w, "Internal Server Error — rename folder: "+err.Error(), http.StatusInternalServerError)
			return
		}
	} else {
		http.Error(w, "Internal Server Error — stat received: "+err.Error(), http.StatusInternalServerError)
		return
	}

	resp := acceptResponse{
		Tracking:     tracking,
		IncomingPath: cleanURL,
		ReceivedPath: receivedURL,
		MovedFiles:   len(fileNames),
		Merged:       merged,
	}

	// Optional Plan Review chain. Invokes executePlanReview directly
	// against the freshly-created received/<tracking>/ path. The ACL
	// gates re-run there — the invoker still needs ActionAdmin on the
	// workflow roots and `c` on received/<tracking>/, both of which
	// they had a moment ago for the move itself. A chained failure does
	// NOT roll back the move: the canonical record is sealed, and the
	// user can re-trigger Plan Review later from the received/<tracking>/
	// folder context menu.
	if req.SetupPlanReview {
		planReq := planReviewRequest{
			ReviewLead:             req.ReviewLead,
			Approver:               req.Approver,
			PlanReviewCompleteDate: req.PlanReviewCompleteDate,
			PlanResponseDate:       req.PlanResponseDate,
		}
		prResp, status, msg := executePlanReview(cfg, r, project, party, tracking, planReq)
		if status != http.StatusOK {
			auditFile(r, "accept-transmittal", cleanURL, status, 0, errors.New(msg))
			http.Error(w, "Chained plan-review: "+msg, status)
			return
		}
		resp.PlanReview = prResp
	}

	w.Header().Set("Content-Type", "application/json")
	w.Header().Set("X-ZDDC-Source", "fileapi:accept-transmittal")
	w.WriteHeader(http.StatusOK)
	_ = json.NewEncoder(w).Encode(resp)
	auditFile(r, "accept-transmittal", cleanURL+" -> "+receivedURL, http.StatusOK, 0, nil)
}
