// 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 store

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

// Learning action constants. See LearningRow.Action.
const (
	LearningActionBoost  = "boost"
	LearningActionHide   = "hide"
	LearningActionAlias  = "alias_of"
	LearningSourceTaught = "taught"
	LearningSourceManual = "manual-forget"
	defaultJaccardMin    = 0.6
	defaultRecallLimit   = 10
)

// LearningRow is one row in the search_learnings table — the LLM-driven
// per-query reranking signal. Populated by the `teach` command at
// response-time and read by the rerank Apply pass in topic/compare on
// subsequent queries.
//
// QueryPattern is always stored normalized (lowercase, whitespace-
// collapsed, stopwords stripped) so two semantically-equivalent natural-
// language queries (e.g., "What are Portugal's World Cup odds?" and
// "portugal world cup odds") collapse to the same row.
type LearningRow struct {
	ID             int64      `json:"id"`
	QueryPattern   string     `json:"query_pattern"`
	Venue          string     `json:"venue,omitempty"`
	ResourceType   string     `json:"resource_type,omitempty"`
	ResourceID     string     `json:"resource_id"`
	Action         string     `json:"action"`
	AliasTarget    string     `json:"alias_target,omitempty"`
	Source         string     `json:"source"`
	Confidence     int        `json:"confidence"`
	CreatedAt      time.Time  `json:"created_at"`
	LastObservedAt *time.Time `json:"last_observed_at,omitempty"`
	Notes          string     `json:"notes,omitempty"`
}

// stopwords stripped from query patterns to normalize semantically
// equivalent natural-language queries. Conservative set — only the
// tokens that bracket every variant of "what are Portugal's odds".
var queryStopwords = map[string]struct{}{
	"a": {}, "an": {}, "the": {},
	"what": {}, "whats": {},
	"are": {}, "is": {}, "was": {}, "were": {},
	"do": {}, "does": {}, "did": {},
	"of": {}, "for": {}, "to": {}, "on": {},
	"in": {}, "at": {}, "by": {}, "with": {},
	"odds": {}, // odds-flavored helper word that exists on both sides
}

// NormalizeQuery lowercases, strips punctuation, collapses whitespace,
// and removes a small stopword set. Exported so the CLI layer uses
// the same normalization at both write (teach) and read (recall + apply)
// time. The token set used by the Jaccard match is a side product —
// see normalizeAndTokens.
func NormalizeQuery(s string) string {
	normalized, _ := normalizeAndTokens(s)
	return normalized
}

// normalizeAndTokens returns both the normalized string and the set of
// non-stopword tokens. The token set is the canonical form the recall
// Jaccard matcher uses; the string form is the canonical key under
// which a learning is stored.
func normalizeAndTokens(s string) (string, map[string]struct{}) {
	s = strings.ToLower(strings.TrimSpace(s))
	// Replace common punctuation with spaces so "portugal's" splits into
	// "portugal" + "s" and "?" disappears entirely.
	b := strings.Builder{}
	b.Grow(len(s))
	for _, r := range s {
		switch {
		case r >= 'a' && r <= 'z':
			b.WriteRune(r)
		case r >= '0' && r <= '9':
			b.WriteRune(r)
		default:
			b.WriteByte(' ')
		}
	}
	rawTokens := strings.Fields(b.String())
	tokens := make(map[string]struct{}, len(rawTokens))
	kept := make([]string, 0, len(rawTokens))
	for _, t := range rawTokens {
		if t == "" || t == "s" {
			continue
		}
		if _, drop := queryStopwords[t]; drop {
			continue
		}
		if _, dup := tokens[t]; dup {
			continue
		}
		tokens[t] = struct{}{}
		kept = append(kept, t)
	}
	return strings.Join(kept, " "), tokens
}

