package services

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

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

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

// DeadlineSearchService backs the unified Fristenrechner search bar
// (t-paliad-131 Phase C). It reads from the paliad.deadline_search
// materialised view (migration 047) and groups hits per concept, so a
// single search returns one card per legal idea (Klageerwiderung,
// Wiedereinsetzung, …) with one pill per (proceeding × rule) or per
// trigger event under that concept.
//
// Two queries per request:
//   1. Rank concept_ids by trigram similarity against name / aliases /
//      legal_source / rule_code, applying optional party / proc / source
//      filters.
//   2. Fetch all matview rows for those concept_ids and assemble the
//      per-pill payload.
//
// v3 (t-paliad-133) extends the service to accept:
//   - EventCategorySlug: drives the B1 decision-tree narrowing. When
//     set, only concepts reachable from that taxonomy node (via the
//     paliad.event_category_concepts junction) appear in results.
//     An empty `q` is permitted when EventCategorySlug is set — the
//     tree alone is enough to produce a candidate concept set.
//   - Forums: a list of forum slugs from the v3 bucket map. Translated
//     to proceeding_type_codes by the search service; trigger-event
//     pills carry a structured legal_source citation (via mig 123)
//     and narrow by the per-forum legal-source prefix set instead of
//     by proceeding_code — see ForumToLegalSourcePrefixes. Before mig
//     123 trigger pills bypassed the forum filter unconditionally;
//     m/paliad#97 (t-paliad-266) requires the cross-cutting sub-rows
//     to narrow with the active court-system chip.
//
// See docs/plans/unified-fristenrechner.md §4.6 + §6 (v2) and
// docs/plans/unified-fristenrechner-v3.md §3.5 + §5.2 (v3).
type DeadlineSearchService struct {
	db          *sqlx.DB
	eventCategory *EventCategoryService
}

// NewDeadlineSearchService wires the service to its DB pool. The
// EventCategoryService dependency is optional — pass nil if the v3
// taxonomy isn't needed (legacy callers).
func NewDeadlineSearchService(db *sqlx.DB) *DeadlineSearchService {
	return &DeadlineSearchService{db: db}
}

// SetEventCategoryService injects the optional v3 event-category
// resolver. Wired by main.go after both services exist.
func (s *DeadlineSearchService) SetEventCategoryService(ec *EventCategoryService) {
	s.eventCategory = ec
}

// ForumToProceedingCodes maps the v3 forum buckets to proceeding_type
// codes. Lives here (rather than in the DB) because the bucket choice
// is presentation, not data — m can rebucket via code change without
// migration. m's spec lock §10 Q8 (2026-05-05): 10 buckets.
//
// Empty bucket slug = no narrowing.
var ForumToProceedingCodes = map[string][]string{
	"upc_cfi":    {CodeUPCInfringement, CodeUPCRevocation, CodeUPCCounterclaim, CodeUPCPreliminary, CodeUPCDamages, CodeUPCDiscovery, CodeUPCAppealOrder},
	"upc_coa":    {CodeUPCAppealMerits, CodeUPCAppealCost},
	"de_lg":      {CodeDEInfringementLG},
	"de_olg":     {CodeDEInfringementOLG},
	"de_bgh":     {CodeDEInfringementBGH, CodeDENullityBGH, CodeDPMAAppealBGH},
	"de_bpatg":   {CodeDENullityBPatG, CodeDPMAAppealBPatG},
	"epa_grant":  {CodeEPAGrant},
	"epa_opp":    {CodeEPAOpposition},
	"epa_appeal": {CodeEPAOppositionAppeal},
	"dpma":       {CodeDPMAOpposition},
}

// ForumToLegalSourcePrefixes maps the v3 forum buckets to the
// structured legal_source prefixes that cross-cutting trigger pills
// must match against (t-paliad-266 / m/paliad#97). Rule pills already
// narrow by proceeding_code via ForumToProceedingCodes; trigger pills
// have no proceeding context, so the narrowing key is the citation
// body itself.
//
// Mapping mirrors m's spec on the issue:
//
//   - UPC chips → UPC.*       (UPC RoP / UPC Agreement / UPC Statute)
//   - DE LG/OLG/BGH chips → DE.ZPO.*           (civil-procedure path)
//   - DE BPatG chip       → DE.PatG.*          (national patent path)
//   - DPMA chip           → DE.PatG.*          (national patent path)
//   - EPA chips           → EU.EPC* / EU.EPÜ*  (EPC / EPÜ citations)
//
// Two forums (de_bgh, de_bpatg) intentionally collapse: BGH hears
// both civil-patent and nullity appeals; PatG covers DPMA + BPatG
// patent jurisdiction. The matching SQL uses startsWith against the
// union of the active forums' prefixes, so a chip combination like
// "DPMA + de_bgh" surfaces every trigger whose legal_source starts
// with DE.PatG.* OR DE.ZPO.* — exactly the user's union expectation.
var ForumToLegalSourcePrefixes = map[string][]string{
	"upc_cfi":    {"UPC."},
	"upc_coa":    {"UPC."},
	"de_lg":      {"DE.ZPO."},
	"de_olg":     {"DE.ZPO."},
	"de_bgh":     {"DE.ZPO."},
	"de_bpatg":   {"DE.PatG."},
	"epa_grant":  {"EU.EPC", "EU.EPÜ"},
	"epa_opp":    {"EU.EPC", "EU.EPÜ"},
	"epa_appeal": {"EU.EPC", "EU.EPÜ"},
	"dpma":       {"DE.PatG."},
}

