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

// teach.go implements the LLM-driven learning surface for the
// learn-loop-example-pp-cli CLI: `teach` (fire-and-forget, silent on success,
// safe to background with &), `recall` (pre-discovery short-circuit
// for known queries), `learnings list` (human inspection),
// `learnings forget` (human undo), `teach-pattern` (manual pattern
// install), and `teach-lookup` (manual entity-lookup row install).
//
// The commands here delegate to the generator-owned internal/learn
// package for the data-shape work; this file is the cobra surface
// that wires flags, validates inputs, and shapes the JSON output the
// MCP layer reflects to agents.

package cli

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"time"

	"github.com/spf13/cobra"

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

// noLearnEnvVar is the environment variable that disables the learning
// loop for a session. Used by deterministic agent flows that don't
// want a learning row to silently change subsequent query results.
const noLearnEnvVar = "LEARN_LOOP_EXAMPLE_NO_LEARN"

// learningsAuditFileName is the JSONL audit log alongside the DB.
const learningsAuditFileName = "learnings.jsonl"

// teachErrLogFileName is the plain-text error log for backgrounded
// teach failures. Distinct from the structured warnings file the
// learn package maintains via TeachLogPath/AppendTeachLogWarning.
const teachErrLogFileName = "teach.log"

// noLearnEnabled reports whether the env var is truthy.
func noLearnEnabled() bool {
	v := strings.ToLower(strings.TrimSpace(os.Getenv(noLearnEnvVar)))
	return v == "true" || v == "1" || v == "yes"
}

// noLearnActive combines the --no-learn flag with the env var so call
// sites stay declarative.
func noLearnActive(flags *rootFlags) bool {
	if flags != nil && flags.noLearn {
		return true
	}
	return noLearnEnabled()
}

// learningsStateDir returns the directory hosting the audit log and
// error log. Created on first use with 0o700 so a multi-user machine
// doesn't accidentally expose one user's learned queries.
func learningsStateDir() (string, error) {
	dir, err := cliutil.StateDir()
	if err != nil {
		return "", err
	}
	if err := os.MkdirAll(dir, 0o700); err != nil {
		return "", err
	}
	return dir, nil
}

func learningsAuditPath() (string, error) {
	dir, err := learningsStateDir()
	if err != nil {
		return "", err
	}
	return filepath.Join(dir, learningsAuditFileName), nil
}

func teachErrLogPath() (string, error) {
	dir, err := learningsStateDir()
	if err != nil {
		return "", err
	}
	return filepath.Join(dir, teachErrLogFileName), nil
}

// writeTeachErrLog appends a single line to teach.log. Best-effort.
func writeTeachErrLog(line string) {
	p, err := teachErrLogPath()
	if err != nil {
		return
	}
	f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
	if err != nil {
		return
	}
	defer f.Close()
	ts := time.Now().UTC().Format(time.RFC3339)
	fmt.Fprintf(f, "%s %s\n", ts, line)
}

// appendLearningsAudit records one event in the JSONL audit log.
func appendLearningsAudit(entry map[string]any) error {
	p, err := learningsAuditPath()
	if err != nil {
		return err
	}
	f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
	if err != nil {
		return err
	}
	defer f.Close()
	entry["ts"] = time.Now().UTC().Format(time.RFC3339)
	return json.NewEncoder(f).Encode(entry)
}

// silentCodeErr returns an error that ExitCode honors but that carries
// no message. Used by the teach command's error path so a backgrounded
// failure leaks nothing to the parent shell's stderr — the diagnosis
// lives in teach.log instead.
func silentCodeErr(code int) error {
	return &cliError{code: code, err: silentSentinel{}}
}

type silentSentinel struct{}

func (silentSentinel) Error() string { return "" }

// learnDBPath resolves the SQLite path the learn commands should use:
// the explicit --db flag wins, otherwise the canonical default.
func learnDBPath(explicit string) string {
	if explicit != "" {
		return explicit
	}
	return defaultDBPath("learn-loop-example-pp-cli")
}

