package usecase

import (
	"context"
	"fmt"
	"strings"
	"testing"
	"time"

	jobRepo "apps/backend/internal/module/job/repository"
	jobUC "apps/backend/internal/module/job/usecase"
	"apps/backend/internal/module/radar/domain"
	"apps/backend/internal/module/radar/repository"
)

type fakeHits struct {
	hits []ThreadHit
	path string
	err  error
}

func (f *fakeHits) SearchHits(context.Context, int64, []string, int) ([]ThreadHit, string, error) {
	return f.hits, f.path, f.err
}

func seedProfileWatch(t *testing.T, svc *Service, owner int64) *domain.RadarWatch {
	t.Helper()
	ctx := context.Background()
	p := &domain.ServiceProfile{
		OwnerUID:     owner,
		Services:     []domain.ServiceItem{{Name: "婚禮攝影", Currency: "TWD"}},
		ServiceAreas: []string{"TPE"},
		RemoteOk:     false,
	}
	if err := p.Normalize(); err != nil {
		// minimal normalize via save path
	}
	p.CreatedAt = domain.NowNano()
	p.UpdatedAt = p.CreatedAt
	if err := svc.Repo.SaveServiceProfile(ctx, p); err != nil {
		t.Fatal(err)
	}
	w := &domain.RadarWatch{
		ID: domain.NewID(), OwnerUID: owner, Terms: []string{"婚攝 推薦"}, Status: domain.WatchActive,
		CreatedAt: domain.NowNano(), UpdatedAt: domain.NowNano(),
	}
	if err := svc.Repo.SaveWatch(ctx, w); err != nil {
		t.Fatal(err)
	}
	return w
}

func TestM2_SW01_ScheduleTwoWatches(t *testing.T) {
	// covered by sweep_schedule_test; keep named alias
	TestScheduleDailySweeps_SW01_TwoActiveWatches(t)
}

func TestM2_OP01_QualifiedRegionMatch(t *testing.T) {
	ctx := context.Background()
	svc := New(repository.NewMemory())
	svc.Quota = FixedQuota{MaxActiveWatches: 5, MaxDailyOpportunities: 30}
	svc.HitFetch = &fakeHits{
		path: domain.SweepPathAPI,
		hits: []ThreadHit{{
			URL: "https://www.threads.net/@a/post/1", Title: "求推薦",
			Snippet: "求推薦台北婚攝，有預算，請問推薦嗎？",
		}},
	}
	w := seedProfileWatch(t, svc, 11)
	res, err := svc.RunSweep(ctx, 11, w.ID, "job-op01")
	if err != nil {
		t.Fatal(err)
	}
	if res.Created < 1 {
		t.Fatalf("OP-01 want created>=1, got %+v", res)
	}
	list, _, _ := svc.Repo.ListOpportunities(ctx, 11, domain.OpportunityListFilter{Page: 1, PageSize: 10})
	if len(list) == 0 {
		t.Fatal("no opportunity")
	}
	o := list[0]
	if o.Status != domain.OppQualified && o.Status != domain.OppRejected {
		t.Fatalf("status=%s", o.Status)
	}
	if err := domain.ValidateReasons(o.Reasons); err != nil {
		t.Fatalf("OP-01 reasons: %v", err)
	}
	if o.RegionMatch != domain.RegionMatch && o.RegionMatch != domain.RegionUnknown {
		// Taipei text should match TPE
		t.Logf("region_match=%s (expected match when detected)", o.RegionMatch)
	}
}

func TestM2_OP02_ProviderOfferRejected(t *testing.T) {
	ctx := context.Background()
	svc := New(repository.NewMemory())
	svc.Quota = FixedQuota{MaxActiveWatches: 5, MaxDailyOpportunities: 30}
	svc.HitFetch = &fakeHits{
		path: domain.SweepPathAPI,
		hits: []ThreadHit{{
			URL:     "https://www.threads.net/@biz/post/2",
			Snippet: "婚攝接案中 歡迎洽詢我 限時優惠 dm me",
		}},
	}
	w := seedProfileWatch(t, svc, 12)
	_, err := svc.RunSweep(ctx, 12, w.ID, "job-op02")
	if err != nil {
		t.Fatal(err)
	}
	list, _, _ := svc.Repo.ListOpportunities(ctx, 12, domain.OpportunityListFilter{Page: 1, PageSize: 10, Status: domain.OppRejected})
	if len(list) == 0 {
		t.Fatal("OP-02 expected rejected opportunity stored")
	}
}

func TestM2_OP06_IncompleteReasonsNotPersisted(t *testing.T) {
	// unit: ValidateReasons rejects incomplete
	err := domain.ValidateReasons([]domain.OpportunityReason{{Dimension: domain.DimAuthenticity, Score: 1, Reason: "x"}})
	if err == nil {
		t.Fatal("OP-06 expected validation error")
	}
}

