// Copyright 2026 printing-press-golden and contributors. Licensed under Apache-2.0. See LICENSE.
// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.

package learn

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

	"learn-loop-example-pp-cli/internal/learn/entities"
	"learn-loop-example-pp-cli/internal/learn/patterns"
)

// Default thresholds. Keep in sync with the documented contract in
// SKILL.md: the recall match-score floor is 0.6 (token-set Jaccard)
// and the default result cap is 10.
//
// defaultCrossAliasJaccardMin is the floor applied specifically when
// the canonical-overlap fallback fires. Cross-alias matches differ on
// literal entity strings, so non-entity Jaccard is the only signal
// left to score on, and it's naturally lower for paraphrased
// same-shape queries -- a separate floor avoids gating out legitimate
// paraphrase hits.
const (
	defaultJaccardMin           = 0.6
	defaultCrossAliasJaccardMin = 0.3
	defaultRecallLimit          = 10
	defaultMinConfidence        = 1
)

// SourcePattern marks a Hit synthesized by the pattern substitution
// engine rather than read from search_learnings.
const SourcePattern = "pattern"

// LearningActionBoost mirrors the action string stored in
// search_learnings for a default rerank rule.
const LearningActionBoost = "boost"

// Hit is one row in the recall envelope. Field tags mirror the JSON
// contract agents read.
type Hit struct {
	ResourceID       string     `json:"resource_id"`
	ResourceType     string     `json:"resource_type,omitempty"`
	Venue            string     `json:"venue,omitempty"`
	Action           string     `json:"action"`
	Confidence       int        `json:"confidence"`
	MatchScore       float64    `json:"match_score"`
	EntityMatch      string     `json:"entity_match"`
	ResourceEntities []string   `json:"resource_entities,omitempty"`
	Source           string     `json:"source"`
	LastObservedAt   *time.Time `json:"last_observed_at,omitempty"`
	AliasTarget      string     `json:"alias_target,omitempty"`
	Warnings         []string   `json:"warnings,omitempty"`
}

// Result is the top-level recall envelope. Found mirrors the legacy
// {found, results} shape with additive entity-aware fields.
//
// Playbook is non-nil when the query's structural family matches a
// stored learning_playbooks row. The agent reads it before any
// discovery step. Notes mirror playbook.notes_text verbatim so the
// agent can surface the gotchas/workarounds even when the structured
// playbook itself is sparse (or absent -- a notes-only row still
// surfaces Notes).
type Result struct {
	Query         string            `json:"query"`
	Normalized    string            `json:"normalized"`
	QueryEntities []string          `json:"query_entities"`
	Found         bool              `json:"found"`
	MatchScore    float64           `json:"match_score,omitempty"`
	Results       []Hit             `json:"results"`
	Mismatches    []Hit             `json:"mismatches,omitempty"`
	Warnings      []string          `json:"warnings,omitempty"`
	Playbook      *ResolvedPlaybook `json:"playbook,omitempty"`
	Notes         string            `json:"notes,omitempty"`
}