// newTeachCmd builds the `teach` cobra command — the LLM-facing write
// surface. Silent on success, safe to background, errors only to
// teach.log. Requires --resource-type so recalls can validate returned
// IDs against the typed resources table.
func newTeachCmd(flags *rootFlags, learnCfg *entities.Config) *cobra.Command {
	var query string
	var resources []string
	var venueArg string
	var resourceType string
	var quiet bool
	var dbPath string
	var notes string
	var noValidate bool
	// Playbook side -- optional. When either is set, after the resource
	// learning lands, also upsert a learning_playbooks row keyed on the
	// query family. Failures here log to teach.log but don't fail the
	// resource learning (graceful degrade).
	var playbookFile string
	var playbookNotesInline string
	var playbookNotesFile string

	cmd := &cobra.Command{
		Use:   "teach",
		Short: "Record a query -> resource mapping for future recall (LLM-fired, silent)",
		Long: `Record one or more "this query -> this resource" mappings so the next
query that matches surfaces the right resources without re-running discovery.

This command is designed to be backgrounded by an LLM right before it
emits the user-facing response: silent on success, errors only to
the CLI state directory's teach.log, safe to fire-and-forget.

Disabling: pass --no-learn or set ` + noLearnEnvVar + `=true.`,
		Example: `  learn-loop-example-pp-cli teach --query "<question>" --resource-type <type> \
    --resource <id> --resource <id> &`,
		RunE: func(cmd *cobra.Command, args []string) error {
			cmd.SilenceUsage = true
			cmd.SilenceErrors = true
			if noLearnActive(flags) {
				return nil
			}
			if dryRunOK(flags) {
				return nil
			}
			if strings.TrimSpace(query) == "" {
				writeTeachErrLog(fmt.Sprintf("teach: missing --query (args=%v resources=%v)", args, resources))
				return silentCodeErr(2)
			}
			if len(resources) == 0 {
				writeTeachErrLog(fmt.Sprintf("teach: missing --resource for query=%q", query))
				return silentCodeErr(2)
			}
			if strings.TrimSpace(resourceType) == "" {
				writeTeachErrLog(fmt.Sprintf("teach: missing --resource-type for query=%q", query))
				return silentCodeErr(2)
			}
			dbPath = learnDBPath(dbPath)
			s, err := store.OpenWithContext(cmd.Context(), dbPath)
			if err != nil {
				writeTeachErrLog(fmt.Sprintf("teach: open db: %v", err))
				return silentCodeErr(1)
			}
			defer s.Close()

			normalized := learn.Normalize(query, learnCfg)
			// Apply entity_lookups promotion symmetrically with recall so
			// lowercase/numeric-prefix aliases land in query_entities even
			// when the capitalization-based extractor missed them. Without
			// this, recall's cross-alias canonical resolver has nothing to
			// compare against on the stored side, and teach-playbook would
			// derive a different family for the same query.
			resolver := learn.NewCanonicalResolver(cmd.Context(), s.DB())
			normalized = learn.PromoteEntities(normalized, resolver)
			for _, rid := range resources {
				rid = strings.TrimSpace(rid)
				if rid == "" {
					continue
				}
				if _, _, uerr := s.UpsertLearning(store.UpsertLearningInput{
					Query:         query,
					QueryEntities: normalized.Entities,
					ResourceID:    rid,
					ResourceType:  resourceType,
					Venue:         venueArg,
					Source:        store.LearningSourceTaught,
					Notes:         notes,
				}); uerr != nil {
					writeTeachErrLog(fmt.Sprintf("teach: upsert %q for query=%q: %v", rid, query, uerr))
					return silentCodeErr(1)
				}

				if !noValidate {
					for _, w := range learn.ValidateResourceShape(cmd.Context(), s.DB(), learnCfg, query, rid, resourceType, nil) {
						if logErr := learn.AppendTeachLogWarning("teach", query, w); logErr != nil {
							writeTeachErrLog(fmt.Sprintf("teach: warn append: %v", logErr))
						}
					}
				}
			}

			// Post-teach pattern extraction. The call is cheap and
			// idempotent (search_patterns unique index silences
			// duplicates); errors are non-fatal.
			if _, exErr := patterns.Extract(s.DB(), nil); exErr != nil {
				writeTeachErrLog(fmt.Sprintf("teach: patterns.Extract: %v", exErr))
			}

			// Optional playbook side: record the structured choreography
			// + free-text gotchas keyed on the query family. Either
			// field may be set; both empty means skip. Failures log to
			// teach.log but don't fail the resource learning above --
			// the agent's primary write (resource learning) already
			// succeeded, so degraded playbook recording is acceptable.
			if strings.TrimSpace(playbookFile) != "" || strings.TrimSpace(playbookNotesInline) != "" || strings.TrimSpace(playbookNotesFile) != "" {
				if pbErr := upsertPlaybookFromTeach(cmd.Context(), s, learnCfg, query, playbookFile, playbookNotesInline, playbookNotesFile, normalized); pbErr != nil {
					writeTeachErrLog(fmt.Sprintf("teach: playbook upsert: %v", pbErr))
				}
			}

			if auditErr := appendLearningsAudit(map[string]any{
				"action":     "teach",
				"query":      query,
				"normalized": normalized.NonEntityNormalized,
				"resources":  resources,
				"venue":      venueArg,
				"notes":      notes,
			}); auditErr != nil {
				writeTeachErrLog(fmt.Sprintf("teach: audit append: %v", auditErr))
			}

			if !quiet && flags.asJSON {
				return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
					"recorded":   true,
					"query":      query,
					"normalized": normalized.NonEntityNormalized,
					"resources":  resources,
				}, flags)
			}
			return nil
		},
	}
	cmd.Flags().StringVar(&query, "query", "", "User's original natural-language question (required)")
	cmd.Flags().StringSliceVar(&resources, "resource", nil, "Resource ID — repeat for multiple (required)")
	cmd.Flags().StringVar(&venueArg, "venue", "", "Optional venue scope tag stored with the learning")
	cmd.Flags().StringVar(&resourceType, "resource-type", "", "Resource type (required; e.g. matches a row in the local resources table)")
	cmd.Flags().BoolVar(&quiet, "quiet", true, "Silent on success (default true — designed for background invocation)")
	cmd.Flags().StringVar(&dbPath, "db", "", "SQLite database file path (default: resolved data directory data.db)")
	cmd.Flags().StringVar(&notes, "notes", "", "Optional free-form note recorded in the audit log")
	cmd.Flags().BoolVar(&noValidate, "no-validate", false, "Suppress teach-time resource-shape validation (warnings to teach.log)")
	cmd.Flags().StringVar(&playbookFile, "playbook-file", "", "Optional path to a JSON playbook recording the CLI choreography for this query family")
	cmd.Flags().StringVar(&playbookNotesInline, "playbook-notes", "", "Optional inline gotchas/workarounds for this query family (stored alongside the playbook)")
	cmd.Flags().StringVar(&playbookNotesFile, "playbook-notes-file", "", "Optional path to a markdown file with playbook notes")
	return cmd
}

