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"
	"mgit.msbls.de/m/paliad/pkg/docforge"
)

// 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"`
	// TemplateTier identifies which tier of resolveSubmissionTemplate
	// produced the bytes — one of per_code_lang, per_code, skeleton_lang,
	// skeleton, letterhead. Lets the editor distinguish a perfect
	// per-firm match from a skeleton fallback. t-paliad-276.
	TemplateTier string `json:"template_tier,omitempty"`
	// LanguageFallback is true when the requested draft.language has no
	// per-firm per-code template (e.g. EN draft falls back to the DE
	// per-code template, or to the universal skeleton). UI surfaces a
	// notice so the lawyer knows the rendered body lacks language-
	// matched code-specific prose. t-paliad-276.
	LanguageFallback bool `json:"language_fallback,omitempty"`
	// AvailableParties is the project's full party roster (t-paliad-277)
	// so the frontend can render the multi-select picker in one round-
	// trip. Empty when the draft has no project attached.
	AvailableParties []submissionDraftPartyJSON `json:"available_parties"`
	// Sections is the per-draft section stack (t-paliad-313 Slice A).
	// Slice A renders these read-only; the lawyer sees what the
	// Composer seeded but can't yet edit prose. nil for pre-Composer
	// drafts (base_id NULL, no submission_sections rows).
	Sections []submissionSectionJSON `json:"sections"`
}

// submissionDraftPartyJSON is the minimal party row the editor sidebar
// needs to render a checkbox + role chip per party.
type submissionDraftPartyJSON struct {
	ID             uuid.UUID `json:"id"`
	Name           string    `json:"name"`
	Role           string    `json:"role,omitempty"`
	Representative string    `json:"representative,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"`
	Language        string                  `json:"language"`
	Variables       services.PlaceholderMap `json:"variables"`
	SelectedParties []uuid.UUID             `json:"selected_parties"`
	LastExportedAt  *time.Time              `json:"last_exported_at,omitempty"`
	LastExportedSHA *string                 `json:"last_exported_sha,omitempty"`
	LastImportedAt  *time.Time              `json:"last_imported_at,omitempty"`
	// BaseID — Composer base reference (t-paliad-313). NULL on
	// pre-Composer drafts; the editor sidebar surfaces this in the
	// base picker. PATCH accepts {"base_id": "<uuid>"} or
	// {"base_id": null} to set or clear.
	BaseID *uuid.UUID `json:"base_id"`
	// TemplateVersionID — pinned uploaded docforge template version
	// (t-paliad-349 slice 7). NULL = base_id/v1 path. The editor's picker
	// surfaces this; PATCH accepts {"template_version_id": "<uuid>"} | null.
	TemplateVersionID *uuid.UUID     `json:"template_version_id"`
	ComposerMeta      map[string]any `json:"composer_meta"`
	CreatedAt         time.Time      `json:"created_at"`
	UpdatedAt         time.Time      `json:"updated_at"`
}

// submissionSectionJSON is the on-the-wire row for each per-draft
// section. Slice A renders these read-only — the lawyer sees the
// section stack but doesn't yet edit prose. Slice B makes content_md_*
// editable + adds the PATCH endpoint.
type submissionSectionJSON struct {
	ID          uuid.UUID `json:"id"`
	SectionKey  string    `json:"section_key"`
	OrderIndex  int       `json:"order_index"`
	Kind        string    `json:"kind"`
	LabelDE     string    `json:"label_de"`
	LabelEN     string    `json:"label_en"`
	Included    bool      `json:"included"`
	ContentMDDE string    `json:"content_md_de"`
	ContentMDEN string    `json:"content_md_en"`
}

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"`
	SelectedParties *[]uuid.UUID             `json:"selected_parties,omitempty"`
	Language        *string                  `json:"language,omitempty"`
	// BaseID accepts three states per the JSON contract:
	//   field absent              → no change (json:"-")
	//   {"base_id": "<uuid>"}     → set to picked base
	//   {"base_id": null}         → clear (return to v1 fallback)
	// We model this with a **uuid.UUID inside a custom UnmarshalJSON
	// in case extends; for now the simpler `*uuid.UUID` + presence
	// flag covers Slice A's set-base flow. Clearing is exposed but
	// rarely used (the editor always picks a base; clearing is for
	// admin-recovery flows).
	BaseID    *uuid.UUID `json:"base_id,omitempty"`
	BaseIDSet bool       `json:"-"`
	// TemplateVersionID pins an uploaded docforge template version
	// (t-paliad-349 slice 7). Same three-state presence contract as
	// base_id: absent = no change, uuid = pin, null = clear.
	TemplateVersionID    *uuid.UUID `json:"template_version_id,omitempty"`
	TemplateVersionIDSet bool       `json:"-"`
	// FilenameKeyword overrides the leading keyword of the exported
	// document name (t-paliad-354). Absent = no change; "" = clear back
	// to the auto-derived rule name; "x" = set. Persisted in
	// composer_meta.filename_keyword.
	FilenameKeyword *string `json:"filename_keyword,omitempty"`
}

