package services

// Submission draft service — CRUD over paliad.submission_drafts plus
// the render+export entry points that combine the variable bag, lawyer
// overrides, and template fetch into a .docx or HTML preview
// (t-paliad-238 Slice A, design doc
// docs/design-submission-page-2026-05-22.md §5.2).
//
// Each draft is owned by one user; multiple drafts per (project,
// submission_code, user_id) are supported via the `name` column. The
// override semantics are explicit:
//
//   variables = {"project.case_number": "2 O 999/25"}  → use this value
//   variables = {"project.case_number": ""}            → force [KEIN WERT: …]
//   key absent                                          → fall back to bag
//
// Visibility flows through ProjectService.GetByID — every read and
// write gates on paliad.can_see_project. RLS in the DB enforces the
// owner-scoped UPDATE/DELETE constraint independently of the Go layer.

import (
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"maps"
	"strings"
	"time"

	"github.com/google/uuid"
	"github.com/jmoiron/sqlx"
	"github.com/lib/pq"

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

// SubmissionDraft mirrors a row in paliad.submission_drafts.
//
// ProjectID is nullable since t-paliad-243 — a draft started from the
// global /submissions/new picker without picking a project is private
// to its creator and carries an empty variable bag (no project /
// parties / deadline state to resolve). All callers must check for nil
// before treating it as a uuid.
type SubmissionDraft struct {
	ID             uuid.UUID  `db:"id"                  json:"id"`
	ProjectID      *uuid.UUID `db:"project_id"          json:"project_id,omitempty"`
	SubmissionCode string     `db:"submission_code"     json:"submission_code"`
	UserID         uuid.UUID  `db:"user_id"             json:"user_id"`
	Name           string     `db:"name"                json:"name"`
	// Language is the output language for the generated .docx — 'de' or
	// 'en'. Drives the template-variant lookup ({code}.{lang}.docx
	// fallback chain) and language-aware variable resolution
	// ({{procedural_event.name}} → name_de or name_en). t-paliad-276.
	Language           string         `db:"language"            json:"language"`
	VariablesRaw       []byte         `db:"variables"           json:"-"`
	SelectedPartiesRaw pq.StringArray `db:"selected_parties"    json:"-"`
	LastExportedAt     *time.Time     `db:"last_exported_at"    json:"last_exported_at,omitempty"`
	LastExportedSHA    *string        `db:"last_exported_sha"   json:"last_exported_sha,omitempty"`
	LastImportedAt     *time.Time     `db:"last_imported_at"    json:"last_imported_at,omitempty"`
	// BaseID is the Composer base reference (t-paliad-313). NULL on
	// pre-Composer drafts — the v1 render path stays the fallback.
	// ON DELETE SET NULL keeps a draft renderable if its base is
	// removed; the lawyer picks a new one via the sidebar.
	BaseID *uuid.UUID `db:"base_id" json:"base_id,omitempty"`
	// TemplateVersionID pins an uploaded docforge template version
	// (t-paliad-349 slice 7). NULL = render via base_id Composer path or
	// the v1 fallback; non-NULL = render the pinned version's carrier.
	// The export/preview path checks this first. ON DELETE SET NULL.
	TemplateVersionID *uuid.UUID `db:"template_version_id" json:"template_version_id,omitempty"`
	// ComposerMetaRaw / ComposerMeta — Composer-side metadata jsonb.
	// Slice A: empty default. Future slices populate section_order,
	// hidden_sections, etc.
	ComposerMetaRaw []byte    `db:"composer_meta"       json:"-"`
	CreatedAt       time.Time `db:"created_at"          json:"created_at"`
	UpdatedAt       time.Time `db:"updated_at"          json:"updated_at"`

	// Variables is the decoded overrides map; populated on read by the
	// service so callers don't have to unmarshal manually.
	Variables PlaceholderMap `json:"variables"`

	// SelectedParties is the parsed uuid form of SelectedPartiesRaw —
	// populated on read by decodeSelectedParties(). An empty slice keeps
	// the backward-compat "include every party" behaviour; a non-empty
	// slice restricts the variable bag to the listed paliad.parties rows.
	SelectedParties []uuid.UUID `json:"selected_parties"`

	// ComposerMeta is the parsed Composer-side metadata (t-paliad-313).
	// Slice A: typically empty. Populated on read by decodeComposerMeta().
	ComposerMeta map[string]any `json:"composer_meta"`
}

// SubmissionDraftService handles CRUD on submission_drafts and exposes
// the render/preview/export entry points the handler layer calls.
//
// The Composer wiring (t-paliad-313, Slice A): bases + sections are
// optional — when nil the service stays back-compat with the v1 shape
// (drafts created without a base_id, no section rows). When wired, new
// drafts created via Create get base_id seeded from the firm default
// and submission_sections rows inserted from the base's section spec.
type SubmissionDraftService struct {
	db       *sqlx.DB
	projects *ProjectService
	vars     *SubmissionVarsService
	renderer *SubmissionRenderer

	// bases + sections are optional Composer wiring (t-paliad-313).
	// Nil means "stay back-compat with the v1 shape" — new drafts
	// keep base_id NULL and no submission_sections rows get seeded.
	bases    *BaseService
	sections *SectionService

	// firmName captures branding.Name at construction time. Used to
	// resolve the firm-default base in Create. Empty string is
	// allowed (treated as "no firm filter" at base-lookup time).
	firmName string
}

// NewSubmissionDraftService wires the service.
func NewSubmissionDraftService(db *sqlx.DB, projects *ProjectService, vars *SubmissionVarsService, renderer *SubmissionRenderer) *SubmissionDraftService {
	return &SubmissionDraftService{
		db:       db,
		projects: projects,
		vars:     vars,
		renderer: renderer,
	}
}

// AttachComposer wires the Composer-side services. Called by
// cmd/server/main.go after constructing the base + section services.
// firm is branding.Name (typically "HLC"); empty string disables the
// firm filter at default-base lookup.
//
// Calling AttachComposer is purely additive — drafts created before the
// call (or with bases==nil) keep the v1 behaviour. Idempotent.
func (s *SubmissionDraftService) AttachComposer(bases *BaseService, sections *SectionService, firm string) {
	s.bases = bases
	s.sections = sections
	s.firmName = firm
}

// DraftPatch carries optional fields for Update. nil pointer = "no
// change"; non-nil = "set to this". Variables is replace-semantics —
// the lawyer's sidebar sends the full map every save.
//
// ProjectID uses a two-level pointer (t-paliad-243) so we can encode
// the three operations the global drafts flow needs:
//
//	patch.ProjectID == nil                 → no change
//	*patch.ProjectID == nil                → detach (re-set to NULL)
//	**patch.ProjectID                      → attach (assign a project)
//
// The detach path stays as scope for symmetry with attach even though
// the current frontend only exposes attach.
type DraftPatch struct {
	Name      *string
	Variables *PlaceholderMap
	ProjectID **uuid.UUID

	// SelectedParties: nil = no change. A non-nil pointer always writes
	// the column; pass *p = nil or an empty slice to reset to "include
	// every party on the project" (the backward-compat default).
	SelectedParties *[]uuid.UUID

	// Language sets the output language. Valid values: "de", "en".
	// Anything else returns ErrInvalidInput. t-paliad-276.
	Language *string

	// BaseID swaps the Composer base. Two-level pointer mirrors the
	// ProjectID shape so callers can encode the three operations:
	//   nil          → no change
	//   *p == nil    → clear (set base_id NULL, return to v1 fallback)
	//   **p          → set to the picked base
	// Slice A: lawyer flips this from the sidebar picker. Section
	// content is unaffected — the base swap is render-side only.
	// t-paliad-313.
	BaseID **uuid.UUID

	// TemplateVersionID pins (or clears) an uploaded docforge template
	// version. Same three-state two-level pointer as BaseID:
	//   nil       → no change
	//   *p == nil → clear (back to base_id / v1)
	//   **p       → pin the version (validated via TemplateStore.GetVersion)
	// t-paliad-349 slice 7.
	TemplateVersionID **uuid.UUID

	// FilenameKeyword sets (or clears) the user override that leads the
	// exported document name "<date> <keyword> (<case>)" (t-paliad-354).
	// Stored under composer_meta.filename_keyword — no dedicated column:
	//   nil        → no change
	//   *p == ""   → clear the key (back to the auto-derived rule name)
	//   *p == "x"  → set the override
	FilenameKeyword *string
}

// ErrSubmissionDraftNotFound is the sentinel for "no draft with that id
// visible to this user". Maps to 404 in the handler.
var ErrSubmissionDraftNotFound = errors.New("submission draft: not found")

// ErrSubmissionDraftNameTaken is the sentinel for duplicate names per
// (project, submission_code, user). Maps to 409 in the handler.
var ErrSubmissionDraftNameTaken = errors.New("submission draft: name already taken")

// draftColumns is the canonical select list — kept in one place so
// every fetch stays in sync.
const draftColumns = `id, project_id, submission_code, user_id, name, language,
                      variables, selected_parties,
                      last_exported_at, last_exported_sha,
                      last_imported_at,
                      base_id, template_version_id, composer_meta,
                      created_at, updated_at`

// List returns every draft for (project, submission_code, user)
// ordered by updated_at DESC. Visibility flows through projects.GetByID.
func (s *SubmissionDraftService) List(ctx context.Context, userID, projectID uuid.UUID, submissionCode string) ([]SubmissionDraft, error) {
	if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
		return nil, err
	}
	var rows []SubmissionDraft
	err := s.db.SelectContext(ctx, &rows,
		`SELECT `+draftColumns+`
		   FROM paliad.submission_drafts
		  WHERE project_id = $1 AND submission_code = $2 AND user_id = $3
		  ORDER BY updated_at DESC`,
		projectID, submissionCode, userID)
	if err != nil {
		return nil, fmt.Errorf("list submission drafts: %w", err)
	}
	for i := range rows {
		if err := rows[i].decode(); err != nil {
			return nil, err
		}
	}
	return rows, nil
}