// upsertPlaybookFromTeach loads playbook + notes inputs and writes a
// learning_playbooks row. Helper for the teach command's optional
// playbook-side write. Failures bubble up to the caller, which logs
// them to teach.log without failing the surrounding resource learning.
//
// ctx is first per Go convention (and to satisfy staticcheck SA1012).
// ctx, learnCfg, and query are accepted today for signature parity with
// the standalone teach-playbook flow; the helper does not yet need them
// directly because normalize+promote runs in the caller. Keeping the
// parameters reserved here means future refinements (e.g. a per-call
// resolver reused for slot validation) don't have to ripple back into
// the cobra surface.
func upsertPlaybookFromTeach(ctx context.Context, s *store.Store, learnCfg *entities.Config, query, playbookFile, notesInline, notesFile string, normalized learn.NormalizedQuery) error {
	playbookJSON, notes, err := resolvePlaybookInputs(playbookFile, notesInline, notesFile)
	if err != nil {
		return err
	}
	if playbookJSON == "" && notes == "" {
		return nil
	}
	family := learn.QueryFamily(normalized)
	if family == "" {
		return fmt.Errorf("query normalized to empty family")
	}
	_, _, err = s.UpsertPlaybook(store.UpsertPlaybookInput{
		QueryFamily:  family,
		PlaybookJSON: playbookJSON,
		NotesText:    notes,
	})
	return err
}

// recallEnvelope is the JSON shape returned by `recall --agent`. The
// LLM consumes this before deciding whether to skip discovery.
//
// Playbook + Notes surface the learning_playbooks row matching the
// query's structural family (when one exists). Older agent prompts
// that only consume {found, results} continue to work; the new fields
// are purely additive.
type recallEnvelope struct {
	Found         bool                    `json:"found"`
	Query         string                  `json:"query"`
	Normalized    string                  `json:"normalized"`
	QueryEntities []string                `json:"query_entities"`
	MatchScore    float64                 `json:"match_score,omitempty"`
	Results       []recallEnvelopeResult  `json:"results"`
	Mismatches    []recallEnvelopeResult  `json:"mismatches,omitempty"`
	Warnings      []string                `json:"warnings,omitempty"`
	Playbook      *learn.ResolvedPlaybook `json:"playbook,omitempty"`
	Notes         string                  `json:"notes,omitempty"`
}

