package services

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

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

	"mgit.msbls.de/m/paliad/internal/models"
	lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
)

// RuleEditorService owns the admin-only rule lifecycle for Phase 3
// Slice 11a (t-paliad-191). m's Q5 option C ruling: "C please — I need
// to see these things. Admin only, ofc."
//
// Lifecycle (mig 078 lifecycle_state enum):
//
//   - draft     — admin work-in-progress. Calculator does NOT include
//                 these in any user-facing surface (the SELECT filters
//                 lifecycle_state='published' or the equivalent). The
//                 admin previewer is the only reader.
//   - published — live, calculator-visible, the corpus the rest of
//                 Paliad runs on.
//   - archived  — historical, kept for audit. The Restore op flips
//                 archived → published; the Publish flow archives
//                 the cloned-from source so each rule_code has at
//                 most one live row.
//
// All writes set paliad.audit_reason via set_config in the same tx
// before the UPDATE so the mig 079 audit trigger captures the
// rationale forever. The reason is mandatory on every write.
//
// Spawn cycle guard: edits that change spawn_proceeding_type_id are
// pre-validated against the global rule graph. A draft that would
// create a cycle when published returns ErrCyclicSpawn rather than
// allowing the write — the guard fires server-side before the row
// hits the DB.
type RuleEditorService struct {
	db    *sqlx.DB
	rules *DeadlineRuleService
}

// NewRuleEditorService wires the service to its dependencies.
func NewRuleEditorService(db *sqlx.DB, rules *DeadlineRuleService) *RuleEditorService {
	return &RuleEditorService{db: db, rules: rules}
}

// Typed errors surfaced to handlers (mapped to HTTP statuses).
var (
	// ErrRuleNotFound — UUID didn't resolve to an existing row.
	ErrRuleNotFound = errors.New("rule not found")
	// ErrInvalidLifecycleState — caller asked for a transition that
	// the current lifecycle_state doesn't allow (e.g. PATCH a
	// published row, Publish a non-draft row, Restore a non-archived
	// row, etc.). 409 Conflict in the handler.
	ErrInvalidLifecycleState = errors.New("invalid lifecycle state for this operation")
	// ErrAuditReasonRequired — write came in without a non-empty
	// reason. 400 in the handler.
	ErrAuditReasonRequired = errors.New("audit_reason required for rule-editor writes")
)

// RulePatch is the partial-update payload for UpdateDraft.
// Only fields the editor allows to change are exposed; system-managed
// fields (id, created_at, lifecycle_state itself, draft_of,
// published_at) are NOT in this struct — lifecycle transitions go
// through the dedicated methods.
type RulePatch struct {
	Name                  *string         `json:"name,omitempty"`
	NameEN                *string         `json:"name_en,omitempty"`
	Description           *string         `json:"description,omitempty"`
	PrimaryParty          *string         `json:"primary_party,omitempty"`
	// EventType is the legacy JSON key; EventKind is the Slice B.5
	// canonical name. Decoder accepts either — coalescePatchKeys()
	// resolves the canonical to the legacy field if only EventKind
	// was sent. Same uuid wire shape; emit-side wraps via
	// adminRuleResponse to expose both keys for one slice.
	EventType             *string         `json:"event_type,omitempty"`
	EventKind             *string         `json:"event_kind,omitempty"`
	DurationValue         *int            `json:"duration_value,omitempty"`
	DurationUnit          *string         `json:"duration_unit,omitempty"`
	Timing                *string         `json:"timing,omitempty"`
	AltDurationValue      *int            `json:"alt_duration_value,omitempty"`
	AltDurationUnit       *string         `json:"alt_duration_unit,omitempty"`
	AltRuleCode           *string         `json:"alt_rule_code,omitempty"`
	AnchorAlt             *string         `json:"anchor_alt,omitempty"`
	CombineOp             *string         `json:"combine_op,omitempty"`
	RuleCode              *string         `json:"rule_code,omitempty"`
	LegalSource           *string         `json:"legal_source,omitempty"`
	DeadlineNotes         *string         `json:"deadline_notes,omitempty"`
	DeadlineNotesEn       *string         `json:"deadline_notes_en,omitempty"`
	Priority              *string         `json:"priority,omitempty"`
	IsCourtSet            *bool           `json:"is_court_set,omitempty"`
	IsSpawn               *bool           `json:"is_spawn,omitempty"`
	SpawnLabel            *string         `json:"spawn_label,omitempty"`
	SpawnProceedingTypeID *int            `json:"spawn_proceeding_type_id,omitempty"`
	TriggerEventID        *int64          `json:"trigger_event_id,omitempty"`
	ConditionExpr         json.RawMessage `json:"condition_expr,omitempty"`
	SequenceOrder         *int            `json:"sequence_order,omitempty"`
	ParentID              *uuid.UUID      `json:"parent_id,omitempty"`
	ConceptID             *uuid.UUID      `json:"concept_id,omitempty"`
}

