package services

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"sort"
	"strings"
	"time"

	"github.com/google/uuid"

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


// FristenrechnerService renders the Paliad public Fristenrechner's
// response shape from DB-stored rules. Post-Slice-A (t-paliad-298) it
// is a thin adapter: the compute engine + types live in
// pkg/litigationplanner, and FristenrechnerService just wires the
// Postgres-backed Catalog + HolidayCalendar + CourtRegistry
// implementations and delegates Calculate / CalculateRule across the
// boundary.
//
// The package owns the wire shape (Timeline / TimelineEntry); paliad's
// historical aliases (UIResponse / UIDeadline) keep call-sites
// unchanged.
type FristenrechnerService struct {
	rules    *DeadlineRuleService
	holidays *HolidayService
	courts   *CourtService

	catalog lp.Catalog
}

// NewFristenrechnerService wires the service to its dependencies.
func NewFristenrechnerService(rules *DeadlineRuleService, holidays *HolidayService, courts *CourtService) *FristenrechnerService {
	s := &FristenrechnerService{rules: rules, holidays: holidays, courts: courts}
	s.catalog = &paliadCatalog{rules: rules}
	return s
}

// Type aliases keep call-sites byte-identical with the pre-Slice-A
// shape. The wire JSON tags are owned by the package.
// (AdjustmentReason + HolidayDTO are aliased in holidays.go.)
type (
	UIResponse                = lp.Timeline
	UIDeadline                = lp.TimelineEntry
	CalcOptions               = lp.CalcOptions
	CalcRuleParams            = lp.CalcRuleParams
	RuleCalculation           = lp.RuleCalculation
	RuleCalculationRule       = lp.RuleCalculationRule
	RuleCalculationProceeding = lp.RuleCalculationProceeding
	SubTrackRouting           = lp.SubTrackRouting
)

// Sentinel errors. Re-exported as package-level vars so handlers that
// errors.Is(..., services.ErrUnknownProceedingType) continue to work.
var (
	ErrUnknownProceedingType = lp.ErrUnknownProceedingType
	ErrUnknownRule           = lp.ErrUnknownRule
)

// Calculate delegates to litigationplanner.Calculate with paliad's
// Postgres-backed Catalog / HolidayCalendar / CourtRegistry implementations.
func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, triggerDateStr string, opts CalcOptions) (*UIResponse, error) {
	return lp.Calculate(ctx, proceedingCode, triggerDateStr, opts, s.catalog, s.holidays, s.courts)
}

// CalculateRule delegates to litigationplanner.CalculateRule. Distinct
// from Calculate: no parent-chain walk, no full-timeline rendering —
// just one date out.
func (s *FristenrechnerService) CalculateRule(ctx context.Context, params CalcRuleParams) (*RuleCalculation, error) {
	return lp.CalculateRule(ctx, params, s.catalog, s.holidays, s.courts)
}

// ListFristenrechnerTypes returns the proceeding types that populate
// the Fristenrechner UI (category='fristenrechner'), ordered by
// sort_order. Stays on the service because the response is a paliad-
// specific surface (the wire shape FristenrechnerType is owned by the
// package but the SQL filter is paliad-side).
func (s *FristenrechnerService) ListFristenrechnerTypes(ctx context.Context) ([]lp.FristenrechnerType, error) {
	return s.ListProceedings(ctx, ProceedingListOptions{})
}

// ProceedingListOptions narrows ListProceedings. Empty values = no
// filter on that axis. Added for the Fristenrechner overhaul S3
// (m/paliad#146): Mode A's "Verfahren" filter chip strip needs to scope
// the proceeding pool by the user's Forum pick (jurisdiction) and by
// kind='proceeding' to exclude the phase / side_action / meta rows
// landed in the taxonomy cleanup (m/paliad#147, mig 153).
type ProceedingListOptions struct {
	// Jurisdiction narrows to one jurisdiction code (UPC / DE / EPA /
	// DPMA). Empty = any.
	Jurisdiction string
	// Kind narrows to one structural kind (proceeding / phase /
	// side_action / meta). Mode A passes "proceeding" to exclude the
	// phase / side_action / meta rows from the chip strip. Empty = any.
	//
	// Filter referenced before mig 153 lands the column → callers
	// pre-mig get a "column kind does not exist" error from Postgres.
	// Sequenced per docs/design-proceeding-types-taxonomy-2026-05-26.md
	// §7 option (c): mig 153 merges to main before the S3 PR ships.
	Kind string
	// EventKind narrows to proceedings that have at least one published
	// sequencing rule anchored on a procedural event of the requested
	// kind ("filing" | "hearing" | "decision" | "order"). Powers the
	// Fristenrechner overhaul Mode B R3 wizard row (§3.2): after R1
	// picks an event_kind, R3 should only chip proceedings whose event
	// roster contains at least one event of that kind. Empty = no
	// event-kind narrowing.
	EventKind string
}

