package usecase_test

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

	"apps/backend/internal/module/ai"
	"apps/backend/internal/module/studio/domain"
	"apps/backend/internal/module/studio/publish"
	"apps/backend/internal/module/studio/repository"
	"apps/backend/internal/module/studio/usecase"
	threadsDomain "apps/backend/internal/module/threads/domain"
	usageDomain "apps/backend/internal/module/usage/domain"
	usageRepo "apps/backend/internal/module/usage/repository"
	usageUC "apps/backend/internal/module/usage/usecase"

	"github.com/stretchr/testify/require"
)

type memAccounts struct {
	byID map[string]*threadsDomain.Account
}

type blockingPublishTransport struct {
	entered chan struct{}
	release chan struct{}
}

func (t *blockingPublishTransport) Publish(ctx context.Context, _ domain.PublishRequest) (*domain.PublishResult, error) {
	close(t.entered)
	select {
	case <-t.release:
		return &domain.PublishResult{MediaID: "renewed-media"}, nil
	case <-ctx.Done():
		return nil, ctx.Err()
	}
}

func (m *memAccounts) Get(_ context.Context, id string) (*threadsDomain.Account, error) {
	a, ok := m.byID[id]
	if !ok {
		return nil, threadsDomain.ErrNotFound
	}
	cp := *a
	return &cp, nil
}

func (m *memAccounts) List(_ context.Context, ownerUID int64) ([]*threadsDomain.Account, error) {
	var out []*threadsDomain.Account
	for _, a := range m.byID {
		if a.OwnerUID == ownerUID {
			cp := *a
			out = append(out, &cp)
		}
	}
	return out, nil
}

func (m *memAccounts) AccessToken(_ context.Context, a *threadsDomain.Account) (string, error) {
	return "tok-" + a.ID, nil
}

func newStudio() (*usecase.Service, *publish.FakeTransport, *memAccounts) {
	repo := repository.NewMemory()
	tp := publish.NewFake()
	acc := &memAccounts{byID: map[string]*threadsDomain.Account{}}
	svc := usecase.New(repo, tp)
	svc.Accounts = acc
	svc.AI = &ai.FakeClient{}
	// usage with unlimited platform
	ures := usageRepo.NewMemory()
	uresolver := &usageUC.StaticResolver{Map: map[string]string{}}
	us := usageUC.New(ures, uresolver)
	svc.Usage = us
	return svc, tp, acc
}

func setupUID(svc *usecase.Service, uid int64) {
	setupUIDWithPlan(svc, uid, usageDomain.PlanPro, true)
}

func setupUIDWithPlan(svc *usecase.Service, uid int64, planID string, unlimited bool) {
	_ = svc.Usage.Repo.SavePrefs(context.Background(), &usageDomain.MemberPrefs{
		UID: uid, PlanID: planID, Unlimited: unlimited, UpdatedAt: domain.NowNano(),
	})
	if sr, ok := svc.Usage.Resolver.(*usageUC.StaticResolver); ok {
		if sr.Map == nil {
			sr.Map = map[string]string{}
		}
		// StaticResolver keys on "uid:meter".
		sr.Map[fmt.Sprintf("%d:%s", uid, usageDomain.MeterAICopy)] = usageDomain.KeyModePlatform
	}
}

// platformCreditsUsed reports what the member has actually been charged this month.
func platformCreditsUsed(t *testing.T, svc *usecase.Service, uid int64) int {
	t.Helper()
	sum, err := svc.Usage.GetSummary(context.Background(), uid, "")
	require.NoError(t, err)
	return sum.Platform.CreditsUsed
}

func addAccount(acc *memAccounts, ownerUID int64, id, user string) {
	acc.byID[id] = &threadsDomain.Account{
		ID: id, OwnerUID: ownerUID, Username: user, DisplayName: user,
		Connection: threadsDomain.ConnectionConnected, IsUsable: true,
	}
}

// ---- AI billing ----

// A request the service itself rejects must not consume credits. These used to charge the
// member up front, so every early return below was a silent debit for work never done.
func TestBilling_RejectedRequestIsNotCharged(t *testing.T) {
	uid := int64(4_009_001)
	cases := []struct {
		name string
		call func(svc *usecase.Service) error
	}{
		{
			name: "mimic without persona",
			call: func(svc *usecase.Service) error {
				_, err := svc.Mimic(context.Background(), uid, "來源文字", "", "", "")
				return err
			},
		},
		{
			name: "mimic with unknown persona",
			call: func(svc *usecase.Service) error {
				_, err := svc.Mimic(context.Background(), uid, "來源文字", "does-not-exist", "", "")
				return err
			},
		},
		{
			name: "analyze post that has no text",
			call: func(svc *usecase.Service) error {
				post := &domain.OwnPost{
					ID: "post-no-text", OwnerUID: uid, Text: "", PublishedAt: domain.NowNano(),
				}
				require.NoError(t, svc.Repo.SaveOwnPost(context.Background(), post))
				_, err := svc.AnalyzePost(context.Background(), uid, post.ID)
				return err
			},
		},
		{
			name: "generate play step without context",
			call: func(svc *usecase.Service) error {
				_, err := svc.GeneratePlayStep(context.Background(), uid, "", "", "", "", true, "root")
				return err
			},
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			svc, _, _ := newStudio()
			// A metered plan, so any charge is visible.
			setupUIDWithPlan(svc, uid, usageDomain.PlanPro, false)
			require.Error(t, tc.call(svc))
			require.Zero(t, platformCreditsUsed(t, svc, uid), "rejected request must not be charged")
		})
	}
}

// A persona analysis that cannot gather enough samples fails after billing has been reserved.
func TestBilling_FailedPersonaAnalysisIsRefunded(t *testing.T) {
	svc, _, _ := newStudio()
	uid := int64(4_009_002)
	setupUIDWithPlan(svc, uid, usageDomain.PlanPro, false)

	p, err := svc.SavePersona(context.Background(), uid, &domain.Persona{Name: "小海"})
	require.NoError(t, err)

	// One sample is below the two-sample minimum, so the analysis is rejected.
	_, err = svc.ExecuteAnalyzeFromText(context.Background(), uid, p.ID, "只有一段文字", "手動", nil)
	require.Error(t, err)
	require.Zero(t, platformCreditsUsed(t, svc, uid))
}