// UpsertLearningInput is the call-shape Upsert accepts. ResourceType and
// Venue may be empty; Action defaults to "boost" when empty.
//
// QueryEntities is the optional precomputed entity slice the caller
// extracted from the raw (pre-normalization) Query. When non-nil it
// is JSON-marshaled and written to the search_learnings.query_entities
// column on insert. When nil, the column is left NULL; a future
// re-Open pass or manual backfill can populate it from the raw query.
//
// Why caller-provided rather than auto-extracted here: the store
// package is domain-agnostic by design (see learnings.go's package
// comment). Domain-specific ticker patterns and stopword vocabulary
// live in internal/learn (one layer up), and the entities themselves
// come from the case-preserving raw query — which is lost by the
// time NormalizeQuery has run inside this function. The CLI teach
// path computes entities from the raw query before calling Upsert
// and passes them through here.
type UpsertLearningInput struct {
	Query         string
	QueryEntities []string
	ResourceID    string
	ResourceType  string
	Venue         string
	Action        string
	AliasTarget   string
	Source        string
	Notes         string
}

// UpsertLearning inserts a learning row or, when (query_pattern,
// resource_id, action) already exists, bumps confidence by 1 and
// refreshes last_observed_at. Source on the existing row is preserved;
// only confidence + last_observed_at update on re-teach. Returns the
// row's ID and a bool indicating whether the row was newly inserted.
func (s *Store) UpsertLearning(in UpsertLearningInput) (int64, bool, error) {
	if strings.TrimSpace(in.ResourceID) == "" {
		return 0, false, fmt.Errorf("upsert learning: resource_id is required")
	}
	action := in.Action
	if action == "" {
		action = LearningActionBoost
	}
	switch action {
	case LearningActionBoost, LearningActionHide, LearningActionAlias:
	default:
		return 0, false, fmt.Errorf("upsert learning: invalid action %q", action)
	}
	if action == LearningActionAlias && strings.TrimSpace(in.AliasTarget) == "" {
		return 0, false, fmt.Errorf("upsert learning: action=alias_of requires alias_target")
	}
	source := in.Source
	if source == "" {
		source = LearningSourceTaught
	}
	pattern := NormalizeQuery(in.Query)
	if pattern == "" {
		return 0, false, fmt.Errorf("upsert learning: query normalized to empty string")
	}

	s.writeMu.Lock()
	defer s.writeMu.Unlock()

	now := time.Now().UTC()
	tx, err := s.db.Begin()
	if err != nil {
		return 0, false, err
	}
	defer tx.Rollback()

	var existingID int64
	err = tx.QueryRow(
		`SELECT id FROM search_learnings
		 WHERE query_pattern = ? AND resource_id = ? AND action = ?`,
		pattern, in.ResourceID, action,
	).Scan(&existingID)
	if err == nil {
		if _, err := tx.Exec(
			`UPDATE search_learnings
			 SET confidence = confidence + 1, last_observed_at = ?
			 WHERE id = ?`,
			now, existingID,
		); err != nil {
			return 0, false, fmt.Errorf("upsert learning bump confidence: %w", err)
		}
		if err := tx.Commit(); err != nil {
			return 0, false, err
		}
		return existingID, false, nil
	}
	if err != sql.ErrNoRows {
		return 0, false, fmt.Errorf("upsert learning lookup: %w", err)
	}

	var venue, resourceType, aliasTarget, notes, queryEntities any
	if in.Venue != "" {
		venue = in.Venue
	}
	if in.ResourceType != "" {
		resourceType = in.ResourceType
	}
	if in.AliasTarget != "" {
		aliasTarget = in.AliasTarget
	}
	if in.Notes != "" {
		notes = in.Notes
	}
	// Materialize the entity slice as a JSON array string so the
	// column stores a stable shape ("[]" or "[\"Portugal\"]") rather
	// than NULL on rows that the caller did provide entities for.
	// Nil slice means "caller has nothing to say"; the column stays
	// NULL and a later migration / re-Open / U3 read path runs the
	// extractor against query_pattern as a fallback.
	if in.QueryEntities != nil {
		// Marshal an empty slice as the literal "[]" so the column
		// distinguishes "caller said no entities" from "caller didn't
		// provide entities at all." json.Marshal turns nil into
		// "null"; we handle the nil branch above to leave NULL.
		ents := in.QueryEntities
		b, err := json.Marshal(ents)
		if err != nil {
			return 0, false, fmt.Errorf("marshal query entities: %w", err)
		}
		queryEntities = string(b)
	}

	// U4: initial confidence is 2, not 1. The skill protocol says "skip
	// discovery on confidence>=2 + entity_match=exact", so landing at 1
	// meant the very first re-use never tripped the fast path -- the
	// learning effectively wasn't useful until its second observation,
	// which usually came too late to matter. Starting at 2 means the
	// first taught mapping is immediately load-bearing. Re-confirmations
	// (the UPDATE branch above) still bump by 1, so high-confidence rows
	// (3+) remain a meaningful signal for ranking ties.
	res, err := tx.Exec(
		`INSERT INTO search_learnings
		 (query_pattern, query_entities, venue, resource_type, resource_id, action, alias_target,
		  source, confidence, created_at, last_observed_at, notes)
		 VALUES (?, ?, ?, ?, ?, ?, ?, ?, 2, ?, ?, ?)`,
		pattern, queryEntities, venue, resourceType, in.ResourceID, action, aliasTarget,
		source, now, now, notes,
	)
	if err != nil {
		return 0, false, fmt.Errorf("upsert learning insert: %w", err)
	}
	id, err := res.LastInsertId()
	if err != nil {
		return 0, false, fmt.Errorf("upsert learning last id: %w", err)
	}
	if err := tx.Commit(); err != nil {
		return 0, false, err
	}
	return id, true, nil
}