// SearchOptions carries the optional facet filters from the URL query
// string. Empty strings / empty slices mean "no filter on this facet".
type SearchOptions struct {
	Party    string
	Proc     string
	Source   string
	// v3 (t-paliad-133):
	EventCategorySlug string   // drives B1 decision-tree narrowing
	Forums            []string // multi-select forum buckets (UNION within)
	// v3 (t-paliad-134): explicit "browse everything" mode for B1 entry,
	// before the user has picked any tree node. Returns every concept
	// that is mapped to any leaf via paliad.event_category_concepts —
	// i.e. the full landscape of B1-reachable concepts. q must be empty
	// when BrowseAll is true; ignored otherwise.
	BrowseAll bool
	Limit    int
	MaxLimit int
}

// SearchFilters is the filter echo returned to the client. nil pointer
// means the facet wasn't filtered.
type SearchFilters struct {
	Party  *string `json:"party"`
	Proc   *string `json:"proc"`
	Source *string `json:"source"`
}

// SearchResponse is the JSON the API hands back. See §6.1.
type SearchResponse struct {
	Query      string         `json:"query"`
	Filters    SearchFilters  `json:"filters"`
	Cards      []ConceptCard  `json:"cards"`
	TotalCards int            `json:"total_cards"`
	TotalPills int            `json:"total_pills"`
}

// ConceptCard is one search hit — a concept plus its proceeding pills.
type ConceptCard struct {
	Concept        ConceptSummary `json:"concept"`
	MatchedAliases []string       `json:"matched_aliases,omitempty"`
	Score          float64        `json:"score"`
	Pills          []Pill         `json:"pills"`
}

// ConceptSummary is the concept payload inside a card.
type ConceptSummary struct {
	ID          string  `json:"id"`
	Slug        string  `json:"slug"`
	NameDE      string  `json:"name_de"`
	NameEN      string  `json:"name_en"`
	Description *string `json:"description,omitempty"`
	Party       *string `json:"party,omitempty"`
	Category    string  `json:"category"`
}

// PillProceeding describes the proceeding context of a rule pill. nil
// for trigger pills (cross-cutting events with no proceeding).
type PillProceeding struct {
	Code         string `json:"code"`
	NameDE       string `json:"name_de"`
	NameEN       string `json:"name_en"`
	Jurisdiction string `json:"jurisdiction"`
}

// PillDuration is the duration spec of a rule pill. nil for trigger pills.
type PillDuration struct {
	Value int    `json:"value"`
	Unit  string `json:"unit"`
	Timing *string `json:"timing,omitempty"`
}

// Pill is one row inside a concept card. Either a rule (with proceeding +
// duration) or a trigger (cross-cutting; just a code + name).
type Pill struct {
	Kind               string          `json:"kind"`
	RuleID             *string         `json:"rule_id,omitempty"`
	TriggerEventID     *int64          `json:"trigger_event_id,omitempty"`
	Proceeding         *PillProceeding `json:"proceeding,omitempty"`
	RuleLocalCode      string          `json:"rule_local_code"`
	RuleNameDE         string          `json:"rule_name_de"`
	RuleNameEN         string          `json:"rule_name_en"`
	LegalSource        *string         `json:"legal_source,omitempty"`
	LegalSourceDisplay *string         `json:"legal_source_display,omitempty"`
	Duration           *PillDuration   `json:"duration,omitempty"`
	Party              string          `json:"party"`
	DrillURL           string          `json:"drill_url"`
	// t-paliad-134: server-assigned ordering hint by real-world
	// proceeding frequency. Frontend doesn't need to read this — the
	// server already sorts before sending — but it's exposed so the
	// frontend can stable-sort if it interleaves cards from multiple
	// requests.
	ProceedingDisplayOrder int `json:"proceeding_display_order"`
}