// UnmarshalJSON on submissionDraftPatchInput sets BaseIDSet=true if
// the "base_id" key appears in the payload (regardless of whether
// the value is null or a uuid string). Lets the handler distinguish
// "field absent" (no change) from "field set to null" (clear).
func (p *submissionDraftPatchInput) UnmarshalJSON(data []byte) error {
	// Phase 1: decode into a raw map to detect key presence.
	raw := map[string]json.RawMessage{}
	if err := json.Unmarshal(data, &raw); err != nil {
		return err
	}
	// Phase 2: decode the typed fields. Use an alias to skip this
	// custom UnmarshalJSON during the re-parse.
	type alias submissionDraftPatchInput
	var a alias
	if err := json.Unmarshal(data, &a); err != nil {
		return err
	}
	*p = submissionDraftPatchInput(a)
	if _, ok := raw["base_id"]; ok {
		p.BaseIDSet = true
	}
	if _, ok := raw["template_version_id"]; ok {
		p.TemplateVersionIDSet = true
	}
	return nil
}

// ─────────────────────────────────────────────────────────────────────
// 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,
		SelectedParties: input.SelectedParties,
		Language:        input.Language,
		FilenameKeyword: input.FilenameKeyword,
	}
	if input.BaseIDSet {
		patch.BaseID = &input.BaseID
	}
	if input.TemplateVersionIDSet {
		if !validateTemplateVersionPin(w, r.Context(), input.TemplateVersionID) {
			return
		}
		patch.TemplateVersionID = &input.TemplateVersionID
	}
	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 := previewTemplateBytes(ctx, d)
	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
	}
	docx, resolved, tplSHA, composerUsed, err := exportSubmissionDraft(ctx, d)
	if err != nil {
		log.Printf("submission_drafts: export (draft=%s): %v", draftID, err)
		writeSubmissionExportError(w, err)
		return
	}

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

	// 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, composerUsed); 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)
	}
}

// validateTemplateVersionPin checks that a non-nil template-version pin
// refers to an existing version (404 otherwise), so a PATCH can't bind a
// draft to a vanished template. A nil pin (clear) is always valid. Returns
// true when the patch may proceed; writes the error response otherwise.
func validateTemplateVersionPin(w http.ResponseWriter, ctx context.Context, pin *uuid.UUID) bool {
	if pin == nil {
		return true
	}
	if dbSvc.templateStore == nil {
		writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "template store not configured"})
		return false
	}
	if _, err := dbSvc.templateStore.GetVersion(ctx, pin.String()); err != nil {
		if errors.Is(err, docforge.ErrTemplateNotFound) {
			writeJSON(w, http.StatusNotFound, map[string]string{"error": "template version not found"})
		} else {
			writeServiceError(w, err)
		}
		return false
	}
	return true
}

// previewTemplateBytes returns the carrier bytes to render a draft's
// preview: the pinned uploaded-template version's carrier when set
// (t-paliad-349 slice 7), otherwise the resolved upstream submission
// template (v1/legacy path). A missing pinned version falls through to the
// upstream resolution rather than failing.
func previewTemplateBytes(ctx context.Context, d *services.SubmissionDraft) ([]byte, error) {
	if d.TemplateVersionID != nil && dbSvc.templateStore != nil {
		tmpl, err := dbSvc.templateStore.GetVersion(ctx, d.TemplateVersionID.String())
		if err == nil {
			return tmpl.CarrierBytes, nil
		}
		if !errors.Is(err, docforge.ErrTemplateNotFound) {
			return nil, err
		}
	}
	b, _, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
	return b, err
}

