package services

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

	"github.com/google/uuid"
	"github.com/jmoiron/sqlx"
)

// ScenarioBuilderService owns the t-paliad-340 / m/paliad#153 B0 surface
// — CRUD over the new normalised builder shape (paliad.scenarios with
// owner_id + status, paliad.scenario_proceedings, paliad.scenario_events,
// paliad.scenario_shares). The legacy spec-jsonb service
// (ScenarioService) keeps serving m/paliad#124 Slice D callers; this
// service strictly handles builder-owned rows (owner_id IS NOT NULL).
//
// Visibility is enforced both in code (the owner / share / can_see_project
// fall-through) and at the row level via the migration-157 RLS policies.
// The application-level check is the load-bearing one — the service
// connects with the service-role credential, which bypasses RLS.
//
// B4 (t-paliad-347 / m/paliad#153) adds the Akte-mode dual-write:
// project-backed scenarios (origin_project_id IS NOT NULL) write flag
// toggles through to paliad.projects.scenario_flags and "filed" event
// toggles through to paliad.deadlines, so the project's Verlauf / Frist
// rail reflect builder activity without a separate sync step. The
// scenario row itself records canvas view-state (ordinal, collapsed,
// per-card horizon, notes); the SSoT for project-bound actuals stays
// paliad.deadlines / paliad.projects.scenario_flags (PRD §2.3 + §10).
type ScenarioBuilderService struct {
	db       *sqlx.DB
	projects *ProjectService
	flags    *ScenarioFlagsService
	// fristenrechner computes planned-deadline due dates during the B5
	// promote-to-project cascade (PRD §5.4 — "due_date=computed"). nil in
	// test setups that don't exercise promotion; the promote path then
	// skips planned events that have no actual_date (it can't assert a
	// date it didn't compute) and reports them via DeadlinesSkipped.
	fristenrechner *FristenrechnerService
}

// NewScenarioBuilderService wires the service to the shared pool plus
// the project + scenario-flags services it leans on for the Akte-mode
// dual-write, and the Fristenrechner calc service the B5 promote path
// uses to compute planned-deadline dates. projects / flags / frist are
// optional in test setups (nil → the dual-write + promote-compute hooks
// short-circuit), but a production wiring should always pass them so
// Akte-backed scenarios stay in sync with project surfaces and
// promotion cascades real dates.
func NewScenarioBuilderService(db *sqlx.DB, projects *ProjectService, flags *ScenarioFlagsService, frist *FristenrechnerService) *ScenarioBuilderService {
	return &ScenarioBuilderService{db: db, projects: projects, flags: flags, fristenrechner: frist}
}

// ErrScenarioBuilderNotVisible is returned when the caller is neither
// owner, an accepted share recipient, nor a global_admin / legacy
// editor for the scenario.
var ErrScenarioBuilderNotVisible = errors.New("scenario not visible to caller")

// -----------------------------------------------------------------------------
// Row types — flat shapes matching the table columns. Deep tree (scenario +
// proceedings + events) is composed at the GET-by-id endpoint.
// -----------------------------------------------------------------------------

// BuilderScenario is one paliad.scenarios row from the builder's perspective.
// Legacy columns (project_id, description, spec, created_by) are still
// returned so a UI can detect a legacy row and refuse to mutate it.
type BuilderScenario struct {
	ID                 uuid.UUID  `db:"id"                   json:"id"`
	OwnerID            *uuid.UUID `db:"owner_id"             json:"owner_id,omitempty"`
	Name               string     `db:"name"                 json:"name"`
	Status             string     `db:"status"               json:"status"`
	OriginProjectID    *uuid.UUID `db:"origin_project_id"    json:"origin_project_id,omitempty"`
	PromotedProjectID  *uuid.UUID `db:"promoted_project_id"  json:"promoted_project_id,omitempty"`
	Stichtag           *time.Time `db:"stichtag"             json:"stichtag,omitempty"`
	Notes              *string    `db:"notes"                json:"notes,omitempty"`
	LegacyProjectID    *uuid.UUID `db:"project_id"           json:"legacy_project_id,omitempty"`
	LegacyDescription  *string    `db:"description"          json:"legacy_description,omitempty"`
	LegacyCreatedBy    *uuid.UUID `db:"created_by"           json:"legacy_created_by,omitempty"`
	CreatedAt          time.Time  `db:"created_at"           json:"created_at"`
	UpdatedAt          time.Time  `db:"updated_at"           json:"updated_at"`
}

// BuilderProceeding is one paliad.scenario_proceedings row.
type BuilderProceeding struct {
	ID                          uuid.UUID       `db:"id"                              json:"id"`
	ScenarioID                  uuid.UUID       `db:"scenario_id"                     json:"scenario_id"`
	ProceedingTypeID            int             `db:"proceeding_type_id"              json:"proceeding_type_id"`
	PrimaryParty                *string         `db:"primary_party"                   json:"primary_party,omitempty"`
	ScenarioFlags               json.RawMessage `db:"scenario_flags"                  json:"scenario_flags"`
	ParentScenarioProceedingID  *uuid.UUID      `db:"parent_scenario_proceeding_id"   json:"parent_scenario_proceeding_id,omitempty"`
	SpawnAnchorEventID          *uuid.UUID      `db:"spawn_anchor_event_id"           json:"spawn_anchor_event_id,omitempty"`
	Ordinal                     int             `db:"ordinal"                         json:"ordinal"`
	Stichtag                    *time.Time      `db:"stichtag"                        json:"stichtag,omitempty"`
	Detailgrad                  string          `db:"detailgrad"                      json:"detailgrad"`
	AppealTarget                *string         `db:"appeal_target"                   json:"appeal_target,omitempty"`
	Collapsed                   bool            `db:"collapsed"                       json:"collapsed"`
	CreatedAt                   time.Time       `db:"created_at"                      json:"created_at"`
	UpdatedAt                   time.Time       `db:"updated_at"                      json:"updated_at"`
}

// BuilderEvent is one paliad.scenario_events row.
type BuilderEvent struct {
	ID                   uuid.UUID  `db:"id"                       json:"id"`
	ScenarioProceedingID uuid.UUID  `db:"scenario_proceeding_id"   json:"scenario_proceeding_id"`
	SequencingRuleID     *uuid.UUID `db:"sequencing_rule_id"       json:"sequencing_rule_id,omitempty"`
	ProceduralEventID    *uuid.UUID `db:"procedural_event_id"      json:"procedural_event_id,omitempty"`
	CustomLabel          *string    `db:"custom_label"             json:"custom_label,omitempty"`
	State                string     `db:"state"                    json:"state"`
	ActualDate           *time.Time `db:"actual_date"              json:"actual_date,omitempty"`
	SkipReason           *string    `db:"skip_reason"              json:"skip_reason,omitempty"`
	Notes                *string    `db:"notes"                    json:"notes,omitempty"`
	HorizonOptional      int        `db:"horizon_optional"         json:"horizon_optional"`
	CreatedAt            time.Time  `db:"created_at"               json:"created_at"`
	UpdatedAt            time.Time  `db:"updated_at"               json:"updated_at"`
}

// BuilderShare is one paliad.scenario_shares row.
type BuilderShare struct {
	ID               uuid.UUID `db:"id"                      json:"id"`
	ScenarioID       uuid.UUID `db:"scenario_id"             json:"scenario_id"`
	SharedWithUserID uuid.UUID `db:"shared_with_user_id"     json:"shared_with_user_id"`
	CreatedBy        uuid.UUID `db:"created_by"              json:"created_by"`
	CreatedAt        time.Time `db:"created_at"              json:"created_at"`
}

// BuilderScenarioDeep bundles a scenario with its proceedings + events
// for the GET /api/builder/scenarios/{id} response. Proceedings sort by
// ordinal asc; events sort by created_at asc within a proceeding.
type BuilderScenarioDeep struct {
	BuilderScenario
	Proceedings []BuilderProceeding `json:"proceedings"`
	Events      []BuilderEvent      `json:"events"`
	Shares      []BuilderShare      `json:"shares"`
}

// -----------------------------------------------------------------------------
// Scenario CRUD
// -----------------------------------------------------------------------------

// CreateBuilderScenarioInput is the POST /api/builder/scenarios body.
// Name defaults to "Unbenanntes Szenario" when blank (PRD §5.1).
type CreateBuilderScenarioInput struct {
	Name            string     `json:"name,omitempty"`
	Stichtag        *time.Time `json:"stichtag,omitempty"`
	Notes           *string    `json:"notes,omitempty"`
	OriginProjectID *uuid.UUID `json:"origin_project_id,omitempty"`
}

// CreateScenario inserts a new builder-owned scenario. owner_id is set to
// the caller; status defaults to 'active'. Audit reason is set inside the
// write tx so any future audit trigger picks it up.
func (s *ScenarioBuilderService) CreateScenario(ctx context.Context, userID uuid.UUID, input CreateBuilderScenarioInput) (*BuilderScenario, error) {
	name := strings.TrimSpace(input.Name)
	if name == "" {
		name = "Unbenanntes Szenario"
	}

	var out BuilderScenario
	err := s.withAuditTx(ctx, "scenario_builder: create scenario", func(tx *sqlx.Tx) error {
		return tx.GetContext(ctx, &out,
			`INSERT INTO paliad.scenarios
			    (owner_id, name, status, stichtag, notes, origin_project_id)
			 VALUES ($1, $2, 'active', $3, $4, $5)
			 RETURNING id, owner_id, name, status, origin_project_id, promoted_project_id,
			           stichtag, notes,
			           project_id, description, created_by,
			           created_at, updated_at`,
			userID, name, input.Stichtag, input.Notes, input.OriginProjectID)
	})
	if err != nil {
		return nil, fmt.Errorf("create builder scenario: %w", err)
	}
	return &out, nil
}