// rankRow is the per-concept score row from query 1.
type rankRow struct {
	ConceptID        string         `db:"concept_id"`
	Score            float64        `db:"score"`
	AliasHit         bool           `db:"alias_hit"`
	ConceptSortOrder int            `db:"concept_sort_order"`
	ConceptNameDE    string         `db:"concept_name_de"`
	MatchedAliases   pq.StringArray `db:"matched_aliases"`
}

// pillRow is the per-(concept, context) row from query 2.
type pillRow struct {
	Kind             string         `db:"kind"`
	ConceptID        string         `db:"concept_id"`
	ConceptSlug      string         `db:"concept_slug"`
	ConceptNameDE    string         `db:"concept_name_de"`
	ConceptNameEN    string         `db:"concept_name_en"`
	ConceptDesc      sql.NullString `db:"concept_description"`
	ConceptParty     sql.NullString `db:"concept_party"`
	ConceptCategory  string         `db:"concept_category"`
	RuleID           sql.NullString `db:"rule_id"`
	TriggerEventID   sql.NullInt64  `db:"trigger_event_id"`
	ProceedingCode   sql.NullString `db:"proceeding_code"`
	ProceedingNameDE sql.NullString `db:"proceeding_name_de"`
	ProceedingNameEN sql.NullString `db:"proceeding_name_en"`
	Jurisdiction     string         `db:"jurisdiction"`
	// t-paliad-134: pill ordering by real-world frequency. Lower =
	// shown first inside each concept card. 9999 for trigger pills
	// (no proceeding context).
	ProceedingDisplayOrder int     `db:"proceeding_display_order"`
	RuleLocalCode    string         `db:"rule_local_code"`
	RuleNameDE       string         `db:"rule_name_de"`
	RuleNameEN       string         `db:"rule_name_en"`
	LegalSource      sql.NullString `db:"legal_source"`
	RuleCode         sql.NullString `db:"rule_code"`
	DurationValue    sql.NullInt32  `db:"duration_value"`
	DurationUnit     sql.NullString `db:"duration_unit"`
	Timing           sql.NullString `db:"timing"`
	EffectiveParty   string         `db:"effective_party"`
}

// Search runs the two-query pipeline and assembles the cards.
//
// q is the raw user input. Empty q returns an empty result set UNLESS
// opts.EventCategorySlug is set — that triggers v3 browse-mode where the
// taxonomy alone produces a candidate concept list (used by the B1
// decision-tree cascade in Pathway B).
func (s *DeadlineSearchService) Search(ctx context.Context, q string, opts SearchOptions) (*SearchResponse, error) {
	limit := opts.Limit
	maxLimit := opts.MaxLimit
	// Browse mode (B1: slug-driven or all-mapped) returns the entire
	// reachable concept set, which exceeds the trigram-search defaults.
	// Lift the ceiling so the user sees the full landscape on entry.
	if opts.BrowseAll || opts.EventCategorySlug != "" {
		if limit <= 0 {
			limit = 200
		}
		if maxLimit <= 0 {
			maxLimit = 500
		}
	} else {
		if limit <= 0 {
			limit = 12
		}
		if maxLimit <= 0 {
			maxLimit = 30
		}
	}
	if limit > maxLimit {
		limit = maxLimit
	}

	resp := &SearchResponse{
		Query:   q,
		Filters: buildFilters(opts),
		Cards:   []ConceptCard{},
	}

	qNorm := normalizeQuery(q)
	browseMode := qNorm == "" && (opts.EventCategorySlug != "" || opts.BrowseAll)

	// v4 (t-paliad-136): resolve the event-category slug — or the
	// browse-all root — to a (concept_id, proceeding_type_code) tuple
	// allow-list. The previous v3 implementation collapsed this to a
	// flat concept_id slice and dropped the per-leaf proc constraint,
	// which leaked DE/EPA/DPMA pills under "UPC infringement opposing
	// party" leaves and similar. Carrying tuples end-to-end fixes the
	// bug.
	var subtree *subtreeFilter
	if opts.EventCategorySlug != "" && s.eventCategory != nil {
		outcomes, err := s.eventCategory.ConceptsForSlug(ctx, opts.EventCategorySlug)
		if err != nil {
			return nil, err
		}
		if len(outcomes) == 0 {
			// Slug resolves to no concepts; return empty without hitting
			// the matview.
			return resp, nil
		}
		subtree = newSubtreeFilter(outcomes)
	} else if opts.BrowseAll && s.eventCategory != nil {
		outcomes, err := s.eventCategory.AllOutcomes(ctx)
		if err != nil {
			return nil, err
		}
		if len(outcomes) == 0 {
			return resp, nil
		}
		subtree = newSubtreeFilter(outcomes)
	}

	// v3: translate forum slugs to proceeding_code allow-list (rule
	// pills) and t-paliad-266: parallel legal_source prefix allow-list
	// for trigger pills. Empty slice for either axis = no narrowing on
	// that pill kind.
	forumCodes := translateForums(opts.Forums)
	forumLegalPrefixes := translateForumsToLegalSourcePrefixes(opts.Forums)

	if !browseMode && qNorm == "" {
		return resp, nil
	}

	party := nullable(opts.Party)
	proc := nullable(opts.Proc)
	source := nullable(opts.Source)

	var ranks []rankRow
	if browseMode {
		// Browse mode: synthesize ranks from the allow-list directly.
		ranks = s.browseRanks(ctx, subtree, party, proc, source, forumCodes, forumLegalPrefixes, limit)
	} else {
		qLow := strings.ToLower(qNorm)
		var err error
		ranks, err = s.rankConcepts(ctx, qNorm, qLow, party, proc, source, subtree, forumCodes, forumLegalPrefixes, limit)
		if err != nil {
			return nil, err
		}
	}
	if len(ranks) == 0 {
		return resp, nil
	}

	conceptIDs := make([]string, len(ranks))
	for i, r := range ranks {
		conceptIDs[i] = r.ConceptID
	}
	pills, err := s.loadPills(ctx, conceptIDs, party, proc, source, subtree, forumCodes, forumLegalPrefixes)
	if err != nil {
		return nil, err
	}

	cards, totalPills := assembleCards(ranks, pills)
	resp.Cards = cards
	resp.TotalCards = len(cards)
	resp.TotalPills = totalPills
	return resp, nil
}