// ListProceedings returns the proceeding_types chips the Fristenrechner
// overhaul Mode A renders in its filter strip. Filters apply
// progressively: pre-mig 153 Kind=="" is the safe default; post-mig 153
// Mode A passes Kind="proceeding" to exclude the phase / side_action /
// meta rows.
func (s *FristenrechnerService) ListProceedings(ctx context.Context, opts ProceedingListOptions) ([]lp.FristenrechnerType, error) {
	where := []string{
		"category = 'fristenrechner'",
		"is_active = true",
	}
	args := []any{}
	add := func(clause string, val any) {
		args = append(args, val)
		where = append(where, fmt.Sprintf(clause, len(args)))
	}
	if opts.Jurisdiction != "" {
		add("jurisdiction = $%d", opts.Jurisdiction)
	}
	if opts.Kind != "" {
		add("kind = $%d", opts.Kind)
	}
	if opts.EventKind != "" {
		add(`EXISTS (
			SELECT 1 FROM paliad.sequencing_rules sr
			JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
			WHERE sr.proceeding_type_id = paliad.proceeding_types.id
			  AND sr.is_active = true AND sr.lifecycle_state = 'published'
			  AND pe.is_active = true AND pe.lifecycle_state = 'published'
			  AND pe.event_kind = $%d
		)`, opts.EventKind)
	}
	query := `SELECT id, code, name, name_en, jurisdiction
		FROM paliad.proceeding_types
		WHERE ` + strings.Join(where, " AND ") + `
		ORDER BY sort_order`

	rows, err := s.rules.db.QueryxContext(ctx, query, args...)
	if err != nil {
		return nil, fmt.Errorf("list proceedings: %w", err)
	}
	defer rows.Close()

	var out []lp.FristenrechnerType
	for rows.Next() {
		var t lp.FristenrechnerType
		var juris sql.NullString
		if err := rows.Scan(&t.ID, &t.Code, &t.Name, &t.NameEN, &juris); err != nil {
			return nil, err
		}
		if juris.Valid {
			t.Group = juris.String
		}
		out = append(out, t)
	}
	return out, rows.Err()
}

// FristenrechnerType is paliad's local alias for lp.FristenrechnerType
// so historical call-sites (services.FristenrechnerType) keep working.
type FristenrechnerType = lp.FristenrechnerType

// ---------------------------------------------------------------------
// paliadCatalog is the paliad-side litigationplanner.Catalog adapter.
// Wraps DeadlineRuleService to expose proceeding + rule lookups against
// paliad.proceeding_types + paliad.deadline_rules.
// ---------------------------------------------------------------------

type paliadCatalog struct {
	rules *DeadlineRuleService
}

// proceedingTypeColumns is canonically defined in
// deadline_rule_service.go; the catalog adapter reuses it via the
// shared package-level const.

// LoadProceeding returns the proceeding-type metadata + rules. The
// ProjectHint is currently ignored on paliad's side (per m's 2026-05-26
// decision dropping the Slice E user-authored rules); kept on the
// interface for forward-compat.
func (c *paliadCatalog) LoadProceeding(ctx context.Context, code string, _ lp.ProjectHint) (*models.ProceedingType, []models.DeadlineRule, error) {
	var pt models.ProceedingType
	err := c.rules.db.GetContext(ctx, &pt,
		`SELECT `+proceedingTypeColumns+`
		   FROM paliad.proceeding_types
		  WHERE code = $1 AND is_active = true`, code)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, nil, lp.ErrUnknownProceedingType
	}
	if err != nil {
		return nil, nil, fmt.Errorf("resolve proceeding %q: %w", code, err)
	}
	rules, err := c.rules.List(ctx, &pt.ID)
	if err != nil {
		return nil, nil, err
	}
	return &pt, rules, nil
}

