package handlers

// Submission draft handlers — backing API + page route for the
// dedicated Submissions/Schriftsätze editor at
// /projects/{id}/submissions/{code}/draft (t-paliad-238 Slice A,
// design doc docs/design-submission-page-2026-05-22.md §6.3).
//
// Endpoints:
//
//   GET    /projects/{id}/submissions/{code}/draft               (page)
//   GET    /projects/{id}/submissions/{code}/draft/{draft_id}    (page)
//
//   GET    /api/projects/{id}/submissions/{code}/drafts
//   POST   /api/projects/{id}/submissions/{code}/drafts
//   GET    /api/projects/{id}/submissions/{code}/drafts/{draft_id}
//   PATCH  /api/projects/{id}/submissions/{code}/drafts/{draft_id}
//   DELETE /api/projects/{id}/submissions/{code}/drafts/{draft_id}
//   GET    /api/projects/{id}/submissions/{code}/drafts/{draft_id}/preview
//   POST   /api/projects/{id}/submissions/{code}/drafts/{draft_id}/export
//
// Visibility: every endpoint funnels through SubmissionDraftService,
// whose Get/List/Create gate on ProjectService.GetByID
// (paliad.can_see_project) and additionally restrict to owner-only
// reads for individual drafts. RLS in the DB enforces the same
// constraint independently.
//
// Slice A template source: the universal HL Patents Style .dotm
// (same path the t-paliad-230 format-only /generate uses). Per-
// submission_code templates with the fallback chain land in Slice B.

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"

	"github.com/google/uuid"

	"mgit.msbls.de/m/paliad/internal/models"
	"mgit.msbls.de/m/paliad/internal/services"
)

// submissionDraftPreviewTimeout caps a single preview round-trip.
const submissionDraftPreviewTimeout = 10 * time.Second

// submissionDraftExportTimeout caps a single export.
const submissionDraftExportTimeout = 30 * time.Second

// ─────────────────────────────────────────────────────────────────────
// Request / response shapes
// ─────────────────────────────────────────────────────────────────────

// submissionDraftView is the JSON shape returned to the editor — the
// raw row plus the resolved bag and the rule metadata the sidebar uses
// to label each variable group.
type submissionDraftView struct {
	Draft           submissionDraftJSON      `json:"draft"`
	Rule            *submissionRuleSummary   `json:"rule,omitempty"`
	ResolvedBag     services.PlaceholderMap  `json:"resolved_bag"`
	MergedBag       services.PlaceholderMap  `json:"merged_bag"`
	PreviewHTML     string                   `json:"preview_html"`
	Lang            string                   `json:"lang"`
	HasTemplate     bool                     `json:"has_template"`
	TemplateMissing bool                     `json:"template_missing,omitempty"`
}

type submissionDraftJSON struct {
	ID              uuid.UUID                `json:"id"`
	ProjectID       *uuid.UUID               `json:"project_id"`
	SubmissionCode  string                   `json:"submission_code"`
	UserID          uuid.UUID                `json:"user_id"`
	Name            string                   `json:"name"`
	Variables       services.PlaceholderMap  `json:"variables"`
	LastExportedAt  *time.Time               `json:"last_exported_at,omitempty"`
	LastExportedSHA *string                  `json:"last_exported_sha,omitempty"`
	CreatedAt       time.Time                `json:"created_at"`
	UpdatedAt       time.Time                `json:"updated_at"`
}

type submissionRuleSummary struct {
	Name                 string  `json:"name"`
	NameEN               string  `json:"name_en"`
	SubmissionCode       string  `json:"submission_code"`
	PrimaryParty         string  `json:"primary_party,omitempty"`
	EventType            string  `json:"event_type,omitempty"`
	LegalSource          string  `json:"legal_source,omitempty"`
	LegalSourcePretty    string  `json:"legal_source_pretty,omitempty"`
	LegalSourcePrettyEN  string  `json:"legal_source_pretty_en,omitempty"`
}

type submissionDraftListResponse struct {
	ProjectID      uuid.UUID             `json:"project_id"`
	SubmissionCode string                `json:"submission_code"`
	Drafts         []submissionDraftJSON `json:"drafts"`
}

type submissionDraftPatchInput struct {
	Name      *string                  `json:"name,omitempty"`
	Variables *services.PlaceholderMap `json:"variables,omitempty"`
}

// ─────────────────────────────────────────────────────────────────────
// Handlers
// ─────────────────────────────────────────────────────────────────────