// subtreeFilter expresses the per-leaf (concept_id, proceeding_type_code)
// tuple constraint in a form the matview SQL can apply.
//
// Two parallel slices are passed to the SQL via unnest; each row of the
// matview must match at least one tuple to pass through. An empty
// proc_code in the slice ('') means "any proceeding for this concept" —
// it's the encoding of a junction row with proceeding_type_code IS NULL,
// which the seed uses for cross-cutting concepts (Wiedereinsetzung,
// Weiterbehandlung, Versäumnisurteil-Einspruch, Schriftsatznachreichung).
//
// SQL match clause:
//
//	EXISTS (
//	    SELECT 1 FROM unnest($cids::uuid[], $procs::text[]) AS t(cid, pcode)
//	     WHERE t.cid = s.concept_id
//	       AND (t.pcode = '' OR t.pcode = s.proceeding_code)
//	)
//
// Trigger pills (kind='trigger', proceeding_code IS NULL) only surface
// when their concept appears with proc_code='' in this filter — i.e. a
// junction row with NULL proc. That matches the seed convention.
type subtreeFilter struct {
	conceptIDs []string // parallel to ProcCodes
	procCodes  []string // '' encodes "any proc for this concept"
}

// newSubtreeFilter builds the parallel arrays from a slice of outcomes.
// Dedup: when a concept has both a (c, NULL) row and one or more (c, X)
// rows in the junction, the NULL row subsumes — keep only the unconstrained
// entry to avoid redundant work in unnest.
func newSubtreeFilter(outcomes []ConceptOutcome) *subtreeFilter {
	unconstrained := make(map[string]bool, len(outcomes))
	for _, o := range outcomes {
		if o.ProceedingTypeCode == nil {
			unconstrained[o.ConceptID] = true
		}
	}
	seen := make(map[string]bool, len(outcomes))
	cids := make([]string, 0, len(outcomes))
	procs := make([]string, 0, len(outcomes))
	for _, o := range outcomes {
		var pc string
		if o.ProceedingTypeCode == nil {
			pc = ""
		} else {
			if unconstrained[o.ConceptID] {
				continue
			}
			pc = *o.ProceedingTypeCode
		}
		key := o.ConceptID + "\x00" + pc
		if seen[key] {
			continue
		}
		seen[key] = true
		cids = append(cids, o.ConceptID)
		procs = append(procs, pc)
	}
	return &subtreeFilter{conceptIDs: cids, procCodes: procs}
}

// args returns the two slice arguments to pass into the SQL placeholder
// pair, or two nil sentinels when no narrowing applies. Calling it on a
// nil receiver is safe.
func (f *subtreeFilter) args() (any, any) {
	if f == nil || len(f.conceptIDs) == 0 {
		return nil, nil
	}
	return pq.Array(f.conceptIDs), pq.Array(f.procCodes)
}