// The success path must still charge exactly once.
func TestBilling_SuccessfulCallIsChargedOnce(t *testing.T) {
	svc, _, _ := newStudio()
	uid := int64(4_009_003)
	setupUIDWithPlan(svc, uid, usageDomain.PlanPro, false)

	_, err := svc.AnalyzeViral(context.Background(), uid, "這是一段要分析的貼文內容，講一個轉折。")
	require.NoError(t, err)
	require.Equal(t, usageDomain.MeterCost(usageDomain.MeterAICopy), platformCreditsUsed(t, svc, uid))
}

// ---- PE ----

func TestPE_01_CreateSave(t *testing.T) {
	svc, _, _ := newStudio()
	uid := int64(4_000_001)
	setupUID(svc, uid)
	p, err := svc.SavePersona(context.Background(), uid, &domain.Persona{Name: "小海", Brief: "溫暖"})
	require.NoError(t, err)
	require.NotEmpty(t, p.ID)
	list, err := svc.ListPersonas(context.Background(), uid)
	require.NoError(t, err)
	require.Len(t, list, 1)
}

func TestPE_02_SetActive(t *testing.T) {
	svc, _, _ := newStudio()
	uid := int64(4_000_002)
	setupUID(svc, uid)
	a, _ := svc.SavePersona(context.Background(), uid, &domain.Persona{Name: "A"})
	b, _ := svc.SavePersona(context.Background(), uid, &domain.Persona{Name: "B"})
	require.NoError(t, svc.SetActivePersonaID(context.Background(), uid, b.ID))
	id, err := svc.GetActivePersonaID(context.Background(), uid)
	require.NoError(t, err)
	require.Equal(t, b.ID, id)
	_ = a
}

func TestPE_03_RemoveActiveResets(t *testing.T) {
	svc, _, _ := newStudio()
	uid := int64(4_000_003)
	setupUID(svc, uid)
	a, _ := svc.SavePersona(context.Background(), uid, &domain.Persona{Name: "A"})
	b, _ := svc.SavePersona(context.Background(), uid, &domain.Persona{Name: "B"})
	_ = svc.SetActivePersonaID(context.Background(), uid, a.ID)
	require.NoError(t, svc.RemovePersona(context.Background(), uid, a.ID))
	id, _ := svc.GetActivePersonaID(context.Background(), uid)
	require.Equal(t, b.ID, id)
}

func TestPE_04_AnalyzeFromText(t *testing.T) {
	svc, _, _ := newStudio()
	uid := int64(4_000_004)
	setupUID(svc, uid)
	p, _ := svc.SavePersona(context.Background(), uid, &domain.Persona{Name: "分析"})
	raw := "今天天氣真好，可是我還是好累有沒有人懂\n\n---\n\n大家早安，後來我改成先寫使用情境再問問題"
	out, err := svc.AnalyzeFromText(context.Background(), uid, p.ID, raw, "貼文")
	require.NoError(t, err)
	require.Equal(t, domain.PersonaReady, out.Status)
	require.True(t, out.Style.AnalyzedAt > 0)
	require.GreaterOrEqual(t, out.Style.SampleCount, 2)
	// 概要 + 指紋 + 8D 都要填滿
	require.NotEmpty(t, out.Brief)
	require.NotEmpty(t, out.Style.DraftText)
	require.NotEmpty(t, out.Style.Draft.Tone)
	require.NotEmpty(t, out.Style.Draft.Identity)
	require.Contains(t, out.Style.DraftText, "【")
	require.Len(t, out.Style.Dimensions, 8)
	require.NotEmpty(t, out.Style.Dimensions["d1Tone"].Summary)
	require.NotEmpty(t, out.Guard.Avoid)
}

func TestPE_05_AnalyzeFromAccount(t *testing.T) {
	restore := usecase.SetProfileFetchForTest(func(ctx context.Context, username, storageStateJSON string, limit int) ([]string, error) {
		return []string{
			"有時候真的會卡在不知道問誰，後來我改成先寫自己的使用情境。",
			"懂那種越研究越焦慮的感覺。你們如果有推的，最好附一句為什麼。",
			"講真的上次踩雷之後我都先問實際用起來再下單。",
		}, nil
	})
	t.Cleanup(restore)

	svc, _, _ := newStudio()
	uid := int64(4_000_005)
	setupUID(svc, uid)
	p, _ := svc.SavePersona(context.Background(), uid, &domain.Persona{Name: "對標"})
	out, err := svc.AnalyzeFromAccount(context.Background(), uid, p.ID, "cool_creator")
	require.NoError(t, err)
	require.Equal(t, domain.PersonaReady, out.Status)
	require.Equal(t, "cool_creator", out.Style.BenchmarkUsername)
	require.GreaterOrEqual(t, out.Style.SampleCount, 2)
	require.NotContains(t, out.Style.SamplePreviews[0], "sample post about")
	require.NotEmpty(t, out.Brief)
	require.NotEmpty(t, out.Style.DraftText)
	require.Len(t, out.Style.Dimensions, 8)
	require.NotEmpty(t, out.Style.Dimensions["d8Risk"].Summary)
}

func TestPE_06_CrossUidForbidden(t *testing.T) {
	svc, _, _ := newStudio()
	setupUID(svc, 4_000_006)
	setupUID(svc, 4_000_007)
	p, _ := svc.SavePersona(context.Background(), 4_000_006, &domain.Persona{Name: "私有"})
	_, err := svc.GetPersona(context.Background(), 4_000_007, p.ID)
	require.ErrorIs(t, err, domain.ErrForbidden)
	err = svc.RemovePersona(context.Background(), 4_000_007, p.ID)
	require.ErrorIs(t, err, domain.ErrForbidden)
}

// ---- PL ----

func TestPL_01_SaveGet(t *testing.T) {
	svc, _, acc := newStudio()
	uid := int64(4_001_001)
	setupUID(svc, uid)
	addAccount(acc, uid, "acc1", "lead")
	play, err := svc.SavePlay(context.Background(), uid, &domain.Play{
		Title: "方案A", Topic: "主題", LeadAccountID: "acc1",
		Steps:           []domain.PlayStep{{Kind: domain.StepRoot, AccountID: "acc1", Text: "主貼"}},
		ScheduleStartAt: domain.NowNano(),
	})
	require.NoError(t, err)
	got, err := svc.GetPlay(context.Background(), uid, play.ID)
	require.NoError(t, err)
	require.Equal(t, "方案A", got.Title)
	require.Len(t, got.Steps, 1)
}