// userSubmissionDraftRow is the on-the-wire shape for the global
// /submissions index — each draft enriched with the project's title +
// reference for the row.
//
// ProjectID / ProjectTitle / ProjectReference are nullable since
// t-paliad-243 — a global Schriftsatz draft started from
// /submissions/new without binding a project surfaces here with all
// three set to null, and the frontend renders a dedicated
// "Ohne Projekt" label for the project column.
type userSubmissionDraftRow struct {
	ID               uuid.UUID  `json:"id"`
	ProjectID        *uuid.UUID `json:"project_id"`
	ProjectTitle     *string    `json:"project_title"`
	ProjectReference *string    `json:"project_reference,omitempty"`
	SubmissionCode   string     `json:"submission_code"`
	Name             string     `json:"name"`
	LastExportedAt   *time.Time `json:"last_exported_at,omitempty"`
	UpdatedAt        time.Time  `json:"updated_at"`
	CreatedAt        time.Time  `json:"created_at"`
}

// handleListUserSubmissionDrafts returns every draft the caller owns
// across every visible project, ordered by updated_at DESC. Backs the
// global /submissions index page (t-paliad-240).
func handleListUserSubmissionDrafts(w http.ResponseWriter, r *http.Request) {
	if !requireDB(w) {
		return
	}
	uid, ok := requireUser(w, r)
	if !ok {
		return
	}
	if dbSvc.submissionDraft == nil {
		writeJSON(w, http.StatusServiceUnavailable, map[string]string{
			"error": "submission drafts not configured",
		})
		return
	}

	rows, err := dbSvc.submissionDraft.ListAllForUser(r.Context(), uid)
	if err != nil {
		writeServiceError(w, err)
		return
	}

	out := make([]userSubmissionDraftRow, 0, len(rows))
	for i := range rows {
		d := &rows[i]
		out = append(out, userSubmissionDraftRow{
			ID:               d.ID,
			ProjectID:        d.ProjectID,
			ProjectTitle:     d.ProjectTitle,
			ProjectReference: d.ProjectReference,
			SubmissionCode:   d.SubmissionCode,
			Name:             d.Name,
			LastExportedAt:   d.LastExportedAt,
			UpdatedAt:        d.UpdatedAt,
			CreatedAt:        d.CreatedAt,
		})
	}
	writeJSON(w, http.StatusOK, map[string]any{"drafts": out})
}

// handleSubmissionsIndexPage serves dist/submissions-index.html for the
// global /submissions index — lists every draft the caller owns across
// visible projects. Sits at top level alongside /checklists, /courts etc.
func handleSubmissionsIndexPage(w http.ResponseWriter, r *http.Request) {
	http.ServeFile(w, r, "dist/submissions-index.html")
}

// handleListSubmissionDrafts returns every draft the caller owns for
// the given (project, submission_code).
func handleListSubmissionDrafts(w http.ResponseWriter, r *http.Request) {
	if !requireDB(w) {
		return
	}
	uid, ok := requireUser(w, r)
	if !ok {
		return
	}
	projectID, ok := parseUUIDPath(w, r, "id", "project id")
	if !ok {
		return
	}
	code, ok := parseSubmissionCodePath(w, r)
	if !ok {
		return
	}
	if dbSvc.submissionDraft == nil {
		writeJSON(w, http.StatusServiceUnavailable, map[string]string{
			"error": "submission drafts not configured",
		})
		return
	}

	rows, err := dbSvc.submissionDraft.List(r.Context(), uid, projectID, code)
	if err != nil {
		writeServiceError(w, err)
		return
	}

	resp := submissionDraftListResponse{
		ProjectID:      projectID,
		SubmissionCode: code,
		Drafts:         make([]submissionDraftJSON, 0, len(rows)),
	}
	for i := range rows {
		resp.Drafts = append(resp.Drafts, draftToJSON(&rows[i]))
	}
	writeJSON(w, http.StatusOK, resp)
}