// translateForums maps a list of forum slugs to the union of their
// proceeding_type_codes via ForumToProceedingCodes. Unknown slugs are
// silently dropped.
func translateForums(slugs []string) []string {
	if len(slugs) == 0 {
		return nil
	}
	seen := map[string]bool{}
	var out []string
	for _, slug := range slugs {
		codes, ok := ForumToProceedingCodes[slug]
		if !ok {
			continue
		}
		for _, c := range codes {
			if seen[c] {
				continue
			}
			seen[c] = true
			out = append(out, c)
		}
	}
	return out
}

// translateForumsToLegalSourcePrefixes maps a list of forum slugs to
// the union of legal_source prefixes those forums admit for trigger
// pills (t-paliad-266). Empty when no slug carries a prefix mapping —
// callers must treat empty as "no trigger narrowing applies" rather
// than "match nothing", mirroring translateForums.
func translateForumsToLegalSourcePrefixes(slugs []string) []string {
	if len(slugs) == 0 {
		return nil
	}
	seen := map[string]bool{}
	var out []string
	for _, slug := range slugs {
		prefixes, ok := ForumToLegalSourcePrefixes[slug]
		if !ok {
			continue
		}
		for _, p := range prefixes {
			if seen[p] {
				continue
			}
			seen[p] = true
			out = append(out, p)
		}
	}
	return out
}

// browseRanks synthesizes a rank list from a subtree-filter tuple set
// (v3 B1 browse mode). No trigram scoring — order is by concept
// sort_order then name. Forum filter applies post-hoc to keep concepts
// that have at least one matching pill.
//
// v4: subtree filter enforces (concept_id, proceeding_code) tuples, not
// concept_id alone — see subtreeFilter doc.
func (s *DeadlineSearchService) browseRanks(
	ctx context.Context,
	subtree *subtreeFilter,
	party, proc, source *string,
	forumCodes []string,
	forumLegalPrefixes []string,
	limit int,
) []rankRow {
	const sqlText = `
SELECT DISTINCT
       s.concept_id,
       false                                              AS alias_hit,
       1.0                                                AS score,
       s.concept_sort_order,
       s.concept_name_de,
       ARRAY[]::text[]                                    AS matched_aliases
  FROM paliad.deadline_search s
 WHERE EXISTS (
        SELECT 1 FROM unnest($1::uuid[], $2::text[]) AS t(cid, pcode)
         WHERE t.cid = s.concept_id
           AND (t.pcode = '' OR t.pcode = s.proceeding_code)
       )
   AND ($3::text IS NULL OR s.effective_party  = $3)
   AND ($4::text IS NULL OR s.proceeding_code  = $4)
   AND ($5::text IS NULL OR s.legal_source    LIKE $5 || '%')
   AND (
        $6::text[] IS NULL
        OR cardinality($6::text[]) = 0
        OR (
             s.kind = 'rule'
             AND s.proceeding_code = ANY($6::text[])
           )
        OR (
             s.kind = 'trigger'
             AND ($8::text[] IS NULL OR cardinality($8::text[]) = 0
                  OR EXISTS (
                       SELECT 1 FROM unnest($8::text[]) AS lp
                        WHERE s.legal_source LIKE lp || '%'
                  ))
           )
       )
 ORDER BY s.concept_sort_order ASC, s.concept_name_de ASC
 LIMIT $7
`
	cidArg, procArg := subtree.args()
	var rows []rankRow
	if err := s.db.SelectContext(ctx, &rows, sqlText,
		cidArg, procArg,
		party, proc, source,
		nullableArray(forumCodes),
		limit,
		nullableArray(forumLegalPrefixes),
	); err != nil {
		// Browse mode failures degrade to empty (taxonomy-driven UX
		// shouldn't crash on a malformed slug); log via the caller.
		return nil
	}
	return rows
}

// nullableArray returns nil for empty input so the SQL `IS NULL OR
// cardinality = 0` short-circuit applies cleanly. pq.Array on a nil
// slice still produces a non-NULL empty array, which doesn't match
// the IS NULL test — hence the explicit nil sentinel.
func nullableArray(s []string) any {
	if len(s) == 0 {
		return nil
	}
	return pq.Array(s)
}