// LoadProceedingByID is the resolver for a rule's parent proceeding.
func (c *paliadCatalog) LoadProceedingByID(ctx context.Context, id int) (*models.ProceedingType, error) {
	var pt models.ProceedingType
	err := c.rules.db.GetContext(ctx, &pt,
		`SELECT `+proceedingTypeColumns+`
		   FROM paliad.proceeding_types
		  WHERE id = $1`, id)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, lp.ErrUnknownProceedingType
	}
	if err != nil {
		return nil, fmt.Errorf("resolve proceeding by id %d: %w", id, err)
	}
	return &pt, nil
}

// LoadRuleByID resolves a rule UUID to the rule row.
func (c *paliadCatalog) LoadRuleByID(ctx context.Context, ruleID string) (*models.DeadlineRule, error) {
	var rule models.DeadlineRule
	err := c.rules.db.GetContext(ctx, &rule,
		`SELECT `+ruleColumns+`
		   FROM paliad.deadline_rules_unified
		  WHERE id = $1 AND is_active = true`, ruleID)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, lp.ErrUnknownRule
	}
	if err != nil {
		return nil, fmt.Errorf("resolve rule by id %q: %w", ruleID, err)
	}
	if err := c.rules.hydrateConceptDefaultEventTypes(ctx, []models.DeadlineRule{rule}); err != nil {
		return nil, err
	}
	return &rule, nil
}

// LoadRuleByCode resolves a rule by (proceedingCode, submissionCode)
// + returns the parent proceeding for use in the response identity.
func (c *paliadCatalog) LoadRuleByCode(ctx context.Context, proceedingCode, submissionCode string) (*models.DeadlineRule, *models.ProceedingType, error) {
	var pt models.ProceedingType
	err := c.rules.db.GetContext(ctx, &pt,
		`SELECT `+proceedingTypeColumns+`
		   FROM paliad.proceeding_types
		  WHERE code = $1 AND is_active = true`, proceedingCode)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, nil, lp.ErrUnknownProceedingType
	}
	if err != nil {
		return nil, nil, fmt.Errorf("resolve proceeding %q: %w", proceedingCode, err)
	}
	var rule models.DeadlineRule
	err = c.rules.db.GetContext(ctx, &rule,
		`SELECT `+ruleColumns+`
		   FROM paliad.deadline_rules_unified
		  WHERE proceeding_type_id = $1 AND submission_code = $2 AND is_active = true`,
		pt.ID, submissionCode)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, nil, lp.ErrUnknownRule
	}
	if err != nil {
		return nil, nil, fmt.Errorf("resolve rule %q in %q: %w", submissionCode, proceedingCode, err)
	}
	if err := c.rules.hydrateConceptDefaultEventTypes(ctx, []models.DeadlineRule{rule}); err != nil {
		return nil, nil, err
	}
	return &rule, &pt, nil
}

// LoadRulesByTriggerEvent lists Pipeline-C trigger-event-rooted rules.
func (c *paliadCatalog) LoadRulesByTriggerEvent(ctx context.Context, triggerEventID int64) ([]models.DeadlineRule, error) {
	return c.rules.ListByTriggerEvent(ctx, triggerEventID)
}

// LoadTriggerEventsByIDs bulk-loads paliad.trigger_events rows for
// the conditional-label override (t-paliad-294 / m/paliad#126).
func (c *paliadCatalog) LoadTriggerEventsByIDs(ctx context.Context, ids []int64) (map[int64]models.TriggerEvent, error) {
	return c.rules.LoadTriggerEventsByIDs(ctx, ids)
}