// handleCreateSubmissionDraft creates a fresh draft and returns the
// view payload (so the client can navigate to /draft/{id} immediately).
func handleCreateSubmissionDraft(w http.ResponseWriter, r *http.Request) {
	if !requireDB(w) {
		return
	}
	uid, ok := requireUser(w, r)
	if !ok {
		return
	}
	projectID, ok := parseUUIDPath(w, r, "id", "project id")
	if !ok {
		return
	}
	code, ok := parseSubmissionCodePath(w, r)
	if !ok {
		return
	}
	if dbSvc.submissionDraft == nil {
		writeJSON(w, http.StatusServiceUnavailable, map[string]string{
			"error": "submission drafts not configured",
		})
		return
	}

	user, _ := dbSvc.users.GetByID(r.Context(), uid)
	lang := userLang(user)

	d, err := dbSvc.submissionDraft.Create(r.Context(), uid, &projectID, code, lang)
	if err != nil {
		writeServiceError(w, err)
		return
	}
	view, err := buildSubmissionDraftView(r.Context(), d, lang)
	if err != nil {
		log.Printf("submission_drafts: build view after create (project=%s code=%s): %v", projectID, code, err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
		return
	}
	writeJSON(w, http.StatusCreated, view)
}

// handleGetSubmissionDraft returns the full editor payload — draft row
// + resolved bag + merged bag (with overrides applied) + HTML preview.
func handleGetSubmissionDraft(w http.ResponseWriter, r *http.Request) {
	if !requireDB(w) {
		return
	}
	uid, ok := requireUser(w, r)
	if !ok {
		return
	}
	if _, ok := parseUUIDPath(w, r, "id", "project id"); !ok {
		return
	}
	if _, ok := parseSubmissionCodePath(w, r); !ok {
		return
	}
	draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
	if !ok {
		return
	}
	if dbSvc.submissionDraft == nil {
		writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission drafts not configured"})
		return
	}

	d, err := dbSvc.submissionDraft.Get(r.Context(), uid, draftID)
	if err != nil {
		writeSubmissionDraftServiceError(w, err)
		return
	}
	user, _ := dbSvc.users.GetByID(r.Context(), uid)
	lang := userLang(user)

	view, err := buildSubmissionDraftView(r.Context(), d, lang)
	if err != nil {
		log.Printf("submission_drafts: build view (draft=%s): %v", draftID, err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
		return
	}
	writeJSON(w, http.StatusOK, view)
}

// handlePatchSubmissionDraft updates name and/or variables and returns
// the refreshed view payload (so the preview pane updates in lockstep
// with the save).
func handlePatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
	if !requireDB(w) {
		return
	}
	uid, ok := requireUser(w, r)
	if !ok {
		return
	}
	if _, ok := parseUUIDPath(w, r, "id", "project id"); !ok {
		return
	}
	if _, ok := parseSubmissionCodePath(w, r); !ok {
		return
	}
	draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
	if !ok {
		return
	}
	if dbSvc.submissionDraft == nil {
		writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission drafts not configured"})
		return
	}

	var input submissionDraftPatchInput
	if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
		return
	}

	patch := services.DraftPatch{Name: input.Name, Variables: input.Variables}
	d, err := dbSvc.submissionDraft.Update(r.Context(), uid, draftID, patch)
	if err != nil {
		writeSubmissionDraftServiceError(w, err)
		return
	}
	user, _ := dbSvc.users.GetByID(r.Context(), uid)
	lang := userLang(user)
	view, err := buildSubmissionDraftView(r.Context(), d, lang)
	if err != nil {
		log.Printf("submission_drafts: build view after patch (draft=%s): %v", draftID, err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
		return
	}
	writeJSON(w, http.StatusOK, view)
}

// handleDeleteSubmissionDraft removes a draft.
func handleDeleteSubmissionDraft(w http.ResponseWriter, r *http.Request) {
	if !requireDB(w) {
		return
	}
	uid, ok := requireUser(w, r)
	if !ok {
		return
	}
	if _, ok := parseUUIDPath(w, r, "id", "project id"); !ok {
		return
	}
	if _, ok := parseSubmissionCodePath(w, r); !ok {
		return
	}
	draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
	if !ok {
		return
	}
	if dbSvc.submissionDraft == nil {
		writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission drafts not configured"})
		return
	}
	if err := dbSvc.submissionDraft.Delete(r.Context(), uid, draftID); err != nil {
		writeSubmissionDraftServiceError(w, err)
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

// handlePreviewSubmissionDraft re-renders the HTML preview with the
// current draft state. Cheaper alternative to the full GET when the
// frontend just wants the preview block refreshed.
func handlePreviewSubmissionDraft(w http.ResponseWriter, r *http.Request) {
	if !requireDB(w) {
		return
	}
	uid, ok := requireUser(w, r)
	if !ok {
		return
	}
	if _, ok := parseUUIDPath(w, r, "id", "project id"); !ok {
		return
	}
	if _, ok := parseSubmissionCodePath(w, r); !ok {
		return
	}
	draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
	if !ok {
		return
	}
	if dbSvc.submissionDraft == nil {
		writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission drafts not configured"})
		return
	}

	ctx, cancel := context.WithTimeout(r.Context(), submissionDraftPreviewTimeout)
	defer cancel()

	d, err := dbSvc.submissionDraft.Get(ctx, uid, draftID)
	if err != nil {
		writeSubmissionDraftServiceError(w, err)
		return
	}
	tplBytes, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode)
	if err != nil {
		log.Printf("submission_drafts: template fetch (draft=%s): %v", draftID, err)
		writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
		return
	}
	html, err := dbSvc.submissionDraft.RenderPreview(ctx, d, tplBytes)
	if err != nil {
		log.Printf("submission_drafts: render preview (draft=%s): %v", draftID, err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed"})
		return
	}
	writeJSON(w, http.StatusOK, map[string]string{"preview_html": html})
}

// handleExportSubmissionDraft merges the draft into the .docx template
// and streams the result. Writes one system_audit_log row and one
// project_events row per successful export.
func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
	if !requireDB(w) {
		return
	}
	uid, ok := requireUser(w, r)
	if !ok {
		return
	}
	if _, ok := parseUUIDPath(w, r, "id", "project id"); !ok {
		return
	}
	if _, ok := parseSubmissionCodePath(w, r); !ok {
		return
	}
	draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
	if !ok {
		return
	}
	if dbSvc.submissionDraft == nil {
		writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission drafts not configured"})
		return
	}

	ctx, cancel := context.WithTimeout(r.Context(), submissionDraftExportTimeout)
	defer cancel()

	d, err := dbSvc.submissionDraft.Get(ctx, uid, draftID)
	if err != nil {
		writeSubmissionDraftServiceError(w, err)
		return
	}
	tplBytes, tplSHA, err := resolveSubmissionTemplate(ctx, d.SubmissionCode)
	if err != nil {
		log.Printf("submission_drafts: export template fetch (draft=%s): %v", draftID, err)
		writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
		return
	}
	docx, resolved, err := dbSvc.submissionDraft.Export(ctx, d, tplBytes)
	if err != nil {
		log.Printf("submission_drafts: export render (draft=%s): %v", draftID, err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed"})
		return
	}

	filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang)

	// Audit + provenance updates are best-effort on a background
	// context so the download still succeeds if the DB races.
	bgCtx, cancelBG := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancelBG()
	if err := dbSvc.submissionDraft.MarkExported(bgCtx, d.ID, tplSHA); err != nil {
		log.Printf("submission_drafts: mark exported (draft=%s): %v", draftID, err)
	}
	if err := writeSubmissionDraftAuditRow(bgCtx, resolved.User, d, filename, tplSHA); err != nil {
		log.Printf("submission_drafts: audit insert failed (draft=%s): %v", draftID, err)
	}
	if err := writeSubmissionDraftProjectEvent(bgCtx, d, resolved, filename); err != nil {
		log.Printf("submission_drafts: project event insert failed (draft=%s): %v", draftID, err)
	}

	w.Header().Set("Content-Type", docxMime)
	w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, filename))
	w.Header().Set("Content-Length", strconv.Itoa(len(docx)))
	if _, err := w.Write(docx); err != nil {
		log.Printf("submission_drafts: response write failed (draft=%s): %v", draftID, err)
	}
}