// ListLearningsFilter narrows ListLearnings. Zero values mean unfiltered.
type ListLearningsFilter struct {
	Query         string
	Source        string
	ResourceID    string
	Action        string
	Limit         int
	MinConfidence int
}

// ListLearnings returns rows ordered by last_observed_at DESC. The query
// filter applies a normalized LIKE match against query_pattern so a
// filter value of "portugal" matches a row taught for "portugal world
// cup odds".
func (s *Store) ListLearnings(f ListLearningsFilter) ([]LearningRow, error) {
	clauses := []string{}
	args := []any{}
	if f.Query != "" {
		clauses = append(clauses, "query_pattern LIKE ?")
		args = append(args, "%"+NormalizeQuery(f.Query)+"%")
	}
	if f.Source != "" {
		clauses = append(clauses, "source = ?")
		args = append(args, f.Source)
	}
	if f.ResourceID != "" {
		clauses = append(clauses, "resource_id = ?")
		args = append(args, f.ResourceID)
	}
	if f.Action != "" {
		clauses = append(clauses, "action = ?")
		args = append(args, f.Action)
	}
	if f.MinConfidence > 0 {
		clauses = append(clauses, "confidence >= ?")
		args = append(args, f.MinConfidence)
	}
	where := ""
	if len(clauses) > 0 {
		where = "WHERE " + strings.Join(clauses, " AND ")
	}
	limit := f.Limit
	if limit <= 0 {
		limit = 200
	}
	args = append(args, limit)

	q := fmt.Sprintf(`SELECT id, query_pattern, COALESCE(venue, ''), COALESCE(resource_type, ''),
		resource_id, action, COALESCE(alias_target, ''), source, confidence,
		created_at, last_observed_at, COALESCE(notes, '')
		FROM search_learnings %s
		ORDER BY last_observed_at DESC, id DESC
		LIMIT ?`, where)

	rows, err := s.db.Query(q, args...)
	if err != nil {
		return nil, fmt.Errorf("list learnings: %w", err)
	}
	defer rows.Close()

	out := make([]LearningRow, 0)
	for rows.Next() {
		var r LearningRow
		var lastObs sql.NullTime
		if err := rows.Scan(&r.ID, &r.QueryPattern, &r.Venue, &r.ResourceType,
			&r.ResourceID, &r.Action, &r.AliasTarget, &r.Source, &r.Confidence,
			&r.CreatedAt, &lastObs, &r.Notes); err != nil {
			return nil, err
		}
		if lastObs.Valid {
			t := lastObs.Time
			r.LastObservedAt = &t
		}
		out = append(out, r)
	}
	return out, rows.Err()
}

// ForgetLearningsFilter selects which rows to delete. At least one
// non-empty field is required; pass All=true to wipe a query pattern
// without specifying resource or action.
type ForgetLearningsFilter struct {
	Query      string
	ResourceID string
	Action     string
	All        bool
}