// CoalesceCanonicalKeys folds the Slice B.5 (t-paliad-305) canonical
// JSON aliases into the legacy field positions so the rest of the
// service can keep using the existing field names. Canonical wins
// when both are sent.
//
//	json:"event_kind"      → EventType (legacy)
//
// Called by the handler immediately after json.Decode. New code can
// adopt the canonical naming; legacy callers continue to work.
func (p *RulePatch) CoalesceCanonicalKeys() {
	if p == nil {
		return
	}
	if p.EventKind != nil {
		p.EventType = p.EventKind
	}
}

// CreateRuleInput is the create payload — a full rule row in draft
// state. Required fields enforce schema NOT-NULL on insert (name,
// name_en, duration_value, duration_unit).
type CreateRuleInput struct {
	Name                  string          `json:"name"`
	NameEN                string          `json:"name_en"`
	ProceedingTypeID      *int            `json:"proceeding_type_id,omitempty"`
	TriggerEventID        *int64          `json:"trigger_event_id,omitempty"`
	ParentID              *uuid.UUID      `json:"parent_id,omitempty"`
	ConceptID             *uuid.UUID      `json:"concept_id,omitempty"`
	// SubmissionCode is the legacy JSON key; Code is the Slice B.5
	// canonical name. Decoder accepts either — CoalesceCanonicalKeys()
	// folds Code → SubmissionCode if only the canonical was sent.
	SubmissionCode        *string         `json:"submission_code,omitempty"`
	Code                  *string         `json:"code,omitempty"`
	PrimaryParty          *string         `json:"primary_party,omitempty"`
	// EventType is the legacy JSON key; EventKind is the Slice B.5
	// canonical name. Same dual-accept pattern as SubmissionCode/Code.
	EventType             *string         `json:"event_type,omitempty"`
	EventKind             *string         `json:"event_kind,omitempty"`
	DurationValue         int             `json:"duration_value"`
	DurationUnit          string          `json:"duration_unit"`
	Timing                *string         `json:"timing,omitempty"`
	AltDurationValue      *int            `json:"alt_duration_value,omitempty"`
	AltDurationUnit       *string         `json:"alt_duration_unit,omitempty"`
	AltRuleCode           *string         `json:"alt_rule_code,omitempty"`
	AnchorAlt             *string         `json:"anchor_alt,omitempty"`
	CombineOp             *string         `json:"combine_op,omitempty"`
	RuleCode              *string         `json:"rule_code,omitempty"`
	LegalSource           *string         `json:"legal_source,omitempty"`
	DeadlineNotes         *string         `json:"deadline_notes,omitempty"`
	DeadlineNotesEn       *string         `json:"deadline_notes_en,omitempty"`
	Priority              string          `json:"priority"`
	IsCourtSet            bool            `json:"is_court_set"`
	IsSpawn               bool            `json:"is_spawn"`
	SpawnLabel            *string         `json:"spawn_label,omitempty"`
	SpawnProceedingTypeID *int            `json:"spawn_proceeding_type_id,omitempty"`
	ConditionExpr         json.RawMessage `json:"condition_expr,omitempty"`
	SequenceOrder         int             `json:"sequence_order"`
}

// CoalesceCanonicalKeys folds the Slice B.5 (t-paliad-305) canonical
// JSON aliases into the legacy field positions. Canonical wins when
// both are sent. Called by the handler immediately after json.Decode.
//
//	json:"code"        → SubmissionCode (legacy)
//	json:"event_kind"  → EventType      (legacy)
func (in *CreateRuleInput) CoalesceCanonicalKeys() {
	if in == nil {
		return
	}
	if in.Code != nil {
		in.SubmissionCode = in.Code
	}
	if in.EventKind != nil {
		in.EventType = in.EventKind
	}
}