func (s *DeadlineSearchService) rankConcepts(
	ctx context.Context,
	q, qLow string,
	party, proc, source *string,
	subtree *subtreeFilter,
	forumCodes []string,
	forumLegalPrefixes []string,
	limit int,
) ([]rankRow, error) {
	// $1 q · $2 qLow · $3 party · $4 proc · $5 source ·
	// $6 subtree_cids uuid[]? · $7 subtree_procs text[]? ·
	// $8 forum_codes text[]? · $9 limit · $10 forum_legal_prefixes text[]?
	const sqlText = `
WITH matched AS (
    SELECT
        s.concept_id,
        s.concept_sort_order,
        s.concept_name_de,
        GREATEST(
            similarity(s.concept_name_de, $1)            * 1.0,
            similarity(s.concept_name_en, $1)            * 1.0,
            COALESCE(similarity(s.legal_source, $1), 0)  * 0.9,
            COALESCE(similarity(s.rule_code,    $1), 0)  * 0.9,
            similarity(s.rule_name_de, $1)               * 0.7,
            similarity(s.rule_name_en, $1)               * 0.7
        ) AS row_score,
        EXISTS (
            SELECT 1 FROM unnest(s.concept_aliases) a
             WHERE lower(a) = $2 OR a % $1
        ) AS row_alias_hit,
        ARRAY(
            SELECT DISTINCT a FROM unnest(s.concept_aliases) a
             WHERE lower(a) = $2 OR a % $1
        ) AS row_matched_aliases
      FROM paliad.deadline_search s
     WHERE (
              s.concept_name_de % $1
           OR s.concept_name_en % $1
           OR s.rule_name_de    % $1
           OR s.rule_name_en    % $1
           OR (s.legal_source IS NOT NULL AND s.legal_source % $1)
           OR (s.rule_code    IS NOT NULL AND s.rule_code    % $1)
           OR EXISTS (
                  SELECT 1 FROM unnest(s.concept_aliases) a
                   WHERE lower(a) = $2 OR a % $1
              )
          )
       AND ($3::text IS NULL OR s.effective_party  = $3)
       AND ($4::text IS NULL OR s.proceeding_code  = $4)
       AND ($5::text IS NULL OR s.legal_source    LIKE $5 || '%')
       AND (
             $6::uuid[] IS NULL
             OR EXISTS (
                  SELECT 1 FROM unnest($6::uuid[], $7::text[]) AS t(cid, pcode)
                   WHERE t.cid = s.concept_id
                     AND (t.pcode = '' OR t.pcode = s.proceeding_code)
                )
           )
       AND (
             $8::text[] IS NULL
             OR cardinality($8::text[]) = 0
             OR (
                  s.kind = 'rule'
                  AND s.proceeding_code = ANY($8::text[])
                )
             OR (
                  s.kind = 'trigger'
                  AND ($10::text[] IS NULL OR cardinality($10::text[]) = 0
                       OR EXISTS (
                            SELECT 1 FROM unnest($10::text[]) AS lp
                             WHERE s.legal_source LIKE lp || '%'
                       ))
                )
           )
)
SELECT
    m.concept_id,
    bool_or(m.row_alias_hit)                                          AS alias_hit,
    max(m.row_score) + CASE WHEN bool_or(m.row_alias_hit)
                            THEN 0.2 ELSE 0 END                        AS score,
    min(m.concept_sort_order)                                          AS concept_sort_order,
    min(m.concept_name_de)                                             AS concept_name_de,
    COALESCE(min(m.row_matched_aliases), ARRAY[]::text[])              AS matched_aliases
  FROM matched m
 GROUP BY m.concept_id
 ORDER BY score DESC, concept_sort_order ASC, concept_name_de ASC
 LIMIT $9
`
	cidArg, procArg := subtree.args()
	var rows []rankRow
	if err := s.db.SelectContext(ctx, &rows, sqlText,
		q, qLow,
		party, proc, source,
		cidArg, procArg,
		nullableArray(forumCodes),
		limit,
		nullableArray(forumLegalPrefixes),
	); err != nil {
		return nil, fmt.Errorf("rank concepts: %w", err)
	}
	return rows, nil
}

