package repository

import (
	"context"
	"errors"
	"testing"
	"time"

	"apps/backend/internal/module/radar/domain"
)

func TestUpsertByExternalID_MergeTermsNoDuplicate(t *testing.T) {
	ctx := context.Background()
	m := NewMemory()

	first := sampleOpp("ext-1", []string{"婚攝 推薦"}, 85)
	got, err := m.UpsertByExternalID(ctx, first)
	if err != nil {
		t.Fatalf("first upsert: %v", err)
	}
	if got.ID == "" {
		t.Fatal("expected id assigned")
	}
	id := got.ID

	// Second watch hits the same post with a different term — must not create a second row.
	second := sampleOpp("ext-1", []string{"台北 婚攝"}, 10) // different score must be ignored
	second.Status = domain.OppRejected
	second.RejectReason = "should not overwrite"
	got2, err := m.UpsertByExternalID(ctx, second)
	if err != nil {
		t.Fatalf("second upsert: %v", err)
	}
	if got2.ID != id {
		t.Fatalf("expected same id %s, got %s", id, got2.ID)
	}
	if got2.Status != domain.OppQualified {
		t.Fatalf("status rewritten on merge: %s", got2.Status)
	}
	if got2.IntentScore != 85 {
		t.Fatalf("score rewritten on merge: %d", got2.IntentScore)
	}
	if len(got2.MatchedTerms) != 2 {
		t.Fatalf("matched_terms want 2, got %v", got2.MatchedTerms)
	}
	seen := map[string]bool{}
	for _, term := range got2.MatchedTerms {
		seen[term] = true
	}
	if !seen["婚攝 推薦"] || !seen["台北 婚攝"] {
		t.Fatalf("matched_terms missing expected terms: %v", got2.MatchedTerms)
	}

	// Only one row in the store.
	list, total, err := m.ListOpportunities(ctx, 42, domain.OpportunityListFilter{})
	if err != nil {
		t.Fatal(err)
	}
	if total != 1 || len(list) != 1 {
		t.Fatalf("want 1 opportunity, total=%d len=%d", total, len(list))
	}
}

func TestUpsertByExternalID_RejectsIncompleteReasons(t *testing.T) {
	ctx := context.Background()
	m := NewMemory()

	o := sampleOpp("ext-missing", []string{"a"}, 70)
	o.Reasons = o.Reasons[:4] // drop fit — OP-06
	_, err := m.UpsertByExternalID(ctx, o)
	if err == nil {
		t.Fatal("expected validation error for incomplete reasons")
	}
	if !errors.Is(err, domain.ErrValidation) {
		t.Fatalf("want ErrValidation, got %v", err)
	}
}

func TestCountToday_UTCDay(t *testing.T) {
	ctx := context.Background()
	m := NewMemory()

	now := domain.NowNano()
	start, end := domain.UTCDayBounds(now)

	o1 := sampleOpp("a", []string{"x"}, 80)
	o1.CreatedAt = start + 1
	if _, err := m.UpsertByExternalID(ctx, o1); err != nil {
		t.Fatal(err)
	}
	o2 := sampleOpp("b", []string{"y"}, 60)
	o2.CreatedAt = end // next day boundary — exclusive
	if _, err := m.UpsertByExternalID(ctx, o2); err != nil {
		t.Fatal(err)
	}

	n, err := m.CountToday(ctx, 42, now)
	if err != nil {
		t.Fatal(err)
	}
	if n != 1 {
		t.Fatalf("CountToday want 1, got %d", n)
	}
}

func TestUpdateStatusAndOverride(t *testing.T) {
	ctx := context.Background()
	m := NewMemory()
	o := sampleOpp("ext-ov", []string{"t"}, 55)
	got, err := m.UpsertByExternalID(ctx, o)
	if err != nil {
		t.Fatal(err)
	}
	if err := m.UpdateOpportunityStatus(ctx, got.ID, domain.OppAccepted); err != nil {
		t.Fatal(err)
	}
	cur, _ := m.GetOpportunity(ctx, got.ID)
	if cur.Status != domain.OppAccepted {
		t.Fatalf("status=%s", cur.Status)
	}

	// rejected → qualified via override path needs a rejected row.
	r := sampleOpp("ext-rej", []string{"t"}, 0)
	r.Status = domain.OppRejected
	r.RejectReason = "硬否決測試"
	r.IntentBand = domain.BandLow
	rej, err := m.UpsertByExternalID(ctx, r)
	if err != nil {
		t.Fatal(err)
	}
	ov := &domain.OpportunityOverride{
		FromBand:   domain.BandLow,
		ToBand:     domain.BandMid,
		FromStatus: domain.OppRejected,
		ToStatus:   domain.OppQualified,
		ActorUID:   42,
	}
	if err := m.SetOpportunityOverride(ctx, rej.ID, ov, domain.BandMid, domain.OppQualified); err != nil {
		t.Fatal(err)
	}
	cur, _ = m.GetOpportunity(ctx, rej.ID)
	if cur.Status != domain.OppQualified || cur.IntentBand != domain.BandMid {
		t.Fatalf("override failed: status=%s band=%s", cur.Status, cur.IntentBand)
	}
	if cur.Override == nil || cur.Override.ActorUID != 42 {
		t.Fatalf("override not stored: %+v", cur.Override)
	}
}