// Create inserts a new rule as lifecycle_state='draft' with
// published_at=NULL. The caller's reason is set on the session BEFORE
// the INSERT so the mig 079 trigger writes an audit row with the
// rationale.
func (s *RuleEditorService) Create(ctx context.Context, input CreateRuleInput, reason string) (*models.DeadlineRule, error) {
	if strings.TrimSpace(reason) == "" {
		return nil, ErrAuditReasonRequired
	}
	if strings.TrimSpace(input.Name) == "" || strings.TrimSpace(input.NameEN) == "" {
		return nil, fmt.Errorf("%w: name + name_en required on create", ErrInvalidInput)
	}
	if strings.TrimSpace(input.Priority) == "" {
		input.Priority = "mandatory"
	}
	// Slice B3 (m/paliad#124 §18.3, mig 135): canonical four-value
	// primary_party vocab. Pre-validate so the user gets a
	// user-friendly error before the DB CHECK fires with the raw
	// constraint-violation message.
	if input.PrimaryParty != nil && !lp.IsValidPrimaryParty(*input.PrimaryParty) {
		return nil, fmt.Errorf(
			"%w: primary_party=%q is not one of %v",
			ErrInvalidInput, *input.PrimaryParty, lp.PrimaryParties,
		)
	}
	if err := s.validateSpawnNoCycle(ctx, nil, input.SpawnProceedingTypeID, input.ProceedingTypeID); err != nil {
		return nil, err
	}

	// m/paliad#149 Phase 2 P2 (design §4.1) — lock the condition_expr
	// grammar to leaf {flag} or composite {op:'and'|'or', args:[…]}.
	// Surfaces an ErrInvalidInput before the row hits the DB so the
	// rule editor gets a friendly 400 instead of relying on a future
	// jsonb CHECK constraint that would land as a generic 500.
	if err := ValidateConditionExpr(ctx, s.db, input.ConditionExpr); err != nil {
		return nil, err
	}

	tx, err := s.db.BeginTxx(ctx, nil)
	if err != nil {
		return nil, fmt.Errorf("begin tx: %w", err)
	}
	defer tx.Rollback()
	if err := setAuditReasonTx(ctx, tx, reason); err != nil {
		return nil, err
	}

	id := uuid.New()
	// Slice 9 (t-paliad-195) dropped is_mandatory / is_optional /
	// condition_flag / condition_rule_id from the schema. The INSERT
	// here writes the live shape only — priority + condition_expr
	// + is_court_set are the new gates.
	if _, err := tx.ExecContext(ctx,
		`INSERT INTO paliad.deadline_rules_unified
		    (id, proceeding_type_id, trigger_event_id, parent_id, concept_id, submission_code,
		     name, name_en, description, primary_party, event_type,
		     duration_value, duration_unit, timing,
		     alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt, combine_op,
		     rule_code, legal_source, deadline_notes, deadline_notes_en,
		     priority, is_court_set, is_spawn, spawn_label, spawn_proceeding_type_id,
		     condition_expr, sequence_order,
		     is_active,
		     lifecycle_state, draft_of, published_at,
		     created_at, updated_at)
		 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NULL, $9, $10,
		         $11, $12, $13,
		         $14, $15, $16, $17, $18,
		         $19, $20, $21, $22,
		         $23, $24, $25, $26, $27,
		         $28, $29,
		         true,
		         'draft', NULL, NULL,
		         now(), now())`,
		id, input.ProceedingTypeID, input.TriggerEventID, input.ParentID, input.ConceptID, input.SubmissionCode,
		input.Name, input.NameEN, input.PrimaryParty, input.EventType,
		input.DurationValue, input.DurationUnit, input.Timing,
		input.AltDurationValue, input.AltDurationUnit, input.AltRuleCode, input.AnchorAlt, input.CombineOp,
		input.RuleCode, input.LegalSource, input.DeadlineNotes, input.DeadlineNotesEn,
		input.Priority, input.IsCourtSet, input.IsSpawn, input.SpawnLabel, input.SpawnProceedingTypeID,
		nullableJSON(input.ConditionExpr), input.SequenceOrder,
	); err != nil {
		return nil, fmt.Errorf("insert rule: %w", err)
	}

	// Slice B.4 (mig 140, t-paliad-305): write routes through the
	// INSTEAD OF triggers on paliad.deadline_rules_unified, which fan
	// out into legal_sources + procedural_events + sequencing_rules.
	// No Go-side mirror call needed — the INSERT above already landed
	// the parallel rows.

	if err := tx.Commit(); err != nil {
		return nil, fmt.Errorf("commit create: %w", err)
	}
	return s.getByID(ctx, id)
}