// exportSubmissionDraft is the shared render entry point used by both
// the project-scoped and global export handlers (t-paliad-313 Slice B).
// Branches on draft.BaseID: if set AND the base + bytes resolve, the
// Composer pipeline assembles the document; otherwise the v1
// template-only path stays the fallback. composerUsed = true means the
// metadata jsonb on the audit row carries "composer": true so admins
// can tell the two paths apart in the feed.
//
// Returns (bytes, resolved-bag, templateSHA, composerUsed, err).
func exportSubmissionDraft(ctx context.Context, d *services.SubmissionDraft) ([]byte, *services.SubmissionVarsResult, string, bool, error) {
	// t-paliad-349 slice 7 — uploaded-template path, checked first. The
	// pinned version's carrier already carries {{slots}}; Export resolves
	// the bag + substitutes them via the same renderer the v1 path uses
	// (no Composer/sections — the uploaded doc IS the document). A missing
	// pinned version falls through to the base_id / v1 paths.
	if d.TemplateVersionID != nil && dbSvc.templateStore != nil {
		tmpl, err := dbSvc.templateStore.GetVersion(ctx, d.TemplateVersionID.String())
		switch {
		case err == nil:
			docx, resolved, rerr := dbSvc.submissionDraft.Export(ctx, d, tmpl.CarrierBytes)
			if rerr != nil {
				return nil, nil, "", false, fmt.Errorf("render: %w", rerr)
			}
			return docx, resolved, "", false, nil
		case errors.Is(err, docforge.ErrTemplateNotFound):
			log.Printf("submission_drafts: pinned template version missing (draft=%s version=%s) — falling back", d.ID, *d.TemplateVersionID)
		default:
			return nil, nil, "", false, fmt.Errorf("template version lookup: %w", err)
		}
	}

	if d.BaseID != nil && dbSvc.submissionBase != nil && dbSvc.submissionSection != nil && dbSvc.submissionComposer != nil {
		base, err := dbSvc.submissionBase.GetByID(ctx, *d.BaseID)
		switch {
		case err == nil:
			baseBytes, baseSHA, err := fetchComposerBaseBytes(ctx, base)
			if err == nil {
				sections, err := dbSvc.submissionSection.ListForDraft(ctx, d.ID)
				if err != nil {
					return nil, nil, "", false, fmt.Errorf("list sections: %w", err)
				}
				bag, resolved, err := dbSvc.submissionDraft.BuildRenderBag(ctx, d)
				if err != nil {
					return nil, nil, "", false, err
				}
				docx, err := dbSvc.submissionComposer.Compose(ctx, services.ComposeOptions{
					Sections:  sections,
					Base:      base,
					BaseBytes: baseBytes,
					Lang:      resolved.Lang,
					Vars:      bag,
					Missing:   services.DefaultMissingMarker(resolved.Lang),
				})
				if err != nil {
					return nil, nil, "", false, fmt.Errorf("composer: %w", err)
				}
				return docx, resolved, baseSHA, true, nil
			}
			log.Printf("submission_drafts: composer base bytes fetch failed (draft=%s base=%s): %v — falling back to v1 path", d.ID, base.Slug, err)
		case errors.Is(err, services.ErrBaseNotFound):
			log.Printf("submission_drafts: composer base missing (draft=%s base_id=%s) — falling back to v1 path", d.ID, *d.BaseID)
		default:
			return nil, nil, "", false, fmt.Errorf("composer base lookup: %w", err)
		}
	}

	// v1 fallback: template-only render via resolveSubmissionTemplate +
	// SubmissionDraftService.Export. Unchanged behaviour for
	// pre-Composer drafts.
	tplBytes, tplSHA, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
	if err != nil {
		return nil, nil, "", false, fmt.Errorf("template upstream: %w", err)
	}
	docx, resolved, err := dbSvc.submissionDraft.Export(ctx, d, tplBytes)
	if err != nil {
		return nil, nil, "", false, fmt.Errorf("render: %w", err)
	}
	return docx, resolved, tplSHA, false, nil
}

// writeSubmissionExportError maps a render-time error to an HTTP
// response. The shape mirrors what the handlers used to inline.
func writeSubmissionExportError(w http.ResponseWriter, err error) {
	if err == nil {
		return
	}
	msg := err.Error()
	switch {
	case strings.Contains(msg, "template upstream"):
		writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
	case strings.Contains(msg, "composer:") || strings.Contains(msg, "render:") || strings.Contains(msg, "list sections"):
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed"})
	default:
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed"})
	}
}