func (s *DeadlineSearchService) loadPills(
	ctx context.Context,
	conceptIDs []string,
	party, proc, source *string,
	subtree *subtreeFilter,
	forumCodes []string,
	forumLegalPrefixes []string,
) ([]pillRow, error) {
	// $1 concept_ids uuid[] · $2 party · $3 proc · $4 source ·
	// $5 subtree_cids uuid[]? · $6 subtree_procs text[]? ·
	// $7 forum_codes text[]? · $8 forum_legal_prefixes text[]?
	const sqlText = `
SELECT
    s.kind,
    s.concept_id,
    s.concept_slug,
    s.concept_name_de,
    s.concept_name_en,
    s.concept_description,
    s.concept_party,
    s.concept_category,
    s.rule_id,
    s.trigger_event_id,
    s.proceeding_code,
    s.proceeding_name_de,
    s.proceeding_name_en,
    s.jurisdiction,
    s.proceeding_display_order,
    s.rule_local_code,
    s.rule_name_de,
    s.rule_name_en,
    s.legal_source,
    s.rule_code,
    s.duration_value,
    s.duration_unit,
    s.timing,
    s.effective_party
  FROM paliad.deadline_search s
 WHERE s.concept_id = ANY($1::uuid[])
   AND ($2::text IS NULL OR s.effective_party  = $2)
   AND ($3::text IS NULL OR s.proceeding_code  = $3)
   AND ($4::text IS NULL OR s.legal_source    LIKE $4 || '%')
   AND (
        $5::uuid[] IS NULL
        OR EXISTS (
             SELECT 1 FROM unnest($5::uuid[], $6::text[]) AS t(cid, pcode)
              WHERE t.cid = s.concept_id
                AND (t.pcode = '' OR t.pcode = s.proceeding_code)
           )
       )
   AND (
        $7::text[] IS NULL
        OR cardinality($7::text[]) = 0
        OR (
             s.kind = 'rule'
             AND s.proceeding_code = ANY($7::text[])
           )
        OR (
             s.kind = 'trigger'
             AND ($8::text[] IS NULL OR cardinality($8::text[]) = 0
                  OR EXISTS (
                       SELECT 1 FROM unnest($8::text[]) AS lp
                        WHERE s.legal_source LIKE lp || '%'
                  ))
           )
       )
 ORDER BY s.concept_id, s.kind, s.proceeding_display_order, s.proceeding_code NULLS LAST, s.rule_local_code
`
	cidArg, procArg := subtree.args()
	var rows []pillRow
	if err := s.db.SelectContext(ctx, &rows, sqlText,
		pq.Array(conceptIDs), party, proc, source,
		cidArg, procArg,
		nullableArray(forumCodes),
		nullableArray(forumLegalPrefixes),
	); err != nil {
		return nil, fmt.Errorf("load pills: %w", err)
	}
	return rows, nil
}

// assembleCards groups pillRows under their ranked concept and builds
// the JSON cards in score order.
func assembleCards(ranks []rankRow, pills []pillRow) ([]ConceptCard, int) {
	pillsByConcept := make(map[string][]pillRow, len(ranks))
	for _, p := range pills {
		pillsByConcept[p.ConceptID] = append(pillsByConcept[p.ConceptID], p)
	}

	cards := make([]ConceptCard, 0, len(ranks))
	totalPills := 0
	for _, r := range ranks {
		ps := pillsByConcept[r.ConceptID]
		if len(ps) == 0 {
			continue
		}
		// First row carries the concept fields.
		first := ps[0]
		concept := ConceptSummary{
			ID:       first.ConceptID,
			Slug:     first.ConceptSlug,
			NameDE:   first.ConceptNameDE,
			NameEN:   first.ConceptNameEN,
			Category: first.ConceptCategory,
		}
		if first.ConceptDesc.Valid {
			concept.Description = &first.ConceptDesc.String
		}
		if first.ConceptParty.Valid {
			concept.Party = &first.ConceptParty.String
		}

		cardPills := make([]Pill, 0, len(ps))
		for _, p := range ps {
			cardPills = append(cardPills, buildPill(p))
		}
		// Stable secondary order: rule pills before trigger pills, then by
		// jurisdiction-ish ordering (UPC, EU, DE, cross-cutting).
		sort.SliceStable(cardPills, func(i, j int) bool {
			return pillSortKey(cardPills[i]) < pillSortKey(cardPills[j])
		})

		card := ConceptCard{
			Concept:        concept,
			MatchedAliases: []string(r.MatchedAliases),
			Score:          roundScore(r.Score),
			Pills:          cardPills,
		}
		cards = append(cards, card)
		totalPills += len(cardPills)
	}
	return cards, totalPills
}

func buildPill(p pillRow) Pill {
	pill := Pill{
		Kind:                   p.Kind,
		RuleLocalCode:          p.RuleLocalCode,
		RuleNameDE:             p.RuleNameDE,
		RuleNameEN:             p.RuleNameEN,
		Party:                  p.EffectiveParty,
		ProceedingDisplayOrder: p.ProceedingDisplayOrder,
	}
	if p.RuleID.Valid {
		pill.RuleID = &p.RuleID.String
	}
	if p.TriggerEventID.Valid {
		v := p.TriggerEventID.Int64
		pill.TriggerEventID = &v
	}
	if p.ProceedingCode.Valid {
		pill.Proceeding = &PillProceeding{
			Code:         p.ProceedingCode.String,
			NameDE:       p.ProceedingNameDE.String,
			NameEN:       p.ProceedingNameEN.String,
			Jurisdiction: p.Jurisdiction,
		}
	}
	if p.LegalSource.Valid {
		ls := p.LegalSource.String
		pill.LegalSource = &ls
		display := FormatLegalSourceDisplay(ls)
		if display != "" {
			pill.LegalSourceDisplay = &display
		}
	}
	if p.DurationValue.Valid && p.DurationUnit.Valid {
		dur := &PillDuration{
			Value: int(p.DurationValue.Int32),
			Unit:  p.DurationUnit.String,
		}
		if p.Timing.Valid && p.Timing.String != "" && p.Timing.String != "after" {
			t := p.Timing.String
			dur.Timing = &t
		}
		pill.Duration = dur
	}
	pill.DrillURL = pillDrillURL(p)
	return pill
}