// LookupEvents queries paliad.deadline_rules for rules matching the
// requested axes, then walks the parent_id graph in Go to honour the
// requested depth. Slice B2 (m/paliad#124 §18.2).
//
// Filter axes apply at the SQL layer:
//   - Jurisdiction: WHERE paliad.proceeding_types.jurisdiction = $X
//   - ProceedingTypeID: WHERE deadline_rules.proceeding_type_id = $X
//   - Party: WHERE deadline_rules.primary_party = $X
//   - EventCategoryID: EXISTS subquery on
//     paliad.event_category_concepts joined via concept_id
//   - AppealTarget: WHERE $X = ANY(deadline_rules.applies_to_target)
//
// Depth is applied post-fetch: for EventLookupDepthNext, anchor rules
// (matched directly) are returned at depth=1 + their immediate
// children (parent_id IN matched-set) at depth=2. For
// EventLookupDepthAllFollowing, the parent_id walk continues
// recursively. The walk stays within the per-proceeding rule set
// (cross-proceeding spawn following is handled by the engine, not by
// LookupEvents).
//
// "published + active" gate: lifecycle_state='published' AND
// is_active=true (matches LoadProceeding's WHERE clause).
func (c *paliadCatalog) LookupEvents(ctx context.Context, axes lp.EventLookupAxes, depth lp.EventLookupDepth) ([]lp.EventMatch, error) {
	// Validate axis values up front; unknown values fall through as
	// "no filter on this axis" so a stale frontend chip doesn't
	// silently drop the entire result set.
	jurisdiction := axes.Jurisdiction
	if jurisdiction != "" && jurisdiction != "UPC" && jurisdiction != "DE" &&
		jurisdiction != "EPA" && jurisdiction != "DPMA" {
		jurisdiction = ""
	}
	party := axes.Party
	if party != "" && !lp.IsValidPrimaryParty(party) {
		party = ""
	}
	appealTarget := axes.AppealTarget
	if appealTarget != "" && !lp.IsValidAppealTarget(appealTarget) {
		appealTarget = ""
	}

	// Build the WHERE clause progressively. Each axis adds a $N
	// placeholder + appends to the args slice.
	where := []string{
		"dr.is_active = true",
		"dr.lifecycle_state = 'published'",
		"pt.is_active = true",
	}
	args := []any{}
	add := func(clause string, val any) {
		args = append(args, val)
		where = append(where, fmt.Sprintf(clause, len(args)))
	}
	if jurisdiction != "" {
		add("pt.jurisdiction = $%d", jurisdiction)
	}
	if axes.ProceedingTypeID != nil {
		add("dr.proceeding_type_id = $%d", *axes.ProceedingTypeID)
	}
	if party != "" {
		add("dr.primary_party = $%d", party)
	}
	if axes.EventCategoryID != nil {
		// Junction-table EXISTS: the rule's concept_id must appear in
		// paliad.event_category_concepts with the matching
		// event_category_id.
		add(`EXISTS (
			SELECT 1 FROM paliad.event_category_concepts ecc
			 WHERE ecc.event_category_id = $%d
			   AND ecc.concept_id = dr.concept_id
		)`, *axes.EventCategoryID)
	}
	if appealTarget != "" {
		add("$%d = ANY(dr.applies_to_target)", appealTarget)
	}

	query := `
		SELECT ` + ruleColumns + `,
		       pt.id AS pt_id, pt.code AS pt_code, pt.name AS pt_name,
		       pt.name_en AS pt_name_en, pt.description AS pt_description,
		       pt.jurisdiction AS pt_jurisdiction, pt.category AS pt_category,
		       pt.default_color AS pt_default_color, pt.sort_order AS pt_sort_order,
		       pt.is_active AS pt_is_active,
		       pt.trigger_event_label_de AS pt_trigger_event_label_de,
		       pt.trigger_event_label_en AS pt_trigger_event_label_en,
		       pt.appeal_target AS pt_appeal_target
		  FROM paliad.deadline_rules_unified dr
		  JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
		 WHERE ` + strings.Join(where, "\n   AND ") + `
		 ORDER BY dr.proceeding_type_id, dr.sequence_order`

	var rows []lookupEventsRow
	if err := c.rules.db.SelectContext(ctx, &rows, query, args...); err != nil {
		return nil, fmt.Errorf("lookup events: %w", err)
	}

	if len(rows) == 0 {
		return []lp.EventMatch{}, nil
	}

	// matchedIDs is the set of rule IDs that satisfied the axes (the
	// "anchor" matches at depth=1). For EventLookupDepthNext we add
	// their direct children. For EventLookupDepthAllFollowing we walk
	// the parent_id chain transitively.
	matchedIDs := make(map[uuid.UUID]bool, len(rows))
	anchorMatch := make(map[uuid.UUID]bool, len(rows))
	rowByID := make(map[uuid.UUID]lookupEventsRow, len(rows))
	for _, r := range rows {
		matchedIDs[r.ID] = true
		anchorMatch[r.ID] = true
		rowByID[r.ID] = r
	}

	// For depth control we need the full per-proceeding rule corpus
	// (so we can find children whose parent_id ∈ matchedIDs even when
	// those children don't match the axes themselves). Skip this when
	// depth is empty (treated as "anchors only" — undocumented but
	// useful as a degenerate case).
	expandFromCorpus := func(corpus []models.DeadlineRule, joinedFor map[int]lookupEventsRow) {
		// We loop until no new descendants are added (transitive
		// closure under parent_id ∈ matchedIDs). EventLookupDepthNext
		// stops after one pass; AllFollowing iterates to fixpoint.
		for {
			grew := false
			for _, r := range corpus {
				if r.ParentID == nil {
					continue
				}
				if !matchedIDs[*r.ParentID] {
					continue
				}
				if matchedIDs[r.ID] {
					continue
				}
				matchedIDs[r.ID] = true
				j := joinedFor[*r.ProceedingTypeID]
				rowByID[r.ID] = lookupEventsRow{
					DeadlineRule:          r,
					PTID:                  j.PTID,
					PTCode:                j.PTCode,
					PTName:                j.PTName,
					PTNameEN:              j.PTNameEN,
					PTDescription:         j.PTDescription,
					PTJurisdiction:        j.PTJurisdiction,
					PTCategory:            j.PTCategory,
					PTDefaultColor:        j.PTDefaultColor,
					PTSortOrder:           j.PTSortOrder,
					PTIsActive:            j.PTIsActive,
					PTTriggerEventLabelDE: j.PTTriggerEventLabelDE,
					PTTriggerEventLabelEN: j.PTTriggerEventLabelEN,
					PTAppealTarget:        j.PTAppealTarget,
				}
				grew = true
			}
			if !grew || depth == lp.EventLookupDepthNext {
				break
			}
		}
	}

	if depth == lp.EventLookupDepthNext || depth == lp.EventLookupDepthAllFollowing {
		// Load the proceeding-scoped corpus for every proceeding_type
		// that appeared in the anchor set. The walk needs full
		// visibility into each proceeding's rule tree so it can
		// resolve parent_id chains.
		procIDs := make(map[int]struct{})
		joinedFor := make(map[int]lookupEventsRow)
		for _, r := range rows {
			procIDs[r.PTID] = struct{}{}
			if _, ok := joinedFor[r.PTID]; !ok {
				joinedFor[r.PTID] = r
			}
		}
		for ptID := range procIDs {
			corpus, err := c.rules.List(ctx, &ptID)
			if err != nil {
				return nil, fmt.Errorf("lookup events: load proceeding %d corpus: %w", ptID, err)
			}
			expandFromCorpus(corpus, joinedFor)
		}
	}

	// depths[id] = sequence-depth from the closest anchor ancestor.
	// Anchors are depth=1; their direct children are depth=2; etc.
	depths := computeDepths(rowByID, anchorMatch)

	// Compose the result slice ordered by (PTID, sequence_order).
	type withKey struct {
		match lp.EventMatch
		key   int64
	}
	items := make([]withKey, 0, len(matchedIDs))
	for id := range matchedIDs {
		r := rowByID[id]
		var parentRuleID *uuid.UUID
		if r.ParentID != nil && matchedIDs[*r.ParentID] {
			p := *r.ParentID
			parentRuleID = &p
		}
		items = append(items, withKey{
			match: lp.EventMatch{
				Rule: r.DeadlineRule,
				ProceedingType: lp.ProceedingType{
					ID:                  r.PTID,
					Code:                r.PTCode,
					Name:                r.PTName,
					NameEN:              r.PTNameEN,
					Description:         r.PTDescription,
					Jurisdiction:        r.PTJurisdiction,
					Category:            r.PTCategory,
					DefaultColor:        r.PTDefaultColor,
					SortOrder:           r.PTSortOrder,
					IsActive:            r.PTIsActive,
					TriggerEventLabelDE: r.PTTriggerEventLabelDE,
					TriggerEventLabelEN: r.PTTriggerEventLabelEN,
					AppealTarget:        r.PTAppealTarget,
				},
				Priority:        r.Priority,
				DepthFromAnchor: depths[id],
				ParentRuleID:    parentRuleID,
			},
			key: int64(r.PTID)*1_000_000 + int64(r.SequenceOrder),
		})
	}
	sort.Slice(items, func(a, b int) bool { return items[a].key < items[b].key })
	out := make([]lp.EventMatch, len(items))
	for i, it := range items {
		out[i] = it.match
	}
	return out, nil
}