// 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"`
	Language  *string                  `json:"language,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
	// SelectedParties: present-but-empty array resets to "all parties",
	// present non-empty array restricts to subset, absent = no change.
	SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
	// BaseID + baseIDProvided mirror the ProjectID pattern — present
	// (regardless of value) means "set"; absent means "no change". Set
	// by UnmarshalJSON. t-paliad-313 Composer Slice A.
	BaseID         *uuid.UUID `json:"base_id,omitempty"`
	baseIDProvided bool
	// TemplateVersionID + provided flag — uploaded-template pin
	// (t-paliad-349 slice 7), same present/absent contract as base_id.
	TemplateVersionID         *uuid.UUID `json:"template_version_id,omitempty"`
	templateVersionIDProvided bool
	// FilenameKeyword overrides the leading keyword of the exported
	// document name (t-paliad-354). Absent = no change; "" = clear; "x" =
	// set. Persisted in composer_meta.filename_keyword.
	FilenameKeyword *string `json:"filename_keyword,omitempty"`
}

func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
	type alias struct {
		Name              *string                  `json:"name,omitempty"`
		Variables         *services.PlaceholderMap `json:"variables,omitempty"`
		Language          *string                  `json:"language,omitempty"`
		ProjectID         *uuid.UUID               `json:"project_id,omitempty"`
		SelectedParties   *[]uuid.UUID             `json:"selected_parties,omitempty"`
		BaseID            *uuid.UUID               `json:"base_id,omitempty"`
		TemplateVersionID *uuid.UUID               `json:"template_version_id,omitempty"`
		FilenameKeyword   *string                  `json:"filename_keyword,omitempty"`
	}
	var a alias
	if err := json.Unmarshal(data, &a); err != nil {
		return err
	}
	g.Name = a.Name
	g.Variables = a.Variables
	g.Language = a.Language
	g.ProjectID = a.ProjectID
	g.SelectedParties = a.SelectedParties
	g.BaseID = a.BaseID
	g.TemplateVersionID = a.TemplateVersionID
	g.FilenameKeyword = a.FilenameKeyword
	// Detect whether "project_id" / "base_id" / "template_version_id" were
	// 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"]
	_, g.baseIDProvided = raw["base_id"]
	_, g.templateVersionIDProvided = raw["template_version_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,
		SelectedParties: in.SelectedParties,
		Language:        in.Language,
		FilenameKeyword: in.FilenameKeyword,
	}
	if in.projectIDProvided {
		pid := in.ProjectID // may be nil → detach
		patch.ProjectID = &pid
	}
	if in.baseIDProvided {
		bid := in.BaseID // may be nil → clear
		patch.BaseID = &bid
	}
	if in.templateVersionIDProvided {
		if !validateTemplateVersionPin(w, r.Context(), in.TemplateVersionID) {
			return
		}
		tv := in.TemplateVersionID // may be nil → clear
		patch.TemplateVersionID = &tv
	}

	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)
}

// handleImportFromProject re-pulls every project-derived variable on
// the draft and bumps last_imported_at (t-paliad-277). The service-
// layer call strips overrides for project.* / parties.* / deadline.* /
// procedural_event.* / rule.* prefixes; firm.* / today.* / user.*
// overrides survive because those values aren't sourced from the
// project record.
//
// Idempotent on repeat clicks. Returns the full editor view so the
// frontend can refresh in one round-trip.
func handleImportFromProject(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.ImportFromProject(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 after import (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
	}
	docx, resolved, tplSHA, composerUsed, err := exportSubmissionDraft(ctx, d)
	if err != nil {
		log.Printf("submission_drafts: export (draft=%s): %v", draftID, err)
		writeSubmissionExportError(w, err)
		return
	}

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

	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, composerUsed); 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,
		AvailableParties: []submissionDraftPartyJSON{},
		Sections:         []submissionSectionJSON{},
	}

	// Composer Slice A — surface seeded sections (read-only). Empty
	// when the draft has no base + no section rows (pre-Composer
	// drafts that haven't been auto-upgraded — that's Slice C).
	if dbSvc.submissionSection != nil {
		secs, err := dbSvc.submissionSection.ListForDraft(ctx, d.ID)
		if err != nil {
			return nil, err
		}
		for _, sec := range secs {
			view.Sections = append(view.Sections, submissionSectionJSON{
				ID:          sec.ID,
				SectionKey:  sec.SectionKey,
				OrderIndex:  sec.OrderIndex,
				Kind:        sec.Kind,
				LabelDE:     sec.LabelDE,
				LabelEN:     sec.LabelEN,
				Included:    sec.Included,
				ContentMDDE: sec.ContentMDDE,
				ContentMDEN: sec.ContentMDEN,
			})
		}
	}

	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 len(resolved.Parties) > 0 {
		view.AvailableParties = make([]submissionDraftPartyJSON, 0, len(resolved.Parties))
		for _, p := range resolved.Parties {
			row := submissionDraftPartyJSON{ID: p.ID, Name: p.Name}
			if p.Role != nil {
				row.Role = *p.Role
			}
			if p.Representative != nil {
				row.Representative = *p.Representative
			}
			view.AvailableParties = append(view.AvailableParties, row)
		}
	}
	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"]
	}

	// t-paliad-349 slice 7 — uploaded-template draft: render the pinned
	// carrier. The Gitea tier / language-fallback notions don't apply (they
	// describe the upstream fallback chain), so they stay at their zero
	// values. A missing pinned version falls through to upstream resolution.
	if d.TemplateVersionID != nil && dbSvc.templateStore != nil {
		if tmpl, terr := dbSvc.templateStore.GetVersion(ctx, d.TemplateVersionID.String()); terr == nil {
			html, rerr := dbSvc.submissionDraft.RenderPreview(ctx, d, tmpl.CarrierBytes)
			if rerr != nil {
				return nil, rerr
			}
			view.PreviewHTML = html
			return view, nil
		} else if !errors.Is(terr, docforge.ErrTemplateNotFound) {
			return nil, terr
		}
	}

	tplBytes, _, tier, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
	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
	}
	view.TemplateTier = string(tier)
	// LanguageFallback signals "no per-firm template in the requested
	// language" — the editor surfaces a notice so the lawyer knows the
	// rendered body lacks code-specific prose. The per-code DE template
	// counts as a fallback when the requested language is EN.
	view.LanguageFallback = languageFallback(d.Language, tier)
	html, err := dbSvc.submissionDraft.RenderPreview(ctx, d, tplBytes)
	if err != nil {
		return nil, err
	}
	view.PreviewHTML = html
	return view, nil
}

// submissionTemplateTier enumerates which tier of the template
// fallback chain produced the bytes returned by resolveSubmissionTemplate.
// Used by the editor to surface "Fallback: universelles Skelett" when
// the requested (code, lang) didn't have a dedicated template.
type submissionTemplateTier string

const (
	tplTierPerCodeLang  submissionTemplateTier = "per_code_lang" // {firm}/{code}.{lang}.docx
	tplTierPerCode      submissionTemplateTier = "per_code"      // {firm}/{code}.docx (unsuffixed)
	tplTierSkeletonLang submissionTemplateTier = "skeleton_lang" // _skeleton.{lang}.docx
	tplTierSkeleton     submissionTemplateTier = "skeleton"      // _skeleton.docx
	tplTierLetterhead   submissionTemplateTier = "letterhead"    // HL Patents Style .dotm
)

// resolveSubmissionTemplate returns the .docx bytes for the given
// (submission_code, language). Merges t-paliad-275 (firm-skeleton tier)
// and t-paliad-276 (language-selector + EN skeleton tier). Lookup order:
//
//  1. per-firm per-(code, lang) template — most specific. e.g.
//     `de.inf.lg.erwidg.en.docx` for EN drafts. t-paliad-276.
//  2. per-firm per-code (unsuffixed) template — DE-baked baseline. The
//     legacy registry shape from before the language selector landed.
//  3. universal language-matched skeleton — `_skeleton.en.docx` for EN
//     drafts. Skipped for DE drafts (steps 4+5 already cover DE).
//  4. firm-formatted skeleton — `_firm-skeleton.docx` (t-paliad-275).
//     HL paragraph + character styles + letterhead, full placeholder
//     bag. DE-flavored: counts as language_fallback=true for EN drafts.
//  5. universal _skeleton.docx — plain DE skeleton, no firm styles.
//     Backstop when the firm skeleton is unreachable.
//  6. universal HL Patents Style .dotm — macro-only letterhead, no
//     placeholders. Last-ditch when every skeleton tier is unreachable.
//
// The returned SHA pins the audit row's template provenance. The tier
// tells the editor whether the result language-matches the request so
// it can surface a "Fallback: universelles Skelett" notice.
func resolveSubmissionTemplate(ctx context.Context, submissionCode, lang string) ([]byte, string, submissionTemplateTier, error) {
	if lang != "de" && lang != "en" {
		lang = "de"
	}
	// 1. per-(code, lang)
	if data, sha, found, err := fetchSubmissionTemplateBytesForLang(ctx, submissionCode, lang); err != nil {
		return nil, "", "", err
	} else if found {
		return data, sha, tplTierPerCodeLang, nil
	}
	// 2. per-code (unsuffixed)
	if data, sha, found, err := fetchSubmissionTemplateBytes(ctx, submissionCode); err != nil {
		return nil, "", "", err
	} else if found {
		return data, sha, tplTierPerCode, nil
	}
	// 3. language-matched skeleton — only meaningful for EN drafts; DE
	//    drafts fall through to the firm/universal DE skeletons below.
	if lang == "en" {
		if data, sha, langMatched, err := fetchSubmissionSkeletonBytesForLang(ctx, lang); err == nil && langMatched {
			return data, sha, tplTierSkeletonLang, nil
		}
	}
	// 4. firm-formatted skeleton (HL styles, DE prose). For DE drafts
	//    this is a first-class match; for EN drafts it counts as a
	//    language fallback (handled by languageFallback()).
	if data, sha, err := fetchFirmSkeletonBytes(ctx); err == nil {
		return data, sha, tplTierSkeleton, nil
	} else {
		log.Printf("submission_drafts: firm-skeleton fetch failed for code=%s lang=%s, falling back to universal skeleton: %v", submissionCode, lang, err)
	}
	// 5. universal plain DE skeleton.
	if data, sha, err := fetchSubmissionSkeletonBytes(ctx); err == nil {
		return data, sha, tplTierSkeleton, nil
	} else {
		log.Printf("submission_drafts: skeleton fetch failed for code=%s lang=%s, falling back to HL Patents Style: %v", submissionCode, lang, err)
	}
	// 6. HL Patents Style letterhead (no placeholders, last-ditch).
	bytes, err := fetchHLPatentsStyleBytes(ctx)
	if err != nil {
		return nil, "", "", err
	}
	sha := hlPatentsStyleSHA()
	return bytes, sha, tplTierLetterhead, nil
}

// languageFallback reports whether the resolved template tier failed
// to match the requested draft language. For an EN draft, anything
// other than per_code_lang or skeleton_lang is a fallback (per_code is
// the legacy DE-baked template, skeleton is the DE skeleton). For a DE
// draft, only `letterhead` counts as a fallback — the DE skeleton and
// per-code template are both first-class DE outputs. t-paliad-276.
func languageFallback(lang string, tier submissionTemplateTier) bool {
	if tier == tplTierLetterhead {
		return true
	}
	if strings.EqualFold(lang, "en") {
		return tier != tplTierPerCodeLang && tier != tplTierSkeletonLang
	}
	return false
}

// 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{}
	}
	selected := d.SelectedParties
	if selected == nil {
		selected = []uuid.UUID{}
	}
	lang := d.Language
	if lang == "" {
		lang = "de"
	}
	meta := d.ComposerMeta
	if meta == nil {
		meta = map[string]any{}
	}
	return submissionDraftJSON{
		ID:                d.ID,
		ProjectID:         d.ProjectID,
		SubmissionCode:    d.SubmissionCode,
		UserID:            d.UserID,
		Name:              d.Name,
		Language:          lang,
		Variables:         vars,
		SelectedParties:   selected,
		LastExportedAt:    d.LastExportedAt,
		LastExportedSHA:   d.LastExportedSHA,
		LastImportedAt:    d.LastImportedAt,
		BaseID:            d.BaseID,
		TemplateVersionID: d.TemplateVersionID,
		ComposerMeta:      meta,
		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, composerUsed bool) error {
	meta := map[string]any{
		"submission_code": d.SubmissionCode,
		"draft_id":        d.ID.String(),
		"draft_name":      d.Name,
		"filename":        filename,
		"template_sha":    templateSHA,
	}
	// t-paliad-313 Slice B — composer flag in metadata so admins can
	// tell the two render paths apart in the audit feed without
	// adding a new event_type.
	if composerUsed {
		meta["composer"] = true
		if d.BaseID != nil {
			meta["base_id"] = d.BaseID.String()
		}
	}
	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
}