type recallEnvelopeResult struct {
	ResourceID       string     `json:"resource_id"`
	ResourceType     string     `json:"resource_type,omitempty"`
	Venue            string     `json:"venue,omitempty"`
	Action           string     `json:"action"`
	AliasTarget      string     `json:"alias_target,omitempty"`
	Confidence       int        `json:"confidence"`
	MatchScore       float64    `json:"match_score"`
	EntityMatch      string     `json:"entity_match,omitempty"`
	ResourceEntities []string   `json:"resource_entities,omitempty"`
	Source           string     `json:"source"`
	LastObservedAt   *time.Time `json:"last_observed_at,omitempty"`
	Warnings         []string   `json:"warnings,omitempty"`
}

// newRecallCmd builds the read side of the loop: an LLM calls recall
// FIRST when starting work on a new user question, and uses the
// returned IDs to short-circuit discovery.
func newRecallCmd(flags *rootFlags, learnCfg *entities.Config) *cobra.Command {
	var minConf int
	var limit int
	var dbPath string
	var debugMismatches bool

	cmd := &cobra.Command{
		Use:   "recall <query>",
		Short: "Check prior learnings for a query before running discovery (LLM-fired, pre-discovery)",
		Long: `Returns prior learnings matching the supplied query by token-set
overlap (Jaccard >= 0.6) plus entity-aware validation. The LLM should
call this FIRST when starting work on a new user question; if
found=true AND results[0].entity_match == "exact" AND confidence >= 2,
the LLM can skip discovery and go straight to live fetch for the
returned resource IDs.

Empty match returns {"found": false, "results": []} with exit 0 — this
is an information query, not a not-found error.

Disabling: ` + noLearnEnvVar + `=true returns the empty shape even
when learnings exist.`,
		Example: `  learn-loop-example-pp-cli recall "<question>" --agent
  learn-loop-example-pp-cli recall "<question>" --agent --min-confidence 2`,
		Annotations: map[string]string{"mcp:read-only": "true"},
		RunE: func(cmd *cobra.Command, args []string) error {
			if len(args) == 0 {
				return cmd.Help()
			}
			if dryRunOK(flags) {
				return nil
			}
			query := strings.Join(args, " ")
			envelope := recallEnvelope{
				Query:         query,
				QueryEntities: []string{},
				Results:       []recallEnvelopeResult{},
			}
			if noLearnActive(flags) {
				normalized := learn.Normalize(query, learnCfg)
				envelope.Normalized = normalized.NonEntityNormalized
				envelope.QueryEntities = append([]string{}, normalized.Entities...)
				if envelope.QueryEntities == nil {
					envelope.QueryEntities = []string{}
				}
				return emitRecall(cmd, flags, envelope)
			}
			dbPath = learnDBPath(dbPath)
			s, err := store.OpenWithContext(cmd.Context(), dbPath)
			if err != nil {
				return fmt.Errorf("recall: %w", err)
			}
			defer s.Close()

			result, err := learn.Recall(cmd.Context(), s.DB(), query, learn.Opts{
				EntityConfig:    learnCfg,
				MinConfidence:   minConf,
				Limit:           limit,
				DebugMismatches: debugMismatches,
			})
			if err != nil {
				return fmt.Errorf("recall: %w", err)
			}
			envelope.Found = result.Found
			envelope.Normalized = result.Normalized
			envelope.QueryEntities = result.QueryEntities
			envelope.MatchScore = result.MatchScore
			envelope.Results = toEnvelopeResults(result.Results)
			if debugMismatches {
				envelope.Mismatches = toEnvelopeResults(result.Mismatches)
			}
			envelope.Warnings = result.Warnings
			envelope.Playbook = result.Playbook
			envelope.Notes = result.Notes
			return emitRecall(cmd, flags, envelope)
		},
	}
	cmd.Flags().IntVar(&minConf, "min-confidence", 1, "Minimum confidence to include in results")
	cmd.Flags().IntVar(&limit, "limit", 10, "Maximum number of learnings to return")
	cmd.Flags().StringVar(&dbPath, "db", "", "SQLite database file path (default: resolved data directory data.db)")
	cmd.Flags().BoolVar(&debugMismatches, "debug-mismatches", false, "Include cross-entity mismatches in the envelope under mismatches[]")
	return cmd
}