// DraftWithProject is the row shape for the global /submissions index —
// a draft joined with the minimal project metadata the table needs.
// Visibility is gated by paliad.can_see_project in the SELECT itself.
//
// ProjectTitle / ProjectReference are pointer-nullable since
// t-paliad-243 — project-less drafts surface in the same list with a
// NULL project ref, and the frontend renders them with a dedicated
// "kein Projekt" label.
type DraftWithProject struct {
	SubmissionDraft
	ProjectTitle     *string `db:"project_title"     json:"project_title,omitempty"`
	ProjectReference *string `db:"project_reference" json:"project_reference,omitempty"`
}

// ListAllForUser returns every draft the user owns across visible
// projects PLUS every project-less draft the user owns, ordered by
// updated_at DESC. LEFT JOIN on paliad.projects keeps project-less rows
// in the result set; the WHERE clause permits project_id IS NULL or a
// visible can_see_project hit, so a draft on a project the user no
// longer has access to is silently dropped.
func (s *SubmissionDraftService) ListAllForUser(ctx context.Context, userID uuid.UUID) ([]DraftWithProject, error) {
	var rows []DraftWithProject
	err := s.db.SelectContext(ctx, &rows,
		`SELECT d.id, d.project_id, d.submission_code, d.user_id, d.name, d.language,
		        d.variables, d.selected_parties,
		        d.last_exported_at, d.last_exported_sha, d.last_imported_at,
		        d.base_id, d.template_version_id, d.composer_meta,
		        d.created_at, d.updated_at,
		        p.title     AS project_title,
		        p.reference AS project_reference
		   FROM paliad.submission_drafts d
		   LEFT JOIN paliad.projects p ON p.id = d.project_id
		  WHERE d.user_id = $1
		    AND (
		      d.project_id IS NULL
		      OR paliad.can_see_project(d.project_id)
		    )
		  ORDER BY d.updated_at DESC`,
		userID)
	if err != nil {
		return nil, fmt.Errorf("list all submission drafts for user: %w", err)
	}
	for i := range rows {
		if err := rows[i].decode(); err != nil {
			return nil, err
		}
	}
	return rows, nil
}