// ListMyScenarios returns the caller's owned scenarios filtered by status.
// Status "" (or "all") returns every status; otherwise filters by the
// given enum value. Sorted by updated_at desc.
func (s *ScenarioBuilderService) ListMyScenarios(ctx context.Context, userID uuid.UUID, status string) ([]BuilderScenario, error) {
	switch status {
	case "", "all":
		// no filter
	case "active", "archived", "promoted":
		// ok
	default:
		return nil, fmt.Errorf("%w: status %q must be one of {active,archived,promoted,all}",
			ErrInvalidInput, status)
	}

	q := `SELECT id, owner_id, name, status, origin_project_id, promoted_project_id,
	             stichtag, notes,
	             project_id, description, created_by,
	             created_at, updated_at
	        FROM paliad.scenarios
	       WHERE owner_id = $1`
	args := []any{userID}
	if status != "" && status != "all" {
		q += ` AND status = $2`
		args = append(args, status)
	}
	q += ` ORDER BY updated_at DESC`

	out := []BuilderScenario{}
	if err := s.db.SelectContext(ctx, &out, q, args...); err != nil {
		return nil, fmt.Errorf("list builder scenarios: %w", err)
	}
	return out, nil
}

// GetScenarioDeep returns the scenario + proceedings + events + shares.
// Visibility: owner, share recipient, global_admin, or legacy editor.
func (s *ScenarioBuilderService) GetScenarioDeep(ctx context.Context, userID, scenarioID uuid.UUID) (*BuilderScenarioDeep, error) {
	sc, err := s.getScenarioRow(ctx, scenarioID)
	if err != nil {
		return nil, err
	}
	visible, err := s.canSeeScenario(ctx, userID, sc)
	if err != nil {
		return nil, err
	}
	if !visible {
		return nil, ErrScenarioBuilderNotVisible
	}

	deep := &BuilderScenarioDeep{
		BuilderScenario: *sc,
		// Initialise to empty so the JSON response always carries arrays,
		// not null — the builder frontend's renderCanvas calls .filter on
		// proceedings/events unconditionally once state.active is set.
		Proceedings: []BuilderProceeding{},
		Events:      []BuilderEvent{},
		Shares:      []BuilderShare{},
	}

	if err := s.db.SelectContext(ctx, &deep.Proceedings, `
		SELECT id, scenario_id, proceeding_type_id, primary_party, scenario_flags,
		       parent_scenario_proceeding_id, spawn_anchor_event_id, ordinal,
		       stichtag, detailgrad, appeal_target, collapsed,
		       created_at, updated_at
		  FROM paliad.scenario_proceedings
		 WHERE scenario_id = $1
		 ORDER BY ordinal ASC, created_at ASC`, scenarioID); err != nil {
		return nil, fmt.Errorf("load proceedings: %w", err)
	}

	if err := s.db.SelectContext(ctx, &deep.Events, `
		SELECT e.id, e.scenario_proceeding_id, e.sequencing_rule_id,
		       e.procedural_event_id, e.custom_label, e.state, e.actual_date,
		       e.skip_reason, e.notes, e.horizon_optional,
		       e.created_at, e.updated_at
		  FROM paliad.scenario_events e
		  JOIN paliad.scenario_proceedings sp ON sp.id = e.scenario_proceeding_id
		 WHERE sp.scenario_id = $1
		 ORDER BY e.created_at ASC`, scenarioID); err != nil {
		return nil, fmt.Errorf("load events: %w", err)
	}

	if err := s.db.SelectContext(ctx, &deep.Shares, `
		SELECT id, scenario_id, shared_with_user_id, created_by, created_at
		  FROM paliad.scenario_shares
		 WHERE scenario_id = $1
		 ORDER BY created_at ASC`, scenarioID); err != nil {
		return nil, fmt.Errorf("load shares: %w", err)
	}

	return deep, nil
}

// PatchBuilderScenarioInput is the PATCH /api/builder/scenarios/{id} body.
// Any nil field means "don't change".
type PatchBuilderScenarioInput struct {
	Name     *string    `json:"name,omitempty"`
	Status   *string    `json:"status,omitempty"`
	Stichtag *time.Time `json:"stichtag,omitempty"`
	Notes    *string    `json:"notes,omitempty"`
}

// PatchScenario updates one or more fields. Status flips to 'promoted'
// are reserved for the B5 wizard (we accept only active⇄archived here).
func (s *ScenarioBuilderService) PatchScenario(ctx context.Context, userID, scenarioID uuid.UUID, input PatchBuilderScenarioInput) (*BuilderScenario, error) {
	sc, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID)
	if err != nil {
		return nil, err
	}
	if sc.Status == "promoted" {
		return nil, fmt.Errorf("%w: scenario is promoted; mutations are blocked", ErrInvalidInput)
	}

	if input.Status != nil {
		switch *input.Status {
		case "active", "archived":
			// ok
		case "promoted":
			return nil, fmt.Errorf("%w: status='promoted' is set by the promote-to-project wizard, not PATCH",
				ErrInvalidInput)
		default:
			return nil, fmt.Errorf("%w: status %q must be one of {active,archived}",
				ErrInvalidInput, *input.Status)
		}
	}

	sets := []string{}
	args := []any{}
	add := func(clause string, val any) {
		args = append(args, val)
		sets = append(sets, fmt.Sprintf(clause, len(args)))
	}
	if input.Name != nil {
		n := strings.TrimSpace(*input.Name)
		if n == "" {
			return nil, fmt.Errorf("%w: name cannot be blank", ErrInvalidInput)
		}
		add("name = $%d", n)
	}
	if input.Status != nil {
		add("status = $%d", *input.Status)
	}
	if input.Stichtag != nil {
		add("stichtag = $%d", *input.Stichtag)
	}
	if input.Notes != nil {
		add("notes = $%d", *input.Notes)
	}
	if len(sets) == 0 {
		return sc, nil
	}

	args = append(args, scenarioID)
	q := fmt.Sprintf(`UPDATE paliad.scenarios SET %s
	    WHERE id = $%d
	  RETURNING id, owner_id, name, status, origin_project_id, promoted_project_id,
	            stichtag, notes,
	            project_id, description, created_by,
	            created_at, updated_at`,
		strings.Join(sets, ", "), len(args))
	var out BuilderScenario
	err = s.withAuditTx(ctx, "scenario_builder: patch scenario", func(tx *sqlx.Tx) error {
		return tx.GetContext(ctx, &out, q, args...)
	})
	if err != nil {
		return nil, fmt.Errorf("patch builder scenario: %w", err)
	}
	return &out, nil
}

// -----------------------------------------------------------------------------
// Proceedings
// -----------------------------------------------------------------------------

// AddProceedingInput is the POST /api/builder/scenarios/{id}/proceedings body.
type AddProceedingInput struct {
	ProceedingTypeID           int             `json:"proceeding_type_id"`
	PrimaryParty               *string         `json:"primary_party,omitempty"`
	ScenarioFlags              json.RawMessage `json:"scenario_flags,omitempty"`
	ParentScenarioProceedingID *uuid.UUID      `json:"parent_scenario_proceeding_id,omitempty"`
	SpawnAnchorEventID         *uuid.UUID      `json:"spawn_anchor_event_id,omitempty"`
	Ordinal                    *int            `json:"ordinal,omitempty"`
	Stichtag                   *time.Time      `json:"stichtag,omitempty"`
	Detailgrad                 *string         `json:"detailgrad,omitempty"`
	AppealTarget               *string         `json:"appeal_target,omitempty"`
}