// Opts tunes Recall behavior. Zero-value defaults:
//
//	MinConfidence -> 1 (any row)
//	Limit         -> 10
//	JaccardMin    -> 0.6
//	EntityConfig  -> entities.NewConfig() (default English stopwords, no ticker patterns)
//
// DebugMismatches surfaces the mismatches array in the envelope.
// NoLearn short-circuits to an empty envelope.
//
// EntityConfig is the per-CLI entity extractor config built once at
// startup. Carried on Opts rather than positionally so callers that
// don't need per-CLI configuration (legacy prediction-goat lineage,
// future opt-out CLIs) can pass Opts{} and inherit defaults.
//
// ResourceTypeFields maps each resource_type to the ordered list of
// JSON fields ResourceEntitiesFromJSON should read to pull entity
// tokens from a stored resource row. Empty / missing entries leave
// the resource side with no entity signal (partial / unknown).
//
// PatternKinds is the CLI's per-spec list of table-backed lookup
// kinds (from LearnConfig.EntityLookupSeeds) the pattern engine
// should try ahead of the built-in computed kinds.
type Opts struct {
	MinConfidence int
	Limit         int
	JaccardMin    float64
	// CrossAliasJaccardMin is the floor used when the canonical-overlap
	// fallback fires. Defaults to 0.3 (defaultCrossAliasJaccardMin) when
	// zero. Cross-alias matches share canonicals across different
	// literal aliases, so non-entity Jaccard is naturally lower and a
	// separate floor avoids gating out legitimate paraphrase hits.
	CrossAliasJaccardMin float64
	DebugMismatches      bool
	NoLearn              bool
	EntityConfig         *entities.Config
	ResourceTypeFields   map[string][]string
	PatternKinds         []string
}