func TestPL_02_ReplyAccountNotSpeaker(t *testing.T) {
	svc, _, acc := newStudio()
	uid := int64(4_001_002)
	setupUID(svc, uid)
	addAccount(acc, uid, "acc1", "lead")
	addAccount(acc, uid, "acc_stranger", "x")
	play, err := svc.SavePlay(context.Background(), uid, &domain.Play{
		Title: "bad", LeadAccountID: "acc1", CastAccountIDs: []string{},
		Steps: []domain.PlayStep{
			{Kind: domain.StepRoot, AccountID: "acc1", Text: "root"},
			{Kind: domain.StepReply, AccountID: "acc_stranger", Text: "reply"},
		},
		ScheduleStartAt: domain.NowNano(),
	})
	require.NoError(t, err)
	_, err = svc.SubmitPlay(context.Background(), uid, play.ID)
	require.Error(t, err)
}

func TestPL_03_SubmitScheduleAligned(t *testing.T) {
	svc, _, acc := newStudio()
	uid := int64(4_001_003)
	setupUID(svc, uid)
	addAccount(acc, uid, "acc1", "lead")
	addAccount(acc, uid, "acc2", "cast")
	// 明確未來開始（>30s）→ 第一則排到該時間
	start := domain.NowNano() + int64(time.Hour)
	play, _ := svc.SavePlay(context.Background(), uid, &domain.Play{
		Title: "sched", LeadAccountID: "acc1", CastAccountIDs: []string{"acc2"},
		Steps: []domain.PlayStep{
			{Kind: domain.StepRoot, AccountID: "acc1", Text: "root", DelayFromPreviousSec: 0},
			{Kind: domain.StepReply, AccountID: "acc2", Text: "r1", DelayFromPreviousSec: 60},
		},
		ScheduleStartAt: start,
	})
	b, err := svc.SubmitPlay(context.Background(), uid, play.ID)
	require.NoError(t, err)
	require.Equal(t, start, b.Steps[0].ScheduledAt)
	// 第二則 = 間隔 60s ± 抖動
	delta := b.Steps[1].ScheduledAt - b.Steps[0].ScheduledAt
	require.GreaterOrEqual(t, delta, int64(15*time.Second))
	require.LessOrEqual(t, delta, int64(120*time.Second))
	require.Equal(t, domain.StepScheduled, b.Steps[0].Status)

	// 未設未來開始 → 第一則立刻 due
	play2, _ := svc.SavePlay(context.Background(), uid, &domain.Play{
		Title: "now", LeadAccountID: "acc1",
		Steps: []domain.PlayStep{
			{Kind: domain.StepRoot, AccountID: "acc1", Text: "r", DelayFromPreviousSec: 0},
		},
		ScheduleStartAt: 0,
	})
	before := domain.NowNano()
	b2, err := svc.SubmitPlay(context.Background(), uid, play2.ID)
	require.NoError(t, err)
	require.InDelta(t, float64(before), float64(b2.Steps[0].ScheduledAt), float64(2*time.Second))
}

func TestPL_04_ResolveExternalLink(t *testing.T) {
	svc, _, _ := newStudio()
	t_, err := svc.ResolveExternalLink(context.Background(), "https://www.threads.net/@alice/post/AbC123")
	require.NoError(t, err)
	require.Contains(t, t_.URL, "threads.net")
	require.Equal(t, "alice", t_.AuthorUsername)
	require.Equal(t, "AbC123", t_.Shortcode)
}

func TestPL_05_ResolveBadURL(t *testing.T) {
	svc, _, _ := newStudio()
	_, err := svc.ResolveExternalLink(context.Background(), "not-a-url")
	require.ErrorIs(t, err, domain.ErrBadURL)
}

func TestPL_06_PublishScheduled(t *testing.T) {
	svc, _, acc := newStudio()
	uid := int64(4_001_006)
	setupUID(svc, uid)
	addAccount(acc, uid, "acc1", "lead")
	future := domain.NowNano() + int64(2*time.Hour)
	b, err := svc.PublishSingle(context.Background(), uid, "acc1", "hello scheduled", "t", nil, future, "", "")
	require.NoError(t, err)
	require.Len(t, b.Steps, 1)
	require.Equal(t, future, b.Steps[0].ScheduledAt)
}

func TestPL_07_PublishDefaultNow(t *testing.T) {
	svc, _, acc := newStudio()
	uid := int64(4_001_007)
	setupUID(svc, uid)
	addAccount(acc, uid, "acc1", "lead")
	before := domain.NowNano()
	b, err := svc.PublishSingle(context.Background(), uid, "acc1", "now post", "", nil, 0, "", "")
	require.NoError(t, err)
	require.InDelta(t, float64(before), float64(b.Steps[0].ScheduledAt), float64(time.Second*2))
}

func TestPL_08_NoUsableAccount(t *testing.T) {
	svc, _, _ := newStudio()
	uid := int64(4_001_008)
	setupUID(svc, uid)
	_, err := svc.PublishSingle(context.Background(), uid, "missing", "x", "", nil, 0, "", "")
	require.ErrorIs(t, err, domain.ErrNoAccount)
}

func TestQueueExternalReply(t *testing.T) {
	svc, _, acc := newStudio()
	uid := int64(4_001_081)
	addAccount(acc, uid, "acc1", "replying")
	before := domain.NowNano()
	bundle, err := svc.QueueExternalReply(context.Background(), uid, "acc1", "123456789", "Thanks for sharing", "External reply")
	require.NoError(t, err)
	require.Equal(t, uid, bundle.OwnerUID)
	require.Equal(t, "External reply", bundle.Title)
	require.Equal(t, domain.OBScheduling, bundle.Status)
	require.Equal(t, "123456789", bundle.ReplyToMediaID)
	require.Len(t, bundle.Steps, 1)
	require.Equal(t, domain.StepReply, bundle.Steps[0].Kind)
	require.Equal(t, "acc1", bundle.Steps[0].AccountID)
	require.Equal(t, "123456789", bundle.Steps[0].ReplyTo)
	require.Equal(t, domain.StepScheduled, bundle.Steps[0].Status)
	require.InDelta(t, float64(before), float64(bundle.Steps[0].ScheduledAt), float64(2*time.Second))

	persisted, err := svc.GetOutbox(context.Background(), uid, bundle.ID)
	require.NoError(t, err)
	require.Equal(t, bundle, persisted)
}