// lookupEventsRow is the joined SELECT shape for LookupEvents — one
// deadline_rules row plus its proceeding_types parent columns. Kept
// at package scope so computeDepths can reference it.
type lookupEventsRow struct {
	models.DeadlineRule
	PTID                  int     `db:"pt_id"`
	PTCode                string  `db:"pt_code"`
	PTName                string  `db:"pt_name"`
	PTNameEN              string  `db:"pt_name_en"`
	PTDescription         *string `db:"pt_description"`
	PTJurisdiction        *string `db:"pt_jurisdiction"`
	PTCategory            *string `db:"pt_category"`
	PTDefaultColor        string  `db:"pt_default_color"`
	PTSortOrder           int     `db:"pt_sort_order"`
	PTIsActive            bool    `db:"pt_is_active"`
	PTTriggerEventLabelDE *string `db:"pt_trigger_event_label_de"`
	PTTriggerEventLabelEN *string `db:"pt_trigger_event_label_en"`
	PTAppealTarget        *string `db:"pt_appeal_target"`
}

// computeDepths walks from each rule up the parent_id chain until it
// hits an anchor match (or runs out). The depth of the anchor is 1;
// each step away adds one. Rules whose entire chain has no anchor
// (defensive — shouldn't happen given the expand-from-corpus walk
// only adds children of matched parents) get depth=1.
//
// Iteration-bounded by the corpus size to prevent infinite loops on
// hypothetical parent_id cycles (mig 134 + the schema CHECKs already
// preclude cycles, but the bound is cheap insurance).
func computeDepths(
	rowByID map[uuid.UUID]lookupEventsRow,
	anchors map[uuid.UUID]bool,
) map[uuid.UUID]int {
	depths := make(map[uuid.UUID]int, len(rowByID))
	for id := range rowByID {
		if anchors[id] {
			depths[id] = 1
			continue
		}
		// Walk parents until we find an anchor or run out.
		d := 1
		cur := id
		maxIter := len(rowByID) + 1
		for i := 0; i < maxIter; i++ {
			r := rowByID[cur]
			if r.ParentID == nil {
				break
			}
			d++
			cur = *r.ParentID
			if anchors[cur] {
				break
			}
		}
		depths[id] = d
	}
	return depths
}