func toEnvelopeResults(in []learn.Hit) []recallEnvelopeResult {
	if len(in) == 0 {
		return []recallEnvelopeResult{}
	}
	out := make([]recallEnvelopeResult, 0, len(in))
	for _, h := range in {
		out = append(out, recallEnvelopeResult{
			ResourceID:       h.ResourceID,
			ResourceType:     h.ResourceType,
			Venue:            h.Venue,
			Action:           h.Action,
			AliasTarget:      h.AliasTarget,
			Confidence:       h.Confidence,
			MatchScore:       h.MatchScore,
			EntityMatch:      h.EntityMatch,
			ResourceEntities: h.ResourceEntities,
			Source:           h.Source,
			LastObservedAt:   h.LastObservedAt,
			Warnings:         h.Warnings,
		})
	}
	return out
}

func emitRecall(cmd *cobra.Command, flags *rootFlags, env recallEnvelope) error {
	if flags.asJSON || !isTerminal(cmd.OutOrStdout()) {
		return printJSONFiltered(cmd.OutOrStdout(), env, flags)
	}
	if !env.Found {
		fmt.Fprintf(cmd.OutOrStdout(), "no prior learnings for %q\n", env.Query)
		return nil
	}
	fmt.Fprintf(cmd.OutOrStdout(), "%d learning(s) for %q (top match %.2f):\n", len(env.Results), env.Query, env.MatchScore)
	for _, r := range env.Results {
		fmt.Fprintf(cmd.OutOrStdout(), "  %s\t%s\tconf=%d\tmatch=%.2f\n", r.Action, r.ResourceID, r.Confidence, r.MatchScore)
	}
	return nil
}

// newLearningsCmd is the human-inspection parent. `learnings list` lists
// rows; `learnings forget` removes them.
func newLearningsCmd(flags *rootFlags, learnCfg *entities.Config) *cobra.Command {
	cmd := &cobra.Command{
		Use:   "learnings",
		Short: "Inspect or forget the local search_learnings table",
		Long: `Surface for browsing, filtering, and deleting rows in the
search_learnings table that the LLM populates via the 'teach' command.`,
		Annotations: map[string]string{"mcp:read-only": "true"},
		RunE:        parentNoSubcommandRunE(flags),
	}
	cmd.AddCommand(newLearningsListCmd(flags))
	cmd.AddCommand(newLearningsForgetCmd(flags))
	return cmd
}

// learningRow is the JSON output shape for `learnings list`. Mirrors
// store.LearningRow but locks the JSON contract at the CLI layer so
// agents that key on it across PP versions stay stable across
// generator upgrades.
type learningRow struct {
	ID             int64      `json:"id"`
	QueryPattern   string     `json:"query_pattern"`
	ResourceID     string     `json:"resource_id"`
	ResourceType   string     `json:"resource_type,omitempty"`
	Venue          string     `json:"venue,omitempty"`
	Action         string     `json:"action"`
	AliasTarget    string     `json:"alias_target,omitempty"`
	Confidence     int        `json:"confidence"`
	Source         string     `json:"source"`
	CreatedAt      time.Time  `json:"created_at"`
	LastObservedAt *time.Time `json:"last_observed_at,omitempty"`
	Notes          string     `json:"notes,omitempty"`
}

// listLearningsRows queries the store via the canonical ListLearnings
// API and translates each row into the CLI envelope shape.
func listLearningsRows(s *store.Store, f store.ListLearningsFilter) ([]learningRow, error) {
	stored, err := s.ListLearnings(f)
	if err != nil {
		return nil, err
	}
	out := make([]learningRow, 0, len(stored))
	for _, r := range stored {
		out = append(out, learningRow{
			ID:             r.ID,
			QueryPattern:   r.QueryPattern,
			ResourceID:     r.ResourceID,
			ResourceType:   r.ResourceType,
			Venue:          r.Venue,
			Action:         r.Action,
			AliasTarget:    r.AliasTarget,
			Confidence:     r.Confidence,
			Source:         r.Source,
			CreatedAt:      r.CreatedAt,
			LastObservedAt: r.LastObservedAt,
			Notes:          r.Notes,
		})
	}
	return out, nil
}