// Get returns a single draft by id, gated on project visibility AND
// owner-only — the caller can only fetch drafts they own. RLS in the
// DB enforces this independently; the Go check makes the 404 semantics
// explicit at the service boundary.
//
// A project-less draft (ProjectID == nil) skips the can_see_project
// gate — the owner-only constraint is the entire visibility check.
func (s *SubmissionDraftService) Get(ctx context.Context, userID, draftID uuid.UUID) (*SubmissionDraft, error) {
	var d SubmissionDraft
	err := s.db.GetContext(ctx, &d,
		`SELECT `+draftColumns+`
		   FROM paliad.submission_drafts
		  WHERE id = $1 AND user_id = $2`,
		draftID, userID)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrSubmissionDraftNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("get submission draft: %w", err)
	}
	if d.ProjectID != nil {
		if _, err := s.projects.GetByID(ctx, userID, *d.ProjectID); err != nil {
			// Project no longer visible → behave as not-found rather than
			// leaking the draft's existence. ON DELETE CASCADE keeps this
			// rare in practice.
			if errors.Is(err, ErrNotVisible) {
				return nil, ErrSubmissionDraftNotFound
			}
			return nil, err
		}
	}
	if err := d.decode(); err != nil {
		return nil, err
	}
	return &d, nil
}

// EnsureLatest returns the user's most-recently-updated draft for
// (project, submission_code). Creates "Entwurf 1" / "Draft 1" if none
// exists. Idempotent on repeat calls — once a draft exists, EnsureLatest
// always returns the freshest one rather than spawning new rows.
func (s *SubmissionDraftService) EnsureLatest(ctx context.Context, userID, projectID uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error) {
	if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
		return nil, err
	}
	var d SubmissionDraft
	err := s.db.GetContext(ctx, &d,
		`SELECT `+draftColumns+`
		   FROM paliad.submission_drafts
		  WHERE project_id = $1 AND submission_code = $2 AND user_id = $3
		  ORDER BY updated_at DESC
		  LIMIT 1`,
		projectID, submissionCode, userID)
	if errors.Is(err, sql.ErrNoRows) {
		return s.Create(ctx, userID, &projectID, submissionCode, lang)
	}
	if err != nil {
		return nil, fmt.Errorf("ensure latest submission draft: %w", err)
	}
	if err := d.decode(); err != nil {
		return nil, err
	}
	return &d, nil
}