// UpdateDraft applies a partial patch to a rule in lifecycle_state=
// 'draft'. Published or archived rows cannot be patched directly —
// the caller must CloneAsDraft first.
func (s *RuleEditorService) UpdateDraft(ctx context.Context, id uuid.UUID, patch RulePatch, reason string) (*models.DeadlineRule, error) {
	if strings.TrimSpace(reason) == "" {
		return nil, ErrAuditReasonRequired
	}
	current, err := s.getByID(ctx, id)
	if err != nil {
		return nil, err
	}
	if current.LifecycleState != "draft" {
		return nil, fmt.Errorf("%w: rule %s is %s, must be draft to patch (clone first)",
			ErrInvalidLifecycleState, id, current.LifecycleState)
	}

	// Slice B3 (m/paliad#124 §18.3, mig 135): pre-validate the
	// patch's primary_party so the user gets a user-friendly error
	// before the DB CHECK fires with the raw constraint-violation
	// message. Patch field is *string — nil means "don't change",
	// dereferenced empty string means "set to NULL" (handled below
	// in buildPatchSets).
	if patch.PrimaryParty != nil && !lp.IsValidPrimaryParty(*patch.PrimaryParty) {
		return nil, fmt.Errorf(
			"%w: primary_party=%q is not one of %v",
			ErrInvalidInput, *patch.PrimaryParty, lp.PrimaryParties,
		)
	}

	// Spawn cycle guard: if the patch sets spawn_proceeding_type_id,
	// validate against the global graph BEFORE the UPDATE so we can
	// surface the cycle clearly instead of relying on a runtime
	// projection failure.
	if patch.SpawnProceedingTypeID != nil {
		if err := s.validateSpawnNoCycle(ctx, &id, patch.SpawnProceedingTypeID, current.ProceedingTypeID); err != nil {
			return nil, err
		}
	}

	// m/paliad#149 Phase 2 P2 (design §4.1) — validate condition_expr
	// patches. Nil patch field = "don't change" (no validation needed);
	// non-nil = the new value must match the grammar.
	if patch.ConditionExpr != nil {
		if err := ValidateConditionExpr(ctx, s.db, patch.ConditionExpr); err != nil {
			return nil, err
		}
	}

	tx, err := s.db.BeginTxx(ctx, nil)
	if err != nil {
		return nil, fmt.Errorf("begin tx: %w", err)
	}
	defer tx.Rollback()
	if err := setAuditReasonTx(ctx, tx, reason); err != nil {
		return nil, err
	}

	sets, args := buildPatchSets(patch)
	if len(sets) == 0 {
		return current, nil // no-op patch; don't fire the audit trigger
	}
	sets = append(sets, fmt.Sprintf("updated_at = $%d", len(args)+1))
	args = append(args, time.Now().UTC())
	args = append(args, id)
	q := fmt.Sprintf(
		`UPDATE paliad.deadline_rules_unified SET %s WHERE id = $%d AND lifecycle_state = 'draft'`,
		strings.Join(sets, ", "), len(args))
	if _, err := tx.ExecContext(ctx, q, args...); err != nil {
		return nil, fmt.Errorf("update rule draft: %w", err)
	}
	// Slice B.4 (mig 140, t-paliad-305): INSTEAD OF trigger handles the
	// new-table writes — the UPDATE above is already fan-out.
	if err := tx.Commit(); err != nil {
		return nil, fmt.Errorf("commit update: %w", err)
	}
	return s.getByID(ctx, id)
}

// CloneAsDraft creates a new lifecycle_state='draft' row that's a
// deep-copy of the source rule (published or archived), with draft_of
// pointing back at the source. Lets editors propose changes to live
// rules without mutating the live row.
func (s *RuleEditorService) CloneAsDraft(ctx context.Context, id uuid.UUID, reason string) (*models.DeadlineRule, error) {
	if strings.TrimSpace(reason) == "" {
		return nil, ErrAuditReasonRequired
	}
	src, err := s.getByID(ctx, id)
	if err != nil {
		return nil, err
	}
	if src.LifecycleState == "draft" {
		return nil, fmt.Errorf("%w: rule %s is already a draft", ErrInvalidLifecycleState, id)
	}

	tx, err := s.db.BeginTxx(ctx, nil)
	if err != nil {
		return nil, fmt.Errorf("begin tx: %w", err)
	}
	defer tx.Rollback()
	if err := setAuditReasonTx(ctx, tx, reason); err != nil {
		return nil, err
	}

	newID := uuid.New()
	if _, err := tx.ExecContext(ctx,
		`INSERT INTO paliad.deadline_rules_unified
		    (id, proceeding_type_id, trigger_event_id, parent_id, concept_id, submission_code,
		     name, name_en, description, primary_party, event_type,
		     duration_value, duration_unit, timing,
		     alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt, combine_op,
		     rule_code, legal_source, deadline_notes, deadline_notes_en,
		     priority, is_court_set, is_spawn, spawn_label, spawn_proceeding_type_id,
		     condition_expr, sequence_order,
		     is_active,
		     lifecycle_state, draft_of, published_at,
		     created_at, updated_at)
		 SELECT $1, proceeding_type_id, trigger_event_id, parent_id, concept_id, submission_code,
		        name, name_en, description, primary_party, event_type,
		        duration_value, duration_unit, timing,
		        alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt, combine_op,
		        rule_code, legal_source, deadline_notes, deadline_notes_en,
		        priority, is_court_set, is_spawn, spawn_label, spawn_proceeding_type_id,
		        condition_expr, sequence_order,
		        is_active,
		        'draft', $2, NULL,
		        now(), now()
		   FROM paliad.deadline_rules_unified
		  WHERE id = $2`,
		newID, id,
	); err != nil {
		return nil, fmt.Errorf("clone rule as draft: %w", err)
	}
	// Slice B.4 (mig 140, t-paliad-305): INSTEAD OF INSERT trigger
	// mints the synthetic 'null.<8hex>' code when submission_code is
	// NULL (matching mig 136 + the legacy dual-write helper's
	// expression).
	if err := tx.Commit(); err != nil {
		return nil, fmt.Errorf("commit clone: %w", err)
	}
	return s.getByID(ctx, newID)
}