// ForgetLearnings deletes rows matching the filter and returns the count
// removed. If All is false the filter must specify at least one of
// ResourceID or Action (the Query is the primary scoping key and is
// always required).
func (s *Store) ForgetLearnings(f ForgetLearningsFilter) (int64, error) {
	if f.Query == "" {
		return 0, fmt.Errorf("forget learnings: query is required")
	}
	if !f.All && f.ResourceID == "" && f.Action == "" {
		return 0, fmt.Errorf("forget learnings: pass --resource, --action, or --all")
	}
	pattern := NormalizeQuery(f.Query)
	if pattern == "" {
		return 0, fmt.Errorf("forget learnings: query normalized to empty string")
	}

	s.writeMu.Lock()
	defer s.writeMu.Unlock()

	clauses := []string{"query_pattern = ?"}
	args := []any{pattern}
	if f.ResourceID != "" {
		clauses = append(clauses, "resource_id = ?")
		args = append(args, f.ResourceID)
	}
	if f.Action != "" {
		clauses = append(clauses, "action = ?")
		args = append(args, f.Action)
	}
	q := "DELETE FROM search_learnings WHERE " + strings.Join(clauses, " AND ")
	res, err := s.db.Exec(q, args...)
	if err != nil {
		return 0, fmt.Errorf("forget learnings: %w", err)
	}
	n, _ := res.RowsAffected()
	return n, nil
}

// RecallMatch is one row returned by Recall — a learning that scored
// above the Jaccard threshold against the supplied query.
type RecallMatch struct {
	LearningRow
	MatchScore float64 `json:"match_score"`
}

// RecallOptions tunes Recall behavior.
type RecallOptions struct {
	// MinConfidence is the minimum confidence required for a row to
	// appear in the results. Defaults to 1 (any row) when 0.
	MinConfidence int
	// Limit caps the result count. Defaults to 10 when 0.
	Limit int
	// JaccardMin overrides the token-set Jaccard floor. Defaults to 0.6
	// when 0. Negative values are treated as 0 (any overlap matches).
	JaccardMin float64
}

// Recall returns learnings whose normalized query_pattern overlaps with
// the supplied query by token-set Jaccard >= opts.JaccardMin. Results
// are sorted by (match_score DESC, confidence DESC, last_observed_at DESC).
// An empty input or no match returns ([], nil) — Recall is an
// information query, not a not-found error.
func (s *Store) Recall(ctx context.Context, query string, opts RecallOptions) ([]RecallMatch, error) {
	_, inputTokens := normalizeAndTokens(query)
	if len(inputTokens) == 0 {
		return nil, nil
	}
	minConf := opts.MinConfidence
	if minConf <= 0 {
		minConf = 1
	}
	limit := opts.Limit
	if limit <= 0 {
		limit = defaultRecallLimit
	}
	jMin := opts.JaccardMin
	if jMin == 0 {
		jMin = defaultJaccardMin
	}
	if jMin < 0 {
		jMin = 0
	}

	// Fast path: count distinct query_patterns. Stays tiny per-user, so a
	// full scan with score-in-Go is the right tradeoff against trying to
	// implement set-overlap in SQL.
	rows, err := s.db.QueryContext(ctx, `SELECT id, query_pattern, COALESCE(venue, ''),
		COALESCE(resource_type, ''), resource_id, action, COALESCE(alias_target, ''),
		source, confidence, created_at, last_observed_at, COALESCE(notes, '')
		FROM search_learnings
		WHERE confidence >= ?`, minConf)
	if err != nil {
		return nil, fmt.Errorf("recall: %w", err)
	}
	defer rows.Close()

	matches := make([]RecallMatch, 0)
	for rows.Next() {
		var r LearningRow
		var lastObs sql.NullTime
		if err := rows.Scan(&r.ID, &r.QueryPattern, &r.Venue, &r.ResourceType,
			&r.ResourceID, &r.Action, &r.AliasTarget, &r.Source, &r.Confidence,
			&r.CreatedAt, &lastObs, &r.Notes); err != nil {
			return nil, err
		}
		if lastObs.Valid {
			t := lastObs.Time
			r.LastObservedAt = &t
		}
		_, rowTokens := normalizeAndTokens(r.QueryPattern)
		score := jaccard(inputTokens, rowTokens)
		if score < jMin {
			continue
		}
		matches = append(matches, RecallMatch{LearningRow: r, MatchScore: score})
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}

	// Sort: match_score DESC, confidence DESC, last_observed_at DESC.
	sortRecallMatches(matches)

	if len(matches) > limit {
		matches = matches[:limit]
	}
	return matches, nil
}

