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

import (
	"context"
	"path/filepath"
	"testing"

	"learn-loop-example-pp-cli/internal/store"
)

func openLearnings(t *testing.T) *store.Store {
	t.Helper()
	dbPath := filepath.Join(t.TempDir(), "learn.db")
	s, err := store.OpenWithContext(context.Background(), dbPath)
	if err != nil {
		t.Fatalf("open: %v", err)
	}
	t.Cleanup(func() { s.Close() })
	return s
}

func TestNormalizeQuery_StripsStopwordsAndPunctuation(t *testing.T) {
	cases := []struct {
		in   string
		want string
	}{
		{"What are the odds Portugal wins?", "portugal wins"},
		{"portugal odds", "portugal"},
		{"  Portugal   World   Cup  ", "portugal world cup"},
		{"What's Portugal's chance?", "portugal chance"},
		{"WHAT ARE THE ODDS", ""},
		{"", ""},
	}
	for _, tc := range cases {
		got := store.NormalizeQuery(tc.in)
		if got != tc.want {
			t.Errorf("NormalizeQuery(%q) = %q, want %q", tc.in, got, tc.want)
		}
	}
}

func TestUpsertLearning_InsertsAndBumpsConfidence(t *testing.T) {
	s := openLearnings(t)
	in := store.UpsertLearningInput{
		Query:        "portugal world cup odds",
		ResourceID:   "KXMENWORLDCUP-26-PT",
		ResourceType: "markets",
		Source:       store.LearningSourceTaught,
	}
	id1, created, err := s.UpsertLearning(in)
	if err != nil {
		t.Fatalf("upsert 1: %v", err)
	}
	if !created || id1 == 0 {
		t.Fatalf("first upsert should be inserted; created=%v id=%d", created, id1)
	}

	id2, created, err := s.UpsertLearning(in)
	if err != nil {
		t.Fatalf("upsert 2: %v", err)
	}
	if created {
		t.Fatalf("second upsert should be a bump, not insert")
	}
	if id2 != id1 {
		t.Fatalf("re-teach should preserve row id; got %d, want %d", id2, id1)
	}

	rows, err := s.ListLearnings(store.ListLearningsFilter{Query: "portugal"})
	if err != nil {
		t.Fatalf("list: %v", err)
	}
	if len(rows) != 1 {
		t.Fatalf("want 1 row, got %d", len(rows))
	}
	// Per U4, first teach lands at confidence=2 (clears the skill's
	// skip threshold immediately); re-confirmation bumps to 3.
	if rows[0].Confidence != 3 {
		t.Errorf("want confidence 3 after two teaches (2 floor + 1 bump), got %d", rows[0].Confidence)
	}
}

func TestUpsertLearning_NormalizesQueryAcrossVariants(t *testing.T) {
	s := openLearnings(t)
	// Two different phrasings should collapse to the same row.
	_, _, err := s.UpsertLearning(store.UpsertLearningInput{
		Query:      "What are the odds Portugal wins?",
		ResourceID: "KXMENWORLDCUP-26-PT",
	})
	if err != nil {
		t.Fatalf("upsert 1: %v", err)
	}
	_, created, err := s.UpsertLearning(store.UpsertLearningInput{
		Query:      "portugal wins",
		ResourceID: "KXMENWORLDCUP-26-PT",
	})
	if err != nil {
		t.Fatalf("upsert 2: %v", err)
	}
	if created {
		t.Fatalf("normalized variant should hit existing row, not insert")
	}
	rows, _ := s.ListLearnings(store.ListLearningsFilter{})
	if len(rows) != 1 {
		t.Fatalf("want 1 row after normalized re-teach, got %d", len(rows))
	}
}