// Create makes a new draft with an auto-incremented "Entwurf N" name
// ("Draft N" for English locale). Lawyer can rename via Update.
//
// A nil projectID creates a project-less draft (t-paliad-243); the
// visibility check is skipped — the caller is the owner and the row is
// private to them.
//
// Composer wiring (t-paliad-313, Slice A): when AttachComposer has
// been called and a base resolves for the submission_code, the INSERT
// runs in a transaction alongside SectionService.SeedFromSpec so the
// new draft and its seeded sections land atomically. If the base
// lookup fails (catalog empty, no firm match, etc.) the draft still
// creates with base_id=NULL — Composer is additive, the v1 fallback
// path remains valid.
func (s *SubmissionDraftService) Create(ctx context.Context, userID uuid.UUID, projectID *uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error) {
	var project *models.Project
	if projectID != nil {
		p, err := s.projects.GetByID(ctx, userID, *projectID)
		if err != nil {
			return nil, err
		}
		project = p
	}
	name, err := s.newDraftName(ctx, userID, project, projectID, submissionCode, lang)
	if err != nil {
		return nil, err
	}
	// Seed the new draft's output language from the user's UI lang so
	// the editor opens in the language the lawyer is already working in.
	// Anything other than "en" normalizes to "de" — matches the DB CHECK
	// constraint and the project's primary-language default.
	draftLang := normalizeDraftLanguage(lang)

	// Resolve the Composer base for this draft. nil result keeps the
	// draft v1-shaped (base_id NULL, no sections rows).
	var baseToSeed *SubmissionBase
	if s.bases != nil {
		base, err := s.bases.GetDefaultForCode(ctx, s.firmName, submissionCode)
		switch {
		case err == nil:
			baseToSeed = base
		case errors.Is(err, ErrBaseNotFound):
			// Catalog empty / no match — fall through to v1 shape.
		default:
			return nil, err
		}
	}

	tx, err := s.db.BeginTxx(ctx, nil)
	if err != nil {
		return nil, fmt.Errorf("begin create submission draft tx: %w", err)
	}
	committed := false
	defer func() {
		if !committed {
			_ = tx.Rollback()
		}
	}()

	var baseID *uuid.UUID
	if baseToSeed != nil {
		id := baseToSeed.ID
		baseID = &id
	}

	var d SubmissionDraft
	err = tx.GetContext(ctx, &d,
		`INSERT INTO paliad.submission_drafts
		     (project_id, submission_code, user_id, name, language, base_id)
		 VALUES ($1, $2, $3, $4, $5, $6)
		 RETURNING `+draftColumns,
		projectID, submissionCode, userID, name, draftLang, baseID)
	if err != nil {
		return nil, fmt.Errorf("create submission draft: %w", err)
	}

	if baseToSeed != nil && s.sections != nil {
		if err := s.sections.SeedFromSpec(ctx, tx, d.ID, baseToSeed.SectionSpec); err != nil {
			return nil, err
		}
	}

	if err := tx.Commit(); err != nil {
		return nil, fmt.Errorf("commit create submission draft tx: %w", err)
	}
	committed = true

	if err := d.decode(); err != nil {
		return nil, err
	}
	return &d, nil
}

// newDraftName picks the title for a freshly-created draft. Project-
// bound drafts get the auto-name scheme (t-paliad-352 / m/paliad#155) —
// "<date> <client> ./. <forum> ./. <opponent>", de-duplicated against
// the user's existing drafts for the same (project, submission_code).
// Project-less drafts (and any project-bound draft whose auto-name
// resolves to nothing) fall back to the "Entwurf N" / "Draft N"
// counter.
//
// Only Create calls this — existing drafts are never renamed (the
// scheme is create-time only, per #155). A lawyer's later manual rename
// flows through Update and is left untouched.
func (s *SubmissionDraftService) newDraftName(ctx context.Context, userID uuid.UUID, project *models.Project, projectID *uuid.UUID, submissionCode, lang string) (string, error) {
	existing, err := s.existingDraftNames(ctx, projectID, submissionCode, userID)
	if err != nil {
		return "", err
	}
	if project != nil {
		auto, err := s.autoNameForProject(ctx, time.Now(), project)
		if err != nil {
			return "", err
		}
		if strings.TrimSpace(auto) != "" {
			return uniqueDraftName(auto, existing), nil
		}
	}
	return nextDraftName(existing, lang), nil
}

// autoNameForProject resolves the three identity segments for a
// project-bound draft and hands them to the pure AutoSubmissionTitle
// assembler. The client is the root ancestor of the project tree (the
// 'client' node), the proceeding type and our_side come off the draft's
// own project node, and the parties hang directly off it.
//
// A failure to resolve the client / proceeding type is not fatal —
// AutoSubmissionTitle just omits the empty segment — so the only errors
// returned here are genuine DB faults.
func (s *SubmissionDraftService) autoNameForProject(ctx context.Context, now time.Time, project *models.Project) (string, error) {
	clientName, err := s.clientNameForProject(ctx, project.ID)
	if err != nil {
		return "", err
	}

	pt, err := s.vars.loadProceedingType(ctx, project.ProceedingTypeID)
	if err != nil {
		return "", err
	}

	var parties []models.Party
	if err := s.db.SelectContext(ctx, &parties,
		`SELECT id, project_id, name, role, representative, contact_info,
		        created_at, updated_at
		   FROM paliad.parties
		  WHERE project_id = $1
		  ORDER BY name`, project.ID); err != nil {
		return "", fmt.Errorf("auto-name: load parties: %w", err)
	}

	return AutoSubmissionTitle(now, clientName, project, parties, pt), nil
}