// LoadScenarios lists scenarios visible to the caller (Slice D,
// m/paliad#124 §5, mig 145). RLS on paliad.scenarios enforces:
// project-scoped rows require paliad.can_see_project(project_id);
// abstract rows require created_by = auth.uid(). The filter narrows
// the SELECT (project_id-bound, abstract-for-user, or all).
func (c *paliadCatalog) LoadScenarios(ctx context.Context, filter lp.ScenarioFilter) ([]lp.Scenario, error) {
	where := []string{}
	args := []any{}
	add := func(clause string, val any) {
		args = append(args, val)
		where = append(where, fmt.Sprintf(clause, len(args)))
	}
	if filter.ProjectID != nil {
		add("project_id = $%d", *filter.ProjectID)
	}
	if filter.AbstractForUser != nil {
		where = append(where, "project_id IS NULL")
		add("created_by = $%d", *filter.AbstractForUser)
	}
	query := `SELECT id, project_id, name, description, spec,
	                 created_by, created_at, updated_at
	            FROM paliad.scenarios`
	if len(where) > 0 {
		query += " WHERE " + strings.Join(where, " AND ")
	}
	query += " ORDER BY created_at DESC"

	var rows []lp.Scenario
	if err := c.rules.db.SelectContext(ctx, &rows, query, args...); err != nil {
		return nil, fmt.Errorf("load scenarios: %w", err)
	}
	return rows, nil
}

// MatchScenario returns the scenario with the given id, or
// lp.ErrUnknownScenario if not visible / not found. RLS gates
// visibility; a not-found result could mean "doesn't exist" OR
// "exists but you can't see it" — either way the caller treats it
// as unknown.
func (c *paliadCatalog) MatchScenario(ctx context.Context, id uuid.UUID) (*lp.Scenario, error) {
	var s lp.Scenario
	err := c.rules.db.GetContext(ctx, &s,
		`SELECT id, project_id, name, description, spec,
		        created_by, created_at, updated_at
		   FROM paliad.scenarios
		  WHERE id = $1`, id)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, lp.ErrUnknownScenario
	}
	if err != nil {
		return nil, fmt.Errorf("match scenario %q: %w", id, err)
	}
	return &s, nil
}