func TestListOpportunitiesLegacyPendingAndResweptToday(t *testing.T) {
	ctx := context.Background()
	m := NewMemory()
	now := domain.NowNano()
	from, to := domain.LocalDayBounds(now, domain.DisplayLocation())
	old := from - int64(72*time.Hour)

	legacy := sampleOpp("legacy-pending", []string{"漏水"}, 80)
	legacy.ReviewState = ""
	legacy.PostedAt = old
	legacy.CreatedAt = old
	legacy.LastMatchedAt = 0
	if _, err := m.UpsertByExternalID(ctx, legacy); err != nil {
		t.Fatal(err)
	}
	// Re-sweep the same source: merge must not hide the card from today's inbox.
	if _, err := m.UpsertByExternalID(ctx, &domain.Opportunity{
		OwnerUID: 42, ExternalID: "legacy-pending", MatchedTerms: []string{"抓漏"},
	}); err != nil {
		t.Fatal(err)
	}

	pending, total, err := m.ListOpportunities(ctx, 42, domain.OpportunityListFilter{
		ReviewState: domain.ReviewPending, TimeScope: "today", PostedFrom: from, PostedTo: to,
	})
	if err != nil || total != 1 || len(pending) != 1 {
		t.Fatalf("legacy rematch missing from pending today: total=%d list=%d err=%v", total, len(pending), err)
	}
	if pending[0].ReviewState != domain.ReviewPending {
		t.Fatalf("legacy review_state = %q", pending[0].ReviewState)
	}

	undated := sampleOpp("undated-today", []string{"漏水"}, 70)
	undated.PostedAt = 0
	undated.CreatedAt = now
	if _, err := m.UpsertByExternalID(ctx, undated); err != nil {
		t.Fatal(err)
	}
	pending, total, err = m.ListOpportunities(ctx, 42, domain.OpportunityListFilter{
		ReviewState: domain.ReviewPending, TimeScope: "today", PostedFrom: from, PostedTo: to,
	})
	if err != nil || total != 2 {
		t.Fatalf("undated created-today row should join pending today: total=%d err=%v", total, err)
	}
}

func TestReviewStateGuardAndTombstone(t *testing.T) {
	ctx := context.Background()
	m := NewMemory()
	row, err := m.UpsertByExternalID(ctx, sampleOpp("review-1", []string{"痛點"}, 80))
	if err != nil {
		t.Fatal(err)
	}
	removed, err := m.UpdateOpportunityReviewState(ctx, 42, row.ID, domain.ReviewStatePatch{State: domain.ReviewRemoved, RemovalReason: domain.RemovalPainMismatch, RemovedBy: 42})
	if err != nil {
		t.Fatal(err)
	}
	if removed.ReviewState != domain.ReviewRemoved || removed.PreviousReviewState != domain.ReviewPending || removed.RemovedBy != 42 {
		t.Fatalf("unexpected tombstone: %+v", removed)
	}
	removedList, total, err := m.ListOpportunities(ctx, 42, domain.OpportunityListFilter{ReviewState: domain.ReviewRemoved, Sort: "newest"})
	if err != nil || total != 1 || len(removedList) != 1 {
		t.Fatalf("review list filter failed: total=%d list=%d err=%v", total, len(removedList), err)
	}
	repeated, err := m.UpdateOpportunityReviewState(ctx, 42, row.ID, domain.ReviewStatePatch{State: domain.ReviewRemoved, RemovalReason: domain.RemovalPainMismatch})
	if err != nil || repeated.RemovedAt != removed.RemovedAt {
		t.Fatalf("same removal should be idempotent: got=%+v err=%v", repeated, err)
	}
	if _, err := m.UpdateOpportunityReviewState(ctx, 42, row.ID, domain.ReviewStatePatch{State: domain.ReviewRemoved, RemovalReason: domain.RemovalOther}); !errors.Is(err, domain.ErrValidation) {
		t.Fatalf("other without note should fail, got %v", err)
	}
	restored, err := m.UpdateOpportunityReviewState(ctx, 42, row.ID, domain.ReviewStatePatch{State: domain.ReviewPending})
	if err != nil || restored.ReviewState != domain.ReviewPending || restored.RemovalReason != "" {
		t.Fatalf("restore failed: %+v err=%v", restored, err)
	}
	events, err := m.ListOpportunityReviewEvents(ctx, 42, row.ID)
	if err != nil || len(events) != 2 || events[0].To != domain.ReviewRemoved || events[1].To != domain.ReviewPending {
		t.Fatalf("audit should be append-only and idempotent: events=%+v err=%v", events, err)
	}
}

func sampleOpp(externalID string, terms []string, score int) *domain.Opportunity {
	return &domain.Opportunity{
		OwnerUID:     42,
		WatchID:      "w1",
		Source:       domain.OppSourceThreads,
		ExternalID:   externalID,
		Permalink:    "https://www.threads.net/t/" + externalID,
		AuthorHandle: "seeker",
		Text:         "求推薦台北婚攝",
		PostedAt:     domain.NowNano(),
		Status:       domain.OppQualified,
		IntentScore:  score,
		IntentBand:   domain.BandFromScore(score),
		Reasons: []domain.OpportunityReason{
			{Dimension: domain.DimAuthenticity, Score: 25, Reason: "真的在求推薦"},
			{Dimension: domain.DimIntent, Score: 28, Reason: "有明確購買意圖"},
			{Dimension: domain.DimRegion, Score: 15, Reason: "地區相符"},
			{Dimension: domain.DimFreshness, Score: 12, Reason: "24 小時內"},
			{Dimension: domain.DimFit, Score: 8, Reason: "對上婚攝服務"},
		},
		RegionMatch:    domain.RegionMatch,
		FreshnessHours: 6,
		MatchedService: "婚禮攝影",
		MatchedTerms:   terms,
	}
}