// clientNameForProject returns the title of the 'client' ancestor in
// the project's path (the firm's mandant). Empty string when the tree
// has no client node — the auto-name then omits the client segment.
func (s *SubmissionDraftService) clientNameForProject(ctx context.Context, projectID uuid.UUID) (string, error) {
	var title string
	err := s.db.GetContext(ctx, &title,
		`SELECT p.title
		   FROM paliad.projects target
		   JOIN paliad.projects p
		     ON p.id = ANY(string_to_array(target.path, '.')::uuid[])
		  WHERE target.id = $1 AND p.type = 'client'
		  LIMIT 1`, projectID)
	if errors.Is(err, sql.ErrNoRows) {
		return "", nil
	}
	if err != nil {
		return "", fmt.Errorf("auto-name: resolve client name: %w", err)
	}
	return title, nil
}

// existingDraftNames returns the names already in use for the
// (project, submission_code, user) slot. A nil projectID scopes to the
// user's project-less drafts for this submission_code — matching the
// DB unique contract (project_id, submission_code, user_id, name) where
// project_id IS NULL is its own equivalence class.
func (s *SubmissionDraftService) existingDraftNames(ctx context.Context, projectID *uuid.UUID, submissionCode string, userID uuid.UUID) ([]string, error) {
	var names []string
	var err error
	if projectID == nil {
		err = s.db.SelectContext(ctx, &names,
			`SELECT name FROM paliad.submission_drafts
			  WHERE project_id IS NULL AND submission_code = $1 AND user_id = $2`,
			submissionCode, userID)
	} else {
		err = s.db.SelectContext(ctx, &names,
			`SELECT name FROM paliad.submission_drafts
			  WHERE project_id = $1 AND submission_code = $2 AND user_id = $3`,
			*projectID, submissionCode, userID)
	}
	if err != nil {
		return nil, fmt.Errorf("scan existing draft names: %w", err)
	}
	return names, nil
}

// nextDraftName returns "Entwurf N" / "Draft N" with N = (highest
// existing N + 1), or N=1 if no draft yet. Falls back to a unique
// suffix if two callers race; the unique constraint on the table is
// the final guard. Pure over the supplied name list.
func nextDraftName(existing []string, lang string) string {
	prefix := "Entwurf"
	if strings.EqualFold(lang, "en") {
		prefix = "Draft"
	}
	highest := 0
	for _, n := range existing {
		var idx int
		if _, scanErr := fmt.Sscanf(n, prefix+" %d", &idx); scanErr == nil && idx > highest {
			highest = idx
		}
	}
	return fmt.Sprintf("%s %d", prefix, highest+1)
}

// uniqueDraftName returns base unchanged when it's free, otherwise
// appends " (N)" with the lowest N≥2 that isn't taken. Mirrors the
// "race → unique constraint is the final guard" contract of
// nextDraftName; pure over the supplied name list.
func uniqueDraftName(base string, existing []string) string {
	taken := make(map[string]struct{}, len(existing))
	for _, n := range existing {
		taken[n] = struct{}{}
	}
	if _, clash := taken[base]; !clash {
		return base
	}
	for i := 2; ; i++ {
		cand := fmt.Sprintf("%s (%d)", base, i)
		if _, clash := taken[cand]; !clash {
			return cand
		}
	}
}