func TestQueueExternalReplyValidation(t *testing.T) {
	svc, _, acc := newStudio()
	uid := int64(4_001_082)
	addAccount(acc, uid, "acc1", "replying")

	_, err := svc.QueueExternalReply(context.Background(), uid, "acc1", "media_123", "reply", "")
	require.ErrorIs(t, err, domain.ErrValidation)
	_, err = svc.QueueExternalReply(context.Background(), uid, "missing", "123", "reply", "")
	require.ErrorIs(t, err, domain.ErrNoAccount)
	acc.byID["acc1"].IsUsable = false
	_, err = svc.QueueExternalReply(context.Background(), uid, "acc1", "123", "reply", "")
	require.ErrorIs(t, err, domain.ErrNoAccount)
}

func TestPL_09_RemovePlayKeepsOutbox(t *testing.T) {
	svc, _, acc := newStudio()
	uid := int64(4_001_009)
	setupUID(svc, uid)
	addAccount(acc, uid, "acc1", "lead")
	play, _ := svc.SavePlay(context.Background(), uid, &domain.Play{
		Title: "del", LeadAccountID: "acc1",
		Steps:           []domain.PlayStep{{Kind: domain.StepRoot, AccountID: "acc1", Text: "r"}},
		ScheduleStartAt: domain.NowNano(),
	})
	b, err := svc.SubmitPlay(context.Background(), uid, play.ID)
	require.NoError(t, err)
	require.NoError(t, svc.RemovePlay(context.Background(), uid, play.ID))
	list, _ := svc.ListPlays(context.Background(), uid)
	require.Empty(t, list)
	// outbox retained
	got, err := svc.GetOutbox(context.Background(), uid, b.ID)
	require.NoError(t, err)
	require.Equal(t, b.ID, got.ID)
}

func TestPL_10_ListByPost(t *testing.T) {
	svc, _, acc := newStudio()
	uid := int64(4_001_010)
	setupUID(svc, uid)
	addAccount(acc, uid, "acc1", "lead")
	_, _ = svc.SavePlay(context.Background(), uid, &domain.Play{
		Title: "p1", LeadAccountID: "acc1", TargetOwnPostID: "post_x",
		Steps: []domain.PlayStep{{Kind: domain.StepReply, AccountID: "acc1", Text: "hi"}},
	})
	_, _ = svc.SavePlay(context.Background(), uid, &domain.Play{
		Title: "p2", LeadAccountID: "acc1", TargetOwnPostID: "other",
		Steps: []domain.PlayStep{{Kind: domain.StepReply, AccountID: "acc1", Text: "hi"}},
	})
	list, err := svc.ListPlaysByPost(context.Background(), uid, "post_x")
	require.NoError(t, err)
	require.Len(t, list, 1)
	require.Equal(t, "p1", list[0].Title)
}

func TestPL_11_ListByExternalUrl(t *testing.T) {
	svc, _, acc := newStudio()
	uid := int64(4_001_011)
	setupUID(svc, uid)
	addAccount(acc, uid, "acc1", "lead")
	url := "https://www.threads.net/@bob/post/ZZ99"
	_, _ = svc.SavePlay(context.Background(), uid, &domain.Play{
		Title: "ext", LeadAccountID: "acc1",
		TargetExternal: &domain.ExternalTarget{URL: url},
		Steps:          []domain.PlayStep{{Kind: domain.StepReply, AccountID: "acc1", Text: "hi"}},
	})
	list, err := svc.ListPlaysByExternalURL(context.Background(), uid, url+"?utm=1")
	require.NoError(t, err)
	require.Len(t, list, 1)
}

// ---- CP ----

func TestCP_01_Mimic(t *testing.T) {
	svc, _, _ := newStudio()
	uid := int64(4_002_001)
	setupUID(svc, uid)
	p, _ := svc.SavePersona(context.Background(), uid, &domain.Persona{Name: "P"})
	_, err := svc.AnalyzeFromText(context.Background(), uid, p.ID, "這是一段足夠完整的人設樣本文字，語氣自然也有明確節奏。\n---\n第二段會換個角度說同一件事，保留這個人平常說話的情緒。", "")
	require.NoError(t, err)
	out, err := svc.Mimic(context.Background(), uid, "原始貼文內容很長", p.ID, "換成討論產品改版的取捨", "")
	require.NoError(t, err)
	require.NotEmpty(t, out)
}

func TestCP_01_MimicRequiresSelectedReadyPersona(t *testing.T) {
	svc, _, _ := newStudio()
	uid := int64(4_002_004)
	setupUID(svc, uid)

	_, err := svc.Mimic(context.Background(), uid, "參考貼文", "missing", "新方向", "")
	require.ErrorIs(t, err, domain.ErrValidation)

	p, err := svc.SavePersona(context.Background(), uid, &domain.Persona{Name: "未分析"})
	require.NoError(t, err)
	_, err = svc.Mimic(context.Background(), uid, "參考貼文", p.ID, "新方向", "")
	require.ErrorIs(t, err, domain.ErrValidation)
}

func TestCP_02_AnalyzeViral(t *testing.T) {
	svc, _, _ := newStudio()
	uid := int64(4_002_002)
	setupUID(svc, uid)
	va, err := svc.AnalyzeViral(context.Background(), uid, "爆紅文結構測試")
	require.NoError(t, err)
	require.NotEmpty(t, va.Hooks)
	require.NotEmpty(t, va.Structure)
}

// ---- OB ----

func TestOB_01_NotDue_NoTransport(t *testing.T) {
	svc, tp, acc := newStudio()
	uid := int64(4_003_001)
	setupUID(svc, uid)
	addAccount(acc, uid, "acc1", "lead")
	future := domain.NowNano() + int64(time.Hour)
	b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "later", "", nil, future, "", "")
	n, err := svc.ProcessDueSteps(context.Background(), domain.NowNano())
	require.NoError(t, err)
	require.Equal(t, 0, n)
	require.Equal(t, 0, tp.CallCount())
	got, _ := svc.GetOutbox(context.Background(), uid, b.ID)
	require.Equal(t, domain.StepScheduled, got.Steps[0].Status)
}