func sortRecallMatches(matches []RecallMatch) {
	// Insertion sort keeps the algorithm simple; n is bounded by the
	// per-user table size and the pre-filter on confidence.
	for i := 1; i < len(matches); i++ {
		for j := i; j > 0; j-- {
			a, b := matches[j], matches[j-1]
			if recallLess(a, b) {
				matches[j-1], matches[j] = a, b
			} else {
				break
			}
		}
	}
}

func recallLess(a, b RecallMatch) bool {
	if a.MatchScore != b.MatchScore {
		return a.MatchScore > b.MatchScore
	}
	if a.Confidence != b.Confidence {
		return a.Confidence > b.Confidence
	}
	at := time.Time{}
	bt := time.Time{}
	if a.LastObservedAt != nil {
		at = *a.LastObservedAt
	}
	if b.LastObservedAt != nil {
		bt = *b.LastObservedAt
	}
	return at.After(bt)
}

func jaccard(a, b map[string]struct{}) float64 {
	if len(a) == 0 || len(b) == 0 {
		return 0
	}
	inter := 0
	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)
}

// ApplyResult is what the rerank layer reports back to the caller for
// envelope emission. Count is the number of rules that visibly touched
// the output (boosts that moved a hit, hides that removed one, aliases
// that swapped one).
type ApplyResult struct {
	Count             int
	HasHighConfidence bool
	Warnings          []string
}

// LearnedHit represents a hit synthesized from a learning whose
// resource_id was not in the original FTS result set. The CLI layer
// fetches the resource from the resources table by (ResourceType, ID)
// and slots it into the output bundle at position 0 with
// `meta.source: "learned"`.
type LearnedHit struct {
	ResourceType string
	ResourceID   string
	Confidence   int
	Source       string // copy of LearningRow.Source for envelope-level diagnostics
}

// Applier abstracts the in-memory hit bundle the rerank layer operates
// on. The CLI layer implements one Applier per command (topic, compare)
// so the layer stays decoupled from each command's hit struct shape.
//
// Indexing is by stable string keys the command picks — typically
// "<source>|<id>" for topic, or per-pair "<venue>|<id>" for compare.
type Applier interface {
	// HasHit reports whether the in-memory bundle already contains a hit
	// keyed (resourceType, resourceID).
	HasHit(resourceType, resourceID string) bool
	// MoveToFront moves an existing hit to position 0 (or position N for
	// the Nth applied boost, in the order MoveToFront is called).
	MoveToFront(resourceType, resourceID string)
	// InsertLearnedHit inserts a synthetic hit at position 0 for a
	// learning whose resource the FTS layer missed entirely. The caller
	// is responsible for fetching the underlying row from the resources
	// table.
	InsertLearnedHit(h LearnedHit) error
	// RemoveHit drops a hit from the bundle.
	RemoveHit(resourceType, resourceID string)
	// ReplaceHit replaces (resourceType, resourceID) with the alias
	// target. Same fetch contract as InsertLearnedHit when the alias
	// target wasn't in the bundle.
	ReplaceHit(srcType, srcID, dstType, dstID string) error
}