// Publish flips a draft to published, sets published_at=now(), and —
// if the draft was cloned from a published peer — archives that peer
// so each rule_code has at most one live row.
func (s *RuleEditorService) Publish(ctx context.Context, id uuid.UUID, reason string) (*models.DeadlineRule, error) {
	if strings.TrimSpace(reason) == "" {
		return nil, ErrAuditReasonRequired
	}
	current, err := s.getByID(ctx, id)
	if err != nil {
		return nil, err
	}
	if current.LifecycleState != "draft" {
		return nil, fmt.Errorf("%w: only drafts can be published (rule %s is %s)",
			ErrInvalidLifecycleState, id, current.LifecycleState)
	}

	tx, err := s.db.BeginTxx(ctx, nil)
	if err != nil {
		return nil, fmt.Errorf("begin tx: %w", err)
	}
	defer tx.Rollback()
	if err := setAuditReasonTx(ctx, tx, reason); err != nil {
		return nil, err
	}

	now := time.Now().UTC()
	if _, err := tx.ExecContext(ctx,
		`UPDATE paliad.deadline_rules_unified
		    SET lifecycle_state = 'published',
		        published_at = $1,
		        updated_at   = $1
		  WHERE id = $2 AND lifecycle_state = 'draft'`,
		now, id,
	); err != nil {
		return nil, fmt.Errorf("publish draft: %w", err)
	}

	// Archive the peer this draft was cloned from, if any.
	if current.DraftOf != nil {
		if _, err := tx.ExecContext(ctx,
			`UPDATE paliad.deadline_rules_unified
			    SET lifecycle_state = 'archived',
			        updated_at      = $1
			  WHERE id = $2 AND lifecycle_state = 'published'`,
			now, *current.DraftOf,
		); err != nil {
			return nil, fmt.Errorf("archive cloned-from source: %w", err)
		}
	}

	// Slice B.4 (mig 140, t-paliad-305): both UPDATEs above route via
	// the INSTEAD OF UPDATE trigger, which mirrors the lifecycle flip
	// onto procedural_events + sequencing_rules in the same TX.

	if err := tx.Commit(); err != nil {
		return nil, fmt.Errorf("commit publish: %w", err)
	}
	return s.getByID(ctx, id)
}

// Archive flips lifecycle_state to 'archived'. Both published and
// draft rules can be archived (a draft might be abandoned without
// publishing).
func (s *RuleEditorService) Archive(ctx context.Context, id uuid.UUID, reason string) (*models.DeadlineRule, error) {
	return s.flipLifecycle(ctx, id, "archived", []string{"published", "draft"}, reason)
}

// Restore flips lifecycle_state from 'archived' to 'published'. Used
// when an editor undoes a previous archive.
func (s *RuleEditorService) Restore(ctx context.Context, id uuid.UUID, reason string) (*models.DeadlineRule, error) {
	return s.flipLifecycle(ctx, id, "published", []string{"archived"}, reason)
}

func (s *RuleEditorService) flipLifecycle(ctx context.Context, id uuid.UUID, target string, allowed []string, reason string) (*models.DeadlineRule, error) {
	if strings.TrimSpace(reason) == "" {
		return nil, ErrAuditReasonRequired
	}
	current, err := s.getByID(ctx, id)
	if err != nil {
		return nil, err
	}
	if !containsString(allowed, current.LifecycleState) {
		return nil, fmt.Errorf("%w: rule %s is %s, cannot flip to %s (allowed: %v)",
			ErrInvalidLifecycleState, id, current.LifecycleState, target, allowed)
	}

	tx, err := s.db.BeginTxx(ctx, nil)
	if err != nil {
		return nil, fmt.Errorf("begin tx: %w", err)
	}
	defer tx.Rollback()
	if err := setAuditReasonTx(ctx, tx, reason); err != nil {
		return nil, err
	}

	now := time.Now().UTC()
	// published_at is set on the published flip (Restore from archived)
	// but NOT touched on Archive — preserving the original publication
	// timestamp helps audit reads ("when was this rule first live?").
	if target == "published" {
		if _, err := tx.ExecContext(ctx,
			`UPDATE paliad.deadline_rules_unified
			    SET lifecycle_state = $1,
			        published_at    = COALESCE(published_at, $2),
			        updated_at      = $2
			  WHERE id = $3`,
			target, now, id,
		); err != nil {
			return nil, fmt.Errorf("flip lifecycle to %s: %w", target, err)
		}
	} else {
		if _, err := tx.ExecContext(ctx,
			`UPDATE paliad.deadline_rules_unified
			    SET lifecycle_state = $1, updated_at = $2
			  WHERE id = $3`,
			target, now, id,
		); err != nil {
			return nil, fmt.Errorf("flip lifecycle to %s: %w", target, err)
		}
	}

	// Slice B.4 (mig 140, t-paliad-305): INSTEAD OF UPDATE trigger
	// mirrors the lifecycle flip onto sr + pe.

	if err := tx.Commit(); err != nil {
		return nil, fmt.Errorf("commit flip: %w", err)
	}
	return s.getByID(ctx, id)
}