// AddProceeding appends a proceeding row to the scenario. The caller must
// own the scenario (or be a legacy editor). Ordinal defaults to max+1.
func (s *ScenarioBuilderService) AddProceeding(ctx context.Context, userID, scenarioID uuid.UUID, input AddProceedingInput) (*BuilderProceeding, error) {
	if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
		return nil, err
	}
	if input.ProceedingTypeID == 0 {
		return nil, fmt.Errorf("%w: proceeding_type_id is required", ErrInvalidInput)
	}
	if input.PrimaryParty != nil {
		switch *input.PrimaryParty {
		case "claimant", "defendant":
		default:
			return nil, fmt.Errorf("%w: primary_party %q must be claimant or defendant",
				ErrInvalidInput, *input.PrimaryParty)
		}
	}
	detailgrad := "selected"
	if input.Detailgrad != nil {
		switch *input.Detailgrad {
		case "selected", "all_options":
			detailgrad = *input.Detailgrad
		default:
			return nil, fmt.Errorf("%w: detailgrad %q must be selected or all_options",
				ErrInvalidInput, *input.Detailgrad)
		}
	}
	flags := input.ScenarioFlags
	if len(flags) == 0 {
		flags = json.RawMessage(`{}`)
	}

	// Resolve ordinal: caller's value or max+1 within the same scenario.
	var ordinal int
	if input.Ordinal != nil {
		ordinal = *input.Ordinal
	} else {
		if err := s.db.GetContext(ctx, &ordinal,
			`SELECT COALESCE(MAX(ordinal), -1) + 1
			   FROM paliad.scenario_proceedings
			  WHERE scenario_id = $1`, scenarioID); err != nil {
			return nil, fmt.Errorf("compute ordinal: %w", err)
		}
	}

	var out BuilderProceeding
	err := s.withAuditTx(ctx, "scenario_builder: add proceeding", func(tx *sqlx.Tx) error {
		if err := tx.GetContext(ctx, &out,
			`INSERT INTO paliad.scenario_proceedings
			    (scenario_id, proceeding_type_id, primary_party, scenario_flags,
			     parent_scenario_proceeding_id, spawn_anchor_event_id, ordinal,
			     stichtag, detailgrad, appeal_target)
			 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
			 RETURNING id, scenario_id, proceeding_type_id, primary_party, scenario_flags,
			           parent_scenario_proceeding_id, spawn_anchor_event_id, ordinal,
			           stichtag, detailgrad, appeal_target, collapsed,
			           created_at, updated_at`,
			scenarioID, input.ProceedingTypeID, input.PrimaryParty, []byte(flags),
			input.ParentScenarioProceedingID, input.SpawnAnchorEventID, ordinal,
			input.Stichtag, detailgrad, input.AppealTarget); err != nil {
			return err
		}
		// touch the scenario's updated_at so the side panel re-orders correctly.
		_, err := tx.ExecContext(ctx,
			`UPDATE paliad.scenarios SET updated_at = now() WHERE id = $1`, scenarioID)
		return err
	})
	if err != nil {
		return nil, fmt.Errorf("add proceeding: %w", err)
	}
	return &out, nil
}

// PatchProceedingInput accepts a subset of mutable proceeding fields.
type PatchProceedingInput struct {
	PrimaryParty  *string         `json:"primary_party,omitempty"`
	ScenarioFlags json.RawMessage `json:"scenario_flags,omitempty"`
	Ordinal       *int            `json:"ordinal,omitempty"`
	Stichtag      *time.Time      `json:"stichtag,omitempty"`
	Detailgrad    *string         `json:"detailgrad,omitempty"`
	AppealTarget  *string         `json:"appeal_target,omitempty"`
	Collapsed     *bool           `json:"collapsed,omitempty"`
}

// PatchProceeding updates fields on one proceeding row.
//
// Dual-write (B4): when the parent scenario is project-backed
// (scenarios.origin_project_id IS NOT NULL) and the patched proceeding
// is the top-level triplet (parent_scenario_proceeding_id IS NULL) and
// the patch includes scenario_flags, the merged flag delta also lands on
// paliad.projects.scenario_flags via ScenarioFlagsService.Patch. Top-
// level only because child triplets (CCR child etc.) represent spawned
// sub-proceedings whose flags don't belong on the parent project row;
// the spawned proceeding will get its own project record when (and if)
// the scenario is promoted via the B5 wizard.
func (s *ScenarioBuilderService) PatchProceeding(ctx context.Context, userID, scenarioID, proceedingID uuid.UUID, input PatchProceedingInput) (*BuilderProceeding, error) {
	sc, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID)
	if err != nil {
		return nil, err
	}

	sets := []string{}
	args := []any{}
	add := func(clause string, val any) {
		args = append(args, val)
		sets = append(sets, fmt.Sprintf(clause, len(args)))
	}
	if input.PrimaryParty != nil {
		switch *input.PrimaryParty {
		case "claimant", "defendant", "":
		default:
			return nil, fmt.Errorf("%w: primary_party %q invalid", ErrInvalidInput, *input.PrimaryParty)
		}
		if *input.PrimaryParty == "" {
			add("primary_party = $%d", nil)
		} else {
			add("primary_party = $%d", *input.PrimaryParty)
		}
	}
	if len(input.ScenarioFlags) > 0 {
		add("scenario_flags = $%d", []byte(input.ScenarioFlags))
	}
	if input.Ordinal != nil {
		add("ordinal = $%d", *input.Ordinal)
	}
	if input.Stichtag != nil {
		add("stichtag = $%d", *input.Stichtag)
	}
	if input.Detailgrad != nil {
		switch *input.Detailgrad {
		case "selected", "all_options":
		default:
			return nil, fmt.Errorf("%w: detailgrad %q invalid", ErrInvalidInput, *input.Detailgrad)
		}
		add("detailgrad = $%d", *input.Detailgrad)
	}
	if input.AppealTarget != nil {
		if *input.AppealTarget == "" {
			add("appeal_target = $%d", nil)
		} else {
			add("appeal_target = $%d", *input.AppealTarget)
		}
	}
	if input.Collapsed != nil {
		add("collapsed = $%d", *input.Collapsed)
	}
	if len(sets) == 0 {
		// nothing to do — re-fetch and return.
		return s.getProceedingRow(ctx, scenarioID, proceedingID)
	}

	args = append(args, proceedingID, scenarioID)
	q := fmt.Sprintf(`UPDATE paliad.scenario_proceedings SET %s
	    WHERE id = $%d AND scenario_id = $%d
	  RETURNING id, scenario_id, proceeding_type_id, primary_party, scenario_flags,
	            parent_scenario_proceeding_id, spawn_anchor_event_id, ordinal,
	            stichtag, detailgrad, appeal_target, collapsed,
	            created_at, updated_at`,
		strings.Join(sets, ", "), len(args)-1, len(args))
	var out BuilderProceeding
	err = s.withAuditTx(ctx, "scenario_builder: patch proceeding", func(tx *sqlx.Tx) error {
		return tx.GetContext(ctx, &out, q, args...)
	})
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, fmt.Errorf("%w: proceeding %s not in scenario %s",
				ErrNotVisible, proceedingID, scenarioID)
		}
		return nil, fmt.Errorf("patch proceeding: %w", err)
	}

	// B4 dual-write: if the scenario is Akte-backed and we just
	// changed scenario_flags on the top-level triplet, mirror the
	// merged delta onto paliad.projects.scenario_flags. The PATCH
	// fires after the scenario_proceedings UPDATE commits — a failure
	// here logs but doesn't roll back the builder write (the builder
	// state is the user-visible canvas; the project mirror is a
	// convenience).
	if sc.OriginProjectID != nil && out.ParentScenarioProceedingID == nil &&
		len(input.ScenarioFlags) > 0 && s.flags != nil {
		if delta, derr := flagDeltaFromBuilder(input.ScenarioFlags); derr == nil && len(delta) > 0 {
			if _, perr := s.flags.Patch(ctx, userID, *sc.OriginProjectID, delta); perr != nil {
				// Don't fail the builder PATCH — log via the audit
				// reason that landed in the tx and surface the
				// error through fmt so callers can still inspect.
				return nil, fmt.Errorf("dual-write to project scenario_flags: %w", perr)
			}
		}
	}
	return &out, nil
}

// flagDeltaFromBuilder converts the builder's scenario_flags jsonb
// (Record<string, unknown>) into the partial delta shape expected by
// ScenarioFlagsService.Patch (map[string]*bool, where nil deletes the
// key). Non-bool values are skipped; the builder only writes booleans
// through its UI but defensive parsing keeps the dual-write honest if
// a stray null sneaks in.
func flagDeltaFromBuilder(raw json.RawMessage) (map[string]*bool, error) {
	if len(raw) == 0 {
		return nil, nil
	}
	var src map[string]any
	if err := json.Unmarshal(raw, &src); err != nil {
		return nil, fmt.Errorf("decode flag delta: %w", err)
	}
	out := make(map[string]*bool, len(src))
	for k, v := range src {
		switch val := v.(type) {
		case bool:
			b := val
			out[k] = &b
		case nil:
			out[k] = nil
		}
	}
	return out, nil
}

// DeleteProceeding removes a proceeding (and cascades to events + children).
func (s *ScenarioBuilderService) DeleteProceeding(ctx context.Context, userID, scenarioID, proceedingID uuid.UUID) error {
	if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
		return err
	}
	var n int64
	err := s.withAuditTx(ctx, "scenario_builder: delete proceeding", func(tx *sqlx.Tx) error {
		res, err := tx.ExecContext(ctx,
			`DELETE FROM paliad.scenario_proceedings
			  WHERE id = $1 AND scenario_id = $2`,
			proceedingID, scenarioID)
		if err != nil {
			return err
		}
		n, _ = res.RowsAffected()
		return nil
	})
	if err != nil {
		return fmt.Errorf("delete proceeding: %w", err)
	}
	if n == 0 {
		return fmt.Errorf("%w: proceeding %s not in scenario %s",
			ErrNotVisible, proceedingID, scenarioID)
	}
	return nil
}

// -----------------------------------------------------------------------------
// Events
// -----------------------------------------------------------------------------

// AddEventInput is the POST .../proceedings/{pid}/events body. At least
// one of {SequencingRuleID, ProceduralEventID, CustomLabel} must be set,
// matching the scenario_events_one_anchor CHECK constraint.
type AddEventInput struct {
	SequencingRuleID  *uuid.UUID `json:"sequencing_rule_id,omitempty"`
	ProceduralEventID *uuid.UUID `json:"procedural_event_id,omitempty"`
	CustomLabel       *string    `json:"custom_label,omitempty"`
	State             *string    `json:"state,omitempty"`
	ActualDate        *time.Time `json:"actual_date,omitempty"`
	SkipReason        *string    `json:"skip_reason,omitempty"`
	Notes             *string    `json:"notes,omitempty"`
	HorizonOptional   *int       `json:"horizon_optional,omitempty"`
}