// Recall is the entity-aware read path. db is the open *sql.DB
// pointing at the local SQLite store with the v6 learn schema; the
// per-CLI entity extractor config is carried on opts.EntityConfig
// (nil falls back to entities.NewConfig() defaults).
//
// Returns a non-nil Result on every call: cold queries get the
// envelope shape populated with the normalized form and the query
// entities. Errors are reserved for DB-level failures.
func Recall(ctx context.Context, db *sql.DB, query string, opts Opts) (Result, error) {
	cfg := opts.EntityConfig
	if cfg == nil {
		cfg = entities.NewConfig()
	}
	normalized := Normalize(query, cfg)
	result := Result{
		Query:         query,
		Normalized:    normalized.NonEntityNormalized,
		QueryEntities: append([]string(nil), normalized.Entities...),
		Results:       []Hit{},
	}
	if result.QueryEntities == nil {
		result.QueryEntities = []string{}
	}
	if opts.NoLearn {
		return result, nil
	}

	minConf := opts.MinConfidence
	if minConf <= 0 {
		minConf = defaultMinConfidence
	}
	limit := opts.Limit
	if limit <= 0 {
		limit = defaultRecallLimit
	}
	jMin := opts.JaccardMin
	if jMin == 0 {
		jMin = defaultJaccardMin
	}
	if jMin < 0 {
		jMin = 0
	}
	crossAliasMin := opts.CrossAliasJaccardMin
	if crossAliasMin == 0 {
		crossAliasMin = defaultCrossAliasJaccardMin
	}
	if crossAliasMin < 0 {
		crossAliasMin = 0
	}

	if len(strings.Fields(normalized.NonEntityNormalized)) == 0 && len(normalized.Entities) == 0 && len(normalized.Tickers) == 0 {
		return result, nil
	}

	// Build a per-call canonical resolver. Looks up each entity in
	// entity_lookups to find the canonical(s) it belongs to. Caches
	// per-call so a query with N entities does N lookups max, not
	// N*M where M is the number of candidate rows.
	resolver := NewCanonicalResolver(ctx, db)

	// Post-Normalize entity promotion via entity_lookups. The
	// capitalization-based entity extractor misses aliases that the
	// lookup table knows about (numeric prefixes, lowercase short
	// codes) because they don't match its detection rules.
	// PromoteEntities walks the non-entity tokens and promotes any
	// whose lowercased form has a row in entity_lookups. Same helper
	// runs at teach time so stored query_entities stays symmetric
	// with what recall sees.
	normalized = PromoteEntities(normalized, resolver)
	queryTokens := strings.Fields(normalized.NonEntityNormalized)
	result.QueryEntities = append([]string(nil), normalized.Entities...)
	if result.QueryEntities == nil {
		result.QueryEntities = []string{}
	}
	result.Normalized = normalized.NonEntityNormalized

	queryCanonicals := resolver.ResolveSet(normalized.Entities)
	// Ambiguous-alias warning surfaces only when a SINGLE query entity
	// resolves to multiple canonicals. A perfectly ordinary multi-entity
	// query resolves to two canonicals via two different entities and
	// must NOT trip this warning. The agent can pass --debug-mismatches
	// to investigate.
	for _, e := range normalized.Entities {
		if len(resolver.Resolve(e)) > 1 {
			result.Warnings = append(result.Warnings, WarningAmbiguousAlias)
			break
		}
	}

	rows, err := db.QueryContext(ctx, `SELECT query_pattern, COALESCE(query_entities, ''),
		COALESCE(venue, ''), COALESCE(resource_type, ''), resource_id, action,
		COALESCE(alias_target, ''), source, confidence, created_at, last_observed_at
		FROM search_learnings
		WHERE confidence >= ?`, minConf)
	if err != nil {
		return result, fmt.Errorf("recall query: %w", err)
	}
	defer rows.Close()

	var hits []Hit
	var mismatches []Hit
	// Track canonicals of stored rows routed to mismatches[] so we
	// can surface a top-level "similar_shape_different_entity"
	// warning even when --debug-mismatches isn't passed. The agent
	// then sees that a structurally-similar learning exists for a
	// different entity, rather than the misleading
	// no_learnings_for_query_family.
	mismatchCanonicals := make(map[string]struct{})

	for rows.Next() {
		var (
			queryPattern   string
			storedEntities string
			venue          string
			resourceType   string
			resourceID     string
			action         string
			aliasTarget    string
			source         string
			confidence     int
			createdAt      time.Time
			lastObserved   sql.NullTime
		)
		if err := rows.Scan(&queryPattern, &storedEntities, &venue, &resourceType,
			&resourceID, &action, &aliasTarget, &source, &confidence, &createdAt, &lastObserved); err != nil {
			return result, fmt.Errorf("recall scan: %w", err)
		}

		storedNorm := Normalize(queryPattern, cfg)
		storedEntitySlice, _ := ParseStoredEntities(storedEntities)
		if len(storedEntitySlice) == 0 {
			storedEntitySlice = storedNorm.Entities
		}
		// Opportunistic backfill for legacy null-entity rows. Rows
		// written before symmetric teach-time promotion landed have
		// query_entities=null and storedNorm.Entities=empty because
		// query_pattern is lowercased on write. Walk the lowercased
		// query_pattern tokens through the resolver and use canonical-
		// resolvable tokens as the effective entity slice for cross-
		// alias matching this call. Read-only -- the stored column
		// stays null so we never silently rewrite user data.
		if len(storedEntitySlice) == 0 {
			for _, tok := range strings.Fields(strings.ToLower(queryPattern)) {
				if cfg != nil && cfg.IsStopword(tok) {
					continue
				}
				if cans := resolver.Resolve(tok); len(cans) > 0 {
					storedEntitySlice = append(storedEntitySlice, tok)
				}
			}
		}

		// Compute the stored non-entity tokens by stripping case-folded
		// stored entities (from the query_entities column, which preserves
		// the original case the entity extractor saw at teach time) plus
		// stopwords from the lowercased query_pattern. The plain
		// Normalize(queryPattern, cfg) misses entities whose stored form
		// is uppercase because query_pattern is lowercased on write --
		// the capitalization-based entity detector can't recover the
		// entity in the lowercased form. Using the stored entity column
		// directly rebuilds the correct non-entity token set.
		storedEntitySet := make(map[string]struct{}, len(storedEntitySlice))
		for _, e := range storedEntitySlice {
			storedEntitySet[strings.ToLower(strings.TrimSpace(e))] = struct{}{}
		}
		storedNonEntityTokens := make([]string, 0)
		for _, raw := range strings.Fields(strings.ToLower(queryPattern)) {
			if _, isEntity := storedEntitySet[raw]; isEntity {
				continue
			}
			// cfg may be the zero-value EntityConfig when a caller passes
			// Opts{} without an EntityConfig; guard the nil receiver to
			// match the backfill loop above and avoid a runtime panic on
			// the first scanned row.
			if cfg != nil && cfg.IsStopword(raw) {
				continue
			}
			storedNonEntityTokens = append(storedNonEntityTokens, raw)
		}

		score := Jaccard(queryTokens, storedNonEntityTokens)

		// Resolve stored entities to canonicals for cross-alias matching.
		// Combined with queryCanonicals computed once at the top, this
		// lets a query alias match a row taught under a different alias
		// when both entities resolve to the same canonical via entity_lookups.
		storedCanonicals := resolver.ResolveSet(storedEntitySlice)
		canonicalOverlap := setIntersects(queryCanonicals, storedCanonicals)

		// Two fallback paths when literal non-entity Jaccard misses:
		//   1. Entity-only fallback: both sides have empty non-entity
		//      content, both have entities -> use literal entity-Jaccard.
		//   2. Cross-alias fallback: canonicals overlap even when literal
		//      entities don't. Also covers paraphrased same-shape queries
		//      where non-entity Jaccard is naturally lower; a separate
		//      CrossAliasJaccardMin floor lets these through. The
		//      downstream validateResource still gates entity match
		//      against the stored resource, so an ambiguous query that
		//      happens to canonical-overlap a stored row still gets
		//      caught at that layer.
		if score < jMin {
			if len(queryTokens) == 0 && len(storedNonEntityTokens) == 0 &&
				len(normalized.Entities) > 0 && len(storedEntitySlice) > 0 {
				score = Jaccard(normalized.Entities, storedEntitySlice)
			}
			if score < jMin {
				// Three fallback branches, gated by the lower
				// crossAliasMin floor:
				//   - canonicalOverlap: cross-alias hit candidate,
				//     promotion to Exact happens downstream.
				//   - no overlap, both sides have entities,
				//     structural overlap >= crossAliasMin:
				//     similar-shape mismatch candidate. Surfaces in
				//     mismatches[] so the envelope warning carries
				//     an alternative canonical instead of misleading
				//     no_learnings_for_query_family.
				//   - otherwise: row is genuine noise, drop it.
				switch {
				case canonicalOverlap:
					// Case 1's purpose is cross-alias matching: the
					// query and stored row use DIFFERENT literal
					// entities that resolve to the same canonical.
					// The canonicalJaccard boost to 1.0 (single
					// canonical on each side, identical) rewards that
					// real cross-alias hit. But when query and stored
					// share the SAME literal entity that happens to
					// be in entity_lookups, canonicalOverlap is also
					// true and canonicalJaccard is also 1.0 --
					// boosting structurally-unrelated rows to score=1.0
					// and admitting them above jMin. Guard the boost:
					// only fire when literal entities genuinely differ.
					// Same-entity rows that miss jMin are genuine
					// structural noise and should drop.
					if entitySlicesIntersect(normalized.Entities, storedEntitySlice) {
						continue
					}
					canonScore := canonicalJaccard(queryCanonicals, storedCanonicals)
					if canonScore > score {
						score = canonScore
					}
					if score < crossAliasMin {
						continue
					}
				case len(normalized.Entities) > 0 && len(storedEntitySlice) > 0:
					// Case 2 is the "similar shape, different entity"
					// candidate path -- its job is to surface
					// similar_shape_different_entity warnings, not
					// admit hits the jMin floor would otherwise reject.
					// If query and stored share a literal entity yet
					// canonicalOverlap is false, entity_lookups has no
					// row for that entity (queryCanonicals and
					// storedCanonicals are both empty, so setIntersects
					// returned false). Without this guard the looser
					// crossAliasMin floor would silently downgrade
					// jMin to 0.3 for any unregistered entity. Drop
					// such rows instead -- case 1 covers the matching-
					// canonical path, case 2 is reserved for actually
					// different entities.
					if entitySlicesIntersect(normalized.Entities, storedEntitySlice) {
						continue
					}
					if score < crossAliasMin {
						continue
					}
				default:
					continue
				}
			}
		}

		hit := Hit{
			ResourceID:   resourceID,
			ResourceType: resourceType,
			Venue:        venue,
			Action:       action,
			Confidence:   confidence,
			MatchScore:   score,
			Source:       source,
			AliasTarget:  aliasTarget,
		}
		if lastObserved.Valid {
			t := lastObserved.Time
			hit.LastObservedAt = &t
		}
		validateResource(ctx, db, cfg, &hit, normalized.Entities, storedEntitySlice, opts.ResourceTypeFields)
		// Cross-alias promotion: if canonicals overlap, the entities
		// are equivalent even when their literal forms differ. Override
		// a Mismatch verdict so the learning isn't filtered into the
		// mismatches bucket. The warning flags it for diagnostic clarity.
		if canonicalOverlap && hit.EntityMatch == EntityMatchMismatch {
			hit.EntityMatch = EntityMatchExact
			hit.Warnings = append(hit.Warnings, WarningCrossAliasMatch)
		}
		if hit.EntityMatch == EntityMatchMismatch {
			// Surface canonicals for the envelope-level similar-shape
			// warning. Fall back to literal stored entities when the
			// row has no canonical resolution -- better to name the
			// raw entity than to silently drop the hint.
			if len(storedCanonicals) > 0 {
				for c := range storedCanonicals {
					mismatchCanonicals[c] = struct{}{}
				}
			} else {
				for _, e := range storedEntitySlice {
					if e = strings.TrimSpace(e); e != "" {
						mismatchCanonicals[e] = struct{}{}
					}
				}
			}
			mismatches = append(mismatches, hit)
		} else {
			hits = append(hits, hit)
		}
	}
	if err := rows.Err(); err != nil {
		return result, fmt.Errorf("recall rows: %w", err)
	}

	// Generalization layer: ask the pattern engine whether any template
	// applies to this query. Errors are swallowed; pattern hits are
	// additive on top of direct hits.
	patternHits, _ := patterns.Apply(ctx, db, query, normalized.NonEntityNormalized, normalized.Entities, patterns.Opts{
		JaccardMin:      jMin,
		Limit:           limit,
		AdditionalKinds: opts.PatternKinds,
	})
	if len(patternHits) > 0 {
		existing := make(map[string]struct{}, len(hits))
		for _, h := range hits {
			existing[hitKey(h.ResourceType, h.ResourceID)] = struct{}{}
		}
		for _, ph := range patternHits {
			key := hitKey(ph.ResourceType, ph.ResourceID)
			if _, dup := existing[key]; dup {
				continue
			}
			existing[key] = struct{}{}
			hits = append(hits, Hit{
				ResourceID:       ph.ResourceID,
				ResourceType:     ph.ResourceType,
				Venue:            ph.Venue,
				Action:           LearningActionBoost,
				Confidence:       ph.Confidence,
				MatchScore:       ph.MatchScore,
				EntityMatch:      EntityMatchExact,
				ResourceEntities: ph.ResourceEntities,
				Source:           SourcePattern,
				LastObservedAt:   ph.LastObservedAt,
			})
		}
	}

	sortHits(hits)
	sortHits(mismatches)

	if len(hits) > limit {
		hits = hits[:limit]
	}
	if len(mismatches) > limit {
		mismatches = mismatches[:limit]
	}

	if hits == nil {
		result.Results = []Hit{}
	} else {
		result.Results = hits
	}
	if opts.DebugMismatches {
		if mismatches == nil {
			result.Mismatches = []Hit{}
		} else {
			result.Mismatches = mismatches
		}
	}
	result.Found = len(hits) > 0
	if result.Found {
		result.MatchScore = hits[0].MatchScore
	}

	// Surface mismatches whose structural shape matches the query
	// as envelope-level warnings naming the alternative canonical.
	// Same-shape rows already passed the Jaccard floor; the only
	// reason they landed in mismatches is the entity differed.
	// Emitting the canonical here lets the agent see that a
	// similar-shape learning exists for a different entity, instead
	// of treating it as a cold start.
	if len(mismatchCanonicals) > 0 {
		canonicals := make([]string, 0, len(mismatchCanonicals))
		for c := range mismatchCanonicals {
			canonicals = append(canonicals, c)
		}
		sort.Strings(canonicals)
		for _, c := range canonicals {
			result.Warnings = append(result.Warnings, WarningSimilarShapeDifferentEntity+":"+c)
		}
	}

	if !result.Found && len(mismatches) == 0 {
		result.Warnings = append(result.Warnings, TopWarningNoLearningsForQueryFamily)
	}

	// Playbook + notes surface: orthogonal to the per-resource path.
	// Look up the structural query family in learning_playbooks. A hit
	// attaches the resolved playbook (with slot bindings) and the notes
	// text verbatim so the agent reads the gotchas before any step.
	//
	// sql.ErrNoRows is the common case; any other error is swallowed to
	// preserve the legacy contract that recall doesn't fail when the
	// optional learning_playbooks table is absent or queryable in
	// unexpected ways. Parse errors on a malformed playbook_json log by
	// omission -- the row's Notes still surface so guidance lands even
	// when the structured choreography is unusable.
	family := QueryFamily(normalized)
	if family != "" {
		var (
			playbookJSON sql.NullString
			notesText    sql.NullString
			pbConfidence int
		)
		lookupErr := db.QueryRowContext(ctx,
			`SELECT COALESCE(playbook_json, ''), COALESCE(notes_text, ''), confidence
			 FROM learning_playbooks WHERE query_family = ?`,
			family,
		).Scan(&playbookJSON, &notesText, &pbConfidence)
		if lookupErr == nil {
			rp := &ResolvedPlaybook{
				QueryFamily: family,
				Confidence:  pbConfidence,
				Notes:       notesText.String,
			}
			if playbookJSON.String != "" {
				if pb, perr := ParsePlaybook([]byte(playbookJSON.String), "learning_playbooks:"+family); perr == nil {
					rp.Playbook = pb
					rp.SlotsResolved = ResolveSlots(pb, normalized, resolver)
				}
				// Parse errors are logged-by-omission: keep Notes,
				// drop the structured playbook. The agent still gets
				// the human guidance even when the JSON is malformed.
			}
			// Only attach when there's at least one piece of content.
			// An empty row would have been rejected at upsert time, but
			// defense in depth keeps the envelope tidy if one slips
			// through.
			if rp.Notes != "" || len(rp.Playbook.Steps) > 0 {
				result.Playbook = rp
				result.Notes = notesText.String
			}
		}
	}

	return result, nil
}