// Preview runs the unified calculator with the given draft rule
// substituted for its published peer (or appended if it's a net-new
// draft with no peer). No DB write, no audit log; pure simulation
// for the editor's "what would this rule do on date X?" affordance.
//
// Implements design §4.5 + Q-H-4 option (a): in-memory override
// passed to Calculate. The peer-discovery walks draft_of → published
// chain; if the draft has no peer, the rule is appended so its
// effect lights up against the rest of the proceeding's rules.
func (s *RuleEditorService) Preview(ctx context.Context, fristen *FristenrechnerService, id uuid.UUID, triggerDate string, flags []string, courtID string) (*UIResponse, error) {
	draft, err := s.getByID(ctx, id)
	if err != nil {
		return nil, err
	}
	if draft.LifecycleState != "draft" {
		return nil, fmt.Errorf("%w: preview only operates on drafts (rule %s is %s)",
			ErrInvalidLifecycleState, id, draft.LifecycleState)
	}
	if draft.ProceedingTypeID == nil {
		return nil, fmt.Errorf("%w: draft has no proceeding_type_id — preview needs a proceeding context", ErrInvalidInput)
	}

	// Resolve proceeding code for the Calculate call.
	var proceedingCode string
	if err := s.db.GetContext(ctx, &proceedingCode,
		`SELECT code FROM paliad.proceeding_types WHERE id = $1 AND is_active = true`,
		*draft.ProceedingTypeID); err != nil {
		return nil, fmt.Errorf("resolve proceeding code: %w", err)
	}

	// The override slice carries the draft itself; Calculate substitutes
	// any rule with matching .ID in the proceeding's rule list. If the
	// draft is cloned-from a published row (draft_of != NULL), the
	// override replaces THAT row's effect — Calculate sees the draft's
	// fields in place of the published row, but the draft's own ID is
	// what shows up in the result. Net-new drafts (draft_of NULL) get
	// appended so they take effect as new rules.
	overrides := []models.DeadlineRule{*draft}
	if draft.DraftOf != nil {
		// Make the draft's ID match the peer's so the override
		// substitutes in place. Saves a callback into Calculate
		// changing the rule_id seen in the response.
		dup := *draft
		dup.ID = *draft.DraftOf
		overrides[0] = dup
	}

	return fristen.Calculate(ctx, proceedingCode, triggerDate, CalcOptions{
		Flags:         flags,
		CourtID:       courtID,
		RuleOverrides: overrides,
	})
}

// RuleAuditEntry mirrors the paliad.deadline_rule_audit row + a friendly
// changed_by display name from paliad.users (NULL on system writes).
// Distinct from services.AuditEntry (the cross-source union for the
// site-wide audit panel) — this one is rule-editor-specific.
type RuleAuditEntry struct {
	models.DeadlineRuleAudit
	ChangedByDisplayName *string `db:"changed_by_display_name" json:"changed_by_display_name,omitempty"`
}

// ListAudit returns paliad.deadline_rule_audit rows for a single rule,
// newest first, with optional offset/limit pagination.
func (s *RuleEditorService) ListAudit(ctx context.Context, ruleID uuid.UUID, offset, limit int) ([]RuleAuditEntry, error) {
	if limit <= 0 || limit > 200 {
		limit = 50
	}
	if offset < 0 {
		offset = 0
	}
	var rows []RuleAuditEntry
	if err := s.db.SelectContext(ctx, &rows, `
		SELECT a.id, a.rule_id, a.changed_by, a.changed_at, a.action,
		       a.before_json, a.after_json, a.reason, a.migration_exported,
		       u.display_name AS changed_by_display_name
		  FROM paliad.deadline_rule_audit a
		  LEFT JOIN paliad.users u ON u.id = a.changed_by
		 WHERE a.rule_id = $1
		 ORDER BY a.changed_at DESC
		 LIMIT $2 OFFSET $3`, ruleID, limit, offset); err != nil {
		return nil, fmt.Errorf("list audit for rule %s: %w", ruleID, err)
	}
	return rows, nil
}

// ListRules returns paginated rules for the admin list view, with
// optional filters: proceeding_type_id, lifecycle_state, trigger_event_id,
// and a fuzzy "q" (matches name OR name_en OR rule_code, ILIKE).
type ListRulesFilter struct {
	ProceedingTypeID *int
	TriggerEventID   *int64
	LifecycleState   string
	Query            string
	Offset           int
	Limit            int
}