// AddEvent inserts an event card under the given proceeding. The
// proceeding must belong to the addressed scenario.
func (s *ScenarioBuilderService) AddEvent(ctx context.Context, userID, scenarioID, proceedingID uuid.UUID, input AddEventInput) (*BuilderEvent, error) {
	if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
		return nil, err
	}
	if input.SequencingRuleID == nil && input.ProceduralEventID == nil &&
		(input.CustomLabel == nil || strings.TrimSpace(*input.CustomLabel) == "") {
		return nil, fmt.Errorf("%w: at least one of sequencing_rule_id, procedural_event_id, custom_label must be set",
			ErrInvalidInput)
	}
	if err := s.assertProceedingInScenario(ctx, scenarioID, proceedingID); err != nil {
		return nil, err
	}

	state := "planned"
	if input.State != nil {
		switch *input.State {
		case "planned", "filed", "skipped":
			state = *input.State
		default:
			return nil, fmt.Errorf("%w: state %q must be one of {planned,filed,skipped}",
				ErrInvalidInput, *input.State)
		}
	}
	horizon := 0
	if input.HorizonOptional != nil {
		if *input.HorizonOptional < 0 {
			return nil, fmt.Errorf("%w: horizon_optional must be >= 0", ErrInvalidInput)
		}
		horizon = *input.HorizonOptional
	}

	var out BuilderEvent
	err := s.withAuditTx(ctx, "scenario_builder: add event", func(tx *sqlx.Tx) error {
		if err := tx.GetContext(ctx, &out,
			`INSERT INTO paliad.scenario_events
			    (scenario_proceeding_id, sequencing_rule_id, procedural_event_id,
			     custom_label, state, actual_date, skip_reason, notes, horizon_optional)
			 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
			 RETURNING id, scenario_proceeding_id, sequencing_rule_id, procedural_event_id,
			           custom_label, state, actual_date, skip_reason, notes,
			           horizon_optional, created_at, updated_at`,
			proceedingID, input.SequencingRuleID, input.ProceduralEventID,
			input.CustomLabel, state, input.ActualDate, input.SkipReason,
			input.Notes, horizon); err != nil {
			return err
		}
		_, err := tx.ExecContext(ctx,
			`UPDATE paliad.scenarios SET updated_at = now() WHERE id = $1`, scenarioID)
		return err
	})
	if err != nil {
		return nil, fmt.Errorf("add event: %w", err)
	}
	return &out, nil
}

// PatchEventInput is the PATCH body for an event card.
type PatchEventInput struct {
	State           *string    `json:"state,omitempty"`
	ActualDate      *time.Time `json:"actual_date,omitempty"`
	SkipReason      *string    `json:"skip_reason,omitempty"`
	Notes           *string    `json:"notes,omitempty"`
	HorizonOptional *int       `json:"horizon_optional,omitempty"`
}

// PatchEvent updates fields on one event card. The card's parent
// proceeding must belong to the addressed scenario.
//
// Dual-write (B4): when the parent scenario is project-backed
// (scenarios.origin_project_id IS NOT NULL), the event's sequencing
// rule is set, and the patch transitions the card to state='filed'
// with an actual_date, the same fact lands on paliad.deadlines
// (status='completed', completed_at=actual_date). If a deadline row
// already exists for the (project_id, sequencing_rule_id) pair it's
// updated in place; otherwise a fresh row is inserted carrying the
// rule's display name + due_date=actual_date. The dual-write runs in
// the same transaction as the scenario_events UPDATE so canvas and
// project surfaces never diverge mid-flight.
func (s *ScenarioBuilderService) PatchEvent(ctx context.Context, userID, scenarioID, eventID uuid.UUID, input PatchEventInput) (*BuilderEvent, error) {
	sc, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID)
	if err != nil {
		return nil, err
	}
	if err := s.assertEventInScenario(ctx, scenarioID, eventID); err != nil {
		return nil, err
	}

	sets := []string{}
	args := []any{}
	add := func(clause string, val any) {
		args = append(args, val)
		sets = append(sets, fmt.Sprintf(clause, len(args)))
	}
	if input.State != nil {
		switch *input.State {
		case "planned", "filed", "skipped":
		default:
			return nil, fmt.Errorf("%w: state %q invalid", ErrInvalidInput, *input.State)
		}
		add("state = $%d", *input.State)
	}
	if input.ActualDate != nil {
		add("actual_date = $%d", *input.ActualDate)
	}
	if input.SkipReason != nil {
		add("skip_reason = $%d", *input.SkipReason)
	}
	if input.Notes != nil {
		add("notes = $%d", *input.Notes)
	}
	if input.HorizonOptional != nil {
		if *input.HorizonOptional < 0 {
			return nil, fmt.Errorf("%w: horizon_optional must be >= 0", ErrInvalidInput)
		}
		add("horizon_optional = $%d", *input.HorizonOptional)
	}
	if len(sets) == 0 {
		return s.getEventRow(ctx, eventID)
	}

	args = append(args, eventID)
	q := fmt.Sprintf(`UPDATE paliad.scenario_events SET %s
	    WHERE id = $%d
	  RETURNING id, scenario_proceeding_id, sequencing_rule_id, procedural_event_id,
	            custom_label, state, actual_date, skip_reason, notes,
	            horizon_optional, created_at, updated_at`,
		strings.Join(sets, ", "), len(args))
	var out BuilderEvent
	err = s.withAuditTx(ctx, "scenario_builder: patch event", func(tx *sqlx.Tx) error {
		if err := tx.GetContext(ctx, &out, q, args...); err != nil {
			return err
		}
		// B4 dual-write: project-backed scenarios reflect "filed"
		// transitions on paliad.deadlines so the project's Verlauf /
		// Frist rail picks them up without a separate writer. We
		// only act when state explicitly flipped to 'filed' on this
		// patch — earlier rows that were already filed don't get
		// re-stamped.
		if sc.OriginProjectID != nil && input.State != nil && *input.State == "filed" &&
			out.SequencingRuleID != nil && out.ActualDate != nil {
			if err := s.dualWriteFiledDeadlineTx(ctx, tx, *sc.OriginProjectID,
				*out.SequencingRuleID, *out.ActualDate); err != nil {
				return fmt.Errorf("dual-write filed deadline: %w", err)
			}
		}
		return nil
	})
	if err != nil {
		return nil, fmt.Errorf("patch event: %w", err)
	}
	return &out, nil
}

// dualWriteFiledDeadlineTx upserts a paliad.deadlines row for the
// (project_id, sequencing_rule_id) pair so a builder-filed event
// surfaces on the project's deadline rail. If a row exists, it's
// flipped to status='completed' + completed_at; otherwise a fresh row
// is inserted with the rule's display name, due_date=actual_date, and
// source='litigation_builder'. The whole thing runs inside the caller
// transaction so the canvas event and the deadline never diverge.
func (s *ScenarioBuilderService) dualWriteFiledDeadlineTx(ctx context.Context, tx *sqlx.Tx, projectID, ruleID uuid.UUID, actualDate time.Time) error {
	// Try update first — keeps any existing approval / event_type
	// hydration intact for deadlines created via the regular Akten
	// path. We touch only the columns the builder owns:
	// status / completed_at / updated_at.
	res, err := tx.ExecContext(ctx,
		`UPDATE paliad.deadlines
		    SET status       = 'completed',
		        completed_at = $1,
		        updated_at   = now()
		  WHERE project_id        = $2
		    AND sequencing_rule_id = $3
		    AND status            <> 'completed'`,
		actualDate, projectID, ruleID)
	if err != nil {
		return fmt.Errorf("update existing deadline: %w", err)
	}
	if n, _ := res.RowsAffected(); n > 0 {
		return nil
	}

	// Already-completed rows: leave them alone, the builder isn't
	// reopening anything. Detect via a count probe so we don't
	// double-insert.
	var existing int
	if err := tx.GetContext(ctx, &existing,
		`SELECT COUNT(*) FROM paliad.deadlines
		  WHERE project_id = $1 AND sequencing_rule_id = $2`,
		projectID, ruleID); err != nil {
		return fmt.Errorf("probe deadline row: %w", err)
	}
	if existing > 0 {
		return nil
	}

	// No existing row — insert a fresh deadline. The title comes from
	// paliad.procedural_events.name joined via sequencing_rules.
	// procedural_event_id (sequencing_rules itself doesn't carry a
	// display label — the name lives on the procedural_event row).
	// rule_code falls back when the event has no name; the literal
	// "Litigation-Builder Event" is the last resort for rules that
	// have no procedural_event_id either. source='rule' (already
	// allowed by deadlines_source_check) since the row is rule-backed
	// — the Litigation Builder doesn't get its own source bucket; the
	// audit_reason on the surrounding tx tells the audit log who
	// inserted it.
	var title string
	if err := tx.GetContext(ctx, &title,
		`SELECT COALESCE(NULLIF(pe.name, ''), NULLIF(sr.rule_code, ''), 'Litigation-Builder Event')
		   FROM paliad.sequencing_rules sr
		   LEFT JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
		  WHERE sr.id = $1`, ruleID); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			title = "Litigation-Builder Event"
		} else {
			return fmt.Errorf("load rule name: %w", err)
		}
	}

	if _, err := tx.ExecContext(ctx,
		`INSERT INTO paliad.deadlines
		    (project_id, title, due_date, sequencing_rule_id, status, completed_at, source, approval_status)
		 VALUES ($1, $2, $3::date, $4, 'completed', $5::timestamptz, 'rule', 'legacy')`,
		projectID, title, actualDate, ruleID, actualDate); err != nil {
		return fmt.Errorf("insert builder deadline: %w", err)
	}
	return nil
}

