package usecase

import (
	"context"
	"errors"
	"fmt"
	"strings"
	"testing"

	"apps/backend/internal/module/radar/domain"
	"apps/backend/internal/module/radar/repository"
	usageDomain "apps/backend/internal/module/usage/domain"
	usageRepo "apps/backend/internal/module/usage/repository"
	usageUC "apps/backend/internal/module/usage/usecase"
)

type stubAI struct {
	reply      string
	err        error
	lastPrompt string
	calls      int
}

func (s *stubAI) Complete(_ context.Context, _, _, prompt string) (string, error) {
	s.calls++
	s.lastPrompt = prompt
	return s.reply, s.err
}

func (s *stubAI) CompleteStream(ctx context.Context, apiKey, model, prompt string, _ func(string) error) (string, error) {
	return s.Complete(ctx, apiKey, model, prompt)
}

func (s *stubAI) ListModels(context.Context, string) ([]string, error) { return nil, nil }

type stubPainTerms struct {
	terms []string
	err   error
}

func (s stubPainTerms) PainTerms(context.Context, int64) ([]string, error) {
	return s.terms, s.err
}

// platformUsage 給一個用平台點數的會員，這樣扣點與退點都真的會落到用量帳上。
func platformUsage(uid int64) *usageUC.Service {
	key := func(meter string) string { return fmt.Sprintf("%d:%s", uid, meter) }
	return usageUC.New(usageRepo.NewMemory(), &usageUC.StaticResolver{Map: map[string]string{
		key(usageDomain.MeterAICopy):     usageDomain.KeyModePlatform,
		key(usageDomain.MeterAIResearch): usageDomain.KeyModePlatform,
	}})
}

func suggestService(t *testing.T, reply string) (*Service, *stubAI, context.Context) {
	t.Helper()
	ai := &stubAI{reply: reply}
	svc := New(repository.NewMemory())
	svc.AI = ai
	ctx := context.Background()
	if _, err := svc.UpsertServiceProfile(ctx, 42, sampleProfile()); err != nil {
		t.Fatalf("seed profile: %v", err)
	}
	return svc, ai, ctx
}

const suggestReply = `[
  {"term":"婚攝 求推薦","reason":"直接在找婚禮攝影的人常這樣問","usage":"include"},
  {"term":"台北 婚攝","reason":"帶地區的人通常已在比較廠商","usage":"include"},
  {"term":"徵婚攝","reason":"這是同業徵才不是客戶需求","usage":"exclude"}
]`

// RW-03：有服務檔案就回得出建議，每則都要有理由。
func TestSuggestWatchTermsReturnsReasonedSuggestions(t *testing.T) {
	svc, ai, ctx := suggestService(t, suggestReply)

	list, err := svc.SuggestWatchTerms(ctx, 42, 0)
	if err != nil {
		t.Fatalf("suggest: %v", err)
	}
	if len(list) != 3 {
		t.Fatalf("got %d suggestions, want 3", len(list))
	}
	for _, s := range list {
		if strings.TrimSpace(s.Reason) == "" {
			t.Fatalf("suggestion %q has no reason", s.Term)
		}
		if s.Usage != domain.SuggestUsageInclude && s.Usage != domain.SuggestUsageExclude {
			t.Fatalf("suggestion %q has usage %q", s.Term, s.Usage)
		}
	}
	if list[2].Usage != domain.SuggestUsageExclude {
		t.Fatalf("exclude suggestion lost its usage: %+v", list[2])
	}

	// prompt 必須帶上服務檔案的內容，否則建議跟這個人的生意無關。
	if !strings.Contains(ai.lastPrompt, "婚禮攝影") {
		t.Fatal("prompt missing the member's service items")
	}
	if !strings.Contains(ai.lastPrompt, "臺北市") {
		t.Fatal("prompt missing the member's service areas")
	}
	if !strings.Contains(ai.lastPrompt, "保證接到案") {
		t.Fatal("prompt missing the forbidden words")
	}
}