// handleSubmissionDraftPage serves dist/submission-draft.html for the
// dedicated draft editor at /projects/{id}/submissions/{code}/draft
// (and …/draft/{draft_id}). Project visibility is enforced server-side
// before serving so an inaccessible id returns 404 + notfound chrome
// rather than leaking a 200 to a guesser. Submission rule lookup +
// draft id existence checks happen client-side via the API endpoints.
func handleSubmissionDraftPage(w http.ResponseWriter, r *http.Request) {
	if !requireDB(w) {
		return
	}
	uid, ok := requireUser(w, r)
	if !ok {
		return
	}
	id, err := uuid.Parse(r.PathValue("id"))
	if err != nil {
		serveSubmissionDraftNotFound(w)
		return
	}
	if _, err := dbSvc.projects.GetByID(r.Context(), uid, id); err != nil {
		if errors.Is(err, services.ErrNotVisible) {
			serveSubmissionDraftNotFound(w)
			return
		}
		writeServiceError(httpDevNullJSON{}, err)
		serveSubmissionDraftNotFound(w)
		return
	}
	http.ServeFile(w, r, "dist/submission-draft.html")
}

// handleSubmissionDraftGlobalPage serves dist/submission-draft.html for
// project-less drafts at /submissions/draft/{draft_id} (t-paliad-243).
// The page shell is identical to the project-scoped one; the client
// bundle parses the URL and switches to no-project mode when no
// /projects/{id}/ prefix is present.
//
// Owner check happens at the API layer when the client fetches the
// draft view; this handler only guards the page chrome and leaves the
// 404-on-not-found semantics to the API.
func handleSubmissionDraftGlobalPage(w http.ResponseWriter, r *http.Request) {
	if !requireDB(w) {
		return
	}
	if _, ok := requireUser(w, r); !ok {
		return
	}
	if _, err := uuid.Parse(r.PathValue("draft_id")); err != nil {
		serveSubmissionDraftNotFound(w)
		return
	}
	http.ServeFile(w, r, "dist/submission-draft.html")
}