func TestOB_02_DueStep_PublishedViaTransport(t *testing.T) {
	svc, tp, acc := newStudio()
	uid := int64(4_003_002)
	setupUID(svc, uid)
	addAccount(acc, uid, "acc1", "lead")
	b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "now", "", nil, 0, "", "accounts_you_follow")
	n, err := svc.ProcessDueSteps(context.Background(), domain.NowNano())
	require.NoError(t, err)
	require.Equal(t, 1, n)
	require.Equal(t, 1, tp.CallCount()) // must go through transport
	require.Equal(t, "accounts_you_follow", tp.Calls[0].ReplyControl)
	got, _ := svc.GetOutbox(context.Background(), uid, b.ID)
	require.Equal(t, domain.StepPublished, got.Steps[0].Status)
	require.Equal(t, domain.OBCompleted, got.Status)
	require.NotEmpty(t, got.Steps[0].MediaID)
}

func TestOB_03_TransportFail(t *testing.T) {
	svc, tp, acc := newStudio()
	uid := int64(4_003_003)
	setupUID(svc, uid)
	addAccount(acc, uid, "acc1", "lead")
	tp.Fail = true
	b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "fail me", "", nil, 0, "", "")
	_, _ = svc.ProcessDueSteps(context.Background(), domain.NowNano())
	got, _ := svc.GetOutbox(context.Background(), uid, b.ID)
	require.Equal(t, domain.StepFailed, got.Steps[0].Status)
	require.NotEmpty(t, got.Steps[0].Error)
	require.NotEqual(t, domain.StepPublished, got.Steps[0].Status)
}

func TestOB_04_RootFail_BlocksReplies(t *testing.T) {
	svc, tp, acc := newStudio()
	uid := int64(4_003_004)
	setupUID(svc, uid)
	addAccount(acc, uid, "acc1", "lead")
	addAccount(acc, uid, "acc2", "cast")
	tp.FailOnNth = 1 // root fails
	past := domain.NowNano() - 1
	play, _ := svc.SavePlay(context.Background(), uid, &domain.Play{
		Title: "rootfail", LeadAccountID: "acc1", CastAccountIDs: []string{"acc2"},
		Steps: []domain.PlayStep{
			{Kind: domain.StepRoot, AccountID: "acc1", Text: "root"},
			{Kind: domain.StepReply, AccountID: "acc2", Text: "reply", DelayFromPreviousSec: 0},
		},
		ScheduleStartAt: past,
	})
	b, err := svc.SubmitPlay(context.Background(), uid, play.ID)
	require.NoError(t, err)
	_, _ = svc.ProcessDueSteps(context.Background(), domain.NowNano())
	got, _ := svc.GetOutbox(context.Background(), uid, b.ID)
	require.Equal(t, domain.StepFailed, got.Steps[0].Status)
	require.NotEqual(t, domain.StepPublished, got.Steps[1].Status)
	require.Equal(t, domain.StepBlocked, got.Steps[1].Status)
}

func TestOB_05_RootOk_ReplyPublishes(t *testing.T) {
	svc, tp, acc := newStudio()
	uid := int64(4_003_005)
	setupUID(svc, uid)
	addAccount(acc, uid, "acc1", "lead")
	addAccount(acc, uid, "acc2", "cast")
	past := domain.NowNano() - 1
	play, _ := svc.SavePlay(context.Background(), uid, &domain.Play{
		Title: "ok", LeadAccountID: "acc1", CastAccountIDs: []string{"acc2"},
		Steps: []domain.PlayStep{
			{Kind: domain.StepRoot, AccountID: "acc1", Text: "root"},
			{Kind: domain.StepReply, AccountID: "acc2", Text: "reply"},
		},
		ScheduleStartAt: past,
	})
	b, _ := svc.SubmitPlay(context.Background(), uid, play.ID)
	n, err := svc.ProcessDueSteps(context.Background(), domain.NowNano())
	require.NoError(t, err)
	require.Equal(t, 2, n)
	require.Equal(t, 2, tp.CallCount())
	got, _ := svc.GetOutbox(context.Background(), uid, b.ID)
	require.Equal(t, domain.StepPublished, got.Steps[1].Status)
}

func TestOB_06_RetryStep(t *testing.T) {
	svc, tp, acc := newStudio()
	uid := int64(4_003_006)
	setupUID(svc, uid)
	addAccount(acc, uid, "acc1", "lead")
	tp.Fail = true
	b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "retry", "", nil, 0, "", "")
	_, _ = svc.ProcessDueSteps(context.Background(), domain.NowNano())
	tp.Fail = false
	got, err := svc.RetryStep(context.Background(), uid, b.ID, b.Steps[0].ID)
	require.NoError(t, err)
	require.Equal(t, domain.StepScheduled, got.Steps[0].Status)
	_, _ = svc.ProcessDueSteps(context.Background(), domain.NowNano())
	got, _ = svc.GetOutbox(context.Background(), uid, b.ID)
	require.Equal(t, domain.StepPublished, got.Steps[0].Status)
}

func TestOB_07_RetryIllegalStatus(t *testing.T) {
	svc, _, acc := newStudio()
	uid := int64(4_003_007)
	setupUID(svc, uid)
	addAccount(acc, uid, "acc1", "lead")
	// scheduled not failed
	b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "x", "", nil, domain.NowNano()+int64(time.Hour), "", "")
	_, err := svc.RetryStep(context.Background(), uid, b.ID, b.Steps[0].ID)
	require.ErrorIs(t, err, domain.ErrIllegalStatus)
}

func TestOB_08_AllSuccessCompleted(t *testing.T) {
	svc, _, acc := newStudio()
	uid := int64(4_003_008)
	setupUID(svc, uid)
	addAccount(acc, uid, "acc1", "lead")
	b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "done", "", nil, 0, "", "")
	_, _ = svc.ProcessDueSteps(context.Background(), domain.NowNano())
	got, _ := svc.GetOutbox(context.Background(), uid, b.ID)
	require.Equal(t, domain.OBCompleted, got.Status)
}

func TestOB_09_PartialFailed(t *testing.T) {
	svc, tp, acc := newStudio()
	uid := int64(4_003_009)
	setupUID(svc, uid)
	addAccount(acc, uid, "acc1", "lead")
	addAccount(acc, uid, "acc2", "cast")
	// fail only second call
	tp.FailOnNth = 2
	past := domain.NowNano() - 1
	play, _ := svc.SavePlay(context.Background(), uid, &domain.Play{
		Title: "partial", LeadAccountID: "acc1", CastAccountIDs: []string{"acc2"},
		Steps: []domain.PlayStep{
			{Kind: domain.StepRoot, AccountID: "acc1", Text: "root"},
			{Kind: domain.StepReply, AccountID: "acc2", Text: "reply"},
		},
		ScheduleStartAt: past,
	})
	b, _ := svc.SubmitPlay(context.Background(), uid, play.ID)
	_, _ = svc.ProcessDueSteps(context.Background(), domain.NowNano())
	got, _ := svc.GetOutbox(context.Background(), uid, b.ID)
	require.Equal(t, domain.OBPartial, got.Status)
}