func TestSuggestDoesNotCreateWatches(t *testing.T) {
	svc, _, ctx := suggestService(t, suggestReply)

	if _, err := svc.SuggestWatchTerms(ctx, 42, 0); err != nil {
		t.Fatalf("suggest: %v", err)
	}
	_, total, err := svc.ListWatches(ctx, 42, domain.WatchListFilter{})
	if err != nil {
		t.Fatalf("list: %v", err)
	}
	if total != 0 {
		t.Fatalf("suggest created %d watches; it must only propose", total)
	}
}

func TestSuggestWorksWithoutServiceProfile(t *testing.T) {
	svc := New(repository.NewMemory())
	ai := &stubAI{reply: suggestReply}
	svc.AI = ai

	list, err := svc.SuggestWatchTerms(context.Background(), 42, 0)
	if err != nil {
		t.Fatalf("suggest without profile: %v", err)
	}
	if len(list) == 0 {
		t.Fatal("want generic suggestions when profile is missing")
	}
	if ai.calls != 1 {
		t.Fatalf("AI calls = %d, want 1", ai.calls)
	}
	if !strings.Contains(ai.lastPrompt, "尚未填服務檔案") {
		t.Fatal("prompt should say the profile is missing instead of inventing one")
	}
}

func TestSuggestRespectsLimit(t *testing.T) {
	svc, ai, ctx := suggestService(t, suggestReply)

	list, err := svc.SuggestWatchTerms(ctx, 42, 2)
	if err != nil {
		t.Fatalf("suggest: %v", err)
	}
	if len(list) != 2 {
		t.Fatalf("got %d suggestions, want the requested 2", len(list))
	}
	if !strings.Contains(ai.lastPrompt, "最多 2 則") {
		t.Fatal("limit was not passed to the prompt")
	}

	if _, err := svc.SuggestWatchTerms(ctx, 42, 999); err != nil {
		t.Fatalf("oversized limit should clamp, not fail: %v", err)
	}
	if !strings.Contains(ai.lastPrompt, "最多 20 則") {
		t.Fatalf("limit was not clamped to %d", domain.MaxSuggestions)
	}
}

func TestSuggestDropsUnusableItems(t *testing.T) {
	// 沒理由、太短、重複丟掉；過長 include 收成可搜短詞，不再整條丟。
	svc, _, ctx := suggestService(t, `[
	  {"term":"婚攝 求推薦","reason":"在找攝影師的人常這樣問","usage":"include"},
	  {"term":"沒有理由的詞","reason":"  ","usage":"include"},
	  {"term":"a","reason":"太短","usage":"include"},
	  {"term":"台北 婚攝 推薦 價格","reason":"三詞以上不合 Threads 規則","usage":"include"},
	  {"term":"婚攝 求推薦","reason":"重複","usage":"include"}
	]`)

	list, err := svc.SuggestWatchTerms(ctx, 42, 0)
	if err != nil {
		t.Fatalf("suggest: %v", err)
	}
	if len(list) != 2 || list[0].Term != "婚攝 求推薦" || list[1].Term != "台北 婚攝" {
		t.Fatalf("got %+v, want shortened multi-token plus the original short term", list)
	}
	for _, item := range list {
		if item.Usage == domain.SuggestUsageInclude && !domain.IsThreadsSearchable(item.Term) {
			t.Fatalf("include not searchable: %+v", item)
		}
	}
}

// 模型愛加開場白或 code fence，這種回應也要吃得下。
func TestSuggestToleratesProseAroundJSON(t *testing.T) {
	svc, _, ctx := suggestService(t, "好的，以下是建議：\n```json\n"+suggestReply+"\n```\n希望有幫助！")

	list, err := svc.SuggestWatchTerms(ctx, 42, 0)
	if err != nil {
		t.Fatalf("suggest: %v", err)
	}
	if len(list) != 3 {
		t.Fatalf("got %d suggestions from a fenced reply, want 3", len(list))
	}
}

// 模型回空話時改走通用短詞，不要讓「建議關鍵字」整顆按鈕壞掉。
func TestSuggestFallsBackOnUnusableReply(t *testing.T) {
	svc, _, ctx := suggestService(t, "我不知道要建議什麼")

	list, err := svc.SuggestWatchTerms(ctx, 42, 0)
	if err != nil {
		t.Fatalf("unusable AI reply should fall back: %v", err)
	}
	if len(list) == 0 {
		t.Fatal("fallback returned nothing")
	}
}