func pillDrillURL(p pillRow) string {
	// m/paliad#151 U4 — drill-in URLs target /tools/procedures, the
	// unified successor to /tools/fristenrechner and
	// /tools/verfahrensablauf. The legacy URLs still 301-redirect, so
	// any cached snapshot keeps working, but new searches land on the
	// new page directly.
	switch p.Kind {
	case "rule":
		if p.ProceedingCode.Valid && p.RuleLocalCode != "" {
			return "/tools/procedures?proc=" + p.ProceedingCode.String + "&focus=" + p.RuleLocalCode
		}
		return "/tools/procedures"
	case "trigger":
		if p.TriggerEventID.Valid {
			return fmt.Sprintf("/tools/procedures?mode=event&triggerId=%d", p.TriggerEventID.Int64)
		}
		return "/tools/procedures?mode=event"
	}
	return "/tools/procedures"
}

// pillSortKey orders pills inside a card. Rule pills before triggers;
// inside rules, by proceeding_display_order (real-world frequency,
// t-paliad-134) ascending; ties broken by rule_local_code so the order
// is stable across runs.
func pillSortKey(p Pill) string {
	kindRank := "1"
	if p.Kind == "trigger" {
		kindRank = "2"
	}
	// Zero-pad to 5 digits so lexicographic compare matches numeric.
	return fmt.Sprintf("%s%05d%s", kindRank, p.ProceedingDisplayOrder, p.RuleLocalCode)
}

func nullable(v string) *string {
	v = strings.TrimSpace(v)
	if v == "" {
		return nil
	}
	return &v
}

func buildFilters(opts SearchOptions) SearchFilters {
	return SearchFilters{
		Party:  nullable(opts.Party),
		Proc:   nullable(opts.Proc),
		Source: nullable(opts.Source),
	}
}

// normalizeQuery strips legal-prefix noise that users naturally type but
// the structured legal_source column doesn't contain. § 82 → 82, Art.108
// → 108, RoP R.23 → RoP 23. The trigram match runs on the result.
func normalizeQuery(q string) string {
	q = strings.TrimSpace(q)
	if q == "" {
		return ""
	}
	// Drop common legal prefixes (§, Art., Section, Sec., R., Rule).
	// We keep meaningful tokens like "RoP", "ZPO", "PatG" because they
	// help narrow the search via trigram on legal_source / rule_code.
	lowers := strings.ToLower(q)
	for _, prefix := range []string{"§", "art.", "art ", "section ", "sec.", "sec ", "rule "} {
		for strings.HasPrefix(lowers, prefix) {
			q = strings.TrimSpace(q[len(prefix):])
			lowers = strings.ToLower(q)
		}
	}
	return q
}

// roundScore truncates to 4 decimals so JSON stays compact.
func roundScore(v float64) float64 {
	return float64(int(v*10000+0.5)) / 10000
}

// FormatLegalSourceDisplay + BuildLegalSourceURL are canonically
// defined in pkg/litigationplanner — kept here as thin re-exports so
// the existing in-package + handler call-sites compile unchanged.
func FormatLegalSourceDisplay(src string) string {
	return lp.FormatLegalSourceDisplay(src)
}

func BuildLegalSourceURL(src string) string {
	return lp.BuildLegalSourceURL(src)
}

// RefreshSearchView re-populates the materialised view. Safe to call on
// every server boot — it's a CONCURRENTLY refresh against a < 1k row
// view, well under 100 ms in practice. Called from cmd/server/main.go
// right after the migration runner finishes so search reflects any
// newly-applied seed migration.
func RefreshSearchView(ctx context.Context, db *sqlx.DB) error {
	_, err := db.ExecContext(ctx, `REFRESH MATERIALIZED VIEW CONCURRENTLY paliad.deadline_search`)
	if err != nil {
		return fmt.Errorf("refresh deadline_search: %w", err)
	}
	return nil
}