func TestOB_10_RemoveStopsWorker(t *testing.T) {
	svc, tp, acc := newStudio()
	uid := int64(4_003_010)
	setupUID(svc, uid)
	addAccount(acc, uid, "acc1", "lead")
	b, _ := svc.PublishSingle(context.Background(), uid, "acc1", "rm", "", nil, 0, "", "")
	require.NoError(t, svc.RemoveOutbox(context.Background(), uid, b.ID))
	n, _ := svc.ProcessDueSteps(context.Background(), domain.NowNano())
	require.Equal(t, 0, n)
	require.Equal(t, 0, tp.CallCount())
}

func TestOB_PublishRenewsStepLease(t *testing.T) {
	repo := repository.NewMemory()
	transport := &blockingPublishTransport{entered: make(chan struct{}), release: make(chan struct{})}
	svc := usecase.New(repo, transport)
	svc.OutboxLeaseDuration = 90 * time.Millisecond
	now := domain.NowNano()
	require.NoError(t, repo.SaveOutbox(context.Background(), &domain.OutboxBundle{
		ID: "lease-bundle", Steps: []domain.OutboxStep{{ID: "lease-step", Kind: domain.StepRoot, Status: domain.StepScheduled, ScheduledAt: now}},
	}))
	done := make(chan error, 1)
	go func() {
		_, err := svc.ProcessDueSteps(context.Background(), now)
		done <- err
	}()
	<-transport.entered
	claimed, err := repo.GetOutbox(context.Background(), "lease-bundle")
	require.NoError(t, err)
	initialExpiry := claimed.Steps[0].LeaseExpiresAt
	require.Eventually(t, func() bool {
		got, getErr := repo.GetOutbox(context.Background(), "lease-bundle")
		return getErr == nil && got.Steps[0].LeaseExpiresAt > initialExpiry
	}, time.Second, 10*time.Millisecond)
	close(transport.release)
	require.NoError(t, <-done)
	got, err := repo.GetOutbox(context.Background(), "lease-bundle")
	require.NoError(t, err)
	require.Equal(t, domain.StepPublished, got.Steps[0].Status)
}

func TestOB_11_CrossUid(t *testing.T) {
	svc, _, acc := newStudio()
	uid1, uid2 := int64(4_003_011), int64(4_003_012)
	setupUID(svc, uid1)
	setupUID(svc, uid2)
	addAccount(acc, uid1, "acc1", "lead")
	b, _ := svc.PublishSingle(context.Background(), uid1, "acc1", "priv", "", nil, domain.NowNano(), "", "")
	_, err := svc.GetOutbox(context.Background(), uid2, b.ID)
	require.ErrorIs(t, err, domain.ErrForbidden)
	_, err = svc.RetryStep(context.Background(), uid2, b.ID, b.Steps[0].ID)
	require.ErrorIs(t, err, domain.ErrForbidden)
}

func TestOB_12_LiveRequiresTransport(t *testing.T) {
	// document: FE simulate is not acceptance — ProcessDueSteps uses transport
	svc, tp, acc := newStudio()
	uid := int64(4_003_013)
	setupUID(svc, uid)
	addAccount(acc, uid, "acc1", "lead")
	_, _ = svc.PublishSingle(context.Background(), uid, "acc1", "x", "", nil, 0, "", "")
	before := tp.CallCount()
	_, _ = svc.ProcessDueSteps(context.Background(), domain.NowNano())
	require.Greater(t, tp.CallCount(), before)
}

func TestPL_PublishRejectsPastSchedule(t *testing.T) {
	svc, _, acc := newStudio()
	uid := int64(4_001_020)
	setupUID(svc, uid)
	addAccount(acc, uid, "acc1", "lead")
	// 超過 1 分鐘才算過期
	past := domain.NowNano() - int64(2*time.Minute)
	_, err := svc.PublishSingle(context.Background(), uid, "acc1", "too late", "", nil, past, "", "")
	require.ErrorIs(t, err, domain.ErrValidation)
	// 略早於現在（grace 內）應可過，並 clamp 成現在
	slight := domain.NowNano() - int64(5*time.Second)
	b, err := svc.PublishSingle(context.Background(), uid, "acc1", "almost now", "", nil, slight, "", "")
	require.NoError(t, err)
	require.NotNil(t, b)
}

// ---- OP ----

func TestOP_01_Sync(t *testing.T) {
	svc, _, acc := newStudio()
	uid := int64(4_004_001)
	setupUID(svc, uid)
	addAccount(acc, uid, "acc1", "me")
	list, err := svc.SyncOwnPosts(context.Background(), uid, "acc1")
	require.NoError(t, err)
	require.NotEmpty(t, list)
	ts, err := svc.LastSyncedAt(context.Background(), uid)
	require.NoError(t, err)
	require.True(t, ts > 0)
}

func TestOP_02_SyncFailDoesNotClear(t *testing.T) {
	svc, _, _ := newStudio()
	uid := int64(4_004_002)
	setupUID(svc, uid)
	_, err := svc.SyncOwnPosts(context.Background(), uid, "nope")
	require.Error(t, err)
}

func TestOP_03_GenerateReply(t *testing.T) {
	svc, _, acc := newStudio()
	uid := int64(4_004_003)
	setupUID(svc, uid)
	addAccount(acc, uid, "acc1", "me")
	list, _ := svc.SyncOwnPosts(context.Background(), uid, "acc1")
	text, err := svc.GenerateReply(context.Background(), uid, list[0].ID, "", "")
	require.NoError(t, err)
	require.NotEmpty(t, text)
}

func TestOP_03_GenerateReplyRejectsMissingReply(t *testing.T) {
	svc, _, acc := newStudio()
	uid := int64(4_004_013)
	setupUID(svc, uid)
	addAccount(acc, uid, "acc1", "me")
	list, _ := svc.SyncOwnPosts(context.Background(), uid, "acc1")

	_, err := svc.GenerateReply(context.Background(), uid, list[0].ID, "missing-reply", "")
	require.ErrorIs(t, err, domain.ErrValidation)
}