func TestUpsertLearning_RejectsEmptyResourceOrQuery(t *testing.T) {
	s := openLearnings(t)
	if _, _, err := s.UpsertLearning(store.UpsertLearningInput{Query: "x", ResourceID: ""}); err == nil {
		t.Errorf("want error for empty resource_id")
	}
	if _, _, err := s.UpsertLearning(store.UpsertLearningInput{Query: "what is", ResourceID: "X"}); err == nil {
		t.Errorf("want error when query normalizes to empty")
	}
	if _, _, err := s.UpsertLearning(store.UpsertLearningInput{Query: "x", ResourceID: "X", Action: "bogus"}); err == nil {
		t.Errorf("want error for invalid action")
	}
	if _, _, err := s.UpsertLearning(store.UpsertLearningInput{Query: "x", ResourceID: "X", Action: store.LearningActionAlias}); err == nil {
		t.Errorf("want error when alias action lacks target")
	}
}

func TestRecall_ExactAndJaccardMatch(t *testing.T) {
	s := openLearnings(t)
	if _, _, err := s.UpsertLearning(store.UpsertLearningInput{
		Query:      "portugal world cup odds",
		ResourceID: "KXMENWORLDCUP-26-PT",
	}); err != nil {
		t.Fatalf("upsert: %v", err)
	}
	if _, _, err := s.UpsertLearning(store.UpsertLearningInput{
		Query:      "portugal world cup odds",
		ResourceID: "will-portugal-win-the-2026-fifa-world-cup-912",
	}); err != nil {
		t.Fatalf("upsert 2: %v", err)
	}

	// Exact match.
	matches, err := s.Recall(context.Background(), "portugal world cup odds", store.RecallOptions{})
	if err != nil {
		t.Fatalf("recall exact: %v", err)
	}
	if len(matches) != 2 {
		t.Fatalf("exact recall: want 2 matches, got %d", len(matches))
	}
	if matches[0].MatchScore < 0.99 {
		t.Errorf("exact match score should be ~1.0, got %v", matches[0].MatchScore)
	}

	// Token-overlap match: "portugal chances at the world cup" normalizes
	// to {portugal, chances, world, cup}; vs {portugal, world, cup} the
	// intersection is 3 and union is 4 — Jaccard 0.75 >= 0.6.
	matches, err = s.Recall(context.Background(), "portugal chances at the world cup", store.RecallOptions{})
	if err != nil {
		t.Fatalf("recall overlap: %v", err)
	}
	if len(matches) != 2 {
		t.Errorf("token overlap: want 2, got %d", len(matches))
	}

	// Unrelated query returns empty (information query, not error).
	matches, err = s.Recall(context.Background(), "lakers tonight", store.RecallOptions{})
	if err != nil {
		t.Fatalf("recall unrelated: %v", err)
	}
	if len(matches) != 0 {
		t.Errorf("unrelated query should return empty, got %d", len(matches))
	}
}

func TestRecall_StopwordSymmetry(t *testing.T) {
	s := openLearnings(t)
	if _, _, err := s.UpsertLearning(store.UpsertLearningInput{
		Query:      "what are portugal's odds",
		ResourceID: "KXMENWORLDCUP-26-PT",
	}); err != nil {
		t.Fatalf("upsert: %v", err)
	}
	// Both queries normalize to "portugal". Stopword stripping makes
	// the two queries identical at the matcher.
	matches, err := s.Recall(context.Background(), "portugal", store.RecallOptions{})
	if err != nil {
		t.Fatalf("recall: %v", err)
	}
	if len(matches) != 1 {
		t.Fatalf("stopword symmetry: want 1 match, got %d", len(matches))
	}
}

func TestRecall_MinConfidenceAndLimit(t *testing.T) {
	s := openLearnings(t)
	if _, _, err := s.UpsertLearning(store.UpsertLearningInput{
		Query:      "portugal world cup",
		ResourceID: "A",
	}); err != nil {
		t.Fatalf("upsert A: %v", err)
	}
	// Per U4: first teach lands at conf=2. Two teaches on B bumps to 3.
	// A stays at 2. min-confidence=3 isolates B.
	for i := 0; i < 2; i++ {
		if _, _, err := s.UpsertLearning(store.UpsertLearningInput{
			Query:      "portugal world cup",
			ResourceID: "B",
		}); err != nil {
			t.Fatalf("upsert B: %v", err)
		}
	}

	matches, err := s.Recall(context.Background(), "portugal world cup", store.RecallOptions{MinConfidence: 3})
	if err != nil {
		t.Fatalf("recall min-conf: %v", err)
	}
	if len(matches) != 1 || matches[0].ResourceID != "B" {
		t.Errorf("min-confidence filter failed: %#v", matches)
	}

	// Limit 1 should cap.
	for i := 0; i < 3; i++ {
		if _, _, err := s.UpsertLearning(store.UpsertLearningInput{
			Query:      "portugal world cup",
			ResourceID: "C" + string(rune('0'+i)),
		}); err != nil {
			t.Fatalf("upsert C%d: %v", i, err)
		}
	}
	matches, _ = s.Recall(context.Background(), "portugal world cup", store.RecallOptions{Limit: 2})
	if len(matches) != 2 {
		t.Errorf("limit 2: want 2, got %d", len(matches))
	}
}