// DeleteEvent removes one event card.
func (s *ScenarioBuilderService) DeleteEvent(ctx context.Context, userID, scenarioID, eventID uuid.UUID) error {
	if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
		return err
	}
	if err := s.assertEventInScenario(ctx, scenarioID, eventID); err != nil {
		return err
	}
	err := s.withAuditTx(ctx, "scenario_builder: delete event", func(tx *sqlx.Tx) error {
		_, err := tx.ExecContext(ctx,
			`DELETE FROM paliad.scenario_events WHERE id = $1`, eventID)
		return err
	})
	if err != nil {
		return fmt.Errorf("delete event: %w", err)
	}
	return nil
}

// -----------------------------------------------------------------------------
// Shares
// -----------------------------------------------------------------------------

// AddShare grants read-only access to another paliad user.
func (s *ScenarioBuilderService) AddShare(ctx context.Context, userID, scenarioID, recipientID uuid.UUID) (*BuilderShare, error) {
	if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
		return nil, err
	}
	if recipientID == uuid.Nil {
		return nil, fmt.Errorf("%w: shared_with_user_id is required", ErrInvalidInput)
	}
	if recipientID == userID {
		return nil, fmt.Errorf("%w: cannot share a scenario with yourself", ErrInvalidInput)
	}
	var out BuilderShare
	err := s.withAuditTx(ctx, "scenario_builder: add share", func(tx *sqlx.Tx) error {
		return tx.GetContext(ctx, &out,
			`INSERT INTO paliad.scenario_shares (scenario_id, shared_with_user_id, created_by)
			 VALUES ($1, $2, $3)
			 ON CONFLICT (scenario_id, shared_with_user_id) DO UPDATE
			    SET created_at = paliad.scenario_shares.created_at
			 RETURNING id, scenario_id, shared_with_user_id, created_by, created_at`,
			scenarioID, recipientID, userID)
	})
	if err != nil {
		return nil, fmt.Errorf("add share: %w", err)
	}
	return &out, nil
}

// DeleteShare revokes a share row.
func (s *ScenarioBuilderService) DeleteShare(ctx context.Context, userID, scenarioID, shareID uuid.UUID) error {
	if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
		return err
	}
	var n int64
	err := s.withAuditTx(ctx, "scenario_builder: delete share", func(tx *sqlx.Tx) error {
		res, err := tx.ExecContext(ctx,
			`DELETE FROM paliad.scenario_shares
			  WHERE id = $1 AND scenario_id = $2`, shareID, scenarioID)
		if err != nil {
			return err
		}
		n, _ = res.RowsAffected()
		return nil
	})
	if err != nil {
		return fmt.Errorf("delete share: %w", err)
	}
	if n == 0 {
		return fmt.Errorf("%w: share %s not in scenario %s", ErrNotVisible, shareID, scenarioID)
	}
	return nil
}

// -----------------------------------------------------------------------------
// Shared-with-me listing (B5)
// -----------------------------------------------------------------------------

// ListSharedWithMe returns scenarios shared read-only with the caller
// (a paliad.scenario_shares row exists for shared_with_user_id = caller).
// The caller's own scenarios are excluded — they live in ListMyScenarios.
// Sorted by the share's created_at desc so the most-recently-shared sits
// on top. Promoted scenarios stay visible (read-only reference) just like
// in the owner's own list.
func (s *ScenarioBuilderService) ListSharedWithMe(ctx context.Context, userID uuid.UUID) ([]BuilderScenario, error) {
	out := []BuilderScenario{}
	if err := s.db.SelectContext(ctx, &out,
		`SELECT sc.id, sc.owner_id, sc.name, sc.status, sc.origin_project_id,
		        sc.promoted_project_id, sc.stichtag, sc.notes,
		        sc.project_id, sc.description, sc.created_by,
		        sc.created_at, sc.updated_at
		   FROM paliad.scenarios sc
		   JOIN paliad.scenario_shares sh ON sh.scenario_id = sc.id
		  WHERE sh.shared_with_user_id = $1
		    AND (sc.owner_id IS NULL OR sc.owner_id <> $1)
		  ORDER BY sh.created_at DESC`, userID); err != nil {
		return nil, fmt.Errorf("list shared scenarios: %w", err)
	}
	return out, nil
}

// -----------------------------------------------------------------------------
// Promote-to-project (B5, PRD §2.4 + §5.4 + §10)
// -----------------------------------------------------------------------------

// PromotePartyInput is one party row the wizard's "Parteien ergänzen"
// step contributes. Mirrors CreatePartyInput minus contact_info (the
// wizard collects names + roles; full contact data is filled in the Akte
// later).
type PromotePartyInput struct {
	Name           string  `json:"name"`
	Role           *string `json:"role,omitempty"`
	Representative *string `json:"representative,omitempty"`
}

// PromoteTeamMemberInput grants a colleague access to the new project at
// promote time. Responsibility defaults to 'member' when blank.
type PromoteTeamMemberInput struct {
	UserID         uuid.UUID `json:"user_id"`
	Responsibility string    `json:"responsibility,omitempty"`
}

// PromoteScenarioInput is the POST /api/builder/scenarios/{id}/promote
// body — the merged payload from wizard steps 2 (Parteien) + 3
// (Akte-Metadaten). The procedural shape (proceeding type, flags,
// perspective) + event states come from the scenario itself; the wizard
// only supplies the client-bound metadata the scenario can't know.
type PromoteScenarioInput struct {
	Title        string                   `json:"title"`
	Reference    *string                  `json:"reference,omitempty"`
	CaseNumber   *string                  `json:"case_number,omitempty"`
	ClientNumber *string                  `json:"client_number,omitempty"`
	ParentID     *uuid.UUID               `json:"parent_id,omitempty"`
	OurSide      *string                  `json:"our_side,omitempty"`
	Parties      []PromotePartyInput      `json:"parties,omitempty"`
	TeamMembers  []PromoteTeamMemberInput `json:"team_members,omitempty"`
}

// PromoteResult is the outcome the wizard navigates on.
type PromoteResult struct {
	ProjectID          uuid.UUID `json:"project_id"`
	DeadlinesCreated   int       `json:"deadlines_created"`
	DeadlinesSkipped   int       `json:"deadlines_skipped"`
	PartiesCreated     int       `json:"parties_created"`
	ProceedingsSkipped int       `json:"proceedings_skipped"`
}