func TestOP_04_SendReply(t *testing.T) {
	svc, tp, acc := newStudio()
	uid := int64(4_004_004)
	setupUID(svc, uid)
	addAccount(acc, uid, "acc1", "me")
	list, _ := svc.SyncOwnPosts(context.Background(), uid, "acc1")
	post, err := svc.SendReply(context.Background(), uid, list[0].ID, "", "thanks!", "acc1", nil)
	require.NoError(t, err)
	require.Equal(t, 1, tp.CallCount())
	require.True(t, post.Replies[len(post.Replies)-1].IsMine)
}

func TestOP_05_SendReplyFail(t *testing.T) {
	svc, tp, acc := newStudio()
	uid := int64(4_004_005)
	setupUID(svc, uid)
	addAccount(acc, uid, "acc1", "me")
	list, _ := svc.SyncOwnPosts(context.Background(), uid, "acc1")
	before := len(list[0].Replies)
	tp.Fail = true
	_, err := svc.SendReply(context.Background(), uid, list[0].ID, "", "nope", "acc1", nil)
	require.Error(t, err)
	got, _ := svc.ListOwnPosts(context.Background(), uid, "acc1")
	// find same post
	for _, p := range got {
		if p.ID == list[0].ID {
			// may have original seed replies only
			require.LessOrEqual(t, len(p.Replies), before+1)
			// last must not be ours if failed before save — count isMine
			mine := 0
			for _, r := range p.Replies {
				if r.IsMine {
					mine++
				}
			}
			require.Equal(t, 0, mine)
		}
	}
}

func TestOP_06_MentionMarkAndSkip(t *testing.T) {
	svc, tp, acc := newStudio()
	uid := int64(4_004_006)
	setupUID(svc, uid)
	addAccount(acc, uid, "acc1", "me")
	_ = svc.SeedMention(context.Background(), &domain.Mention{
		OwnerUID: uid, AccountID: "acc1", MediaID: "media_mention_1",
		FromUsername: "x", Text: "hi @me", ContextSnippet: "ctx",
	})
	list, _ := svc.ListMentions(context.Background(), uid, "")
	require.Len(t, list, 1)
	m, err := svc.MarkMentionReplied(context.Background(), uid, list[0].ID, "ok", nil)
	require.NoError(t, err)
	require.Equal(t, domain.MentionReplied, m.Status)
	require.NotEmpty(t, tp.Calls)
	require.Equal(t, "media_mention_1", tp.Calls[len(tp.Calls)-1].ReplyTo)
	_ = svc.SeedMention(context.Background(), &domain.Mention{
		OwnerUID: uid, AccountID: "acc1", MediaID: "media_mention_2",
		FromUsername: "y", Text: "skip", ContextSnippet: "c",
	})
	list2, _ := svc.ListMentions(context.Background(), uid, "")
	var skipID string
	for _, x := range list2 {
		if x.Status == domain.MentionPending {
			skipID = x.ID
		}
	}
	s, err := svc.SkipMention(context.Background(), uid, skipID)
	require.NoError(t, err)
	require.Equal(t, domain.MentionSkipped, s.Status)
}

func TestOP_07_SyncOtherAccountForbidden(t *testing.T) {
	svc, _, acc := newStudio()
	uid1, uid2 := int64(4_004_007), int64(4_004_008)
	setupUID(svc, uid1)
	setupUID(svc, uid2)
	addAccount(acc, uid1, "acc1", "a")
	_, err := svc.SyncOwnPosts(context.Background(), uid2, "acc1")
	require.ErrorIs(t, err, domain.ErrForbidden)
}

func TestOP_ManageReplyAndReplyControl(t *testing.T) {
	svc, _, acc := newStudio()
	uid := int64(4_004_031)
	setupUID(svc, uid)
	addAccount(acc, uid, "acc1", "me")
	list, err := svc.SyncOwnPosts(context.Background(), uid, "acc1")
	require.NoError(t, err)
	require.NotEmpty(t, list)
	require.NotEmpty(t, list[0].Replies)

	_, err = svc.ManageOwnPostReply(context.Background(), uid, list[0].ID, list[0].MediaID, true)
	require.Error(t, err)

	post, err := svc.ManageOwnPostReply(context.Background(), uid, list[0].ID, list[0].Replies[0].ID, true)
	require.NoError(t, err)
	require.Equal(t, "HIDDEN", post.Replies[0].HideStatus)

	post, err = svc.ManageOwnPostReply(context.Background(), uid, list[0].ID, list[0].Replies[0].ID, false)
	require.NoError(t, err)
	require.Equal(t, "NOT_HUSHED", post.Replies[0].HideStatus)

	post, err = svc.SetOwnPostReplyControl(context.Background(), uid, list[0].ID, "accounts_you_follow")
	require.NoError(t, err)
	require.Equal(t, "accounts_you_follow", post.ReplyControl)

	_, err = svc.SetOwnPostReplyControl(context.Background(), uid, list[0].ID, "not-a-value")
	require.Error(t, err)

	svc.Media = &fakeInsightsMedia{}
	_, err = svc.SetOwnPostReplyControl(context.Background(), uid, list[0].ID, "everyone")
	require.Error(t, err)
	require.Contains(t, err.Error(), "發文時")

	b, err := svc.PublishSingle(context.Background(), uid, "acc1", "who can reply", "", nil, 0, "", "accounts_you_follow")
	require.NoError(t, err)
	require.Equal(t, "accounts_you_follow", b.Steps[0].ReplyControl)
}

func TestOP_08_AnalyzePost(t *testing.T) {
	svc, _, acc := newStudio()
	uid := int64(4_004_009)
	setupUID(svc, uid)
	addAccount(acc, uid, "acc1", "me")
	list, _ := svc.SyncOwnPosts(context.Background(), uid, "acc1")
	post, err := svc.AnalyzePost(context.Background(), uid, list[0].ID)
	require.NoError(t, err)
	require.NotEmpty(t, post.FormulaSummary)
	require.NotEmpty(t, post.FormulaDetail)
}

func TestOP_09_MentionList(t *testing.T) {
	svc, _, _ := newStudio()
	uid := int64(4_004_010)
	setupUID(svc, uid)
	_ = svc.SeedMention(context.Background(), &domain.Mention{
		OwnerUID: uid, AccountID: "a1", FromUsername: "u", Text: "t", ContextSnippet: "c",
	})
	_ = svc.SeedMention(context.Background(), &domain.Mention{
		OwnerUID: uid, AccountID: "a2", FromUsername: "u2", Text: "t2", ContextSnippet: "c",
	})
	list, err := svc.ListMentions(context.Background(), uid, "a1")
	require.NoError(t, err)
	require.Len(t, list, 1)
}