// handleSubmissionsNewPage serves dist/submissions-new.html — the
// global "Neuer Entwurf" picker (t-paliad-243). The page lists the
// full submission catalog grouped by proceeding and lets the lawyer
// pick a template with or without binding it to a project.
func handleSubmissionsNewPage(w http.ResponseWriter, r *http.Request) {
	http.ServeFile(w, r, "dist/submissions-new.html")
}

// globalCreateDraftInput is the body shape for POST /api/submission-drafts.
// project_id is optional; when omitted or null the draft is created
// without a project binding (t-paliad-243).
type globalCreateDraftInput struct {
	SubmissionCode string     `json:"submission_code"`
	ProjectID      *uuid.UUID `json:"project_id,omitempty"`
}

// handleCreateGlobalSubmissionDraft creates a draft from the global
// /submissions/new picker. Compared to the project-scoped sibling, the
// project_id comes from the JSON body (optional) instead of the URL
// path. Used by the picker to spawn a draft and redirect to the editor.
func handleCreateGlobalSubmissionDraft(w http.ResponseWriter, r *http.Request) {
	if !requireDB(w) {
		return
	}
	uid, ok := requireUser(w, r)
	if !ok {
		return
	}
	if dbSvc.submissionDraft == nil {
		writeJSON(w, http.StatusServiceUnavailable, map[string]string{
			"error": "submission drafts not configured",
		})
		return
	}

	var in globalCreateDraftInput
	if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
		return
	}
	code := strings.TrimSpace(in.SubmissionCode)
	if code == "" {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "submission_code required"})
		return
	}

	user, _ := dbSvc.users.GetByID(r.Context(), uid)
	lang := userLang(user)

	d, err := dbSvc.submissionDraft.Create(r.Context(), uid, in.ProjectID, code, lang)
	if err != nil {
		writeSubmissionDraftServiceError(w, err)
		return
	}
	view, err := buildSubmissionDraftView(r.Context(), d, lang)
	if err != nil {
		log.Printf("submission_drafts: build view after global create (code=%s): %v", code, err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
		return
	}
	writeJSON(w, http.StatusCreated, view)
}

// handleGetGlobalSubmissionDraft returns the editor payload by draft
// id alone (t-paliad-243). The project-less editor at
// /submissions/draft/{draft_id} doesn't have a project segment to
// route through the existing project-scoped endpoint; this is the
// global counterpart. Works for both project-less AND project-scoped
// drafts since the draft row carries its own project_id.
func handleGetGlobalSubmissionDraft(w http.ResponseWriter, r *http.Request) {
	if !requireDB(w) {
		return
	}
	uid, ok := requireUser(w, r)
	if !ok {
		return
	}
	draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
	if !ok {
		return
	}
	if dbSvc.submissionDraft == nil {
		writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission drafts not configured"})
		return
	}

	d, err := dbSvc.submissionDraft.Get(r.Context(), uid, draftID)
	if err != nil {
		writeSubmissionDraftServiceError(w, err)
		return
	}
	user, _ := dbSvc.users.GetByID(r.Context(), uid)
	lang := userLang(user)

	view, err := buildSubmissionDraftView(r.Context(), d, lang)
	if err != nil {
		log.Printf("submission_drafts: build view (draft=%s): %v", draftID, err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
		return
	}
	writeJSON(w, http.StatusOK, view)
}

// globalDraftPatchInput is PATCH input for /api/submission-drafts/{draft_id}.
// Same Name + Variables semantics as the project-scoped patch, plus
// ProjectID for the "Projekt zuweisen" affordance — the lawyer can
// attach (assign a UUID) or detach (set null) at any time. A missing
// `project_id` key is treated as "no change"; a present-but-null value
// detaches.
type globalDraftPatchInput struct {
	Name      *string                  `json:"name,omitempty"`
	Variables *services.PlaceholderMap `json:"variables,omitempty"`
	// projectIDProvided is true when the JSON included the "project_id"
	// key (regardless of value); needed to distinguish "no change" from
	// "set to null". Set by the custom UnmarshalJSON below.
	ProjectID         *uuid.UUID `json:"project_id,omitempty"`
	projectIDProvided bool
}

func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
	type alias struct {
		Name      *string                  `json:"name,omitempty"`
		Variables *services.PlaceholderMap `json:"variables,omitempty"`
		ProjectID *uuid.UUID               `json:"project_id,omitempty"`
	}
	var a alias
	if err := json.Unmarshal(data, &a); err != nil {
		return err
	}
	g.Name = a.Name
	g.Variables = a.Variables
	g.ProjectID = a.ProjectID
	// Detect whether "project_id" was present in the JSON object.
	var raw map[string]json.RawMessage
	if err := json.Unmarshal(data, &raw); err != nil {
		return err
	}
	_, g.projectIDProvided = raw["project_id"]
	return nil
}