// PromoteScenario turns a scenario into a real paliad.projects 'case' row
// in a single transaction (PRD §10 — no partial promotions). It promotes
// the scenario's primary proceeding (the lowest-ordinal top-level
// triplet) plus its spawned descendants (the CCR child etc., whose rules
// fold into the primary's timeline under the active flags). Additional
// unrelated top-level proceedings are left in the scenario and reported
// via ProceedingsSkipped — v1 promotes one case file per call, matching
// the singular acceptance criterion (one project, navigate to one id);
// the scenario stays visible as 'promoted' for historical reference and
// can seed a second promotion later.
//
// The cascade, all inside the tx:
//  1. INSERT paliad.projects (type='case', client metadata from the
//     wizard, proceeding_type_id + scenario_flags from the primary
//     triplet, origin_scenario_id = scenario.id).
//  2. INSERT the creator as team lead + any wizard-selected colleagues.
//  3. INSERT parties from the wizard's step-2 payload.
//  4. For each event under the promoted proceedings: filed → a completed
//     deadline (due_date + completed_at = actual_date); planned → an open
//     ('pending') deadline with the computed due_date; skipped → no row.
//     Planned events with no computable date (court-set / conditional /
//     no actual_date) are skipped and counted.
//  5. UPDATE the scenario: status='promoted', promoted_project_id = new.
//
// Any error rolls the whole transaction back.
func (s *ScenarioBuilderService) PromoteScenario(ctx context.Context, userID, scenarioID uuid.UUID, input PromoteScenarioInput) (*PromoteResult, error) {
	sc, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID)
	if err != nil {
		return nil, err
	}
	if sc.Status == "promoted" {
		return nil, fmt.Errorf("%w: scenario is already promoted", ErrInvalidInput)
	}
	title := strings.TrimSpace(input.Title)
	if title == "" {
		return nil, fmt.Errorf("%w: title is required", ErrInvalidInput)
	}
	if input.OurSide != nil {
		if err := validateOurSide(*input.OurSide); err != nil {
			return nil, err
		}
	}
	for i := range input.Parties {
		if strings.TrimSpace(input.Parties[i].Name) == "" {
			return nil, fmt.Errorf("%w: party %d has a blank name", ErrInvalidInput, i+1)
		}
	}
	for _, tm := range input.TeamMembers {
		if tm.UserID == uuid.Nil {
			return nil, fmt.Errorf("%w: team member has an empty user_id", ErrInvalidInput)
		}
		if tm.Responsibility != "" && !IsValidResponsibility(tm.Responsibility) {
			return nil, fmt.Errorf("%w: invalid responsibility %q", ErrInvalidInput, tm.Responsibility)
		}
	}

	// Parent visibility (mirrors ProjectService.Create): a litigation
	// parent the caller can't see would leak the new sub-tree.
	if input.ParentID != nil && s.projects != nil {
		if _, perr := s.projects.GetByID(ctx, userID, *input.ParentID); perr != nil {
			return nil, fmt.Errorf("%w: litigation parent not visible", ErrForbidden)
		}
	}

	// Load the proceeding + event tree.
	proceedings := []BuilderProceeding{}
	if err := s.db.SelectContext(ctx, &proceedings, `
		SELECT id, scenario_id, proceeding_type_id, primary_party, scenario_flags,
		       parent_scenario_proceeding_id, spawn_anchor_event_id, ordinal,
		       stichtag, detailgrad, appeal_target, collapsed, created_at, updated_at
		  FROM paliad.scenario_proceedings
		 WHERE scenario_id = $1
		 ORDER BY ordinal ASC, created_at ASC`, scenarioID); err != nil {
		return nil, fmt.Errorf("load proceedings: %w", err)
	}
	if len(proceedings) == 0 {
		return nil, fmt.Errorf("%w: scenario has no proceedings to promote", ErrInvalidInput)
	}

	// Primary = first top-level proceeding (lowest ordinal). Collect it +
	// its spawned descendants; those form the one case file we promote.
	var primary *BuilderProceeding
	for i := range proceedings {
		if proceedings[i].ParentScenarioProceedingID == nil {
			primary = &proceedings[i]
			break
		}
	}
	if primary == nil {
		return nil, fmt.Errorf("%w: scenario has no top-level proceeding", ErrInvalidInput)
	}
	promoteSet := collectProceedingSubtree(proceedings, primary.ID)
	topLevelCount := 0
	for i := range proceedings {
		if proceedings[i].ParentScenarioProceedingID == nil {
			topLevelCount++
		}
	}

	// Resolve the primary proceeding's catalog code (the calc engine keys
	// off code, not id).
	var primaryCode string
	if err := s.db.GetContext(ctx, &primaryCode,
		`SELECT code FROM paliad.proceeding_types WHERE id = $1`, primary.ProceedingTypeID); err != nil {
		return nil, fmt.Errorf("resolve proceeding code: %w", err)
	}

	// Resolve our_side: explicit wizard value wins; otherwise fold the
	// primary triplet's perspective down to the project axis.
	ourSide := input.OurSide
	if ourSide == nil {
		ourSide = primary.PrimaryParty
	}

	// Compute the primary proceeding's timeline so planned events get real
	// dates. The CCR child's rules fold into this timeline under the
	// primary's flags (sub-track routing), so one calc covers the whole
	// promoted subtree. Keyed by lowercased rule id → display name/code/date.
	type computed struct {
		name    string
		code    string
		dueDate string
	}
	timelineByRule := map[string]computed{}
	if s.fristenrechner != nil {
		stichtag := promoteStichtag(primary, sc)
		opts := CalcOptions{Flags: scenarioFlagsTruthyKeys(primary.ScenarioFlags)}
		tl, cerr := s.fristenrechner.Calculate(ctx, primaryCode, stichtag, opts)
		if cerr != nil {
			// A calc failure is not fatal — filed events still carry their
			// own actual_date. Planned events then fall to DeadlinesSkipped.
			tl = nil
		}
		if tl != nil {
			for _, e := range tl.Deadlines {
				if e.RuleID == "" {
					continue
				}
				timelineByRule[strings.ToLower(e.RuleID)] = computed{
					name: e.Name, code: e.Code, dueDate: e.DueDate,
				}
			}
		}
	}

	// Load events for the promoted proceedings only.
	events := []BuilderEvent{}
	if err := s.db.SelectContext(ctx, &events, `
		SELECT e.id, e.scenario_proceeding_id, e.sequencing_rule_id,
		       e.procedural_event_id, e.custom_label, e.state, e.actual_date,
		       e.skip_reason, e.notes, e.horizon_optional, e.created_at, e.updated_at
		  FROM paliad.scenario_events e
		  JOIN paliad.scenario_proceedings sp ON sp.id = e.scenario_proceeding_id
		 WHERE sp.scenario_id = $1
		 ORDER BY e.created_at ASC`, scenarioID); err != nil {
		return nil, fmt.Errorf("load events: %w", err)
	}

	result := &PromoteResult{
		ProceedingsSkipped: topLevelCount - 1,
	}
	newProjectID := uuid.New()

	err = s.withAuditTx(ctx, "scenario_builder: promote scenario", func(tx *sqlx.Tx) error {
		now := time.Now().UTC()

		// 1. Project row. path is filled by the BEFORE INSERT trigger
		//    (projects_sync_path); '' satisfies the NOT NULL constraint.
		if _, err := tx.ExecContext(ctx,
			`INSERT INTO paliad.projects
			    (id, type, parent_id, path, title, reference, status, created_by,
			     case_number, client_number, proceeding_type_id, our_side,
			     scenario_flags, origin_scenario_id, metadata, created_at, updated_at)
			 VALUES ($1, 'case', $2, '', $3, $4, 'active', $5,
			         $6, $7, $8, $9, $10::jsonb, $11, '{}'::jsonb, $12, $12)`,
			newProjectID, input.ParentID, title, input.Reference, userID,
			nullableTrimmed(stringPtrOrNil(input.CaseNumber)),
			nullableTrimmed(input.ClientNumber),
			primary.ProceedingTypeID, nullableOurSide(ourSide),
			[]byte(primary.ScenarioFlags), scenarioID, now); err != nil {
			return fmt.Errorf("insert project: %w", err)
		}

		// 2a. Creator as team lead (RLS visibility, matches Create).
		if _, err := tx.ExecContext(ctx,
			`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
			 VALUES ($1, $2, 'lead', 'lead', false, $2)`, newProjectID, userID); err != nil {
			return fmt.Errorf("insert creator team row: %w", err)
		}
		// 2b. Wizard-selected colleagues.
		for _, tm := range input.TeamMembers {
			if tm.UserID == userID {
				continue // creator already added as lead
			}
			resp := tm.Responsibility
			if resp == "" {
				resp = ResponsibilityMember
			}
			if _, err := tx.ExecContext(ctx,
				`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
				 VALUES ($1, $2, $3, $4, false, $5)
				 ON CONFLICT (project_id, user_id) DO UPDATE
				    SET role = EXCLUDED.role, responsibility = EXCLUDED.responsibility`,
				newProjectID, tm.UserID, legacyRoleFromResponsibility(resp), resp, userID); err != nil {
				return fmt.Errorf("insert team member: %w", err)
			}
		}

		// 3. Parties.
		for _, p := range input.Parties {
			if _, err := tx.ExecContext(ctx,
				`INSERT INTO paliad.parties (project_id, name, role, representative, contact_info)
				 VALUES ($1, $2, $3, $4, '{}'::jsonb)`,
				newProjectID, strings.TrimSpace(p.Name), p.Role, p.Representative); err != nil {
				return fmt.Errorf("insert party: %w", err)
			}
			result.PartiesCreated++
		}

		// 4. Deadlines from the promoted proceedings' events.
		for _, ev := range events {
			if !promoteSet[ev.ScenarioProceedingID] {
				continue
			}
			if ev.State == "skipped" {
				continue
			}
			if ev.SequencingRuleID == nil {
				// Free-form / procedural-event-only cards have no rule to
				// anchor a deadline on in v1 — skip (counts as skipped only
				// when it was a dated plan; here just leave it out).
				continue
			}
			ruleKey := strings.ToLower(ev.SequencingRuleID.String())
			comp := timelineByRule[ruleKey]
			title := comp.name
			if strings.TrimSpace(title) == "" {
				title = "Litigation-Builder Frist"
			}
			ruleCode := comp.code

			if ev.State == "filed" && ev.ActualDate != nil {
				if _, err := tx.ExecContext(ctx,
					`INSERT INTO paliad.deadlines
					    (project_id, title, rule_code, due_date, sequencing_rule_id,
					     status, completed_at, source, approval_status)
					 VALUES ($1, $2, $3, $4::date, $5, 'completed', $6::timestamptz, 'rule', 'legacy')`,
					newProjectID, title, nullableTrimmed(&ruleCode), *ev.ActualDate,
					*ev.SequencingRuleID, *ev.ActualDate); err != nil {
					return fmt.Errorf("insert filed deadline: %w", err)
				}
				result.DeadlinesCreated++
				continue
			}

			// planned — need a date. Prefer an explicit actual_date
			// (court-set override the user pinned), else the computed date.
			var dueDate *time.Time
			if ev.ActualDate != nil {
				dueDate = ev.ActualDate
			} else if comp.dueDate != "" {
				if d, perr := time.Parse("2006-01-02", comp.dueDate); perr == nil {
					dueDate = &d
				}
			}
			if dueDate == nil {
				result.DeadlinesSkipped++
				continue
			}
			if _, err := tx.ExecContext(ctx,
				`INSERT INTO paliad.deadlines
				    (project_id, title, rule_code, due_date, sequencing_rule_id,
				     status, source, approval_status)
				 VALUES ($1, $2, $3, $4::date, $5, 'pending', 'rule', 'legacy')`,
				newProjectID, title, nullableTrimmed(&ruleCode), *dueDate,
				*ev.SequencingRuleID); err != nil {
				return fmt.Errorf("insert planned deadline: %w", err)
			}
			result.DeadlinesCreated++
		}

		// 5. Flip the scenario to promoted.
		if _, err := tx.ExecContext(ctx,
			`UPDATE paliad.scenarios
			    SET status = 'promoted', promoted_project_id = $1, updated_at = now()
			  WHERE id = $2`, newProjectID, scenarioID); err != nil {
			return fmt.Errorf("mark scenario promoted: %w", err)
		}
		return nil
	})
	if err != nil {
		return nil, fmt.Errorf("promote scenario: %w", err)
	}
	result.ProjectID = newProjectID
	return result, nil
}