// Update patches the draft. Variables is replace-semantics — pass the
// full map. Name patches go through a uniqueness check to surface
// ErrSubmissionDraftNameTaken cleanly instead of a raw constraint
// violation.
func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uuid.UUID, patch DraftPatch) (*SubmissionDraft, error) {
	existing, err := s.Get(ctx, userID, draftID)
	if err != nil {
		return nil, err
	}

	setParts := []string{}
	args := []any{}
	idx := 1

	if patch.Name != nil {
		newName := strings.TrimSpace(*patch.Name)
		if newName == "" {
			return nil, ErrInvalidInput
		}
		if newName != existing.Name {
			// Pre-check for the unique constraint so we can return a
			// typed error instead of a raw PG conflict. NULL project_id
			// is its own equivalence class in the unique index (NULLs
			// don't collide), so the no-project flow checks `IS NULL`.
			var dup int
			var qErr error
			if existing.ProjectID == nil {
				qErr = s.db.GetContext(ctx, &dup,
					`SELECT COUNT(*) FROM paliad.submission_drafts
					  WHERE project_id IS NULL AND submission_code = $1
					    AND user_id = $2 AND name = $3 AND id <> $4`,
					existing.SubmissionCode, userID, newName, draftID)
			} else {
				qErr = s.db.GetContext(ctx, &dup,
					`SELECT COUNT(*) FROM paliad.submission_drafts
					  WHERE project_id = $1 AND submission_code = $2
					    AND user_id = $3 AND name = $4 AND id <> $5`,
					*existing.ProjectID, existing.SubmissionCode, userID, newName, draftID)
			}
			if qErr != nil {
				return nil, fmt.Errorf("check name uniqueness: %w", qErr)
			}
			if dup > 0 {
				return nil, ErrSubmissionDraftNameTaken
			}
		}
		setParts = append(setParts, fmt.Sprintf("name = $%d", idx))
		args = append(args, newName)
		idx++
	}

	if patch.Variables != nil {
		raw, err := json.Marshal(*patch.Variables)
		if err != nil {
			return nil, fmt.Errorf("marshal variables: %w", err)
		}
		setParts = append(setParts, fmt.Sprintf("variables = $%d::jsonb", idx))
		args = append(args, string(raw))
		idx++
	}

	if patch.ProjectID != nil {
		newPID := *patch.ProjectID // *uuid.UUID — nil means detach
		if newPID != nil {
			// Caller must be able to see the project they're attaching
			// the draft to; same gate as Create.
			if _, err := s.projects.GetByID(ctx, userID, *newPID); err != nil {
				return nil, err
			}
		}
		setParts = append(setParts, fmt.Sprintf("project_id = $%d", idx))
		args = append(args, newPID)
		idx++
	}

	if patch.SelectedParties != nil {
		ids := *patch.SelectedParties
		strs := make([]string, 0, len(ids))
		for _, id := range ids {
			strs = append(strs, id.String())
		}
		setParts = append(setParts, fmt.Sprintf("selected_parties = $%d::uuid[]", idx))
		args = append(args, pq.StringArray(strs))
		idx++
	}

	if patch.Language != nil {
		newLang := strings.ToLower(strings.TrimSpace(*patch.Language))
		if newLang != "de" && newLang != "en" {
			return nil, ErrInvalidInput
		}
		setParts = append(setParts, fmt.Sprintf("language = $%d", idx))
		args = append(args, newLang)
		idx++
	}

	if patch.BaseID != nil {
		newBID := *patch.BaseID // *uuid.UUID — nil means clear
		if newBID != nil && s.bases != nil {
			// Validate the picked base exists + is active.
			if _, err := s.bases.GetByID(ctx, *newBID); err != nil {
				return nil, err
			}
		}
		setParts = append(setParts, fmt.Sprintf("base_id = $%d", idx))
		args = append(args, newBID)
		idx++
	}

	if patch.TemplateVersionID != nil {
		newTV := *patch.TemplateVersionID // *uuid.UUID — nil means clear
		// Existence is enforced by the FK + validated at the handler via
		// TemplateStore.GetVersion (clean 404); here we just set it.
		setParts = append(setParts, fmt.Sprintf("template_version_id = $%d", idx))
		args = append(args, newTV)
		idx++
	}

	if patch.FilenameKeyword != nil {
		// Targeted jsonb merge so other composer_meta keys survive. An
		// empty override removes the key entirely, restoring the
		// auto-derived rule name as the filename keyword (t-paliad-354).
		kw := strings.TrimSpace(*patch.FilenameKeyword)
		if kw == "" {
			setParts = append(setParts, "composer_meta = composer_meta - 'filename_keyword'")
		} else {
			setParts = append(setParts,
				fmt.Sprintf("composer_meta = composer_meta || jsonb_build_object('filename_keyword', $%d::text)", idx))
			args = append(args, kw)
			idx++
		}
	}

	if len(setParts) == 0 {
		return existing, nil
	}

	args = append(args, draftID, userID)
	q := fmt.Sprintf(
		`UPDATE paliad.submission_drafts
		    SET %s
		  WHERE id = $%d AND user_id = $%d
		  RETURNING %s`,
		strings.Join(setParts, ", "), idx, idx+1, draftColumns,
	)

	var d SubmissionDraft
	err = s.db.GetContext(ctx, &d, q, args...)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrSubmissionDraftNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("update submission draft: %w", err)
	}
	if err := d.decode(); err != nil {
		return nil, err
	}
	return &d, nil
}

// Delete removes the draft. Visibility-gated via Get; the DELETE itself
// is owner-scoped (user_id = caller).
func (s *SubmissionDraftService) Delete(ctx context.Context, userID, draftID uuid.UUID) error {
	if _, err := s.Get(ctx, userID, draftID); err != nil {
		return err
	}
	_, err := s.db.ExecContext(ctx,
		`DELETE FROM paliad.submission_drafts WHERE id = $1 AND user_id = $2`,
		draftID, userID)
	if err != nil {
		return fmt.Errorf("delete submission draft: %w", err)
	}
	return nil
}