func TestSuggestFallsBackWhenAIFails(t *testing.T) {
	svc, ai, ctx := suggestService(t, "")
	ai.err = errors.New("provider down")

	list, err := svc.SuggestWatchTerms(ctx, 42, 0)
	if err != nil {
		t.Fatalf("AI failure should fall back: %v", err)
	}
	if len(list) == 0 {
		t.Fatal("fallback returned nothing")
	}
}

func TestSuggestUsesPainTermsAsMaterialWhenAvailable(t *testing.T) {
	svc, ai, ctx := suggestService(t, suggestReply)
	svc.PainTerms = stubPainTerms{terms: []string{"找不到有檔期的攝影師"}}

	if _, err := svc.SuggestWatchTerms(ctx, 42, 0); err != nil {
		t.Fatalf("suggest: %v", err)
	}
	if !strings.Contains(ai.lastPrompt, "找不到有檔期的攝影師") {
		t.Fatal("existing pain terms were not used as prompt material")
	}

	// 素材拿不到只影響品質，不該讓整個請求失敗。
	svc.PainTerms = stubPainTerms{err: errors.New("scout unavailable")}
	if _, err := svc.SuggestWatchTerms(ctx, 42, 0); err != nil {
		t.Fatalf("pain term failure must not break suggest: %v", err)
	}
}

/*
計費對照（spec §5.5）：走既有 ai_copy meter，source 標 radar.suggest。

source 前綴是 P1 價格校準的唯一資料來源，標錯就等於這次雷達的成本無法歸因。
*/
func TestSuggestRecordsAiCopyUsageWithRadarSource(t *testing.T) {
	svc, _, ctx := suggestService(t, suggestReply)
	usage := platformUsage(42)
	svc.Usage = usage

	if _, err := svc.SuggestWatchTerms(ctx, 42, 0); err != nil {
		t.Fatalf("suggest: %v", err)
	}

	events, err := usage.ListEvents(ctx, 42, usageDomain.CurrentMonthKey(), "all", 0)
	if err != nil {
		t.Fatalf("list events: %v", err)
	}
	if len(events) != 1 {
		t.Fatalf("got %d usage events, want exactly 1", len(events))
	}
	if events[0].Meter != usageDomain.MeterAICopy {
		t.Fatalf("meter = %q, want %q", events[0].Meter, usageDomain.MeterAICopy)
	}
	if events[0].Source != "radar.suggest" {
		t.Fatalf("source = %q, want radar.suggest", events[0].Source)
	}
}

// AI 失敗改走 fallback：使用者仍拿到詞，但沒真正呼叫模型，所以要退點。
func TestSuggestReleasesCreditWhenAIFails(t *testing.T) {
	svc, ai, ctx := suggestService(t, "")
	ai.err = errors.New("provider down")
	usage := platformUsage(42)
	svc.Usage = usage

	list, err := svc.SuggestWatchTerms(ctx, 42, 0)
	if err != nil {
		t.Fatalf("AI failure should fall back: %v", err)
	}
	if len(list) == 0 {
		t.Fatal("fallback returned nothing")
	}
	events, err := usage.ListEvents(ctx, 42, usageDomain.CurrentMonthKey(), "all", 0)
	if err != nil {
		t.Fatalf("list events: %v", err)
	}
	if len(events) != 0 {
		t.Fatalf("charged %d events for a fallback call", len(events))
	}
}

func TestSuggestWithoutAIClientUsesFallback(t *testing.T) {
	svc := New(repository.NewMemory())
	ctx := context.Background()

	list, err := svc.SuggestWatchTerms(ctx, 42, 0)
	if err != nil {
		t.Fatalf("missing AI client should fall back: %v", err)
	}
	if len(list) == 0 {
		t.Fatal("fallback returned nothing")
	}
	found := false
	for _, item := range list {
		if item.Term == "求推薦" {
			found = true
			break
		}
	}
	if !found {
		t.Fatalf("fallback missing 求推薦: %+v", list)
	}
}