// Apply loads learnings for the supplied query and runs the
// boost/hide/alias rules against the supplied Applier. Returns the
// count of rules that actually touched the output, plus any warnings
// (alias cycles, missing resources) for the caller to log.
//
// The match scope is: learnings whose normalized query_pattern equals
// the current normalized query, OR is a substring match (LIKE) against
// it. Substring widens "bitcoin" rules to fire on "bitcoin 100k".
func (s *Store) Apply(ctx context.Context, query string, ap Applier) (ApplyResult, error) {
	result := ApplyResult{}
	pattern := NormalizeQuery(query)
	if pattern == "" {
		return result, nil
	}
	rows, err := s.db.QueryContext(ctx,
		`SELECT id, query_pattern, COALESCE(venue, ''), COALESCE(resource_type, ''),
			resource_id, action, COALESCE(alias_target, ''), source, confidence,
			created_at, last_observed_at, COALESCE(notes, '')
		 FROM search_learnings
		 WHERE query_pattern = ? OR ? LIKE '%' || query_pattern || '%'
		 ORDER BY confidence DESC, last_observed_at DESC`,
		pattern, pattern,
	)
	if err != nil {
		return result, fmt.Errorf("apply learnings: %w", err)
	}
	defer rows.Close()

	type ruleRow struct {
		LearningRow
	}
	rules := make([]ruleRow, 0)
	for rows.Next() {
		var r LearningRow
		var lastObs sql.NullTime
		if err := rows.Scan(&r.ID, &r.QueryPattern, &r.Venue, &r.ResourceType,
			&r.ResourceID, &r.Action, &r.AliasTarget, &r.Source, &r.Confidence,
			&r.CreatedAt, &lastObs, &r.Notes); err != nil {
			return result, err
		}
		if lastObs.Valid {
			t := lastObs.Time
			r.LastObservedAt = &t
		}
		rules = append(rules, ruleRow{r})
	}
	if err := rows.Err(); err != nil {
		return result, err
	}

	// Detect alias cycles before applying any rule. A simple cycle is
	// (A -> B) + (B -> A) under the same query_pattern; drop both with
	// a warning rather than thrash the bundle.
	aliasGraph := make(map[string]string)
	for _, r := range rules {
		if r.Action == LearningActionAlias && r.AliasTarget != "" {
			aliasGraph[r.ResourceID] = r.AliasTarget
		}
	}
	cycleSrcs := make(map[string]struct{})
	for src, dst := range aliasGraph {
		if hop, ok := aliasGraph[dst]; ok && hop == src {
			cycleSrcs[src] = struct{}{}
			cycleSrcs[dst] = struct{}{}
		}
	}
	if len(cycleSrcs) > 0 {
		srcs := make([]string, 0, len(cycleSrcs))
		for src := range cycleSrcs {
			srcs = append(srcs, src)
		}
		result.Warnings = append(result.Warnings, fmt.Sprintf("alias cycle detected, dropping rules for: %s", strings.Join(srcs, ", ")))
	}

	for _, r := range rules {
		switch r.Action {
		case LearningActionBoost:
			if ap.HasHit(r.ResourceType, r.ResourceID) {
				ap.MoveToFront(r.ResourceType, r.ResourceID)
				result.Count++
			} else {
				if err := ap.InsertLearnedHit(LearnedHit{
					ResourceType: r.ResourceType,
					ResourceID:   r.ResourceID,
					Confidence:   r.Confidence,
					Source:       r.Source,
				}); err != nil {
					result.Warnings = append(result.Warnings, fmt.Sprintf("insert learned hit %s/%s: %v", r.ResourceType, r.ResourceID, err))
					continue
				}
				result.Count++
			}
			if r.Confidence >= 3 {
				result.HasHighConfidence = true
			}
		case LearningActionHide:
			if ap.HasHit(r.ResourceType, r.ResourceID) {
				ap.RemoveHit(r.ResourceType, r.ResourceID)
				result.Count++
			}
		case LearningActionAlias:
			if _, isCycle := cycleSrcs[r.ResourceID]; isCycle {
				continue
			}
			if r.AliasTarget == "" {
				continue
			}
			if err := ap.ReplaceHit(r.ResourceType, r.ResourceID, r.ResourceType, r.AliasTarget); err != nil {
				result.Warnings = append(result.Warnings, fmt.Sprintf("alias replace %s -> %s: %v", r.ResourceID, r.AliasTarget, err))
				continue
			}
			result.Count++
		}
	}

	return result, nil
}

// MarshalLearnings returns the JSON envelope shape used by both
// `learnings list` and the `recall` command for --agent output. Keeps
// the JSON shape decoupled from any cobra-level types.
func MarshalLearnings(rows []LearningRow) ([]byte, error) {
	return json.Marshal(rows)
}