// handleGlobalPatchSubmissionDraft updates a draft by id (t-paliad-243).
// Supports the project_id mutation in addition to name + variables so
// the project-less editor can offer "Projekt zuweisen" and persist the
// chosen project on the same row.
func handleGlobalPatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
	if !requireDB(w) {
		return
	}
	uid, ok := requireUser(w, r)
	if !ok {
		return
	}
	draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
	if !ok {
		return
	}
	if dbSvc.submissionDraft == nil {
		writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission drafts not configured"})
		return
	}

	var in globalDraftPatchInput
	if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
		return
	}

	patch := services.DraftPatch{Name: in.Name, Variables: in.Variables}
	if in.projectIDProvided {
		pid := in.ProjectID // may be nil → detach
		patch.ProjectID = &pid
	}

	d, err := dbSvc.submissionDraft.Update(r.Context(), uid, draftID, patch)
	if err != nil {
		writeSubmissionDraftServiceError(w, err)
		return
	}
	user, _ := dbSvc.users.GetByID(r.Context(), uid)
	lang := userLang(user)
	view, err := buildSubmissionDraftView(r.Context(), d, lang)
	if err != nil {
		log.Printf("submission_drafts: build view after global patch (draft=%s): %v", draftID, err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
		return
	}
	writeJSON(w, http.StatusOK, view)
}

// handleGlobalDeleteSubmissionDraft removes a draft by id.
func handleGlobalDeleteSubmissionDraft(w http.ResponseWriter, r *http.Request) {
	if !requireDB(w) {
		return
	}
	uid, ok := requireUser(w, r)
	if !ok {
		return
	}
	draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
	if !ok {
		return
	}
	if dbSvc.submissionDraft == nil {
		writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission drafts not configured"})
		return
	}
	if err := dbSvc.submissionDraft.Delete(r.Context(), uid, draftID); err != nil {
		writeSubmissionDraftServiceError(w, err)
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

// handleGlobalExportSubmissionDraft renders and streams the .docx for
// the draft. Shares writeSubmissionDraftAuditRow + writeSubmissionDraftProjectEvent
// with the project-scoped sibling — both handle a nil ProjectID
// correctly (audit scope flips to 'user', project-event is skipped).
func handleGlobalExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
	if !requireDB(w) {
		return
	}
	uid, ok := requireUser(w, r)
	if !ok {
		return
	}
	draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
	if !ok {
		return
	}
	if dbSvc.submissionDraft == nil {
		writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission drafts not configured"})
		return
	}

	ctx, cancel := context.WithTimeout(r.Context(), submissionDraftExportTimeout)
	defer cancel()

	d, err := dbSvc.submissionDraft.Get(ctx, uid, draftID)
	if err != nil {
		writeSubmissionDraftServiceError(w, err)
		return
	}
	tplBytes, tplSHA, err := resolveSubmissionTemplate(ctx, d.SubmissionCode)
	if err != nil {
		log.Printf("submission_drafts: export template fetch (draft=%s): %v", draftID, err)
		writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
		return
	}
	docx, resolved, err := dbSvc.submissionDraft.Export(ctx, d, tplBytes)
	if err != nil {
		log.Printf("submission_drafts: export render (draft=%s): %v", draftID, err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed"})
		return
	}

	filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang)

	bgCtx, cancelBG := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancelBG()
	if err := dbSvc.submissionDraft.MarkExported(bgCtx, d.ID, tplSHA); err != nil {
		log.Printf("submission_drafts: mark exported (draft=%s): %v", draftID, err)
	}
	if err := writeSubmissionDraftAuditRow(bgCtx, resolved.User, d, filename, tplSHA); err != nil {
		log.Printf("submission_drafts: audit insert failed (draft=%s): %v", draftID, err)
	}
	if err := writeSubmissionDraftProjectEvent(bgCtx, d, resolved, filename); err != nil {
		log.Printf("submission_drafts: project event insert failed (draft=%s): %v", draftID, err)
	}

	w.Header().Set("Content-Type", docxMime)
	w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, filename))
	w.Header().Set("Content-Length", strconv.Itoa(len(docx)))
	if _, err := w.Write(docx); err != nil {
		log.Printf("submission_drafts: response write failed (draft=%s): %v", draftID, err)
	}
}

