package usecase

import (
	"context"
	"fmt"
	"testing"

	"apps/backend/internal/module/scout/domain"
	"apps/backend/internal/module/scout/repository"
)

type recordingProvider struct {
	calls [][]string
}

func (p *recordingProvider) SearchThreads(_ context.Context, terms []string, limit int) ([]ThreadSearchResult, error) {
	cp := append([]string{}, terms...)
	p.calls = append(p.calls, cp)
	out := make([]ThreadSearchResult, 0, len(terms))
	for _, term := range terms {
		out = append(out, ThreadSearchResult{
			URL:     "https://www.threads.net/@t/post/" + term,
			Title:   term,
			Snippet: "hit for " + term,
		})
	}
	if limit > 0 && len(out) > limit {
		out = out[:limit]
	}
	return out, nil
}

func TestSearchHitsOnlyFansOutPerTermAndDedupes(t *testing.T) {
	prov := &recordingProvider{}
	svc := &Service{Provider: prov}
	// two terms → two provider calls (one term each), not one joined query
	hits, path, err := svc.SearchHitsOnly(context.Background(), 1, []string{"保母", "求推薦"}, 20)
	if err != nil {
		t.Fatalf("SearchHitsOnly: %v", err)
	}
	if path != domain.PathAPI {
		t.Fatalf("path=%q want %q", path, domain.PathAPI)
	}
	if len(prov.calls) < 2 {
		t.Fatalf("provider calls=%d want >=2 (fan-out, plus sparse top-up)", len(prov.calls))
	}
	for _, c := range prov.calls {
		if len(c) != 1 {
			t.Fatalf("each fan-out call must pass a single term, got %v", c)
		}
	}
	if len(hits) != 2 {
		t.Fatalf("hits=%d want 2", len(hits))
	}

	// same URL from both terms → one result after dedupe
	prov2 := &recordingProvider{}
	// override SearchThreads to return same URL
	svc2 := &Service{Provider: &sameURLProvider{inner: prov2}}
	hits2, _, err := svc2.SearchHitsOnly(context.Background(), 1, []string{"甲", "乙"}, 20)
	if err != nil {
		t.Fatalf("SearchHitsOnly dedupe: %v", err)
	}
	if len(hits2) != 1 {
		t.Fatalf("deduped hits=%d want 1", len(hits2))
	}
}

type sameURLProvider struct {
	inner *recordingProvider
}

func (p *sameURLProvider) SearchThreads(ctx context.Context, terms []string, limit int) ([]ThreadSearchResult, error) {
	p.inner.calls = append(p.inner.calls, append([]string{}, terms...))
	return []ThreadSearchResult{{
		URL: "https://www.threads.net/@t/post/same", Title: "t", Snippet: "shared",
	}}, nil
}

// countingProvider returns unique URLs per call (for top-up tests).
type countingProvider struct {
	calls int
}

func (p *countingProvider) SearchThreads(_ context.Context, terms []string, limit int) ([]ThreadSearchResult, error) {
	p.calls++
	term := "x"
	if len(terms) > 0 {
		term = terms[0]
	}
	n := 3
	if limit > 0 && limit < n {
		n = limit
	}
	out := make([]ThreadSearchResult, 0, n)
	for i := 0; i < n; i++ {
		out = append(out, ThreadSearchResult{
			URL:     "https://www.threads.net/@t/post/" + term + "-c" + string(rune('0'+p.calls)) + "-i" + string(rune('0'+i)),
			Title:   term,
			Snippet: "hit",
		})
	}
	return out, nil
}

func TestFillSearchHitsToTargetBoostsSamePath(t *testing.T) {
	prov := &countingProvider{}
	svc := &Service{Provider: prov}
	// seed with one hit; target 5 → fill should call provider and merge unique URLs
	hits := []ThreadSearchResult{{
		URL: "https://www.threads.net/@t/post/seed", Title: "市集", Snippet: "seed",
	}}
	got := fillSearchHitsToTarget(context.Background(), svc, []string{"市集"}, hits, 5, domain.PathAPI, "")
	if len(got) < 2 {
		t.Fatalf("after top-up hits=%d want >=2 (got more unique URLs)", len(got))
	}
	if prov.calls < 1 {
		t.Fatalf("provider calls=%d want >=1 (boost pass)", prov.calls)
	}
}

func TestMergeHitsDedupe(t *testing.T) {
	a := []ThreadSearchResult{{URL: "https://www.threads.net/@a/post/1", Title: "a"}}
	b := []ThreadSearchResult{
		{URL: "https://www.threads.net/@a/post/1", Title: "dup"},
		{URL: "https://www.threads.net/@b/post/2", Title: "b"},
	}
	got := mergeHitsDedupe(a, b)
	if len(got) != 2 {
		t.Fatalf("merge len=%d want 2", len(got))
	}
}

type alwaysDevMode struct{}

func (alwaysDevMode) DevModeEnabled(context.Context, int64) (bool, error) { return true, nil }