// collectProceedingSubtree returns the set of proceeding ids rooted at
// rootID (inclusive), walking parent_scenario_proceeding_id downwards.
func collectProceedingSubtree(all []BuilderProceeding, rootID uuid.UUID) map[uuid.UUID]bool {
	set := map[uuid.UUID]bool{rootID: true}
	// Iterate to a fixpoint; depth is tiny (<=2 today) so a few passes suffice.
	for changed := true; changed; {
		changed = false
		for i := range all {
			p := &all[i]
			if p.ParentScenarioProceedingID != nil && set[*p.ParentScenarioProceedingID] && !set[p.ID] {
				set[p.ID] = true
				changed = true
			}
		}
	}
	return set
}

// promoteStichtag picks the calc anchor for the promote timeline: the
// primary proceeding's own stichtag, else the scenario default, else today.
func promoteStichtag(primary *BuilderProceeding, sc *BuilderScenario) string {
	if primary.Stichtag != nil {
		return primary.Stichtag.Format("2006-01-02")
	}
	if sc.Stichtag != nil {
		return sc.Stichtag.Format("2006-01-02")
	}
	return time.Now().UTC().Format("2006-01-02")
}

// scenarioFlagsTruthyKeys returns the flag keys set to boolean true in the
// builder's scenario_flags jsonb — the array shape the calc engine's
// CalcOptions.Flags consumes.
func scenarioFlagsTruthyKeys(raw json.RawMessage) []string {
	if len(raw) == 0 {
		return nil
	}
	var m map[string]any
	if err := json.Unmarshal(raw, &m); err != nil {
		return nil
	}
	out := []string{}
	for k, v := range m {
		if b, ok := v.(bool); ok && b {
			out = append(out, k)
		}
	}
	return out
}

// stringPtrOrNil normalises a *string so an all-whitespace value becomes
// nil before nullableTrimmed sees it (case_number empty → NULL column).
func stringPtrOrNil(p *string) *string {
	if p == nil {
		return nil
	}
	if strings.TrimSpace(*p) == "" {
		return nil
	}
	return p
}

// -----------------------------------------------------------------------------
// Internal helpers
// -----------------------------------------------------------------------------

func (s *ScenarioBuilderService) getScenarioRow(ctx context.Context, scenarioID uuid.UUID) (*BuilderScenario, error) {
	var out BuilderScenario
	err := s.db.GetContext(ctx, &out,
		`SELECT id, owner_id, name, status, origin_project_id, promoted_project_id,
		        stichtag, notes,
		        project_id, description, created_by,
		        created_at, updated_at
		   FROM paliad.scenarios
		  WHERE id = $1`, scenarioID)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, fmt.Errorf("%w: scenario %s not found", ErrNotVisible, scenarioID)
	}
	if err != nil {
		return nil, fmt.Errorf("get scenario: %w", err)
	}
	return &out, nil
}

func (s *ScenarioBuilderService) getProceedingRow(ctx context.Context, scenarioID, proceedingID uuid.UUID) (*BuilderProceeding, error) {
	var out BuilderProceeding
	err := s.db.GetContext(ctx, &out,
		`SELECT id, scenario_id, proceeding_type_id, primary_party, scenario_flags,
		        parent_scenario_proceeding_id, spawn_anchor_event_id, ordinal,
		        stichtag, detailgrad, appeal_target, collapsed,
		        created_at, updated_at
		   FROM paliad.scenario_proceedings
		  WHERE id = $1 AND scenario_id = $2`, proceedingID, scenarioID)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, fmt.Errorf("%w: proceeding %s not in scenario %s",
			ErrNotVisible, proceedingID, scenarioID)
	}
	if err != nil {
		return nil, fmt.Errorf("get proceeding: %w", err)
	}
	return &out, nil
}

func (s *ScenarioBuilderService) getEventRow(ctx context.Context, eventID uuid.UUID) (*BuilderEvent, error) {
	var out BuilderEvent
	err := s.db.GetContext(ctx, &out,
		`SELECT id, scenario_proceeding_id, sequencing_rule_id, procedural_event_id,
		        custom_label, state, actual_date, skip_reason, notes,
		        horizon_optional, created_at, updated_at
		   FROM paliad.scenario_events
		  WHERE id = $1`, eventID)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, fmt.Errorf("%w: event %s not found", ErrNotVisible, eventID)
	}
	if err != nil {
		return nil, fmt.Errorf("get event: %w", err)
	}
	return &out, nil
}

func (s *ScenarioBuilderService) assertProceedingInScenario(ctx context.Context, scenarioID, proceedingID uuid.UUID) error {
	var exists bool
	if err := s.db.GetContext(ctx, &exists,
		`SELECT EXISTS (SELECT 1 FROM paliad.scenario_proceedings
		                 WHERE id = $1 AND scenario_id = $2)`,
		proceedingID, scenarioID); err != nil {
		return fmt.Errorf("check proceeding membership: %w", err)
	}
	if !exists {
		return fmt.Errorf("%w: proceeding %s not in scenario %s",
			ErrNotVisible, proceedingID, scenarioID)
	}
	return nil
}

func (s *ScenarioBuilderService) assertEventInScenario(ctx context.Context, scenarioID, eventID uuid.UUID) error {
	var exists bool
	if err := s.db.GetContext(ctx, &exists,
		`SELECT EXISTS (
		    SELECT 1 FROM paliad.scenario_events e
		      JOIN paliad.scenario_proceedings sp ON sp.id = e.scenario_proceeding_id
		     WHERE e.id = $1 AND sp.scenario_id = $2
		 )`,
		eventID, scenarioID); err != nil {
		return fmt.Errorf("check event membership: %w", err)
	}
	if !exists {
		return fmt.Errorf("%w: event %s not in scenario %s",
			ErrNotVisible, eventID, scenarioID)
	}
	return nil
}

// canSeeScenario mirrors the SQL paliad.can_see_scenario(...) function in
// Go. The service connection bypasses RLS, so this check is the
// authoritative gate.
func (s *ScenarioBuilderService) canSeeScenario(ctx context.Context, userID uuid.UUID, sc *BuilderScenario) (bool, error) {
	// owner — fast path
	if sc.OwnerID != nil && *sc.OwnerID == userID {
		return true, nil
	}
	// global_admin
	var isAdmin bool
	if err := s.db.GetContext(ctx, &isAdmin,
		`SELECT EXISTS (SELECT 1 FROM paliad.users WHERE id = $1 AND global_role = 'global_admin')`,
		userID); err != nil {
		return false, fmt.Errorf("check global_admin: %w", err)
	}
	if isAdmin {
		return true, nil
	}
	// share recipient
	var shared bool
	if err := s.db.GetContext(ctx, &shared,
		`SELECT EXISTS (SELECT 1 FROM paliad.scenario_shares
		                 WHERE scenario_id = $1 AND shared_with_user_id = $2)`,
		sc.ID, userID); err != nil {
		return false, fmt.Errorf("check share: %w", err)
	}
	if shared {
		return true, nil
	}
	// legacy project-scoped — visible via project team membership
	if sc.OwnerID == nil && sc.LegacyProjectID != nil {
		var ok bool
		if err := s.db.GetContext(ctx, &ok,
			`SELECT paliad.can_see_project($1::uuid)`,
			*sc.LegacyProjectID); err == nil && ok {
			return true, nil
		}
	}
	// legacy abstract — owner-only via created_by
	if sc.OwnerID == nil && sc.LegacyProjectID == nil && sc.LegacyCreatedBy != nil &&
		*sc.LegacyCreatedBy == userID {
		return true, nil
	}
	return false, nil
}

// requireOwnerOrLegacyEditor fetches the scenario and validates that the
// caller has write rights. Returns the loaded row for downstream use.
func (s *ScenarioBuilderService) requireOwnerOrLegacyEditor(ctx context.Context, userID, scenarioID uuid.UUID) (*BuilderScenario, error) {
	sc, err := s.getScenarioRow(ctx, scenarioID)
	if err != nil {
		return nil, err
	}
	// owner
	if sc.OwnerID != nil && *sc.OwnerID == userID {
		return sc, nil
	}
	// legacy project-scoped editor
	if sc.OwnerID == nil && sc.LegacyProjectID != nil {
		var ok bool
		if err := s.db.GetContext(ctx, &ok,
			`SELECT paliad.can_see_project($1::uuid)`,
			*sc.LegacyProjectID); err == nil && ok {
			return sc, nil
		}
	}
	// legacy abstract creator
	if sc.OwnerID == nil && sc.LegacyProjectID == nil && sc.LegacyCreatedBy != nil &&
		*sc.LegacyCreatedBy == userID {
		return sc, nil
	}
	return nil, ErrScenarioBuilderNotVisible
}