func (s *RuleEditorService) ListRules(ctx context.Context, f ListRulesFilter) ([]models.DeadlineRule, error) {
	if f.Limit <= 0 || f.Limit > 500 {
		f.Limit = 100
	}
	if f.Offset < 0 {
		f.Offset = 0
	}
	var (
		conds []string
		args  []any
	)
	addArg := func(v any) string {
		args = append(args, v)
		return fmt.Sprintf("$%d", len(args))
	}
	if f.ProceedingTypeID != nil {
		conds = append(conds, "proceeding_type_id = "+addArg(*f.ProceedingTypeID))
	}
	if f.TriggerEventID != nil {
		conds = append(conds, "trigger_event_id = "+addArg(*f.TriggerEventID))
	}
	if f.LifecycleState != "" {
		conds = append(conds, "lifecycle_state = "+addArg(f.LifecycleState))
	}
	if strings.TrimSpace(f.Query) != "" {
		q := "%" + f.Query + "%"
		conds = append(conds,
			"(name ILIKE "+addArg(q)+" OR name_en ILIKE "+addArg(q)+" OR rule_code ILIKE "+addArg(q)+")")
	}
	where := ""
	if len(conds) > 0 {
		where = "WHERE " + strings.Join(conds, " AND ")
	}
	query := `SELECT ` + ruleColumns + `
		   FROM paliad.deadline_rules_unified
		   ` + where + `
		  ORDER BY proceeding_type_id NULLS LAST, sequence_order
		  LIMIT ` + addArg(f.Limit) + ` OFFSET ` + addArg(f.Offset)
	var rows []models.DeadlineRule
	if err := s.db.SelectContext(ctx, &rows, query, args...); err != nil {
		return nil, fmt.Errorf("list rules: %w", err)
	}
	return rows, nil
}

// LoadProceedingTypeCodes returns an id → code map for every distinct
// non-NULL proceeding_type_id present in rows. Single SELECT against
// paliad.proceeding_types (firm-wide reference data, no RLS). Used by
// /admin/api/procedural-events to enrich the LIST response with a
// proceeding_type_code field so the admin UI can disambiguate
// same-named rules at a glance (t-paliad-321).
func (s *RuleEditorService) LoadProceedingTypeCodes(ctx context.Context, rows []models.DeadlineRule) (map[int]string, error) {
	seen := map[int]bool{}
	var ids []int
	for _, r := range rows {
		if r.ProceedingTypeID != nil && !seen[*r.ProceedingTypeID] {
			seen[*r.ProceedingTypeID] = true
			ids = append(ids, *r.ProceedingTypeID)
		}
	}
	if len(ids) == 0 {
		return nil, nil
	}
	type pair struct {
		ID   int    `db:"id"`
		Code string `db:"code"`
	}
	var pairs []pair
	if err := s.db.SelectContext(ctx, &pairs,
		`SELECT id, code FROM paliad.proceeding_types WHERE id = ANY($1)`,
		pq.Array(ids),
	); err != nil {
		return nil, fmt.Errorf("load proceeding_type codes: %w", err)
	}
	out := make(map[int]string, len(pairs))
	for _, p := range pairs {
		out[p.ID] = p.Code
	}
	return out, nil
}

// GetByID returns a single rule. Exported so the handler can call it
// directly without round-tripping through ListRules.
func (s *RuleEditorService) GetByID(ctx context.Context, id uuid.UUID) (*models.DeadlineRule, error) {
	return s.getByID(ctx, id)
}

func (s *RuleEditorService) getByID(ctx context.Context, id uuid.UUID) (*models.DeadlineRule, error) {
	var r models.DeadlineRule
	err := s.db.GetContext(ctx, &r,
		`SELECT `+ruleColumns+` FROM paliad.deadline_rules_unified WHERE id = $1`, id)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrRuleNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("get rule %s: %w", id, err)
	}
	return &r, nil
}

// =============================================================================
// Internal helpers
// =============================================================================

// setAuditReasonTx writes the audit reason into the session-local
// paliad.audit_reason setting via set_config(name, value, is_local=true).
// The mig 079 trigger reads it via current_setting('paliad.audit_reason', true).
func setAuditReasonTx(ctx context.Context, tx *sqlx.Tx, reason string) error {
	if _, err := tx.ExecContext(ctx,
		`SELECT set_config('paliad.audit_reason', $1, true)`, reason); err != nil {
		return fmt.Errorf("set audit_reason: %w", err)
	}
	return nil
}