func TestForgetLearnings(t *testing.T) {
	s := openLearnings(t)
	for _, rid := range []string{"X", "Y", "Z"} {
		if _, _, err := s.UpsertLearning(store.UpsertLearningInput{
			Query:      "portugal world cup",
			ResourceID: rid,
		}); err != nil {
			t.Fatalf("upsert %s: %v", rid, err)
		}
	}

	// Without --all / --resource / --action, refuse.
	if _, err := s.ForgetLearnings(store.ForgetLearningsFilter{Query: "portugal world cup"}); err == nil {
		t.Errorf("want error when no filter provided")
	}

	// Targeted delete by resource.
	n, err := s.ForgetLearnings(store.ForgetLearningsFilter{Query: "portugal world cup", ResourceID: "Y"})
	if err != nil {
		t.Fatalf("forget Y: %v", err)
	}
	if n != 1 {
		t.Errorf("forget Y: want 1 deleted, got %d", n)
	}

	// Wipe the rest.
	n, err = s.ForgetLearnings(store.ForgetLearningsFilter{Query: "portugal world cup", All: true})
	if err != nil {
		t.Fatalf("forget all: %v", err)
	}
	if n != 2 {
		t.Errorf("forget all: want 2 deleted, got %d", n)
	}
}

// stubApplier is a minimal in-memory bundle for testing the rerank Apply
// pass without depending on the topic/compare hit structs.
type stubApplier struct {
	hits    []stubHit
	fetched map[string]struct{} // simulated resources table
	inserts []store.LearnedHit
}

type stubHit struct {
	rtype string
	rid   string
}

func newStubApplier(initial []stubHit, available []stubHit) *stubApplier {
	fetched := make(map[string]struct{})
	for _, h := range available {
		fetched[h.rtype+"|"+h.rid] = struct{}{}
	}
	return &stubApplier{hits: append([]stubHit{}, initial...), fetched: fetched}
}

func (a *stubApplier) HasHit(rt, rid string) bool {
	for _, h := range a.hits {
		if h.rtype == rt && h.rid == rid {
			return true
		}
	}
	return false
}

func (a *stubApplier) MoveToFront(rt, rid string) {
	idx := -1
	for i, h := range a.hits {
		if h.rtype == rt && h.rid == rid {
			idx = i
			break
		}
	}
	if idx <= 0 {
		return
	}
	h := a.hits[idx]
	a.hits = append(a.hits[:idx], a.hits[idx+1:]...)
	a.hits = append([]stubHit{h}, a.hits...)
}

func (a *stubApplier) InsertLearnedHit(h store.LearnedHit) error {
	a.inserts = append(a.inserts, h)
	a.hits = append([]stubHit{{rtype: h.ResourceType, rid: h.ResourceID}}, a.hits...)
	return nil
}

func (a *stubApplier) RemoveHit(rt, rid string) {
	for i, h := range a.hits {
		if h.rtype == rt && h.rid == rid {
			a.hits = append(a.hits[:i], a.hits[i+1:]...)
			return
		}
	}
}

func (a *stubApplier) ReplaceHit(srcType, srcID, dstType, dstID string) error {
	for i, h := range a.hits {
		if h.rtype == srcType && h.rid == srcID {
			a.hits[i] = stubHit{rtype: dstType, rid: dstID}
			return nil
		}
	}
	// Not in hits; insert as learned.
	return a.InsertLearnedHit(store.LearnedHit{ResourceType: dstType, ResourceID: dstID})
}