// serveSubmissionDraftNotFound writes the standard notfound chrome
// with a 404 status — same shape the chart page uses.
func serveSubmissionDraftNotFound(w http.ResponseWriter) {
	body, err := os.ReadFile("dist/notfound.html")
	if err != nil {
		http.Error(w, "404 page not found", http.StatusNotFound)
		return
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.WriteHeader(http.StatusNotFound)
	_, _ = w.Write(body)
}

// ─────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────

// buildSubmissionDraftView composes the full editor payload: the draft
// row, the resolved bag from project state, the merged bag (overrides
// layered on top), the HTML preview, and metadata for the sidebar's
// per-rule heading.
func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft, lang string) (*submissionDraftView, error) {
	view := &submissionDraftView{
		Draft:       draftToJSON(d),
		Lang:        lang,
		HasTemplate: true,
	}

	merged, resolved, err := dbSvc.submissionDraft.BuildRenderBag(ctx, d)
	if err != nil {
		return nil, err
	}
	view.ResolvedBag = resolved.Placeholders
	view.MergedBag = merged
	if resolved.Lang != "" {
		view.Lang = resolved.Lang
	}
	if resolved.Rule != nil {
		view.Rule = &submissionRuleSummary{
			Name:                derefStringHandler(resolved.Rule.SubmissionCode),
			SubmissionCode:      derefStringHandler(resolved.Rule.SubmissionCode),
			NameEN:              resolved.Rule.NameEN,
			PrimaryParty:        derefStringHandler(resolved.Rule.PrimaryParty),
			EventType:           derefStringHandler(resolved.Rule.EventType),
			LegalSource:         derefStringHandler(resolved.Rule.LegalSource),
		}
		view.Rule.Name = resolved.Rule.Name
		view.Rule.LegalSourcePretty = merged["rule.legal_source_pretty"]
	}

	tplBytes, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode)
	if err != nil {
		log.Printf("submission_drafts: template fetch for view (draft=%s): %v", d.ID, err)
		view.TemplateMissing = true
		view.HasTemplate = false
		view.PreviewHTML = `<p class="preview-error">Vorlage konnte nicht geladen werden.</p>`
		return view, nil
	}
	html, err := dbSvc.submissionDraft.RenderPreview(ctx, d, tplBytes)
	if err != nil {
		return nil, err
	}
	view.PreviewHTML = html
	return view, nil
}

// resolveSubmissionTemplate returns the .docx bytes for the given
// submission code. Lookup order matches the cronus design fallback chain
// §8 plus the t-paliad-259 universal-skeleton slot:
//
//  1. per-firm per-submission_code template registered in
//     submissionTemplateRegistry (e.g. de.inf.lg.erwidg.docx) — code-
//     specific structure plus the full variable bag.
//  2. universal _skeleton.docx — same variable bag, no submission_code-
//     specific prose. Catches every code without a dedicated template
//     so the editor preview / generate flow still has variables to
//     substitute instead of falling through to the bare letterhead.
//  3. universal HL Patents Style .dotm — macro-only letterhead, no
//     placeholders. Final fallback when even the skeleton is unreachable
//     (mWorkRepo outage etc.). Preserves the pre-t-paliad-259 behaviour
//     for resilience.
//
// The returned SHA is the cache entry's commit SHA so the export audit
// row can record provenance.
func resolveSubmissionTemplate(ctx context.Context, submissionCode string) ([]byte, string, error) {
	if data, sha, found, err := fetchSubmissionTemplateBytes(ctx, submissionCode); err != nil {
		return nil, "", err
	} else if found {
		return data, sha, nil
	}
	if data, sha, err := fetchSubmissionSkeletonBytes(ctx); err == nil {
		return data, sha, nil
	} else {
		log.Printf("submission_drafts: skeleton fetch failed for code=%s, falling back to HL Patents Style: %v", submissionCode, err)
	}
	bytes, err := fetchHLPatentsStyleBytes(ctx)
	if err != nil {
		return nil, "", err
	}
	sha := hlPatentsStyleSHA()
	return bytes, sha, nil
}

// hlPatentsStyleSHA reads the current cache SHA for the universal
// template if one is loaded. Returns "" when the cache hasn't been
// populated yet — the export still proceeds, the audit row just
// records an unpinned provenance.
func hlPatentsStyleSHA() string {
	ce := getCacheEntry(hlPatentsStyleSlug)
	ce.mu.RLock()
	defer ce.mu.RUnlock()
	return ce.sha
}

// draftToJSON projects a services.SubmissionDraft into the on-the-wire
// shape — strips the raw jsonb bytes and exposes the decoded
// PlaceholderMap directly.
func draftToJSON(d *services.SubmissionDraft) submissionDraftJSON {
	vars := d.Variables
	if vars == nil {
		vars = services.PlaceholderMap{}
	}
	return submissionDraftJSON{
		ID:              d.ID,
		ProjectID:       d.ProjectID,
		SubmissionCode:  d.SubmissionCode,
		UserID:          d.UserID,
		Name:            d.Name,
		Variables:       vars,
		LastExportedAt:  d.LastExportedAt,
		LastExportedSHA: d.LastExportedSHA,
		CreatedAt:       d.CreatedAt,
		UpdatedAt:       d.UpdatedAt,
	}
}