type expiredCrawler struct{ calls int }

func (c *expiredCrawler) SearchChrome(context.Context, string, []string, int) ([]ThreadSearchResult, error) {
	c.calls++
	return nil, fmt.Errorf(`Chrome crawler status 422: {"error":"crawler session expired"}`)
}

func (c *expiredCrawler) ResolveMediaID(context.Context, string, string) (string, error) {
	return "", fmt.Errorf("crawler session expired")
}

func TestSearchHitsOnlyFallsBackToAPIWhenCrawlerSessionExpired(t *testing.T) {
	crawler := &expiredCrawler{}
	prov := &recordingProvider{}
	svc := &Service{
		Provider:      prov,
		Crawler:       crawler,
		Settings:      alwaysDevMode{},
		Repo:          repository.NewMemory(),
		SessionSecret: "test-crawler-session-secret",
	}
	if err := svc.SetCrawlerSession(context.Background(), 1, `{"cookies":[{"domain":".threads.net","expires":4102444800}]}`); err != nil {
		t.Fatalf("seed session: %v", err)
	}
	hits, path, err := svc.SearchHitsOnly(context.Background(), 1, []string{"保母", "求推薦"}, 20)
	if err != nil {
		t.Fatalf("expired crawler must fall back to api: %v", err)
	}
	if path != domain.PathAPI {
		t.Fatalf("path=%q want api after crawler session expired", path)
	}
	if len(hits) == 0 {
		t.Fatal("api fallback returned no hits")
	}
	if crawler.calls != 1 {
		t.Fatalf("dead session should stop crawler fan-out, calls=%d", crawler.calls)
	}
	if len(prov.calls) == 0 {
		t.Fatal("api provider was not used")
	}
}

func TestSearchHitsOnlyFallsBackToAPIWhenCrawlerSessionMissing(t *testing.T) {
	prov := &recordingProvider{}
	svc := &Service{Provider: prov, Settings: alwaysDevMode{}}
	hits, path, err := svc.SearchHitsOnly(context.Background(), 1, []string{"保母"}, 10)
	if err != nil {
		t.Fatalf("missing session must fall back to api: %v", err)
	}
	if path != domain.PathAPI || len(hits) == 0 {
		t.Fatalf("path=%q hits=%d", path, len(hits))
	}
}

func TestSearchHitsOnlyErrorsWhenCrawlerExpiredAndAPIUnavailable(t *testing.T) {
	crawler := &expiredCrawler{}
	svc := &Service{
		Crawler:       crawler,
		Settings:      alwaysDevMode{},
		Repo:          repository.NewMemory(),
		SessionSecret: "test-crawler-session-secret",
	}
	if err := svc.SetCrawlerSession(context.Background(), 1, `{"cookies":[{"domain":".threads.net","expires":4102444800}]}`); err != nil {
		t.Fatalf("seed session: %v", err)
	}
	_, path, err := svc.SearchHitsOnly(context.Background(), 1, []string{"保母"}, 10)
	if err == nil {
		t.Fatal("both paths down must error")
	}
	if path != domain.PathCrawler {
		t.Fatalf("path=%q want crawler", path)
	}
	if !isCrawlerSessionDead(err) {
		t.Fatalf("err=%v want session-dead", err)
	}
}

func TestCanonicalPostIdentityDedupesThreadsURLAliases(t *testing.T) {
	aliases := []string{
		"https://www.threads.net/@alice/post/AbC123?xmt=AQG",
		"https://threads.com/@alice/post/AbC123/",
		"https://m.threads.net/@alice-renamed/post/AbC123#reply",
		"https://www.threads.com/t/AbC123",
	}
	want := "threads-post:AbC123"
	for _, raw := range aliases {
		if got := canonicalPostIdentity(raw); got != want {
			t.Fatalf("canonicalPostIdentity(%q)=%q want %q", raw, got, want)
		}
	}
	for _, raw := range aliases[1:] {
		if permalinkID(42, raw) != permalinkID(42, aliases[0]) {
			t.Fatalf("alias produced a different persisted ID: %q", raw)
		}
	}
	if got := canonicalPermalink(aliases[1]); got != "https://www.threads.net/@alice/post/AbC123" {
		t.Fatalf("canonical permalink=%q", got)
	}
}

func TestMergeHitsDedupeUsesPostIdentityNotHostname(t *testing.T) {
	got := mergeHitsDedupe(
		[]ThreadSearchResult{{URL: "https://threads.net/@alice/post/SameCode?x=1"}},
		[]ThreadSearchResult{
			{URL: "https://www.threads.com/@alice-renamed/post/SameCode"},
			{URL: "https://threads.com/@alice/post/OtherCode"},
		},
	)
	if len(got) != 2 {
		t.Fatalf("merge len=%d want 2 unique post shortcodes", len(got))
	}
	if got[0].URL != "https://www.threads.net/@alice/post/SameCode" {
		t.Fatalf("first canonical URL=%q", got[0].URL)
	}
}