// ImportFromProject re-pulls every project-derived variable on the
// draft by stripping the lawyer's overrides for those keys and bumping
// `last_imported_at`. Project-derived prefixes today are project.*,
// parties.*, deadline.* and (because the rule is keyed on
// submission_code) procedural_event.* / rule.*; the lawyer's overrides
// for firm.*, today.*, user.* survive because those values aren't
// "imported from the project" in any meaningful sense.
//
// Idempotent on repeat clicks: nothing else mutates on the second
// call apart from the new timestamp. The draft must be owned by the
// caller (Get() applies the same ErrNotFound semantics as the rest of
// the service).
func (s *SubmissionDraftService) ImportFromProject(ctx context.Context, userID, draftID uuid.UUID) (*SubmissionDraft, error) {
	existing, err := s.Get(ctx, userID, draftID)
	if err != nil {
		return nil, err
	}
	if existing.ProjectID == nil {
		// No project to import from — surface as 400 via ErrInvalidInput.
		return nil, fmt.Errorf("%w: cannot import from project on a project-less draft", ErrInvalidInput)
	}

	// Strip overrides that came from project state.
	cleaned := PlaceholderMap{}
	for k, v := range existing.Variables {
		if isProjectDerivedKey(k) {
			continue
		}
		cleaned[k] = v
	}
	raw, err := json.Marshal(cleaned)
	if err != nil {
		return nil, fmt.Errorf("marshal variables: %w", err)
	}

	var d SubmissionDraft
	err = s.db.GetContext(ctx, &d,
		`UPDATE paliad.submission_drafts
		    SET variables = $1::jsonb,
		        last_imported_at = now()
		  WHERE id = $2 AND user_id = $3
		  RETURNING `+draftColumns,
		string(raw), draftID, userID)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrSubmissionDraftNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("import from project: %w", err)
	}
	if err := d.decode(); err != nil {
		return nil, err
	}
	return &d, nil
}

// isProjectDerivedKey reports whether a placeholder key sources its
// value from the project record (rather than firm-wide or user-wide
// state). The "Aus Projekt importieren" affordance strips overrides
// for exactly these keys so the lawyer's manual edits don't survive
// a re-pull.
func isProjectDerivedKey(key string) bool {
	switch {
	case strings.HasPrefix(key, "project."):
		return true
	case strings.HasPrefix(key, "parties."):
		return true
	case strings.HasPrefix(key, "deadline."):
		return true
	case strings.HasPrefix(key, "procedural_event."):
		return true
	case strings.HasPrefix(key, "rule."):
		return true
	}
	return false
}

// MarkExported updates the last_exported_* columns after a successful
// export. Background-context safe.
func (s *SubmissionDraftService) MarkExported(ctx context.Context, draftID uuid.UUID, templateSHA string) error {
	var sha any
	if templateSHA != "" {
		sha = templateSHA
	}
	_, err := s.db.ExecContext(ctx,
		`UPDATE paliad.submission_drafts
		    SET last_exported_at = now(),
		        last_exported_sha = $1
		  WHERE id = $2`,
		sha, draftID)
	if err != nil {
		return fmt.Errorf("mark submission draft exported: %w", err)
	}
	return nil
}

// BuildRenderBag composes the placeholder map for a draft — pulls
// project/parties/rule/deadline state from SubmissionVarsService, then
// layers the lawyer's overrides on top.
//
// Override semantics:
//
//	variables[key] = ""  → delete the key (force [KEIN WERT: key])
//	variables[key] = "X" → bag[key] = "X"
//	key absent           → bag[key] unchanged (falls back to resolved value)
//
// Returns the final PlaceholderMap along with the SubmissionVarsResult
// so callers (export, file naming) get the resolved entities too. A
// project-less draft (ProjectID == nil, t-paliad-243) skips project /
// parties / deadline lookups — the resolved bag carries only the
// user-independent variables (firm, today) plus the user.* group; the
// lawyer's overrides fill the rest.
func (s *SubmissionDraftService) BuildRenderBag(ctx context.Context, draft *SubmissionDraft) (PlaceholderMap, *SubmissionVarsResult, error) {
	resolved, err := s.vars.Build(ctx, SubmissionVarsContext{
		UserID:          draft.UserID,
		ProjectID:       draft.ProjectID,
		SubmissionCode:  draft.SubmissionCode,
		SelectedParties: draft.SelectedParties,
		// The draft's language overrides the user's UI lang — the lawyer
		// can author an EN draft in a DE-UI session and vice versa
		// (t-paliad-276). Empty / unknown falls back to "de".
		Lang: normalizeDraftLanguage(draft.Language),
	})
	if err != nil {
		return nil, nil, err
	}
	bag := PlaceholderMap{}
	maps.Copy(bag, resolved.Placeholders)
	for k, v := range draft.Variables {
		if v == "" {
			delete(bag, k)
			continue
		}
		bag[k] = v
	}
	return bag, resolved, nil
}

// RenderPreview returns the HTML preview of the merged document body
// for the draft-editor preview pane. Read-only; emits one <p> per <w:p>
// with <strong>/<em> spans for runs flagged bold/italic.
func (s *SubmissionDraftService) RenderPreview(ctx context.Context, draft *SubmissionDraft, templateBytes []byte) (string, error) {
	bag, resolved, err := s.BuildRenderBag(ctx, draft)
	if err != nil {
		return "", err
	}
	return s.renderer.RenderHTML(templateBytes, bag, DefaultMissingMarker(resolved.Lang))
}