func TestM2_SW05_QuotaTruncation(t *testing.T) {
	ctx := context.Background()
	svc := New(repository.NewMemory())
	svc.Quota = FixedQuota{MaxActiveWatches: 5, MaxDailyOpportunities: 2}
	hits := make([]ThreadHit, 0, 5)
	for i := 0; i < 5; i++ {
		hits = append(hits, ThreadHit{
			URL:     fmt.Sprintf("https://www.threads.net/@u/post/%d", i),
			Snippet: "求推薦台北婚攝 預算多少 請問推薦嗎？",
		})
	}
	svc.HitFetch = &fakeHits{path: domain.SweepPathAPI, hits: hits}
	w := seedProfileWatch(t, svc, 13)
	res, err := svc.RunSweep(ctx, 13, w.ID, "job-sw05")
	if err != nil {
		t.Fatal(err)
	}
	if res.Created > 2 {
		t.Fatalf("SW-05 created=%d want <=2", res.Created)
	}
	// truncated may be 0 if some rejected; still exercise path
	t.Logf("SW-05 created=%d truncated=%d judged=%d", res.Created, res.Truncated, res.Judged)
}

func TestM2_SW04_FetchFailure(t *testing.T) {
	ctx := context.Background()
	svc := New(repository.NewMemory())
	svc.Quota = FixedQuota{MaxActiveWatches: 5, MaxDailyOpportunities: 10}
	svc.HitFetch = &fakeHits{path: domain.SweepPathAPI, err: fmt.Errorf("api path unavailable")}
	w := seedProfileWatch(t, svc, 14)
	res, err := svc.RunSweep(ctx, 14, w.ID, "job-sw04")
	if err == nil {
		t.Fatal("SW-04 expected fetch error")
	}
	if res == nil || !res.FetchFailed || res.FailedReason == "" {
		t.Fatalf("SW-04 want failed reason, got %+v", res)
	}
}

func TestM2_SW06_DedupeTerms(t *testing.T) {
	ctx := context.Background()
	mem := repository.NewMemory()
	svc := New(mem)
	svc.Quota = FixedQuota{MaxActiveWatches: 5, MaxDailyOpportunities: 30}
	o := &domain.Opportunity{
		OwnerUID: 15, Source: domain.OppSourceThreads, ExternalID: "ext-same",
		Permalink: "https://x", AuthorHandle: "a", Text: "求推薦台北婚攝",
		Status: domain.OppQualified, IntentScore: 80, IntentBand: domain.BandHigh,
		Reasons: []domain.OpportunityReason{
			{Dimension: domain.DimAuthenticity, Score: 30, Reason: "真需求"},
			{Dimension: domain.DimIntent, Score: 30, Reason: "求推薦"},
			{Dimension: domain.DimRegion, Score: 15, Reason: "台北"},
			{Dimension: domain.DimFreshness, Score: 15, Reason: "新"},
			{Dimension: domain.DimFit, Score: 10, Reason: "婚攝"},
		},
		RegionMatch: domain.RegionMatch, MatchedTerms: []string{"婚攝"},
	}
	first, err := mem.UpsertByExternalID(ctx, o)
	if err != nil {
		t.Fatal(err)
	}
	second, err := mem.UpsertByExternalID(ctx, &domain.Opportunity{
		OwnerUID: 15, ExternalID: "ext-same", MatchedTerms: []string{"台北 婚攝"},
	})
	if err != nil {
		t.Fatal(err)
	}
	if first.ID != second.ID {
		t.Fatal("SW-06 expected same id")
	}
	if len(second.MatchedTerms) != 2 {
		t.Fatalf("SW-06 terms=%v", second.MatchedTerms)
	}
	_ = svc
}