func validateResource(ctx context.Context, db *sql.DB, cfg *entities.Config, hit *Hit, queryEntities, storedEntitySlice []string, fieldsByType map[string][]string) {
	var data string
	err := db.QueryRowContext(ctx,
		`SELECT data FROM resources WHERE resource_type = ? AND id = ?`,
		hit.ResourceType, hit.ResourceID,
	).Scan(&data)
	if err != nil {
		hit.Warnings = append(hit.Warnings, WarningResourceNotInStore)
		hit.EntityMatch = ClassifyEntityMatch(queryEntities, storedEntitySlice)
		if hit.EntityMatch == EntityMatchPartial && len(queryEntities) == 0 && len(storedEntitySlice) == 0 {
			hit.EntityMatch = EntityMatchUnknown
		}
		addLowConfidenceWarning(hit)
		return
	}
	var fields []string
	if fieldsByType != nil {
		fields = fieldsByType[hit.ResourceType]
	}
	resourceEntities := ResourceEntitiesFromJSON([]byte(data), fields, cfg)
	hit.ResourceEntities = resourceEntities
	hit.EntityMatch = ClassifyEntityMatch(queryEntities, resourceEntities)
	addLowConfidenceWarning(hit)
}

func addLowConfidenceWarning(hit *Hit) {
	if hit.Confidence < 2 {
		hit.Warnings = append(hit.Warnings, WarningLowConfidence)
	}
}