// -----------------------------------------------------------------------------
// Akte mode — project-backed scenarios (B4, t-paliad-347)
// -----------------------------------------------------------------------------

// CreateScenarioFromProject builds a fresh project-backed scenario from
// a paliad.projects row: the scenario's origin_project_id points at the
// project, one top-level proceeding mirrors the project's
// proceeding_type_id + our_side + scenario_flags, and every existing
// paliad.deadlines row with a sequencing_rule_id surfaces as a
// scenario_events row (state='filed' when the deadline is completed,
// 'planned' otherwise).
//
// The scenario is the canvas view-state; paliad.projects.scenario_flags
// + paliad.deadlines remain the SSoT for project-bound actuals (PRD
// §2.3 + §10). Subsequent PatchProceeding / PatchEvent calls on this
// scenario route their writes through to those SSoT tables via the
// dual-write hooks below.
//
// Visibility: the caller must be able to see the project; the project's
// type must be 'case' (it's the proceeding-bearing project rung) and
// must have a proceeding_type_id set (otherwise there's nothing to seed
// the builder with). Returns ErrInvalidInput when those preconditions
// don't hold.
func (s *ScenarioBuilderService) CreateScenarioFromProject(ctx context.Context, userID, projectID uuid.UUID) (*BuilderScenarioDeep, error) {
	if s.projects == nil {
		return nil, fmt.Errorf("%w: project service not wired", ErrInvalidInput)
	}
	proj, err := s.projects.GetByID(ctx, userID, projectID)
	if err != nil {
		return nil, err
	}
	if proj == nil {
		return nil, ErrNotVisible
	}
	if proj.ProceedingTypeID == nil || *proj.ProceedingTypeID <= 0 {
		return nil, fmt.Errorf("%w: project %s has no proceeding_type_id — Akte-mode requires one", ErrInvalidInput, projectID)
	}

	// Read the project's persisted scenario_flags. The column is jsonb
	// NOT NULL DEFAULT '{}' (mig 154) so an empty map is always safe.
	var rawFlags []byte
	if err := s.db.GetContext(ctx, &rawFlags,
		`SELECT scenario_flags FROM paliad.projects WHERE id = $1`, projectID); err != nil {
		return nil, fmt.Errorf("read project scenario_flags: %w", err)
	}
	if len(rawFlags) == 0 {
		rawFlags = []byte(`{}`)
	}

	// Pull every active+published sequencing_rule deadline row on the
	// project so the canvas can render filed/planned actuals as event
	// cards from first paint. CCR sub-projects are reached separately
	// when the user toggles with_ccr; the seed only covers the addressed
	// project's deadlines.
	type deadlineRow struct {
		ID               uuid.UUID  `db:"id"`
		SequencingRuleID *uuid.UUID `db:"sequencing_rule_id"`
		Status           string     `db:"status"`
		DueDate          time.Time  `db:"due_date"`
		CompletedAt      *time.Time `db:"completed_at"`
	}
	var deadlines []deadlineRow
	if err := s.db.SelectContext(ctx, &deadlines,
		`SELECT id, sequencing_rule_id, status, due_date, completed_at
		   FROM paliad.deadlines
		  WHERE project_id = $1 AND sequencing_rule_id IS NOT NULL`,
		projectID); err != nil {
		return nil, fmt.Errorf("read project deadlines: %w", err)
	}

	// Derive the builder-side primary_party from the project's
	// our_side. The Project.OurSide column accepts the wider sub-role
	// set (claimant / applicant / appellant; defendant / respondent;
	// third_party / other) but the builder triplet has a binary
	// claimant|defendant axis per PRD §3.3 — fold the wider set down,
	// drop third_party / other to NULL (no perspective preselected).
	primaryParty := mapProjectOurSideToTripletParty(proj.OurSide)

	name := strings.TrimSpace(proj.Title)
	if name == "" {
		name = "Akte"
	}

	deep := &BuilderScenarioDeep{
		Proceedings: []BuilderProceeding{},
		Events:      []BuilderEvent{},
		Shares:      []BuilderShare{},
	}

	err = s.withAuditTx(ctx, "scenario_builder: create from project", func(tx *sqlx.Tx) error {
		// 1. Insert the scenario header. origin_project_id pins the
		//    Akte link; promotion later overwrites promoted_project_id
		//    independently.
		if err := tx.GetContext(ctx, &deep.BuilderScenario,
			`INSERT INTO paliad.scenarios
			    (owner_id, name, status, origin_project_id)
			 VALUES ($1, $2, 'active', $3)
			 RETURNING id, owner_id, name, status, origin_project_id, promoted_project_id,
			           stichtag, notes,
			           project_id, description, created_by,
			           created_at, updated_at`,
			userID, name, projectID); err != nil {
			return fmt.Errorf("insert scenario row: %w", err)
		}

		// 2. Insert one top-level proceeding mirroring the project's
		//    procedural shape + flags. scenario_flags is copied
		//    verbatim from the project — subsequent toggles on the
		//    builder propagate back via PatchProceeding's dual-write.
		var proc BuilderProceeding
		if err := tx.GetContext(ctx, &proc,
			`INSERT INTO paliad.scenario_proceedings
			    (scenario_id, proceeding_type_id, primary_party, scenario_flags, ordinal, detailgrad)
			 VALUES ($1, $2, $3, $4::jsonb, 0, 'selected')
			 RETURNING id, scenario_id, proceeding_type_id, primary_party, scenario_flags,
			           parent_scenario_proceeding_id, spawn_anchor_event_id, ordinal,
			           stichtag, detailgrad, appeal_target, collapsed,
			           created_at, updated_at`,
			deep.BuilderScenario.ID, *proj.ProceedingTypeID, primaryParty, rawFlags); err != nil {
			return fmt.Errorf("insert seed proceeding: %w", err)
		}
		deep.Proceedings = append(deep.Proceedings, proc)

		// 3. One scenario_events row per project deadline. Filed
		//    deadlines render with state='filed' + actual_date =
		//    completed_at (falling back to due_date when the column
		//    was never set). Pending / approved deadlines render
		//    planned. Skipped is not derivable from the deadline row
		//    shape; users mark skip on the canvas via PatchEvent.
		for _, d := range deadlines {
			state := "planned"
			var actualDate *time.Time
			if d.Status == "completed" {
				state = "filed"
				if d.CompletedAt != nil {
					actualDate = d.CompletedAt
				} else {
					due := d.DueDate
					actualDate = &due
				}
			}
			var ev BuilderEvent
			if err := tx.GetContext(ctx, &ev,
				`INSERT INTO paliad.scenario_events
				    (scenario_proceeding_id, sequencing_rule_id, state, actual_date)
				 VALUES ($1, $2, $3, $4)
				 RETURNING id, scenario_proceeding_id, sequencing_rule_id, procedural_event_id,
				           custom_label, state, actual_date, skip_reason, notes,
				           horizon_optional, created_at, updated_at`,
				proc.ID, *d.SequencingRuleID, state, actualDate); err != nil {
				return fmt.Errorf("insert seed event: %w", err)
			}
			deep.Events = append(deep.Events, ev)
		}
		return nil
	})
	if err != nil {
		return nil, fmt.Errorf("create scenario from project: %w", err)
	}
	return deep, nil
}

// mapProjectOurSideToTripletParty folds paliad.projects.our_side (which
// allows the wider claimant/applicant/appellant + defendant/respondent
// + third_party/other set, mig 112) down to the builder triplet's
// binary claimant|defendant axis (PRD §3.3). Returns nil when the
// project hasn't picked a side or the role doesn't map (third_party /
// other) — the canvas shows both columns equally in that case.
func mapProjectOurSideToTripletParty(side *string) *string {
	if side == nil {
		return nil
	}
	switch *side {
	case "claimant", "applicant", "appellant":
		s := "claimant"
		return &s
	case "defendant", "respondent":
		s := "defendant"
		return &s
	}
	return nil
}

// withAuditTx opens a transaction, stamps paliad.audit_reason via
// set_config(..., true) so the reason persists for the duration of the
// tx (matching the mig-079 audit-trigger pattern used by event_choice_
// service.go), invokes fn, and commits. Any error returned by fn rolls
// back. The audit reason is appended with the task slug so audit-log
// readers can trace writes back to t-paliad-340.
func (s *ScenarioBuilderService) withAuditTx(ctx context.Context, reason string, fn func(tx *sqlx.Tx) error) error {
	tx, err := s.db.BeginTxx(ctx, nil)
	if err != nil {
		return fmt.Errorf("begin tx: %w", err)
	}
	defer func() { _ = tx.Rollback() }()

	if _, err := tx.ExecContext(ctx,
		`SELECT set_config('paliad.audit_reason', $1, true)`,
		fmt.Sprintf("%s (t-paliad-340)", reason)); err != nil {
		return fmt.Errorf("set audit_reason: %w", err)
	}
	if err := fn(tx); err != nil {
		return err
	}
	if err := tx.Commit(); err != nil {
		return fmt.Errorf("commit: %w", err)
	}
	return nil
}