func TestApply_BoostMovesExistingHitToFront(t *testing.T) {
	s := openLearnings(t)
	if _, _, err := s.UpsertLearning(store.UpsertLearningInput{
		Query:        "portugal world cup",
		ResourceID:   "KXMENWORLDCUP-26-PT",
		ResourceType: "markets",
	}); err != nil {
		t.Fatalf("upsert: %v", err)
	}
	ap := newStubApplier(
		[]stubHit{
			{"markets", "KXFUSION"},
			{"markets", "KXMENWORLDCUP-26-PT"},
		},
		nil,
	)
	res, err := s.Apply(context.Background(), "portugal world cup", ap)
	if err != nil {
		t.Fatalf("apply: %v", err)
	}
	if res.Count != 1 {
		t.Errorf("count: want 1, got %d", res.Count)
	}
	if ap.hits[0].rid != "KXMENWORLDCUP-26-PT" {
		t.Errorf("boost should move ticker to front; got %#v", ap.hits)
	}
}

func TestApply_BoostInsertsSyntheticLearnedHit(t *testing.T) {
	s := openLearnings(t)
	if _, _, err := s.UpsertLearning(store.UpsertLearningInput{
		Query:        "portugal world cup",
		ResourceID:   "KXMENWORLDCUP-26-PT",
		ResourceType: "markets",
	}); err != nil {
		t.Fatalf("upsert: %v", err)
	}
	ap := newStubApplier(
		[]stubHit{{"markets", "KXFUSION"}},
		nil,
	)
	res, err := s.Apply(context.Background(), "portugal world cup", ap)
	if err != nil {
		t.Fatalf("apply: %v", err)
	}
	if res.Count != 1 || len(ap.inserts) != 1 {
		t.Errorf("expected 1 synthetic insert; count=%d inserts=%d", res.Count, len(ap.inserts))
	}
	if ap.hits[0].rid != "KXMENWORLDCUP-26-PT" {
		t.Errorf("synthetic hit should be at position 0, got %#v", ap.hits)
	}
}

func TestApply_HideRemoves(t *testing.T) {
	s := openLearnings(t)
	if _, _, err := s.UpsertLearning(store.UpsertLearningInput{
		Query:        "portugal world cup",
		ResourceID:   "KXFUSION",
		ResourceType: "series",
		Action:       store.LearningActionHide,
	}); err != nil {
		t.Fatalf("upsert: %v", err)
	}
	ap := newStubApplier(
		[]stubHit{
			{"series", "KXFUSION"},
			{"markets", "KXMENWORLDCUP-26-PT"},
		},
		nil,
	)
	res, err := s.Apply(context.Background(), "portugal world cup", ap)
	if err != nil {
		t.Fatalf("apply: %v", err)
	}
	if res.Count != 1 {
		t.Errorf("count: want 1, got %d", res.Count)
	}
	if len(ap.hits) != 1 || ap.hits[0].rid != "KXMENWORLDCUP-26-PT" {
		t.Errorf("hide failed: %#v", ap.hits)
	}
}

func TestApply_AliasReplaces(t *testing.T) {
	s := openLearnings(t)
	if _, _, err := s.UpsertLearning(store.UpsertLearningInput{
		Query:        "portugal world cup",
		ResourceID:   "OLD",
		ResourceType: "markets",
		Action:       store.LearningActionAlias,
		AliasTarget:  "NEW",
	}); err != nil {
		t.Fatalf("upsert: %v", err)
	}
	ap := newStubApplier(
		[]stubHit{{"markets", "OLD"}},
		nil,
	)
	if _, err := s.Apply(context.Background(), "portugal world cup", ap); err != nil {
		t.Fatalf("apply: %v", err)
	}
	if len(ap.hits) != 1 || ap.hits[0].rid != "NEW" {
		t.Errorf("alias should rewrite OLD -> NEW; got %#v", ap.hits)
	}
}