func newLearningsListCmd(flags *rootFlags) *cobra.Command {
	var queryFilter string
	var sourceFilter string
	var resourceFilter string
	var actionFilter string
	var minConf int
	var limit int
	var dbPath string
	var warningsOnly bool

	cmd := &cobra.Command{
		Use:   "list",
		Short: "List recorded learnings",
		Example: `  learn-loop-example-pp-cli learnings list --agent
  learn-loop-example-pp-cli learnings list --query "<substring>"
  learn-loop-example-pp-cli learnings list --warnings --agent`,
		Annotations: map[string]string{"mcp:read-only": "true"},
		RunE: func(cmd *cobra.Command, args []string) error {
			if dryRunOK(flags) {
				return nil
			}
			if warningsOnly {
				var filterIDs []string
				if strings.TrimSpace(resourceFilter) != "" {
					filterIDs = []string{resourceFilter}
				}
				entries, err := learn.ReadTeachLogWarnings(filterIDs...)
				if err != nil {
					return fmt.Errorf("learnings list --warnings: %w", err)
				}
				if entries == nil {
					entries = []learn.TeachLogEntry{}
				}
				if flags.asJSON || !isTerminal(cmd.OutOrStdout()) {
					return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
						"warnings": entries,
					}, flags)
				}
				if len(entries) == 0 {
					fmt.Fprintln(cmd.OutOrStdout(), "(no teach.log warnings recorded)")
					return nil
				}
				for _, e := range entries {
					fmt.Fprintf(cmd.OutOrStdout(), "%s\t%s\t%s\tsuggested=%s\n",
						e.TS, e.Warning, e.Resource, e.Suggested)
				}
				return nil
			}

			dbPath = learnDBPath(dbPath)
			s, err := store.OpenWithContext(cmd.Context(), dbPath)
			if err != nil {
				return fmt.Errorf("learnings list: %w", err)
			}
			defer s.Close()

			rows, err := listLearningsRows(s, store.ListLearningsFilter{
				Query:         queryFilter,
				Source:        sourceFilter,
				ResourceID:    resourceFilter,
				Action:        actionFilter,
				MinConfidence: minConf,
				Limit:         limit,
			})
			if err != nil {
				return fmt.Errorf("learnings list: %w", err)
			}

			if flags.asJSON || !isTerminal(cmd.OutOrStdout()) {
				return printJSONFiltered(cmd.OutOrStdout(), rows, flags)
			}
			if len(rows) == 0 {
				fmt.Fprintln(cmd.OutOrStdout(), "(no learnings recorded)")
				return nil
			}
			for _, r := range rows {
				fmt.Fprintf(cmd.OutOrStdout(), "%s\t%s\t%s\tconf=%d\tsource=%s\n",
					r.QueryPattern, r.Action, r.ResourceID, r.Confidence, r.Source)
			}
			return nil
		},
	}
	cmd.Flags().StringVar(&queryFilter, "query", "", "Filter by normalized query substring")
	cmd.Flags().StringVar(&sourceFilter, "source", "", "Filter by source (taught | manual-forget)")
	cmd.Flags().StringVar(&resourceFilter, "resource", "", "Filter by resource_id")
	cmd.Flags().StringVar(&actionFilter, "action", "", "Filter by action (boost | hide | alias_of)")
	cmd.Flags().IntVar(&minConf, "min-confidence", 0, "Filter by minimum confidence")
	cmd.Flags().IntVar(&limit, "limit", 200, "Maximum rows to return")
	cmd.Flags().StringVar(&dbPath, "db", "", "SQLite database file path (default: resolved data directory data.db)")
	cmd.Flags().BoolVar(&warningsOnly, "warnings", false, "Return teach-time warnings from teach.log instead of stored learning rows")
	return cmd
}

// forgetLearningsRows deletes search_learnings rows matching the
// canonical store.ForgetLearningsFilter and returns the count
// removed. Requires at least one of ResourceID, Action, or All on the
// filter (enforced by the store side).
func forgetLearningsRows(s *store.Store, query string, f store.ForgetLearningsFilter) (int64, error) {
	if s == nil {
		return 0, errors.New("forgetLearningsRows: store is nil")
	}
	if strings.TrimSpace(query) == "" {
		return 0, errors.New("forgetLearningsRows: query is required")
	}
	f.Query = query
	return s.ForgetLearnings(f)
}