// validateSpawnNoCycle checks that spawning from `sourceProceedingID`
// (the rule's proceeding) into `targetProceedingID` doesn't create a
// cycle in the global rule graph. Reuses the design §6 cycle-guard
// semantics: walk the target's spawn rules transitively; if any of
// them spawn back to sourceProceedingID (or to a proceeding already in
// the chain), refuse.
//
// Skipped when either side is nil (no spawn intent or no source
// context). The ruleID parameter is used to exclude the rule itself
// from the walk so an edit that already had a spawn doesn't see
// itself as the cycle source.
func (s *RuleEditorService) validateSpawnNoCycle(ctx context.Context, ruleID *uuid.UUID, target *int, source *int) error {
	if target == nil || source == nil {
		return nil
	}
	if *target == *source {
		return fmt.Errorf("%w: cannot spawn into the same proceeding", ErrCyclicSpawn)
	}
	// Walk the target proceeding's spawn rules. If any of them have a
	// spawn_proceeding_type_id equal to source, that's the cycle.
	visited := map[int]bool{*source: true}
	queue := []int{*target}
	maxHops := maxSpawnDepth
	for len(queue) > 0 && maxHops > 0 {
		maxHops--
		current := queue[0]
		queue = queue[1:]
		if visited[current] {
			return fmt.Errorf("%w: edit would create a cycle through proceeding %d",
				ErrCyclicSpawn, current)
		}
		visited[current] = true
		var nexts []sql.NullInt64
		q := `SELECT DISTINCT spawn_proceeding_type_id::bigint
		        FROM paliad.deadline_rules_unified
		       WHERE proceeding_type_id = $1
		         AND is_spawn = true
		         AND spawn_proceeding_type_id IS NOT NULL
		         AND is_active = true
		         AND lifecycle_state IN ('published', 'draft')`
		args := []any{current}
		if ruleID != nil {
			q += " AND id <> $2"
			args = append(args, *ruleID)
		}
		if err := s.db.SelectContext(ctx, &nexts, q, args...); err != nil {
			return fmt.Errorf("walk spawn graph from %d: %w", current, err)
		}
		for _, n := range nexts {
			if !n.Valid {
				continue
			}
			queue = append(queue, int(n.Int64))
		}
	}
	if maxHops == 0 {
		return fmt.Errorf("%w: spawn graph walk exceeded max depth %d", ErrCyclicSpawn, maxSpawnDepth)
	}
	return nil
}

// buildPatchSets walks the RulePatch and produces (SET clauses, args)
// for the UPDATE statement. Order is stable (per-field) so the
// generated SQL stays diff-friendly. Returns empty slices when the
// patch is empty (caller short-circuits without writing).
func buildPatchSets(p RulePatch) (sets []string, args []any) {
	add := func(col string, val any) {
		args = append(args, val)
		sets = append(sets, fmt.Sprintf("%s = $%d", col, len(args)))
	}
	if p.Name != nil { add("name", *p.Name) }
	if p.NameEN != nil { add("name_en", *p.NameEN) }
	if p.Description != nil { add("description", *p.Description) }
	if p.PrimaryParty != nil { add("primary_party", *p.PrimaryParty) }
	if p.EventType != nil { add("event_type", *p.EventType) }
	if p.DurationValue != nil { add("duration_value", *p.DurationValue) }
	if p.DurationUnit != nil { add("duration_unit", *p.DurationUnit) }
	if p.Timing != nil { add("timing", *p.Timing) }
	if p.AltDurationValue != nil { add("alt_duration_value", *p.AltDurationValue) }
	if p.AltDurationUnit != nil { add("alt_duration_unit", *p.AltDurationUnit) }
	if p.AltRuleCode != nil { add("alt_rule_code", *p.AltRuleCode) }
	if p.AnchorAlt != nil { add("anchor_alt", *p.AnchorAlt) }
	if p.CombineOp != nil { add("combine_op", *p.CombineOp) }
	if p.RuleCode != nil { add("rule_code", *p.RuleCode) }
	if p.LegalSource != nil { add("legal_source", *p.LegalSource) }
	if p.DeadlineNotes != nil { add("deadline_notes", *p.DeadlineNotes) }
	if p.DeadlineNotesEn != nil { add("deadline_notes_en", *p.DeadlineNotesEn) }
	if p.Priority != nil { add("priority", *p.Priority) }
	if p.IsCourtSet != nil { add("is_court_set", *p.IsCourtSet) }
	if p.IsSpawn != nil { add("is_spawn", *p.IsSpawn) }
	if p.SpawnLabel != nil { add("spawn_label", *p.SpawnLabel) }
	if p.SpawnProceedingTypeID != nil { add("spawn_proceeding_type_id", *p.SpawnProceedingTypeID) }
	if p.TriggerEventID != nil { add("trigger_event_id", *p.TriggerEventID) }
	if p.ConditionExpr != nil { add("condition_expr", nullableJSON(p.ConditionExpr)) }
	if p.SequenceOrder != nil { add("sequence_order", *p.SequenceOrder) }
	if p.ParentID != nil { add("parent_id", *p.ParentID) }
	if p.ConceptID != nil { add("concept_id", *p.ConceptID) }
	return sets, args
}

// nullableJSON returns nil for empty / "null" raw so the SQL driver
// writes NULL into the jsonb column, otherwise the byte slice itself.
func nullableJSON(b json.RawMessage) any {
	if len(b) == 0 || string(b) == "null" {
		return nil
	}
	return []byte(b)
}