// writeSubmissionDraftAuditRow logs one row in paliad.system_audit_log
// per export. Distinct event_type from the format-only 'submission.generated'
// so admins can tell the two paths apart in the feed.
//
// For a project-less draft (t-paliad-243) the scope is widened to
// 'user' with scope_root = draft.user_id; the audit feed therefore
// surfaces these exports on the user's row rather than against a
// (non-existent) project.
func writeSubmissionDraftAuditRow(ctx context.Context, user *models.User, d *services.SubmissionDraft, filename, templateSHA string) error {
	meta := map[string]any{
		"submission_code": d.SubmissionCode,
		"draft_id":        d.ID.String(),
		"draft_name":      d.Name,
		"filename":        filename,
		"template_sha":    templateSHA,
	}
	body, _ := json.Marshal(meta)
	var (
		actorID    any
		actorEmail string
	)
	if user != nil {
		actorID = user.ID
		actorEmail = user.Email
	}
	scope := "project"
	scopeRoot := ""
	if d.ProjectID != nil {
		scopeRoot = d.ProjectID.String()
	} else {
		scope = "user"
		scopeRoot = d.UserID.String()
	}
	_, err := dbSvc.projects.DB().ExecContext(ctx,
		`INSERT INTO paliad.system_audit_log
		    (event_type, actor_id, actor_email, scope, scope_root, metadata)
		 VALUES ('submission.exported', $1, $2, $3, $4, $5::jsonb)`,
		actorID, actorEmail, scope, scopeRoot, string(body),
	)
	return err
}

// writeSubmissionDraftProjectEvent records a project-level Verlauf
// entry so the export surfaces on the project's timeline. Uses the
// same event_type convention as other custom milestones. A project-less
// draft (t-paliad-243) has no timeline to write to — the caller skips
// this row entirely; we no-op defensively here for safety.
func writeSubmissionDraftProjectEvent(ctx context.Context, d *services.SubmissionDraft, resolved *services.SubmissionVarsResult, filename string) error {
	if d.ProjectID == nil {
		return nil
	}
	ruleName := ""
	if resolved.Rule != nil {
		ruleName = resolved.Rule.Name
	}
	meta := map[string]any{
		"submission_code": d.SubmissionCode,
		"draft_id":        d.ID.String(),
		"draft_name":      d.Name,
		"rule_name":       ruleName,
		"filename":        filename,
	}
	body, _ := json.Marshal(meta)
	_, err := dbSvc.projects.DB().ExecContext(ctx,
		`INSERT INTO paliad.project_events
		    (project_id, event_type, title, metadata, created_by, event_date, timeline_kind)
		 VALUES ($1, 'submission_exported', $2, $3::jsonb, $4, now(), 'custom_milestone')`,
		*d.ProjectID, fmt.Sprintf("%s exportiert", strings.TrimSpace(ruleName)),
		string(body), d.UserID,
	)
	return err
}

// writeSubmissionDraftServiceError maps draft-specific sentinels to
// the right HTTP status; falls back to the shared writeServiceError
// for everything else.
func writeSubmissionDraftServiceError(w http.ResponseWriter, err error) {
	switch {
	case errors.Is(err, services.ErrSubmissionDraftNotFound):
		writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
	case errors.Is(err, services.ErrSubmissionDraftNameTaken):
		writeJSON(w, http.StatusConflict, map[string]string{"error": "name already in use"})
	case errors.Is(err, services.ErrSubmissionRuleNotFound):
		writeJSON(w, http.StatusNotFound, map[string]string{"error": "submission rule not found"})
	default:
		writeServiceError(w, err)
	}
}

// parseUUIDPath parses a UUID path parameter, writing a 400 and
// returning false on bad input.
func parseUUIDPath(w http.ResponseWriter, r *http.Request, key, label string) (uuid.UUID, bool) {
	id, err := uuid.Parse(r.PathValue(key))
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid " + label})
		return uuid.Nil, false
	}
	return id, true
}

// parseSubmissionCodePath pulls the {code} segment from the URL and
// validates it as a non-empty submission_code shape.
func parseSubmissionCodePath(w http.ResponseWriter, r *http.Request) (string, bool) {
	code := strings.TrimSpace(r.PathValue("code"))
	if code == "" {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "submission code required"})
		return "", false
	}
	return code, true
}

// userLang picks the user's preferred language with a German fallback.
func userLang(u *models.User) string {
	if u != nil && u.Lang != "" {
		return u.Lang
	}
	return "de"
}

// derefStringHandler safely dereferences a *string in handler-land.
// Mirrors the same helper in services/submission_vars.go (not exported
// from services to keep the package surface minimal).
func derefStringHandler(s *string) string {
	if s == nil {
		return ""
	}
	return *s
}