func TestApply_AliasCycleIsDropped(t *testing.T) {
	s := openLearnings(t)
	if _, _, err := s.UpsertLearning(store.UpsertLearningInput{
		Query:        "portugal world cup",
		ResourceID:   "A",
		ResourceType: "markets",
		Action:       store.LearningActionAlias,
		AliasTarget:  "B",
	}); err != nil {
		t.Fatalf("upsert A->B: %v", err)
	}
	if _, _, err := s.UpsertLearning(store.UpsertLearningInput{
		Query:        "portugal world cup",
		ResourceID:   "B",
		ResourceType: "markets",
		Action:       store.LearningActionAlias,
		AliasTarget:  "A",
	}); err != nil {
		t.Fatalf("upsert B->A: %v", err)
	}
	ap := newStubApplier(
		[]stubHit{
			{"markets", "A"},
			{"markets", "B"},
		},
		nil,
	)
	res, err := s.Apply(context.Background(), "portugal world cup", ap)
	if err != nil {
		t.Fatalf("apply: %v", err)
	}
	if len(res.Warnings) == 0 {
		t.Errorf("expected an alias-cycle warning")
	}
	// Hits should be unchanged.
	if len(ap.hits) != 2 {
		t.Errorf("cycle should leave hits intact; got %#v", ap.hits)
	}
}

func TestApply_SubstringMatchOnQueryPattern(t *testing.T) {
	s := openLearnings(t)
	// Rule keyed on "bitcoin" alone.
	if _, _, err := s.UpsertLearning(store.UpsertLearningInput{
		Query:        "bitcoin",
		ResourceID:   "KXBTCMAX100",
		ResourceType: "series",
	}); err != nil {
		t.Fatalf("upsert: %v", err)
	}
	ap := newStubApplier(
		[]stubHit{{"markets", "BTCETHATH-29DEC31"}},
		nil,
	)
	res, err := s.Apply(context.Background(), "bitcoin 100k", ap)
	if err != nil {
		t.Fatalf("apply: %v", err)
	}
	if res.Count != 1 {
		t.Errorf("substring match should fire; count=%d", res.Count)
	}
	if ap.hits[0].rid != "KXBTCMAX100" {
		t.Errorf("synthetic hit should be at front; got %#v", ap.hits)
	}
}

func TestApply_NormalizationAtApplyTime(t *testing.T) {
	s := openLearnings(t)
	if _, _, err := s.UpsertLearning(store.UpsertLearningInput{
		Query:        "Portugal World Cup",
		ResourceID:   "KXMENWORLDCUP-26-PT",
		ResourceType: "markets",
	}); err != nil {
		t.Fatalf("upsert: %v", err)
	}
	ap := newStubApplier(
		[]stubHit{{"markets", "KXMENWORLDCUP-26-PT"}},
		nil,
	)
	// Different case + whitespace + stopwords on the apply side.
	if _, err := s.Apply(context.Background(), "  what are the portugal  world cup  ", ap); err != nil {
		t.Fatalf("apply: %v", err)
	}
	if ap.hits[0].rid != "KXMENWORLDCUP-26-PT" {
		t.Errorf("normalization mismatch; hits=%#v", ap.hits)
	}
}

func TestListLearnings_FiltersByQueryAndSource(t *testing.T) {
	s := openLearnings(t)
	if _, _, err := s.UpsertLearning(store.UpsertLearningInput{
		Query:      "portugal world cup",
		ResourceID: "A",
		Source:     store.LearningSourceTaught,
	}); err != nil {
		t.Fatalf("upsert A: %v", err)
	}
	if _, _, err := s.UpsertLearning(store.UpsertLearningInput{
		Query:      "lakers tonight",
		ResourceID: "B",
		Source:     store.LearningSourceTaught,
	}); err != nil {
		t.Fatalf("upsert B: %v", err)
	}

	rows, err := s.ListLearnings(store.ListLearningsFilter{Query: "portugal"})
	if err != nil {
		t.Fatalf("list portugal: %v", err)
	}
	if len(rows) != 1 || rows[0].ResourceID != "A" {
		t.Errorf("filter by query failed: %#v", rows)
	}

	rows, _ = s.ListLearnings(store.ListLearningsFilter{Source: store.LearningSourceTaught})
	if len(rows) != 2 {
		t.Errorf("filter by source: want 2, got %d", len(rows))
	}
}