func TestM2_QT01_AtCapNoError(t *testing.T) {
	ctx := context.Background()
	svc := New(repository.NewMemory())
	svc.Quota = FixedQuota{MaxActiveWatches: 5, MaxDailyOpportunities: 1}
	// pre-fill one opportunity today
	w := seedProfileWatch(t, svc, 16)
	o := &domain.Opportunity{
		OwnerUID: 16, Source: domain.OppSourceThreads, ExternalID: "pre",
		Permalink: "https://x/pre", AuthorHandle: "a", Text: "x", Status: domain.OppQualified,
		IntentScore: 90, IntentBand: domain.BandHigh,
		Reasons: []domain.OpportunityReason{
			{Dimension: domain.DimAuthenticity, Score: 30, Reason: "a"},
			{Dimension: domain.DimIntent, Score: 30, Reason: "b"},
			{Dimension: domain.DimRegion, Score: 15, Reason: "c"},
			{Dimension: domain.DimFreshness, Score: 15, Reason: "d"},
			{Dimension: domain.DimFit, Score: 10, Reason: "e"},
		},
		RegionMatch: domain.RegionUnknown, MatchedTerms: []string{"t"},
		CreatedAt: domain.NowNano(),
	}
	if _, err := svc.Repo.UpsertByExternalID(ctx, o); err != nil {
		t.Fatal(err)
	}
	svc.HitFetch = &fakeHits{path: domain.SweepPathAPI, hits: []ThreadHit{{
		URL: "https://www.threads.net/@z/post/new", Snippet: "求推薦婚攝",
	}}}
	res, err := svc.RunSweep(ctx, 16, w.ID, "job-qt01")
	if err != nil {
		t.Fatalf("QT-01 must not error at cap: %v", err)
	}
	if res.Truncated < 1 && res.Created != 0 {
		t.Logf("QT-01 result %+v", res)
	}
}

func TestM2_ManualTriggerRunsAgainAfterDailySuccess(t *testing.T) {
	ctx := context.Background()
	jobs := jobUC.New(jobRepo.NewMemory())
	svc := New(repository.NewMemory())
	svc.SweepJobs = radarManualJobs{jobs: jobs}
	now := domain.NowNano()
	w := &domain.RadarWatch{
		ID: "w-live", OwnerUID: 17, Terms: []string{"痛點"}, Status: domain.WatchActive,
		CreatedAt: now, UpdatedAt: now,
	}
	if err := svc.Repo.SaveWatch(ctx, w); err != nil {
		t.Fatal(err)
	}
	daily, err := jobs.ScheduleRadarSweep(ctx, 17, w.ID, now)
	if err != nil {
		t.Fatal(err)
	}
	claimed, err := jobs.ClaimNext(ctx, "w")
	if err != nil || claimed.ID != daily.ID {
		t.Fatalf("claim daily: %v", err)
	}
	if _, err := jobs.SucceedJob(ctx, daily.ID, "daily done"); err != nil {
		t.Fatal(err)
	}
	manualID, err := svc.TriggerSweep(ctx, 17, w.ID)
	if err != nil {
		t.Fatal(err)
	}
	if manualID == daily.ID {
		t.Fatal("立即巡邏 must not reuse the finished daily job")
	}
}

type radarManualJobs struct{ jobs *jobUC.Service }

func (a radarManualJobs) ScheduleRadarSweep(ctx context.Context, ownerUID int64, watchID string, runAt int64) (string, error) {
	j, err := a.jobs.ScheduleRadarSweep(ctx, ownerUID, watchID, runAt)
	if err != nil {
		return "", err
	}
	return j.ID, nil
}

func (a radarManualJobs) ScheduleManualRadarSweep(ctx context.Context, ownerUID int64, watchID string, runAt int64) (string, error) {
	j, err := a.jobs.ScheduleManualRadarSweep(ctx, ownerUID, watchID, runAt)
	if err != nil {
		return "", err
	}
	return j.ID, nil
}

func TestM2_ManualTriggerPausedRejected(t *testing.T) {
	ctx := context.Background()
	jobs := jobUC.New(jobRepo.NewMemory())
	svc := New(repository.NewMemory())
	svc.SweepJobs = SweepJobSchedulerFunc(func(ctx context.Context, ownerUID int64, watchID string, runAt int64) (string, error) {
		j, err := jobs.ScheduleRadarSweep(ctx, ownerUID, watchID, runAt)
		if err != nil {
			return "", err
		}
		return j.ID, nil
	})
	w := &domain.RadarWatch{
		ID: "paused-1", OwnerUID: 17, Terms: []string{"a"}, Status: domain.WatchPaused,
		CreatedAt: domain.NowNano(), UpdatedAt: domain.NowNano(),
	}
	_ = svc.Repo.SaveWatch(ctx, w)
	_, err := svc.TriggerSweep(ctx, 17, w.ID)
	if err == nil || !strings.Contains(err.Error(), "active") {
		t.Fatalf("want paused error, got %v", err)
	}
}

func TestM2_BandThresholds(t *testing.T) {
	if domain.BandFromScore(80) != domain.BandHigh || domain.BandFromScore(79) != domain.BandMid || domain.BandFromScore(49) != domain.BandLow {
		t.Fatal("band thresholds broken")
	}
}

func TestM2_PastDailySlot(t *testing.T) {
	at := time.Date(2026, 7, 31, 22, 0, 0, 0, time.UTC)
	if !PastDailySweepSlot(at) {
		t.Fatal("expected past slot")
	}
}