func TestOP_10_MentionGenerateReply(t *testing.T) {
	svc, _, _ := newStudio()
	uid := int64(4_004_011)
	setupUID(svc, uid)
	_ = svc.SeedMention(context.Background(), &domain.Mention{
		OwnerUID: uid, AccountID: "a1", FromUsername: "fan", Text: "喜歡你的文", ContextSnippet: "ctx",
	})
	list, _ := svc.ListMentions(context.Background(), uid, "")
	m, err := svc.GenerateMentionReply(context.Background(), uid, list[0].ID, "")
	require.NoError(t, err)
	require.NotEmpty(t, m.DraftText)
}

func TestOP_11_GenerateFromFormulaRemoved(t *testing.T) {
	svc, _, _ := newStudio()
	err := svc.GenerateFromFormula(context.Background(), "p1", "")
	require.ErrorIs(t, err, domain.ErrFormulaRemove)
}

// fakeInsightsMedia serves a fixed post list and lets a test make the insights call fail, which
// is what a Threads rate limit looks like mid-sync.
type fakeInsightsMedia struct {
	threads     []usecase.FetchedThread
	insights    usecase.FetchedInsights
	insightsErr error
}

func (f *fakeInsightsMedia) ListThreads(context.Context, string, int) ([]usecase.FetchedThread, error) {
	return f.threads, nil
}
func (f *fakeInsightsMedia) GetInsights(context.Context, string, string) (usecase.FetchedInsights, error) {
	if f.insightsErr != nil {
		return usecase.FetchedInsights{}, f.insightsErr
	}
	return f.insights, nil
}
func (f *fakeInsightsMedia) ListConversation(context.Context, string, string, int) ([]usecase.FetchedReply, error) {
	return nil, nil
}
func (f *fakeInsightsMedia) ListMentions(context.Context, string, string, int) ([]usecase.FetchedMention, error) {
	return nil, nil
}
func (f *fakeInsightsMedia) ListProfilePosts(context.Context, string, string, int) ([]usecase.FetchedThread, error) {
	return nil, nil
}

// A single rate-limited insights call used to overwrite a post's real like/view counts with
// zeros, which then corrupted the insights summary, benchmark and account health downstream.
func TestSyncOwnPosts_FailedInsightsKeepsPreviousCounts(t *testing.T) {
	svc, _, acc := newStudio()
	uid := int64(4_004_020)
	setupUID(svc, uid)
	addAccount(acc, uid, "acc1", "me")

	media := &fakeInsightsMedia{
		threads:  []usecase.FetchedThread{{ID: "m_1", Text: "貼文", PublishedAt: domain.NowNano()}},
		insights: usecase.FetchedInsights{Views: 500, Likes: 42, Replies: 7, Status: "ok"},
	}
	svc.Media = media

	first, err := svc.SyncOwnPosts(context.Background(), uid, "acc1")
	require.NoError(t, err)
	require.Len(t, first, 1)
	require.Equal(t, 42, first[0].LikeCount)
	require.Equal(t, 500, first[0].ViewCount)

	// Threads starts rate limiting; the next sync must not zero the numbers out.
	media.insightsErr = errors.New("rate limited")
	second, err := svc.SyncOwnPosts(context.Background(), uid, "acc1")
	require.NoError(t, err)
	require.Len(t, second, 1)
	require.Equal(t, 42, second[0].LikeCount, "a failed insights fetch must not erase real counts")
	require.Equal(t, 500, second[0].ViewCount)
	require.Equal(t, 7, second[0].ReplyCount)
	require.Equal(t, "error", second[0].InsightsStatus, "but the failure must still be visible")
}

type fakeMentionsMedia struct {
	hits []usecase.FetchedMention
	err  error
}

func (f *fakeMentionsMedia) ListThreads(context.Context, string, int) ([]usecase.FetchedThread, error) {
	return nil, nil
}
func (f *fakeMentionsMedia) GetInsights(context.Context, string, string) (usecase.FetchedInsights, error) {
	return usecase.FetchedInsights{Status: "ok"}, nil
}
func (f *fakeMentionsMedia) ListConversation(context.Context, string, string, int) ([]usecase.FetchedReply, error) {
	return nil, nil
}
func (f *fakeMentionsMedia) ListMentions(context.Context, string, string, int) ([]usecase.FetchedMention, error) {
	if f.err != nil {
		return nil, f.err
	}
	return f.hits, nil
}
func (f *fakeMentionsMedia) ListProfilePosts(context.Context, string, string, int) ([]usecase.FetchedThread, error) {
	return nil, nil
}

func TestOP_12_SyncMentionsFromGraph(t *testing.T) {
	svc, _, acc := newStudio()
	uid := int64(4_004_012)
	setupUID(svc, uid)
	addAccount(acc, uid, "acc1", "me")
	// real-ish token (not fake-)
	svc.Media = &fakeMentionsMedia{hits: []usecase.FetchedMention{
		{ID: "m_100", Text: "嗨 @me 看看", Username: "fan_a", Permalink: "https://threads.net/t/x", PublishedAt: domain.NowNano()},
		{ID: "m_101", Text: "引用一下", Username: "fan_b", IsQuotePost: true, PublishedAt: domain.NowNano()},
	}}
	list, err := svc.SyncMentions(context.Background(), uid, "acc1")
	require.NoError(t, err)
	require.Len(t, list, 2)
	byMedia := map[string]*domain.Mention{}
	for _, m := range list {
		byMedia[m.MediaID] = m
	}
	require.Contains(t, byMedia, "m_100")
	require.Equal(t, "fan_a", byMedia["m_100"].FromUsername)
	// re-sync preserves replied status
	byMedia["m_100"].Status = domain.MentionReplied
	byMedia["m_100"].DraftText = "已回"
	_ = svc.Repo.SaveMention(context.Background(), byMedia["m_100"])
	list2, err := svc.SyncMentions(context.Background(), uid, "acc1")
	require.NoError(t, err)
	var kept *domain.Mention
	for _, m := range list2 {
		if m.MediaID == "m_100" {
			kept = m
		}
	}
	require.NotNil(t, kept)
	require.Equal(t, domain.MentionReplied, kept.Status)
	require.Equal(t, "已回", kept.DraftText)
}