func newLearningsForgetCmd(flags *rootFlags) *cobra.Command {
	var resourceArg string
	var actionArg string
	var all bool
	var dbPath string

	cmd := &cobra.Command{
		Use:   "forget <query>",
		Short: "Delete learnings matching a query (use --all to wipe every rule for that query)",
		Long: `Removes rows from the search_learnings table so a bad teach can be
undone without dropping the whole DB.

Requires at least one of --resource, --action, or --all.`,
		Example: `  learn-loop-example-pp-cli learnings forget "<question>" --resource <id>
  learn-loop-example-pp-cli learnings forget "<question>" --all`,
		RunE: func(cmd *cobra.Command, args []string) error {
			if len(args) == 0 {
				return cmd.Help()
			}
			if dryRunOK(flags) {
				return nil
			}
			query := strings.Join(args, " ")
			dbPath = learnDBPath(dbPath)
			s, err := store.OpenWithContext(cmd.Context(), dbPath)
			if err != nil {
				return fmt.Errorf("learnings forget: %w", err)
			}
			defer s.Close()

			n, err := forgetLearningsRows(s, query, store.ForgetLearningsFilter{
				ResourceID: resourceArg,
				Action:     actionArg,
				All:        all,
			})
			if err != nil {
				return usageErr(fmt.Errorf("learnings forget: %w", err))
			}
			_ = appendLearningsAudit(map[string]any{
				"action":       "forget",
				"query":        query,
				"filter":       map[string]any{"resource": resourceArg, "action": actionArg, "all": all},
				"rows_deleted": n,
			})

			if flags.asJSON || !isTerminal(cmd.OutOrStdout()) {
				return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
					"deleted": n,
					"query":   query,
				}, flags)
			}
			fmt.Fprintf(cmd.OutOrStdout(), "forgot %d learning(s) for %q\n", n, query)
			return nil
		},
	}
	cmd.Flags().StringVar(&resourceArg, "resource", "", "Delete only the rule for this resource_id")
	cmd.Flags().StringVar(&actionArg, "action", "", "Delete only rules with this action (boost | hide | alias_of)")
	cmd.Flags().BoolVar(&all, "all", false, "Delete every rule for the supplied query")
	cmd.Flags().StringVar(&dbPath, "db", "", "SQLite database file path (default: resolved data directory data.db)")
	return cmd
}