func sortHits(hits []Hit) {
	sort.SliceStable(hits, func(i, j int) bool {
		pi := entityMatchPriority(hits[i].EntityMatch)
		pj := entityMatchPriority(hits[j].EntityMatch)
		if pi != pj {
			return pi < pj
		}
		si := sourcePriority(hits[i].Source)
		sj := sourcePriority(hits[j].Source)
		if si != sj {
			return si < sj
		}
		if hits[i].Confidence != hits[j].Confidence {
			return hits[i].Confidence > hits[j].Confidence
		}
		if hits[i].MatchScore != hits[j].MatchScore {
			return hits[i].MatchScore > hits[j].MatchScore
		}
		ai := time.Time{}
		aj := time.Time{}
		if hits[i].LastObservedAt != nil {
			ai = *hits[i].LastObservedAt
		}
		if hits[j].LastObservedAt != nil {
			aj = *hits[j].LastObservedAt
		}
		return ai.After(aj)
	})
}

func sourcePriority(source string) int {
	if source == SourcePattern {
		return 1
	}
	return 0
}

func hitKey(resourceType, resourceID string) string {
	return resourceType + "|" + resourceID
}

func entityMatchPriority(em string) int {
	switch em {
	case EntityMatchExact:
		return 0
	case EntityMatchPartial:
		return 1
	case EntityMatchUnknown:
		return 2
	case EntityMatchMismatch:
		return 3
	default:
		return 4
	}
}