// _ proves paliadCatalog satisfies lp.Catalog at compile time.
var _ lp.Catalog = (*paliadCatalog)(nil)

// Ensure HolidayService satisfies lp.HolidayCalendar at compile time.
// HolidayService.AdjustForNonWorkingDaysWithReason returns the
// AdjustmentReason via paliad's internal type — since lp.AdjustmentReason
// is now the canonical definition and AdjustmentReason inside services
// is aliased to it, the signatures align verbatim.
var _ lp.HolidayCalendar = (*HolidayService)(nil)

// Ensure CourtService satisfies lp.CourtRegistry at compile time.
var _ lp.CourtRegistry = (*CourtService)(nil)

// ---------------------------------------------------------------------
// Helpers used by sibling services (event_trigger_service,
// event_deadline_service). Re-exported as thin wrappers so the existing
// call-sites in those services continue to compile without an import
// rewrite. A future slice can collapse them onto direct lp.* imports.
// ---------------------------------------------------------------------

// applyDuration delegates to litigationplanner.ApplyDuration.
func applyDuration(base time.Time, value int, unit, timing, country, regime string, holidays *HolidayService) (raw, adjusted time.Time, didAdjust bool, reason *AdjustmentReason) {
	return lp.ApplyDuration(base, value, unit, timing, country, regime, holidays)
}

// addWorkingDays delegates to litigationplanner.AddWorkingDays.
//
//nolint:unused // referenced for forward-compat with sibling services
func addWorkingDays(from time.Time, n int, country, regime string, holidays *HolidayService) time.Time {
	return lp.AddWorkingDays(from, n, country, regime, holidays)
}

// evalConditionExpr delegates to litigationplanner.EvalConditionExpr.
func evalConditionExpr(expr []byte, flags map[string]struct{}) bool {
	return lp.EvalConditionExpr(expr, flags)
}

// hasConditionExpr delegates to litigationplanner.HasConditionExpr.
func hasConditionExpr(expr models.NullableJSON) bool {
	return lp.HasConditionExpr(expr)
}

// extractFlagsFromExpr delegates to litigationplanner.ExtractFlagsFromExpr.
//
//nolint:unused // retained for sibling services that may want it
func extractFlagsFromExpr(expr models.NullableJSON) []string {
	return lp.ExtractFlagsFromExpr(expr)
}

// allFlagsSet delegates to litigationplanner.AllFlagsSet. Retained for
// the paliad-side test suite that asserts the helper's contract.
func allFlagsSet(required []string, set map[string]struct{}) bool {
	return lp.AllFlagsSet(required, set)
}

// wireFlagsFromPriority delegates to
// litigationplanner.WireFlagsFromPriority. Retained for the paliad-side
// test suite that asserts the priority → (isMandatory, isOptional)
// mapping.
func wireFlagsFromPriority(priority string) (isMandatory, isOptional bool) {
	return lp.WireFlagsFromPriority(priority)
}

// sortDeadlinesByDurationWithinTriggerGroup is the paliad-side wrapper
// retained for the t-paliad-296 sort tests. Delegates to the
// package-internal sort over the lp.TimelineEntry shape — which is
// just an alias for UIDeadline, so callers pass []UIDeadline directly.
func sortDeadlinesByDurationWithinTriggerGroup(
	deadlines []UIDeadline,
	ruleByID map[uuid.UUID]models.DeadlineRule,
) {
	lp.SortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
}

// DefaultsForJurisdiction delegates to
// litigationplanner.DefaultsForJurisdiction. Public re-export so
// handlers (deadline_rules_db.go) can keep using
// services.DefaultsForJurisdiction without an import-rewrite.
func DefaultsForJurisdiction(jurisdiction *string) (country, regime string) {
	return lp.DefaultsForJurisdiction(jurisdiction)
}

// applyRuleOverrides delegates to litigationplanner.ApplyRuleOverrides.
//
//nolint:unused // retained for sibling services that may want it
func applyRuleOverrides(src, overrides []models.DeadlineRule) []models.DeadlineRule {
	return lp.ApplyRuleOverrides(src, overrides)
}