// newTeachPatternCmd installs a manual generalization pattern. The
// recall path uses patterns to substitute entities into resource
// templates.
func newTeachPatternCmd(flags *rootFlags) *cobra.Command {
	var queryTemplate string
	var resourceTemplate string
	var resourceType string
	var venue string
	var strategy string
	var entityKind string
	var dbPath string

	cmd := &cobra.Command{
		Use:   "teach-pattern",
		Short: "Install a manual generalization pattern (query_template, resource_template, entity_kind)",
		Long: `Adds one row to search_patterns. The recall path uses patterns to
substitute live query entities into a resource template, so a pattern
with query_template="items in {entity}" and
resource_template="GROUP-{entity:category}" generalizes one teach into
a whole family.`,
		Example: `  learn-loop-example-pp-cli teach-pattern \
    --query-template "items in {entity}" \
    --resource-template "GROUP-{entity:category}" \
    --resource-type "items" \
    --entity-kind "category" \
    --strategy substitute`,
		RunE: func(cmd *cobra.Command, args []string) error {
			if dryRunOK(flags) {
				return nil
			}
			if noLearnActive(flags) {
				return nil
			}
			if strings.TrimSpace(queryTemplate) == "" {
				return usageErr(fmt.Errorf("--query-template is required"))
			}
			if strings.TrimSpace(resourceTemplate) == "" {
				return usageErr(fmt.Errorf("--resource-template is required"))
			}
			if strings.TrimSpace(resourceType) == "" {
				return usageErr(fmt.Errorf("--resource-type is required"))
			}
			if strings.TrimSpace(entityKind) == "" {
				return usageErr(fmt.Errorf("--entity-kind is required"))
			}
			if strategy == "" {
				strategy = patterns.StrategySubstitute
			}
			dbPath = learnDBPath(dbPath)
			s, err := store.OpenWithContext(cmd.Context(), dbPath)
			if err != nil {
				return fmt.Errorf("teach-pattern: %w", err)
			}
			defer s.Close()

			id, inserted, err := patterns.Upsert(s.DB(), patterns.Pattern{
				QueryTemplate:    queryTemplate,
				ResourceTemplate: resourceTemplate,
				ResourceType:     resourceType,
				Venue:            venue,
				Strategy:         strategy,
				EntityKind:       entityKind,
				Source:           patterns.SourceTaught,
			})
			if err != nil {
				return fmt.Errorf("teach-pattern: %w", err)
			}
			return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
				"recorded":          true,
				"newly_inserted":    inserted,
				"pattern_id":        id,
				"query_template":    queryTemplate,
				"resource_template": resourceTemplate,
				"resource_type":     resourceType,
				"venue":             venue,
				"strategy":          strategy,
				"entity_kind":       entityKind,
			}, flags)
		},
	}
	cmd.Flags().StringVar(&queryTemplate, "query-template", "", "Query template with {entity} placeholder (required)")
	cmd.Flags().StringVar(&resourceTemplate, "resource-template", "", "Resource template with typed entity slot, e.g. PREFIX-{entity:country} (required)")
	cmd.Flags().StringVar(&resourceType, "resource-type", "", "Resource type for substituted IDs (required)")
	cmd.Flags().StringVar(&venue, "venue", "", "Optional venue scope tag")
	cmd.Flags().StringVar(&strategy, "strategy", "", "Substitution strategy: substitute | substitute-then-search-prefix (default substitute)")
	cmd.Flags().StringVar(&entityKind, "entity-kind", "", "Entity kind name registered in entity_lookups (required)")
	cmd.Flags().StringVar(&dbPath, "db", "", "SQLite database file path (default: resolved data directory data.db)")
	return cmd
}

// newTeachLookupCmd installs a manual entity-lookup row.
func newTeachLookupCmd(flags *rootFlags) *cobra.Command {
	var kind string
	var canonical string
	var value string
	var dbPath string

	cmd := &cobra.Command{
		Use:   "teach-lookup",
		Short: "Install a manual entity-lookup row (kind, canonical, value)",
		Long: `Adds one row to entity_lookups. Used by the recall path's pattern
engine to substitute values for canonical names at substitution time.
Computed kinds (lowercase, uppercase, kebab-case, capitalize-first, slug)
cannot be taught — they are derived from the canonical input.`,
		Example: `  learn-loop-example-pp-cli teach-lookup --kind country --canonical "United States" --value USA`,
		RunE: func(cmd *cobra.Command, args []string) error {
			if dryRunOK(flags) {
				return nil
			}
			if noLearnActive(flags) {
				return nil
			}
			if strings.TrimSpace(kind) == "" {
				return usageErr(fmt.Errorf("--kind is required"))
			}
			if strings.TrimSpace(canonical) == "" {
				return usageErr(fmt.Errorf("--canonical is required"))
			}
			if strings.TrimSpace(value) == "" {
				return usageErr(fmt.Errorf("--value is required"))
			}
			if lookups.IsComputedKind(kind) {
				return usageErr(fmt.Errorf("--kind %q is a computed kind and cannot be taught", kind))
			}
			dbPath = learnDBPath(dbPath)
			s, err := store.OpenWithContext(cmd.Context(), dbPath)
			if err != nil {
				return fmt.Errorf("teach-lookup: %w", err)
			}
			defer s.Close()

			if err := lookups.Upsert(s.DB(), kind, canonical, value, "taught"); err != nil {
				return fmt.Errorf("teach-lookup: %w", err)
			}
			return printJSONFiltered(cmd.OutOrStdout(), map[string]any{
				"recorded":  true,
				"kind":      kind,
				"canonical": canonical,
				"value":     value,
			}, flags)
		},
	}
	cmd.Flags().StringVar(&kind, "kind", "", "Entity kind, e.g. country, team (required, must not be a computed kind)")
	cmd.Flags().StringVar(&canonical, "canonical", "", "Canonical entity name (required)")
	cmd.Flags().StringVar(&value, "value", "", "Value/alias that should resolve to the canonical (required)")
	cmd.Flags().StringVar(&dbPath, "db", "", "SQLite database file path (default: resolved data directory data.db)")
	return cmd
}