// entitySlicesIntersect reports whether two literal entity slices
// share at least one element after case-insensitive comparison.
// Used to detect "same literal entity" -- normalized.Entities is
// lowercased by Normalize, but storedEntitySlice comes straight from
// ParseStoredEntities (which preserves the case the extractor saw at
// teach time). A naive case-sensitive comparison would miss the match.
// Same-entity rows must not slip through the lower crossAliasMin floor
// or get inflated by canonicalJaccard.
func entitySlicesIntersect(a, b []string) bool {
	if len(a) == 0 || len(b) == 0 {
		return false
	}
	if len(a) > len(b) {
		a, b = b, a
	}
	seen := make(map[string]struct{}, len(a))
	for _, v := range a {
		seen[strings.ToLower(strings.TrimSpace(v))] = struct{}{}
	}
	for _, v := range b {
		if _, ok := seen[strings.ToLower(strings.TrimSpace(v))]; ok {
			return true
		}
	}
	return false
}

// setIntersects reports whether two canonical sets share at least one
// element. Used as the cross-alias gate for entity-classification
// promotion (Mismatch -> Exact when canonicals overlap).
func setIntersects(a, b map[string]struct{}) bool {
	if len(a) == 0 || len(b) == 0 {
		return false
	}
	// Walk the smaller set for efficiency.
	if len(a) > len(b) {
		a, b = b, a
	}
	for k := range a {
		if _, ok := b[k]; ok {
			return true
		}
	}
	return false
}

// canonicalJaccard returns the Jaccard score between two canonical
// sets. Used as the score for cross-alias matches when literal
// Jaccard misses the threshold but canonicals overlap.
func canonicalJaccard(a, b map[string]struct{}) float64 {
	if len(a) == 0 || len(b) == 0 {
		return 0
	}
	var inter int
	for k := range a {
		if _, ok := b[k]; ok {
			inter++
		}
	}
	union := len(a) + len(b) - inter
	if union == 0 {
		return 0
	}
	return float64(inter) / float64(union)
}