// Export renders the merged .docx for download. Returns the bytes, the
// resolved bag (for audit row + file naming), and the variables result
// (lang, rule.Name, project.case_number). Callers wire MarkExported and
// the audit writes.
func (s *SubmissionDraftService) Export(ctx context.Context, draft *SubmissionDraft, templateBytes []byte) ([]byte, *SubmissionVarsResult, error) {
	bag, resolved, err := s.BuildRenderBag(ctx, draft)
	if err != nil {
		return nil, nil, err
	}
	out, err := s.renderer.Render(templateBytes, bag, DefaultMissingMarker(resolved.Lang))
	if err != nil {
		return nil, nil, err
	}
	return out, resolved, nil
}

// RenderProjectSubmission renders the given .docx template with a fresh
// variable bag for (user, project, submissionCode). No lawyer overrides
// — the output reflects exactly what SubmissionVarsService resolves
// from project state. Used by the one-click /api/projects/{id}/
// submissions/{code}/generate path which has no saved draft row.
//
// Returns the merged bytes plus the resolved bag (for audit row + file
// naming). Visibility is enforced by SubmissionVarsService.Build via
// ProjectService.GetByID — callers get ErrNotFound on no-access.
// ErrSubmissionRuleNotFound surfaces when no published rule matches the
// requested submission_code.
func (s *SubmissionDraftService) RenderProjectSubmission(ctx context.Context, userID, projectID uuid.UUID, submissionCode, lang string, templateBytes []byte) ([]byte, *SubmissionVarsResult, error) {
	pid := projectID
	resolved, err := s.vars.Build(ctx, SubmissionVarsContext{
		UserID:         userID,
		ProjectID:      &pid,
		SubmissionCode: submissionCode,
		Lang:           normalizeDraftLanguage(lang),
	})
	if err != nil {
		return nil, nil, err
	}
	out, err := s.renderer.Render(templateBytes, resolved.Placeholders, DefaultMissingMarker(resolved.Lang))
	if err != nil {
		return nil, nil, err
	}
	return out, resolved, nil
}

// decode fills the parsed views (Variables, SelectedParties,
// ComposerMeta) from the raw scan fields. Called by every fetch path
// so the caller sees them populated together.
func (d *SubmissionDraft) decode() error {
	if err := d.decodeVariables(); err != nil {
		return err
	}
	if err := d.decodeSelectedParties(); err != nil {
		return err
	}
	return d.decodeComposerMeta()
}

// decodeComposerMeta turns the raw composer_meta jsonb into a
// map[string]any. NULL or empty payload yields an empty map.
func (d *SubmissionDraft) decodeComposerMeta() error {
	if len(d.ComposerMetaRaw) == 0 {
		d.ComposerMeta = map[string]any{}
		return nil
	}
	out := map[string]any{}
	if err := json.Unmarshal(d.ComposerMetaRaw, &out); err != nil {
		return fmt.Errorf("decode submission draft composer_meta: %w", err)
	}
	d.ComposerMeta = out
	return nil
}

// decodeVariables turns the raw jsonb bytes into the PlaceholderMap.
func (d *SubmissionDraft) decodeVariables() error {
	if len(d.VariablesRaw) == 0 {
		d.Variables = PlaceholderMap{}
		return nil
	}
	out := PlaceholderMap{}
	if err := json.Unmarshal(d.VariablesRaw, &out); err != nil {
		return fmt.Errorf("decode submission draft variables: %w", err)
	}
	d.Variables = out
	return nil
}

// decodeSelectedParties parses the uuid[] payload from pq.StringArray
// into []uuid.UUID. Unparseable entries are dropped so a single bad
// row never bricks the fetch — the worst case is one extra party
// silently dropped from the selection, which surfaces as it not being
// rendered in the merged document.
func (d *SubmissionDraft) decodeSelectedParties() error {
	if len(d.SelectedPartiesRaw) == 0 {
		d.SelectedParties = nil
		return nil
	}
	out := make([]uuid.UUID, 0, len(d.SelectedPartiesRaw))
	for _, s := range d.SelectedPartiesRaw {
		id, err := uuid.Parse(s)
		if err != nil {
			continue
		}
		out = append(out, id)
	}
	d.SelectedParties = out
	return nil
}

// normalizeDraftLanguage maps any input to one of the two allowed
// language values for paliad.submission_drafts.language. Anything other
// than "en" (case-insensitive) collapses to "de" — matches the DB CHECK
// constraint, the project's primary-language default, and the seed
// behaviour for existing rows that came in before the column existed.
func normalizeDraftLanguage(lang string) string {
	if strings.EqualFold(strings.TrimSpace(lang), "en") {
		return "en"
	}
	return "de"
}

// Compile-time guard: ensure the *models.User reference in the import
// graph doesn't get optimised away by linters. The service doesn't
// dereference User directly — that happens in SubmissionVarsService —
// but the import keeps the package compile-time-aware of the dependency
// chain that wires us into the bundle.
var _ = (*models.User)(nil)
