package usecase

import (
	"context"
	"crypto/rand"
	"encoding/json"
	"errors"
	"fmt"
	"net/url"
	"strings"
	"sync"
	"time"
	"unicode/utf8"

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

	"github.com/google/uuid"
	"github.com/zeromicro/go-zero/core/logx"
)

// AccountLookup resolves Threads accounts for ownership + token.
type AccountLookup interface {
	Get(ctx context.Context, id string) (*threadsDomain.Account, error)
	List(ctx context.Context, ownerUID int64) ([]*threadsDomain.Account, error)
	// DecryptAccess returns plaintext access token for publish (or fake token).
	AccessToken(ctx context.Context, a *threadsDomain.Account) (string, error)
}

// CrawlerSessionSource optional Playwright storageState for profile crawl.
type CrawlerSessionSource interface {
	GetCrawlerSessionToken(ctx context.Context, ownerUID int64) (string, error)
}

// ThreadsMediaSource — 真 Threads Graph：列貼文／insights／對話回覆／提及／公開人設。
// 未設定時 SyncOwnPosts 退回假 seed（測試／無 Meta）。
type ThreadsMediaSource interface {
	ListThreads(ctx context.Context, accessToken string, limit int) ([]FetchedThread, error)
	GetInsights(ctx context.Context, accessToken, mediaID string) (FetchedInsights, error)
	ListConversation(ctx context.Context, accessToken, mediaID string, limit int) ([]FetchedReply, error)
	ListMentions(ctx context.Context, accessToken, threadsUserID string, limit int) ([]FetchedMention, error)
	// ListProfilePosts — 公開 @username 貼文（threads_profile_discovery）
	ListProfilePosts(ctx context.Context, accessToken, username string, limit int) ([]FetchedThread, error)
}

// Fetched* 與 provider 解耦，避免 studio 依賴 provider 包。
type FetchedThread struct {
	ID, Text, MediaType, MediaURL, ThumbnailURL, Permalink, Shortcode, TopicTag, Username string
	PublishedAt                                                                           int64 // unix ns
	ReplyControl                                                                          string
}

type FetchedInsights struct {
	Views, Likes, Replies, Reposts, Quotes, Shares int
	Status, ErrorMsg                               string
}

type FetchedReply struct {
	ID, Text, Username, ParentMediaID string
	PublishedAt                       int64
	IsMine                            bool
	LikeCount                         int
	HideStatus                        string
}

// ThreadsReplyManager is optional; live Meta bridge implements hide / reply_control.
type ThreadsReplyManager interface {
	ManageReply(ctx context.Context, accessToken, replyID string, hide bool) error
	SetReplyControl(ctx context.Context, accessToken, mediaID, control string) error
}

// FetchedMention — Graph /{user-id}/mentions
type FetchedMention struct {
	ID, Text, Username, Permalink, RootPostID, ParentID string
	IsReply, IsQuotePost                                bool
	PublishedAt                                         int64
}

// AIKeySource resolves provider/model/apiKey for a member (settings + platform).
type AIKeySource interface {
	// ResolveAI returns provider, model, apiKey for LLM calls.
	ResolveAI(ctx context.Context, ownerUID int64) (provider, model, apiKey string, err error)
}

// PersonaAnalyzeScheduler enqueues durable background analyze jobs (leave-page safe).
// When nil (unit tests), AnalyzeFrom* runs synchronously.
type PersonaAnalyzeScheduler interface {
	SchedulePersonaAnalyzeAccount(ctx context.Context, ownerUID int64, personaID, username, lang string) (jobID string, err error)
	SchedulePersonaAnalyzeText(ctx context.Context, ownerUID int64, personaID, rawText, sourceLabel, lang string) (jobID string, err error)
}

// Service is the M4 studio facade (personas/plays/outbox/compose/ownposts/mentions).
type Service struct {
	Repo      domain.Repository
	Transport publish.Transport
	Accounts  AccountLookup
	Usage     *usageUC.Service
	AI        ai.Client // tests / fake fallback
	// AIRegistry real xai / opencode-go clients
	AIRegistry *ai.Registry
	// Keys optional; when set, persona analyze uses member AI settings
	Keys AIKeySource
	// Crawler optional: Chrome extension sync'd storageState for public profile scrape
	Crawler CrawlerSessionSource
	// Jobs optional: when set, analyze APIs only enqueue; worker runs Execute*
	Jobs PersonaAnalyzeScheduler
	// Media optional: Meta Threads 拉「我的貼文」
	Media ThreadsMediaSource
	// Storage optional: 發文暫存圖（temp/*）發成功後刪除
	Storage fsDomain.Storage
	// StoragePublicBase — 與 ObjectStorage.PublicBaseURL 對齊，用來從公開 URL 反推 object key
	StoragePublicBase string
	// optional key resolver path via Usage.PrepareCall + Static/Settings
	KeyModeDefault string // if Usage nil, use this for tests
	OutboxWorkerID string
	// OutboxLeaseDuration is configurable for focused lease-renewal tests.
	OutboxLeaseDuration time.Duration
	// OnStepPublished optional growth-loop outcome hook after real publish.
	OnStepPublished func(ctx context.Context, ownerUID int64, bundleID, stepID, accountID string)
}

func New(repo domain.Repository, transport publish.Transport) *Service {
	return &Service{
		Repo: repo, Transport: transport,
		OutboxWorkerID: "studio-" + uuid.NewString(), OutboxLeaseDuration: 3 * time.Minute,
	}
}

// ---------- Personas (PE) ----------

func (s *Service) ListPersonas(ctx context.Context, ownerUID int64) ([]*domain.Persona, error) {
	return s.Repo.ListPersonas(ctx, ownerUID)
}

func (s *Service) GetPersona(ctx context.Context, ownerUID int64, id string) (*domain.Persona, error) {
	p, err := s.Repo.GetPersona(ctx, id)
	if err != nil {
		return nil, err
	}
	if p.OwnerUID != ownerUID {
		return nil, domain.ErrForbidden
	}
	return p, nil
}

func (s *Service) SavePersona(ctx context.Context, ownerUID int64, p *domain.Persona) (*domain.Persona, error) {
	if ownerUID <= 0 {
		return nil, domain.ErrForbidden
	}
	now := domain.NowNano()
	if p.ID == "" {
		p.ID = "pe_" + uuid.NewString()[:12]
		p.CreatedAt = now
	} else {
		existing, err := s.Repo.GetPersona(ctx, p.ID)
		if err == nil {
			if existing.OwnerUID != ownerUID {
				return nil, domain.ErrForbidden
			}
			p.CreatedAt = existing.CreatedAt
			p.OwnerUID = existing.OwnerUID
		} else if err != domain.ErrNotFound {
			return nil, err
		} else {
			p.CreatedAt = now
		}
	}
	p.OwnerUID = ownerUID
	if p.Status == "" {
		p.Status = domain.PersonaEmpty
	}
	if p.Guard.MaxChars == 0 {
		p.Guard.MaxChars = 500
	}
	p.UpdatedAt = now
	if err := s.Repo.SavePersona(ctx, p); err != nil {
		return nil, err
	}
	// first persona becomes active if none
	aid, _ := s.Repo.GetActivePersonaID(ctx, ownerUID)
	if aid == "" {
		_ = s.Repo.SetActivePersonaID(ctx, ownerUID, p.ID)
	}
	return p, nil
}

func (s *Service) RemovePersona(ctx context.Context, ownerUID int64, id string) error {
	p, err := s.GetPersona(ctx, ownerUID, id)
	if err != nil {
		return err
	}
	if err := s.Repo.DeletePersona(ctx, p.ID); err != nil {
		return err
	}
	aid, _ := s.Repo.GetActivePersonaID(ctx, ownerUID)
	if aid == id {
		list, _ := s.Repo.ListPersonas(ctx, ownerUID)
		next := ""
		if len(list) > 0 {
			next = list[0].ID
		}
		_ = s.Repo.SetActivePersonaID(ctx, ownerUID, next)
	}
	return nil
}

func (s *Service) GetActivePersonaID(ctx context.Context, ownerUID int64) (string, error) {
	return s.Repo.GetActivePersonaID(ctx, ownerUID)
}

func (s *Service) SetActivePersonaID(ctx context.Context, ownerUID int64, id string) error {
	if id != "" {
		if _, err := s.GetPersona(ctx, ownerUID, id); err != nil {
			return err
		}
	}
	return s.Repo.SetActivePersonaID(ctx, ownerUID, id)
}

// AnalyzeFromText — API 路徑：有 Jobs 則只入列背景任務（離開頁面不中斷）；無 Jobs（測試）同步跑完。
func (s *Service) AnalyzeFromText(ctx context.Context, ownerUID int64, id, rawText, sourceLabel string) (*domain.Persona, error) {
	p, err := s.GetPersona(ctx, ownerUID, id)
	if err != nil {
		return nil, err
	}
	rawText = strings.TrimSpace(rawText)
	if rawText == "" {
		return nil, fmt.Errorf("%w: empty text", domain.ErrValidation)
	}
	samples := splitSamples(rawText, 12)
	if len(samples) < 2 {
		return nil, fmt.Errorf("%w: 請至少貼 2 段參考文字（可用 --- 分隔），每段至少約 10 字", domain.ErrValidation)
	}
	if s.Jobs != nil {
		lang := ai.ResponseLanguageFrom(ctx)
		p.Status = domain.PersonaAnalyzing
		p.UpdatedAt = domain.NowNano()
		if err := s.Repo.SavePersona(ctx, p); err != nil {
			return nil, err
		}
		if _, err := s.Jobs.SchedulePersonaAnalyzeText(ctx, ownerUID, p.ID, rawText, sourceLabel, lang); err != nil {
			p.Status = domain.PersonaEmpty
			p.UpdatedAt = domain.NowNano()
			_ = s.Repo.SavePersona(ctx, p)
			return nil, err
		}
		return p, nil
	}
	return s.ExecuteAnalyzeFromText(ctx, ownerUID, id, rawText, sourceLabel, nil)
}

// AnalyzeFromAccount — API 路徑：有 Jobs 則入列（爬文+分析在 worker）；無 Jobs 同步。
func (s *Service) AnalyzeFromAccount(ctx context.Context, ownerUID int64, id, username string) (*domain.Persona, error) {
	p, err := s.GetPersona(ctx, ownerUID, id)
	if err != nil {
		return nil, err
	}
	username = strings.TrimPrefix(strings.TrimSpace(username), "@")
	if username == "" {
		return nil, fmt.Errorf("%w: empty username", domain.ErrValidation)
	}
	if strings.ContainsAny(username, " /") || strings.Contains(username, "threads.") {
		return nil, fmt.Errorf("%w: username 請只填帳號，例如 ultralab_tw", domain.ErrValidation)
	}
	if s.Jobs != nil {
		lang := ai.ResponseLanguageFrom(ctx)
		p.Status = domain.PersonaAnalyzing
		p.Style.BenchmarkUsername = username
		p.UpdatedAt = domain.NowNano()
		if err := s.Repo.SavePersona(ctx, p); err != nil {
			return nil, err
		}
		if _, err := s.Jobs.SchedulePersonaAnalyzeAccount(ctx, ownerUID, p.ID, username, lang); err != nil {
			p.Status = domain.PersonaEmpty
			p.UpdatedAt = domain.NowNano()
			_ = s.Repo.SavePersona(ctx, p)
			return nil, err
		}
		return p, nil
	}
	return s.ExecuteAnalyzeFromAccount(ctx, ownerUID, id, username, nil)
}

// ExecuteAnalyzeFromText — worker／測試：扣費 + LLM 分析 + 存檔 ready。
func (s *Service) ExecuteAnalyzeFromText(ctx context.Context, ownerUID int64, id, rawText, sourceLabel string, onProgress ProgressFn) (_ *domain.Persona, err error) {
	report := func(pct int, sum string) {
		if onProgress != nil {
			onProgress(pct, sum)
		}
	}
	p, err := s.GetPersona(ctx, ownerUID, id)
	if err != nil {
		return nil, err
	}
	rawText = strings.TrimSpace(rawText)
	if rawText == "" {
		return nil, fmt.Errorf("%w: empty text", domain.ErrValidation)
	}
	samples := splitSamples(rawText, 12)
	if len(samples) < 2 {
		_ = s.markPersonaAnalyzeFailed(ctx, p, "參考文字不足 2 段")
		return nil, fmt.Errorf("%w: 請至少貼 2 段參考文字（可用 --- 分隔），每段至少約 10 字", domain.ErrValidation)
	}
	report(25, "人設分析 · 檢查文字樣本…")
	charge, err := s.billAI(ctx, ownerUID, "persona analyze text", "personas.analyzeFromText")
	if err != nil {
		_ = s.markPersonaAnalyzeFailed(ctx, p, err.Error())
		return nil, err
	}
	defer charge.Settle(ctx, &err)
	p.Status = domain.PersonaAnalyzing
	p.UpdatedAt = domain.NowNano()
	_ = s.Repo.SavePersona(ctx, p)

	label := strings.TrimSpace(sourceLabel)
	if label == "" {
		label = "手動貼文"
	}
	report(55, fmt.Sprintf("人設分析 · AI 分析「%s」中…", label))
	if err := s.finishPersonaStyleAnalysis(ctx, ownerUID, p, samples, "manual", "", label); err != nil {
		_ = s.markPersonaAnalyzeFailed(ctx, p, err.Error())
		return nil, err
	}
	report(90, "人設分析 · 寫入指紋／範本…")
	if err := s.Repo.SavePersona(ctx, p); err != nil {
		return nil, err
	}
	return p, nil
}

// ExecuteAnalyzeFromAccount — worker：爬公開貼文 + LLM + 存檔 ready。
func (s *Service) ExecuteAnalyzeFromAccount(ctx context.Context, ownerUID int64, id, username string, onProgress ProgressFn) (_ *domain.Persona, err error) {
	report := func(pct int, sum string) {
		if onProgress != nil {
			onProgress(pct, sum)
		}
	}
	p, err := s.GetPersona(ctx, ownerUID, id)
	if err != nil {
		return nil, err
	}
	username = strings.TrimPrefix(strings.TrimSpace(username), "@")
	if username == "" {
		return nil, fmt.Errorf("%w: empty username", domain.ErrValidation)
	}
	if strings.ContainsAny(username, " /") || strings.Contains(username, "threads.") {
		return nil, fmt.Errorf("%w: username 請只填帳號，例如 ultralab_tw", domain.ErrValidation)
	}
	report(15, fmt.Sprintf("人設分析 · 準備爬取 @%s…", username))
	charge, err := s.billAI(ctx, ownerUID, "persona analyze account", "personas.analyzeFromAccount")
	if err != nil {
		_ = s.markPersonaAnalyzeFailed(ctx, p, err.Error())
		return nil, err
	}
	defer charge.Settle(ctx, &err)

	p.Status = domain.PersonaAnalyzing
	p.Style.BenchmarkUsername = username
	p.UpdatedAt = domain.NowNano()
	_ = s.Repo.SavePersona(ctx, p)

	// 取樣順序（不強制 Chrome 爬蟲）：
	//  1) Threads Graph profile_posts（已連帳 + threads_profile_discovery）
	//  2) Playwright 公開頁（可選 extension session 提高成功率）
	report(28, fmt.Sprintf("人設分析 · 讀取 @%s 公開貼文…", username))
	samples, source, scrapeErr := s.collectPersonaSamples(ctx, ownerUID, username, 12)
	if source != "" {
		report(45, fmt.Sprintf("人設分析 · 來源 %s，已抓 %d 則…", source, len(samples)))
	}

	// 樣本不足 → 明確錯誤（不要再回英文假貼文）
	if scrapeErr != nil || len(samples) < 2 {
		hint := "建議：① 到 Crew 連 Threads 帳號（需重新 OAuth 以取得公開人設權限）後再試；② 或改用「貼上參考文字」；③ 可選：設定頁同步 Chrome Session 提高公開頁爬取成功率。"
		var msg string
		if scrapeErr != nil {
			msg = fmt.Sprintf("無法讀取 @%s 公開貼文（%v）。%s", username, scrapeErr, hint)
		} else {
			msg = fmt.Sprintf("@%s 可讀貼文不足 2 篇（目前 %d）。%s", username, len(samples), hint)
		}
		_ = s.markPersonaAnalyzeFailed(ctx, p, msg)
		return nil, fmt.Errorf("%w: %s", domain.ErrValidation, msg)
	}

	report(60, fmt.Sprintf("人設分析 · 已抓 %d 則，AI 分析中…", len(samples)))
	if err := s.finishPersonaStyleAnalysis(ctx, ownerUID, p, samples, "benchmark", username, "@"+username); err != nil {
		_ = s.markPersonaAnalyzeFailed(ctx, p, err.Error())
		return nil, err
	}
	report(90, "人設分析 · 寫入指紋／範本…")
	if err := s.Repo.SavePersona(ctx, p); err != nil {
		return nil, err
	}
	return p, nil
}

func (s *Service) markPersonaAnalyzeFailed(ctx context.Context, p *domain.Persona, msg string) error {
	if p == nil {
		return nil
	}
	p.Status = domain.PersonaEmpty
	if msg != "" {
		p.Notes = "⚠️ 分析失敗：" + truncate(msg, 240)
	}
	p.UpdatedAt = domain.NowNano()
	return s.Repo.SavePersona(ctx, p)
}

// collectPersonaSamples — Graph API 優先，Playwright 公開頁後備（Chrome session 可選）。
// source: graph | playwright | ""
func (s *Service) collectPersonaSamples(ctx context.Context, ownerUID int64, username string, limit int) (samples []string, source string, err error) {
	// 1) Meta Graph profile_posts（用任一可用連帳 token）
	if s.Media != nil && s.Accounts != nil {
		if texts, gerr := s.fetchProfilePostsViaGraph(ctx, ownerUID, username, limit); gerr == nil && len(texts) >= 2 {
			return texts, "graph", nil
		} else if gerr != nil {
			err = gerr // 保留最後錯誤，若 scrape 也失敗再回
		} else if len(texts) > 0 && len(texts) < 2 {
			err = fmt.Errorf("Graph API 只拿到 %d 則", len(texts))
		}
	}

	// 2) Playwright 公開頁（不必有 Chrome session；有則帶入）
	storageState := ""
	if s.Crawler != nil {
		if tok, cerr := s.Crawler.GetCrawlerSessionToken(ctx, ownerUID); cerr == nil {
			storageState = strings.TrimSpace(tok)
		}
	}
	texts, serr := fetchProfilePostTexts(ctx, username, storageState, limit)
	if serr == nil && len(texts) >= 2 {
		return texts, "playwright", nil
	}
	if serr != nil {
		if err != nil {
			return nil, "", fmt.Errorf("Graph: %v; 公開頁: %v", err, serr)
		}
		return nil, "", serr
	}
	if len(texts) > 0 {
		return texts, "playwright", nil // 可能 <2，上層再判
	}
	if err != nil {
		return nil, "", err
	}
	return nil, "", fmt.Errorf("找不到公開貼文")
}

func (s *Service) fetchProfilePostsViaGraph(ctx context.Context, ownerUID int64, username string, limit int) ([]string, error) {
	if s.Media == nil || s.Accounts == nil {
		return nil, fmt.Errorf("media/accounts not configured")
	}
	accs, err := s.Accounts.List(ctx, ownerUID)
	if err != nil {
		return nil, err
	}
	var lastErr error
	for _, acc := range accs {
		if acc == nil || !acc.IsUsable {
			continue
		}
		token, terr := s.Accounts.AccessToken(ctx, acc)
		if terr != nil || strings.TrimSpace(token) == "" || strings.HasPrefix(token, "fake-") {
			continue
		}
		list, lerr := s.Media.ListProfilePosts(ctx, token, username, limit)
		if lerr != nil {
			lastErr = lerr
			continue
		}
		out := make([]string, 0, len(list))
		seen := map[string]struct{}{}
		for _, th := range list {
			t := strings.TrimSpace(th.Text)
			if len(t) < 8 {
				continue
			}
			if _, ok := seen[t]; ok {
				continue
			}
			seen[t] = struct{}{}
			out = append(out, t)
		}
		if len(out) > 0 {
			return out, nil
		}
	}
	if lastErr != nil {
		return nil, lastErr
	}
	return nil, fmt.Errorf("無可用 Threads 帳號 token 或 profile_posts 為空（請重新連帳以取得 threads_profile_discovery）")
}

// ProgressFn reports job progress from long-running execute (worker → MarkRunningProgress).
type ProgressFn func(percent int, summary string)

// ExecutePersonaAnalyzeJob parses job payload and runs account/text analyze (worker entry).
func (s *Service) ExecutePersonaAnalyzeJob(ctx context.Context, templateType string, ownerUID int64, personaID, payloadJSON string, onProgress ProgressFn) error {
	report := func(pct int, sum string) {
		if onProgress != nil {
			onProgress(pct, sum)
		}
	}
	switch templateType {
	case "persona_analyze_account":
		var pl struct {
			Username string `json:"username"`
			Lang     string `json:"lang,omitempty"`
		}
		if err := json.Unmarshal([]byte(payloadJSON), &pl); err != nil {
			return fmt.Errorf("invalid persona analyze account payload: %w", err)
		}
		if pl.Lang != "" {
			ctx = ai.WithResponseLanguage(ctx, pl.Lang)
		}
		_, err := s.ExecuteAnalyzeFromAccount(ctx, ownerUID, personaID, pl.Username, report)
		return err
	case "persona_analyze_text":
		var pl struct {
			RawText     string `json:"raw_text"`
			SourceLabel string `json:"source_label,omitempty"`
			Lang        string `json:"lang,omitempty"`
		}
		if err := json.Unmarshal([]byte(payloadJSON), &pl); err != nil {
			return fmt.Errorf("invalid persona analyze text payload: %w", err)
		}
		if pl.Lang != "" {
			ctx = ai.WithResponseLanguage(ctx, pl.Lang)
		}
		_, err := s.ExecuteAnalyzeFromText(ctx, ownerUID, personaID, pl.RawText, pl.SourceLabel, report)
		return err
	default:
		return fmt.Errorf("unknown persona analyze template: %s", templateType)
	}
}

// finishPersonaStyleAnalysis：先規則底稿，再以會員設定的 provider/model 做真 LLM 分析覆寫。
func (s *Service) finishPersonaStyleAnalysis(
	ctx context.Context,
	ownerUID int64,
	p *domain.Persona,
	samples []string,
	source, username, sourceLabel string,
) error {
	// 底稿（LLM 失敗時仍有可用結果）
	applyStyleFromSamples(p, samples, source, username, sourceLabel)

	provider, model, apiKey, kerr := s.resolveUserAI(ctx, ownerUID)
	if kerr != nil || strings.TrimSpace(apiKey) == "" || isSyntheticAIKey(apiKey) {
		// 無真實 key：標註非 LLM
		p.Notes = "⚠️ 規則摘要（非正式 AI）。請到設定填寫 xAI 或 OpenCode Go API Key 後重跑分析。"
		p.UpdatedAt = domain.NowNano()
		return nil
	}

	raw, aerr := s.completeLLM(ctx, provider, model, apiKey, buildStyleAnalysisPrompt(samples, username, sourceLabel))
	if aerr != nil {
		p.Notes = "⚠️ AI 分析失敗，已保留規則摘要：" + truncate(aerr.Error(), 160)
		p.UpdatedAt = domain.NowNano()
		return nil // 不整段失敗；規則結果仍可用
	}
	if !applyAIStyleJSON(p, raw, samples, source, username, sourceLabel) {
		// AI 回了非 JSON：仍把全文放 notes，draft 用規則
		p.Notes = "AI 原文（未解析為結構化 JSON）：" + truncate(raw, 500)
		p.UpdatedAt = domain.NowNano()
		return nil
	}
	p.Notes = fmt.Sprintf("LLM 分析 · %s / %s · 樣本 %d 則", provider, model, len(samples))
	// growth-loop: 資產複利可見
	p.LearningVersion++
	p.LearnedFromPostsCount = len(samples)
	p.LastLearnedAt = domain.NowNano()
	if p.LearningSummary == "" {
		p.LearningSummary = fmt.Sprintf("已從 %d 則樣本學到語氣／結構指紋（v%d）", len(samples), p.LearningVersion)
	} else {
		p.LearningSummary = fmt.Sprintf("更新語言指紋 v%d · 樣本 %d 則", p.LearningVersion, len(samples))
	}
	p.UpdatedAt = domain.NowNano()
	return nil
}

func (s *Service) resolveUserAI(ctx context.Context, ownerUID int64) (provider, model, apiKey string, err error) {
	if s.Keys != nil {
		return s.Keys.ResolveAI(ctx, ownerUID)
	}
	// tests：FakeClient + any key
	if s.AI != nil {
		return "xai", "grok-3", "test-key", nil
	}
	return "", "", "", fmt.Errorf("AI not configured")
}

func (s *Service) completeLLM(ctx context.Context, provider, model, apiKey, prompt string) (string, error) {
	// system 語言 prompt 由 ai.Client 依 ctx 強制注入（見 openai_compatible.Complete）
	// real registry first
	if s.AIRegistry != nil && !isSyntheticAIKey(apiKey) {
		if c, err := s.AIRegistry.Client(provider); err == nil {
			return c.Complete(ctx, apiKey, model, prompt)
		}
	}
	if s.AI != nil {
		return s.AI.Complete(ctx, apiKey, model, prompt)
	}
	return "", fmt.Errorf("no AI client")
}

func isSyntheticAIKey(key string) bool {
	k := strings.TrimSpace(strings.ToLower(key))
	return k == "" || strings.HasPrefix(k, "fake-") || k == "platform-key" || k == "test-key"
}

// ---------- Plays (PL) ----------

func (s *Service) ListPlays(ctx context.Context, ownerUID int64) ([]*domain.Play, error) {
	return s.Repo.ListPlays(ctx, ownerUID)
}

func (s *Service) GetPlay(ctx context.Context, ownerUID int64, id string) (*domain.Play, error) {
	p, err := s.Repo.GetPlay(ctx, id)
	if err != nil {
		return nil, err
	}
	if p.OwnerUID != ownerUID {
		return nil, domain.ErrForbidden
	}
	return p, nil
}

func (s *Service) SavePlay(ctx context.Context, ownerUID int64, p *domain.Play) (*domain.Play, error) {
	if ownerUID <= 0 {
		return nil, domain.ErrForbidden
	}
	now := domain.NowNano()
	if p.ID == "" {
		p.ID = "play_" + uuid.NewString()[:12]
		p.CreatedAt = now
	} else {
		ex, err := s.Repo.GetPlay(ctx, p.ID)
		if err == nil {
			if ex.OwnerUID != ownerUID {
				return nil, domain.ErrForbidden
			}
			p.CreatedAt = ex.CreatedAt
		} else if err != domain.ErrNotFound {
			return nil, err
		} else {
			p.CreatedAt = now
		}
	}
	p.OwnerUID = ownerUID
	// own vs external mutually exclusive
	if p.TargetOwnPostID != "" {
		p.TargetExternal = nil
	}
	if p.TargetExternal != nil && p.TargetExternal.URL != "" {
		p.TargetOwnPostID = ""
		p.TargetExternal.URL = normalizeURL(p.TargetExternal.URL)
	}
	if p.Status == "" {
		p.Status = domain.PlayDraft
	}
	// renumber steps
	for i := range p.Steps {
		p.Steps[i].SortOrder = i
		if p.Steps[i].ID == "" {
			p.Steps[i].ID = "step_" + uuid.NewString()[:8]
		}
		if underPost(p) {
			p.Steps[i].Kind = domain.StepReply
		}
	}
	p.UpdatedAt = now
	if err := s.Repo.SavePlay(ctx, p); err != nil {
		return nil, err
	}
	return p, nil
}

func (s *Service) RemovePlay(ctx context.Context, ownerUID int64, id string) error {
	if _, err := s.GetPlay(ctx, ownerUID, id); err != nil {
		return err
	}
	// outbox retained (documented strategy)
	return s.Repo.DeletePlay(ctx, id)
}

func (s *Service) ListPlaysByPost(ctx context.Context, ownerUID int64, ownPostID string) ([]*domain.Play, error) {
	all, err := s.Repo.ListPlays(ctx, ownerUID)
	if err != nil {
		return nil, err
	}
	var out []*domain.Play
	for _, p := range all {
		if p.TargetOwnPostID == ownPostID {
			out = append(out, p)
		}
	}
	return out, nil
}

func (s *Service) ListPlaysByExternalURL(ctx context.Context, ownerUID int64, raw string) ([]*domain.Play, error) {
	key := normalizeURL(raw)
	if key == "" {
		return nil, nil
	}
	all, err := s.Repo.ListPlays(ctx, ownerUID)
	if err != nil {
		return nil, err
	}
	var out []*domain.Play
	for _, p := range all {
		if p.TargetExternal != nil && normalizeURL(p.TargetExternal.URL) == key {
			out = append(out, p)
		}
	}
	return out, nil
}

func (s *Service) ResolveExternalLink(_ context.Context, raw string) (*domain.ExternalTarget, error) {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return nil, domain.ErrBadURL
	}
	u, err := url.Parse(raw)
	if err != nil || u.Host == "" {
		// allow threads-like paths without scheme
		if !strings.Contains(raw, "threads.net") && !strings.HasPrefix(raw, "http") {
			return nil, domain.ErrBadURL
		}
		u, err = url.Parse("https://" + strings.TrimPrefix(raw, "//"))
		if err != nil {
			return nil, domain.ErrBadURL
		}
	}
	host := strings.ToLower(u.Host)
	if !strings.Contains(host, "threads.net") && !strings.Contains(host, "threads.com") {
		return nil, domain.ErrBadURL
	}
	norm := normalizeURL(u.String())
	parts := strings.Split(strings.Trim(u.Path, "/"), "/")
	shortcode := ""
	author := ""
	if len(parts) >= 1 && strings.HasPrefix(parts[0], "@") {
		author = strings.TrimPrefix(parts[0], "@")
	}
	if len(parts) >= 3 && parts[1] == "post" {
		shortcode = parts[2]
	}
	return &domain.ExternalTarget{
		URL:            norm,
		RawURL:         raw,
		Shortcode:      shortcode,
		AuthorUsername: author,
		TextPreview:    "External thread preview · " + shortcode,
		MediaID:        "ext_" + shortcode,
		ResolvedAt:     domain.NowNano(),
	}, nil
}

func (s *Service) SubmitPlay(ctx context.Context, ownerUID int64, playID string) (*domain.OutboxBundle, error) {
	play, err := s.GetPlay(ctx, ownerUID, playID)
	if err != nil {
		return nil, err
	}
	if err := validatePlay(play); err != nil {
		return nil, err
	}
	if err := s.ensureUsableAccounts(ctx, ownerUID, play); err != nil {
		return nil, err
	}
	// 互回／掛文：把目標 Threads media_id 寫進 outbox（否則 Meta 無法 reply_to）
	replyToMedia := ""
	if play.TargetExternal != nil {
		replyToMedia = strings.TrimSpace(play.TargetExternal.MediaID)
		if replyToMedia == "" || strings.HasPrefix(replyToMedia, "ext_") {
			return nil, fmt.Errorf("%w: 外部連結尚無法解析真實 media_id，請改用「我的貼文」當目標，或先同步後貼上可回覆的貼文", domain.ErrValidation)
		}
	}
	if play.TargetOwnPostID != "" {
		post, perr := s.Repo.GetOwnPost(ctx, play.TargetOwnPostID)
		if perr != nil || post == nil {
			return nil, fmt.Errorf("%w: 找不到目標貼文，請先到「我的貼文」同步", domain.ErrValidation)
		}
		if post.OwnerUID != ownerUID {
			return nil, domain.ErrForbidden
		}
		replyToMedia = strings.TrimSpace(post.MediaID)
		if replyToMedia == "" {
			return nil, fmt.Errorf("%w: 目標貼文缺少 media_id，請重新同步「我的貼文」", domain.ErrValidation)
		}
	}
	// 每步要有正文
	for _, st := range play.Steps {
		if strings.TrimSpace(st.Text) == "" {
			return nil, fmt.Errorf("%w: 劇本步驟不可空白，請先 AI 產文或手寫", domain.ErrValidation)
		}
	}
	bundle := playToOutbox(ownerUID, play)
	if replyToMedia != "" {
		bundle.ReplyToMediaID = replyToMedia
		for i := range bundle.Steps {
			if bundle.Steps[i].Kind == domain.StepReply {
				bundle.Steps[i].ReplyTo = replyToMedia
			}
		}
	}
	if err := s.Repo.SaveOutbox(ctx, bundle); err != nil {
		return nil, err
	}
	play.Status = domain.PlayScheduling
	play.UpdatedAt = domain.NowNano()
	_ = s.Repo.SavePlay(ctx, play)
	return bundle, nil
}

// GeneratePlayScript — 一次 LLM 產完整劇本並寫回 play.steps（onlyEmpty=true 只填空白步）。
// 回傳填入步數。供 worker job / 同步測試。
func (s *Service) GeneratePlayScript(ctx context.Context, ownerUID int64, playID string, onlyEmpty bool) (_ int, err error) {
	play, err := s.GetPlay(ctx, ownerUID, playID)
	if err != nil {
		return 0, err
	}
	if len(play.Steps) == 0 {
		return 0, fmt.Errorf("%w: play has no steps", domain.ErrValidation)
	}
	// 需要產的步
	type need struct {
		idx   int
		id    string
		label string
		mode  string
	}
	var needs []need
	for i, st := range play.Steps {
		if onlyEmpty && strings.TrimSpace(st.Text) != "" {
			continue
		}
		label := st.AccountID
		mode := "reply"
		if st.Kind == domain.StepRoot || (i == 0 && !underPost(play)) {
			mode = "root"
		}
		// 人設標籤
		if p := s.loadPersonaForGen(ctx, ownerUID, st.PersonaID); p != nil && p.Name != "" {
			label = p.Name
		}
		needs = append(needs, need{idx: i, id: st.ID, label: label, mode: mode})
	}
	if len(needs) == 0 {
		return 0, fmt.Errorf("%w: 沒有空白步驟可產（全部已有正文）", domain.ErrValidation)
	}
	charge, err := s.billAI(ctx, ownerUID, "play generate script", "plays.generateScript")
	if err != nil {
		return 0, err
	}
	defer charge.Settle(ctx, &err)

	// 主上下文：目標貼文／主題
	targetCtx := strings.TrimSpace(play.Topic)
	if play.TargetOwnPostID != "" {
		if post, e := s.Repo.GetOwnPost(ctx, play.TargetOwnPostID); e == nil && post != nil {
			if t := strings.TrimSpace(post.Text); t != "" {
				targetCtx = t
			}
		}
	}

	// 選一人設當「主指紋」（優先第一空步的 persona，再 active）
	personaID := ""
	for _, n := range needs {
		if pid := strings.TrimSpace(play.Steps[n.idx].PersonaID); pid != "" {
			personaID = pid
			break
		}
	}
	persona := s.loadPersonaForGen(ctx, ownerUID, personaID)
	fp := personaExpressionFingerprintBlock(persona)
	if utf8.RuneCountInString(fp) > 400 {
		fp = string([]rune(fp)[:400]) + "…"
	}
	if utf8.RuneCountInString(targetCtx) > 280 {
		targetCtx = string([]rune(targetCtx)[:280]) + "…"
	}

	// 骨架：已有正文的步當上下文，空步標 [待產]
	var skeleton strings.Builder
	for i, st := range play.Steps {
		skeleton.WriteString(fmt.Sprintf("%d. id=%s kind=%s speaker=%s\n", i+1, st.ID, st.Kind, st.AccountID))
		if t := strings.TrimSpace(st.Text); t != "" {
			if utf8.RuneCountInString(t) > 120 {
				t = string([]rune(t)[:120]) + "…"
			}
			skeleton.WriteString("   已有：")
			skeleton.WriteString(t)
			skeleton.WriteString("\n")
		} else {
			skeleton.WriteString("   待產：是\n")
		}
	}

	prompt := fmt.Sprintf(`你是 Threads 互回編劇。一次寫完下列「待產」步驟的正文。

規則：
- 只輸出 JSON（不要 markdown 圍欄）：{"steps":[{"id":"步驟id","text":"正文"},...]}
- 只輸出待產步驟；id 必須與下方 id 完全一致
- 繁體中文口語、像真人互回；root 主貼依內容需要完整寫完，reply 維持 25～100 字
- 接住主文／上一則，不要重複抄全文、不要暴露 AI

【指紋】
%s

【主文／話題】
%s

【步驟表】
%s

JSON：`, fp, targetCtx, skeleton.String())

	provider, model, apiKey, kerr := s.resolveUserAI(ctx, ownerUID)
	if kerr != nil || strings.TrimSpace(apiKey) == "" || isSyntheticAIKey(apiKey) {
		if s.AI != nil {
			// 測試 Fake：填固定短句
			for _, n := range needs {
				play.Steps[n.idx].Text = fmt.Sprintf("（測試）步驟 %d 互回", n.idx+1)
			}
			play.UpdatedAt = domain.NowNano()
			if err := s.Repo.SavePlay(ctx, play); err != nil {
				return 0, err
			}
			return len(needs), nil
		}
		return 0, fmt.Errorf("%w: 無法產文，請到設定填寫真實 AI Key", domain.ErrValidation)
	}

	llmCtx := ctx
	cancel := func() {}
	if _, ok := ctx.Deadline(); !ok {
		llmCtx, cancel = context.WithTimeout(ctx, 95*time.Second)
	}
	defer cancel()

	out, aerr := s.completeLLM(llmCtx, provider, model, apiKey, prompt)
	if aerr != nil {
		msg := aerr.Error()
		if llmCtx.Err() != nil || strings.Contains(msg, "timeout") || strings.Contains(msg, "deadline") {
			return 0, fmt.Errorf("%w: AI 回應逾時（%s/%s）。請換較快模型", domain.ErrValidation, provider, model)
		}
		return 0, fmt.Errorf("%w: AI 產文失敗（%s/%s）：%s", domain.ErrValidation, provider, model, truncate(msg, 180))
	}

	filled := applyPlayScriptJSON(play, out)
	if filled == 0 {
		// 解析失敗：嘗試整包當一步（不建議）；直接報錯
		return 0, fmt.Errorf("%w: AI 回傳無法解析成步驟（%s/%s）。請換模型後重試", domain.ErrValidation, provider, model)
	}
	play.UpdatedAt = domain.NowNano()
	if err := s.Repo.SavePlay(ctx, play); err != nil {
		return 0, err
	}
	return filled, nil
}

// applyPlayScriptJSON 把 {"steps":[{"id","text"}]} 寫入 play
func applyPlayScriptJSON(play *domain.Play, raw string) int {
	s := strings.TrimSpace(raw)
	s = strings.TrimPrefix(s, "```json")
	s = strings.TrimPrefix(s, "```")
	s = strings.TrimSuffix(s, "```")
	s = strings.TrimSpace(s)
	// 取 JSON 物件
	if i := strings.Index(s, "{"); i >= 0 {
		if j := strings.LastIndex(s, "}"); j > i {
			s = s[i : j+1]
		}
	}
	var parsed struct {
		Steps []struct {
			ID   string `json:"id"`
			Text string `json:"text"`
		} `json:"steps"`
	}
	if err := json.Unmarshal([]byte(s), &parsed); err != nil || len(parsed.Steps) == 0 {
		return 0
	}
	byID := map[string]string{}
	for _, st := range parsed.Steps {
		id := strings.TrimSpace(st.ID)
		t := strings.TrimSpace(st.Text)
		if id == "" || t == "" {
			continue
		}
		byID[id] = t
	}
	// 也允許依順序填（若 id 對不上）
	filled := 0
	orderIdx := 0
	ordered := make([]string, 0, len(parsed.Steps))
	for _, st := range parsed.Steps {
		if t := strings.TrimSpace(st.Text); t != "" {
			ordered = append(ordered, t)
		}
	}
	for i := range play.Steps {
		id := play.Steps[i].ID
		if t, ok := byID[id]; ok {
			play.Steps[i].Text = cleanPlayGeneratedText(play.Steps[i].Kind, t)
			filled++
			continue
		}
		// 僅空白步才用順序填
		if strings.TrimSpace(play.Steps[i].Text) == "" && orderIdx < len(ordered) {
			play.Steps[i].Text = cleanPlayGeneratedText(play.Steps[i].Kind, ordered[orderIdx])
			orderIdx++
			filled++
		}
	}
	return filled
}

func cleanPlayGeneratedText(kind, text string) string {
	if kind == domain.StepRoot {
		return cleanGeneratedPostText(text)
	}
	return cleanGeneratedTextMax(text, 280)
}

// GeneratePlayStep — 互回／串場劇本：依人設真 LLM 產一步正文。
// 注意：與 compose mimic 相同，OpenCode reasoning 模型可能 30～90s 且偶發空白回覆。
func (s *Service) GeneratePlayStep(ctx context.Context, ownerUID int64, personaID, contextText, topic, speakerLabel string, isLead bool, mode string) (_ string, err error) {
	contextText = strings.TrimSpace(contextText)
	topic = strings.TrimSpace(topic)
	if contextText == "" && topic == "" {
		return "", fmt.Errorf("%w: empty context", domain.ErrValidation)
	}
	charge, err := s.billAI(ctx, ownerUID, "play generate step", "plays.generateStep")
	if err != nil {
		return "", err
	}
	defer charge.Settle(ctx, &err)
	mode = strings.ToLower(strings.TrimSpace(mode))
	if mode == "" {
		mode = "reply"
	}
	persona := s.loadPersonaForGen(ctx, ownerUID, personaID)
	fp := personaFingerprintBlock(persona)
	if mode != "root" {
		fp = personaExpressionFingerprintBlock(persona)
	}
	// 指紋過長會拖慢 reasoning 模型（仿寫已踩過）
	if utf8.RuneCountInString(fp) > 500 {
		fp = string([]rune(fp)[:500]) + "…"
	}
	if utf8.RuneCountInString(contextText) > 400 {
		contextText = string([]rune(contextText)[:400]) + "…"
	}
	prompt := buildPlayStepPrompt(fp, contextText, topic, speakerLabel, isLead, mode)

	provider, model, apiKey, kerr := s.resolveUserAI(ctx, ownerUID)
	if kerr != nil || strings.TrimSpace(apiKey) == "" || isSyntheticAIKey(apiKey) {
		// 單元測試 FakeClient
		if s.AI != nil {
			if out, e := s.AI.Complete(ctx, "test-key", "grok-3", prompt); e == nil {
				if t := cleanGeneratedTextMax(out, 400); t != "" {
					return t, nil
				}
			}
		}
		return "", fmt.Errorf("%w: 無法產文，請到設定填寫真實 AI Key（並選模型）後再試", domain.ErrValidation)
	}

	// 低於 gateway 120s；OpenCode 慢模型常 40～90s
	llmCtx := ctx
	cancel := func() {}
	if _, ok := ctx.Deadline(); !ok {
		llmCtx, cancel = context.WithTimeout(ctx, 95*time.Second)
	}
	defer cancel()

	out, aerr := s.completeLLM(llmCtx, provider, model, apiKey, prompt)
	if aerr != nil {
		msg := aerr.Error()
		if llmCtx.Err() != nil || strings.Contains(msg, "context deadline") || strings.Contains(msg, "Client.Timeout") || strings.Contains(msg, "timeout") {
			return "", fmt.Errorf("%w: AI 回應逾時（%s / %s）。請換較快的模型，或稍後再試", domain.ErrValidation, provider, model)
		}
		return "", fmt.Errorf("%w: AI 產文失敗（%s/%s）：%s", domain.ErrValidation, provider, model, truncate(msg, 200))
	}
	text := cleanGeneratedTextMax(out, 400)
	if mode == "root" {
		text = cleanGeneratedPostText(out)
	}
	if text == "" {
		text = strings.TrimSpace(out)
	}
	if text == "" {
		// 與仿寫相同：reasoning 模型有時只吐 thinking、content 空
		return "", fmt.Errorf("%w: AI 回傳空白（%s / %s）。請到設定換較快的模型（勿用過慢的 reasoning），或重試一次", domain.ErrValidation, provider, model)
	}
	return text, nil
}

func buildPlayStepPrompt(fp, contextText, topic, speakerLabel string, isLead bool, mode string) string {
	// 精簡 prompt：降低 reasoning 模型耗時（仿寫踩過的坑）
	var b strings.Builder
	if mode == "root" {
		b.WriteString("寫一則 Threads 主貼。只輸出正文（繁中口語）；依內容需要自然展開，把觀點與情緒完整講完，不設固定字數，勿標題/markdown。\n")
	} else {
		b.WriteString("寫一則 Threads 互回短回覆。先理解上下文，再回應其中一個具體內容；人設只控制表達方式，不替內容套模板。只輸出正文（繁中口語），15～120字，勿分析/markdown/暴露AI。\n")
		b.WriteString("不要預設用建議、共感、感謝或問句開頭；從上下文自然決定切入點，也不必刻意用問句收尾。\n")
	}
	if speakerLabel != "" {
		b.WriteString("發言者：")
		b.WriteString(speakerLabel)
		if isLead {
			b.WriteString("（主帳）")
		}
		b.WriteString("\n")
	}
	if topic != "" {
		b.WriteString("話題：")
		b.WriteString(topic)
		b.WriteString("\n")
	}
	b.WriteString("【指紋】\n")
	b.WriteString(fp)
	b.WriteString("\n")
	if contextText != "" {
		b.WriteString("【上下文】\n")
		b.WriteString(contextText)
		b.WriteString("\n")
	}
	b.WriteString("正文：")
	return b.String()
}

// ---------- Outbox (OB) ----------

func (s *Service) ListOutbox(ctx context.Context, ownerUID int64) ([]*domain.OutboxBundle, error) {
	return s.Repo.ListOutbox(ctx, ownerUID)
}

func (s *Service) GetOutbox(ctx context.Context, ownerUID int64, id string) (*domain.OutboxBundle, error) {
	b, err := s.Repo.GetOutbox(ctx, id)
	if err != nil {
		return nil, err
	}
	if b.OwnerUID != ownerUID {
		return nil, domain.ErrForbidden
	}
	return b, nil
}

func (s *Service) RemoveOutbox(ctx context.Context, ownerUID int64, id string) error {
	b, err := s.GetOutbox(ctx, ownerUID, id)
	if err != nil {
		return err
	}
	// Delete has a repository-level no-publishing guard. Clean up only after it
	// succeeds, otherwise a publisher may still need these URLs.
	if err := s.Repo.DeleteOutbox(ctx, id); err != nil {
		return err
	}
	for i := range b.Steps {
		s.cleanupEphemeralImages(ctx, b.Steps[i].ImageURLs)
	}
	return nil
}

func (s *Service) RetryStep(ctx context.Context, ownerUID int64, bundleID, stepID string) (*domain.OutboxBundle, error) {
	b, err := s.GetOutbox(ctx, ownerUID, bundleID)
	if err != nil {
		return nil, err
	}
	idx := -1
	for i := range b.Steps {
		if b.Steps[i].ID == stepID {
			idx = i
			break
		}
	}
	if idx < 0 {
		return nil, domain.ErrNotFound
	}
	st := &b.Steps[idx]
	// only failed (or blocked after root fixed) can retry
	if st.Status != domain.StepFailed {
		return nil, domain.ErrIllegalStatus
	}
	if st.Kind == domain.StepReply {
		root := findRoot(b)
		if root == nil || root.Status != domain.StepPublished {
			return nil, domain.ErrRootBlocked
		}
	}
	return s.Repo.RetryOutboxStep(ctx, bundleID, stepID, domain.NowNano())
}

// ProcessDueSteps is the worker tick — publishes via Transport only.
func (s *Service) ProcessDueSteps(ctx context.Context, now int64) (published int, err error) {
	if now <= 0 {
		now = domain.NowNano()
	}
	all, err := s.Repo.ListAllOutbox(ctx)
	if err != nil {
		return 0, err
	}
	for _, b := range all {
		n, e := s.processBundle(ctx, b, now)
		published += n
		if e != nil && err == nil {
			err = e
		}
	}
	return published, err
}

func (s *Service) processBundle(ctx context.Context, b *domain.OutboxBundle, now int64) (int, error) {
	count := 0
	for i := range b.Steps {
		st := &b.Steps[i]
		claimable := st.Status == domain.StepScheduled && st.ScheduledAt <= now
		stale := st.Status == domain.StepPublishing && st.LeaseExpiresAt <= now
		if !claimable && !stale {
			continue
		}
		root := findRoot(b)
		if st.Kind == domain.StepReply {
			if root != nil && root.Status != domain.StepPublished {
				continue
			}
		}

		leaseDuration := s.OutboxLeaseDuration
		if leaseDuration <= 0 {
			leaseDuration = 3 * time.Minute
		}
		claimOwner := s.OutboxWorkerID + ":" + uuid.NewString()
		claimNow := domain.NowNano()
		claimed, claimErr := s.Repo.ClaimOutboxStep(ctx, b.ID, st.ID, claimOwner, now, claimNow+int64(leaseDuration))
		if claimErr != nil {
			if claimErr == domain.ErrIllegalStatus || claimErr == domain.ErrNotFound {
				continue
			}
			return count, claimErr
		}
		if stale {
			logx.Infof("outbox reclaimed expired publishing lease step=%s bundle=%s", st.ID, b.ID)
		}
		claimedStep := findOutboxStep(claimed, st.ID)
		if claimedStep == nil {
			return count, domain.ErrNotFound
		}
		result := *claimedStep
		result.LeaseOwner, result.LeaseExpiresAt = "", 0
		fail := func(message string) error {
			result.Status, result.Error = domain.StepFailed, message
			finishedAt := domain.NowNano()
			if err := s.Repo.FinishOutboxStep(ctx, b.ID, result.ID, claimOwner, &result, finishedAt); err != nil {
				return err
			}
			return s.Repo.RecomputeOutboxStatus(ctx, b.ID, finishedAt)
		}
		if s.Transport == nil {
			if err := fail("publish transport not configured"); err != nil {
				return count, err
			}
			continue
		}

		token := "fake-token"
		if s.Accounts != nil {
			acc, aerr := s.Accounts.Get(ctx, result.AccountID)
			if aerr != nil || acc == nil || !acc.IsUsable {
				if err := fail("account not usable"); err != nil {
					return count, err
				}
				continue
			}
			if t, terr := s.Accounts.AccessToken(ctx, acc); terr != nil {
				if err := fail(terr.Error()); err != nil {
					return count, err
				}
				continue
			} else if t != "" {
				token = t
			}
		}
		root = findRoot(claimed)
		replyTo := result.ReplyTo
		if replyTo == "" && result.Kind == domain.StepReply {
			if root != nil && root.MediaID != "" {
				replyTo = root.MediaID
			} else if claimed.ReplyToMediaID != "" {
				replyTo = claimed.ReplyToMediaID
			}
		}
		request := domain.PublishRequest{
			AccessToken: token,
			AccountID:   result.AccountID,
			Text:        result.Text,
			ReplyTo:     replyTo,
			ImageURLs:   result.ImageURLs,
			// 僅主貼帶話題標籤
			TopicTag: func() string {
				if result.Kind == domain.StepRoot {
					return result.TopicTag
				}
				return ""
			}(),
			ReplyControl: func() string {
				if result.Kind == domain.StepRoot {
					return result.ReplyControl
				}
				return ""
			}(),
		}
		res, perr := s.publishWithLease(ctx, b.ID, result.ID, claimOwner, leaseDuration, request)
		if perr != nil {
			if errors.Is(perr, domain.ErrLeaseLost) {
				return count, perr
			}
			if err := fail(perr.Error()); err != nil {
				return count, err
			}
		} else {
			result.Status, result.Error = domain.StepPublished, ""
			result.PublishedAt = domain.NowNano()
			if res != nil {
				result.MediaID = res.MediaID
			}
			images := append([]string(nil), result.ImageURLs...)
			result.ImageURLs = nil
			finishedAt := domain.NowNano()
			if err := s.Repo.FinishOutboxStep(ctx, b.ID, result.ID, claimOwner, &result, finishedAt); err != nil {
				// The remote result may have succeeded. Never let a stale lease write
				// final state; a reclaim can duplicate because Meta has no idempotency key.
				return count, err
			}
			if err := s.Repo.RecomputeOutboxStatus(ctx, b.ID, finishedAt); err != nil {
				return count, err
			}
			s.cleanupEphemeralImages(ctx, images)
			if s.OnStepPublished != nil {
				s.OnStepPublished(ctx, b.OwnerUID, b.ID, result.ID, result.AccountID)
			}
			count++
		}
		fresh, getErr := s.Repo.GetOutbox(ctx, b.ID)
		if getErr != nil {
			return count, getErr
		}
		b = fresh
	}
	return count, nil
}

func (s *Service) publishWithLease(ctx context.Context, bundleID, stepID, leaseOwner string, leaseDuration time.Duration, request domain.PublishRequest) (*domain.PublishResult, error) {
	publishCtx, cancel := context.WithCancel(ctx)
	defer cancel()
	renewEvery := leaseDuration / 3
	if renewEvery <= 0 {
		renewEvery = time.Millisecond
	}
	renewErr := make(chan error, 1)
	done := make(chan struct{})
	stopped := make(chan struct{})
	go func() {
		defer close(stopped)
		ticker := time.NewTicker(renewEvery)
		defer ticker.Stop()
		for {
			select {
			case <-done:
				return
			case <-ticker.C:
				now := domain.NowNano()
				if err := s.Repo.RenewOutboxStepLease(publishCtx, bundleID, stepID, leaseOwner, now, now+int64(leaseDuration)); err != nil {
					renewErr <- err
					cancel()
					return
				}
			}
		}
	}()
	res, err := s.Transport.Publish(publishCtx, request)
	close(done)
	<-stopped
	select {
	case leaseErr := <-renewErr:
		return res, fmt.Errorf("%w: renew failed: %v", domain.ErrLeaseLost, leaseErr)
	default:
		return res, err
	}
}

// ---------- Compose (CP) ----------

func (s *Service) Mimic(ctx context.Context, ownerUID int64, sourceText, personaID, direction, structureNotes string) (_ string, err error) {
	sourceText = strings.TrimSpace(sourceText)
	if sourceText == "" {
		return "", fmt.Errorf("%w: empty source", domain.ErrValidation)
	}
	personaID = strings.TrimSpace(personaID)
	if personaID == "" {
		return "", fmt.Errorf("%w: 請選擇已完成人設分析的人設", domain.ErrValidation)
	}
	p, err := s.GetPersona(ctx, ownerUID, personaID)
	if err != nil || p == nil {
		return "", fmt.Errorf("%w: 選擇的人設不存在或無法使用", domain.ErrValidation)
	}
	if p.Status != domain.PersonaReady {
		return "", fmt.Errorf("%w: 選擇的人設尚未完成分析", domain.ErrValidation)
	}
	charge, err := s.billAI(ctx, ownerUID, "compose mimic", "compose.mimic")
	if err != nil {
		return "", err
	}
	defer charge.Settle(ctx, &err)
	fp := personaExpressionFingerprintBlock(p)
	// 指紋過長會拖慢 reasoning 模型
	if utf8.RuneCountInString(fp) > 800 {
		fp = string([]rune(fp)[:800]) + "…"
	}
	notes := strings.TrimSpace(structureNotes)
	if utf8.RuneCountInString(notes) > 800 {
		notes = string([]rune(notes)[:800]) + "…"
	}
	direction = strings.TrimSpace(direction)
	if utf8.RuneCountInString(direction) > 300 {
		direction = string([]rune(direction)[:300]) + "…"
	}
	notesBlock := ""
	if notes != "" {
		notesBlock = fmt.Sprintf("\n\n（節奏提示，勿照抄）\n%s\n", notes)
	}

	prompt := buildMimicPrompt(fp, sourceText, direction, notesBlock)

	provider, model, apiKey, kerr := s.resolveUserAI(ctx, ownerUID)
	if kerr != nil || strings.TrimSpace(apiKey) == "" || isSyntheticAIKey(apiKey) {
		if s.AI != nil && (apiKey == "test-key" || s.Keys == nil) {
			if out, e := s.AI.Complete(ctx, "test-key", "grok-3", prompt); e == nil {
				if t := cleanGeneratedPostText(out); t != "" {
					return t, nil
				}
			}
		}
		return "", fmt.Errorf("%w: 無法仿寫：請到設定填寫真實 AI Key 後再試", domain.ErrValidation)
	}

	// HTTP 同步路徑：沒有外層 deadline 時限 95s（低於 gateway 120s）
	// Job worker 會帶 4 分鐘 deadline，這裡不覆蓋
	llmCtx := ctx
	cancel := func() {}
	if _, ok := ctx.Deadline(); !ok {
		llmCtx, cancel = context.WithTimeout(ctx, 95*time.Second)
	}
	defer cancel()

	out, aerr := s.completeLLM(llmCtx, provider, model, apiKey, prompt)
	if aerr != nil {
		msg := aerr.Error()
		if llmCtx.Err() != nil || strings.Contains(msg, "context deadline") || strings.Contains(msg, "Client.Timeout") || strings.Contains(msg, "timeout") {
			return "", fmt.Errorf("%w: AI 回應逾時（%s / %s）。請縮短「結構分析」備註後再試，或換較快的模型", domain.ErrValidation, provider, model)
		}
		return "", fmt.Errorf("%w: AI 仿寫失敗（%s/%s）：%s", domain.ErrValidation, provider, model, truncate(msg, 200))
	}
	text := cleanGeneratedPostText(out)
	if text == "" {
		text = strings.TrimSpace(out)
	}
	if text == "" {
		return "", fmt.Errorf("%w: AI 回傳空白（%s / %s）。請到設定確認模型後再試", domain.ErrValidation, provider, model)
	}
	return text, nil
}

func buildMimicPrompt(fp, sourceText, direction, notesBlock string) string {
	direction = strings.TrimSpace(direction)
	if direction == "" {
		direction = "請自行從參考文涵蓋的領域延伸一個相關、具體，但明顯不同的新角度；不可沿用原文事件或結論。"
	}
	return strings.TrimSpace(fmt.Sprintf(`
你就是【指定人設】本人在寫一篇全新的 Threads 貼文，不是助手，也不是在改寫原句。

請在心中依序完成，但不要輸出分析：
1. 綜觀【參考文】與【結構分析】，只抽取可借用的敘事骨架、資訊密度、轉折方式與情緒曲線。
2. 依【新內容方向】重新立題，先想清楚這篇新貼文自己的核心觀點、素材與情緒，不沿用參考文內容。
3. 用全新的首句、例子、論述與收尾寫完內容。
4. 最後套用【人設表達軌跡】的慣用詞、語氣、節奏、標點與情緒表達；成品必須明顯像這個人說的話。

規則：
- 參考文只供學習結構，不是內容來源。不得沿用其人物、事件、品牌、例子、數字、觀點句、首句或結論。
- 不是摘要、潤稿或同義改寫；新貼文讀起來必須是另一篇文章。
- 新內容與人設同等重要：內容要完整，人設聲紋也要清楚，但不得套固定鉤子或 CTA。
- 不要固定從建議、共感、提問、金句或結論開始；重新設計最適合新主題的切入點。
- 不補空泛雞湯，不為了互動硬加問句，不捏造具體個人經歷或不可驗證事實。
- 段落與篇幅跟著內容走，不強制列點、固定段數或固定字數；把文章完整講完，但不要灌水。
- 只輸出完成後的正文，不要標題、分析、markdown 或任何前綴。

【新內容方向】
%s

【人設表達軌跡】
%s

【參考文，只借結構】
%s
%s
`, direction, fp, sourceText, notesBlock))
}

func (s *Service) AnalyzeViral(ctx context.Context, ownerUID int64, text string) (_ *domain.ViralAnalysis, err error) {
	charge, err := s.billAI(ctx, ownerUID, "compose analyze viral", "compose.analyzeViral")
	if err != nil {
		return nil, err
	}
	defer charge.Settle(ctx, &err)
	return s.analyzeViralUnbilled(ctx, ownerUID, text)
}

// analyzeViralUnbilled — 真 LLM 結構分析（呼叫端自行 billAI，避免雙重計費）
func (s *Service) analyzeViralUnbilled(ctx context.Context, ownerUID int64, text string) (*domain.ViralAnalysis, error) {
	text = strings.TrimSpace(text)
	if text == "" {
		return nil, fmt.Errorf("%w: empty text", domain.ErrValidation)
	}

	// 規則底稿（LLM 失敗時仍有可用輸出，但會標註）
	fallback := ruleViralAnalysis(text)

	provider, model, apiKey, kerr := s.resolveUserAI(ctx, ownerUID)
	if kerr != nil || strings.TrimSpace(apiKey) == "" || isSyntheticAIKey(apiKey) {
		// 測試：FakeClient 可走下面；正式無 key 回規則 + 提示
		if s.AI != nil {
			if out, e := s.AI.Complete(ctx, "test-key", "grok-3", buildViralAnalysisPrompt(text)); e == nil {
				if va, ok := parseViralAnalysisJSON(out); ok {
					return va, nil
				}
			}
		}
		fallback.Summary = fallback.Summary + "（規則摘要：請到設定填 AI Key 以取得真分析）"
		return fallback, nil
	}

	raw, aerr := s.completeLLM(ctx, provider, model, apiKey, buildViralAnalysisPrompt(text))
	if aerr != nil {
		fallback.Summary = fallback.Summary + "（AI 失敗，規則底稿：" + truncate(aerr.Error(), 80) + "）"
		return fallback, nil
	}
	if va, ok := parseViralAnalysisJSON(raw); ok {
		return va, nil
	}
	// 非 JSON：仍把 AI 重點塞進 summary
	fallback.Summary = "AI 原文：" + truncate(cleanGeneratedText(raw), 400)
	return fallback, nil
}

func buildViralAnalysisPrompt(text string) string {
	return strings.TrimSpace(fmt.Sprintf(`
你是 Threads 內容編輯。請分析下面這則貼文的「可複製結構」，服務創作者改寫／學習。

規則：
- 只輸出 JSON（不要 markdown 圍欄、不要前後說明）
- 繁體中文（台灣用語）
- 字段都要有實質內容，勿空字串、勿敷衍套話

JSON schema：
{
  "hooks": "開場鉤子怎麼抓注意力（1～3 句）",
  "structure": "段落結構骨架（如：情境→痛點→經驗→提問）",
  "emotion": "情緒節奏（好奇／共感／緊迫等）",
  "summary": "為什麼這則有互動潛力（2～4 句）",
  "cta": "結尾 CTA／互動設計",
  "copyable": "別人可複製的 2～4 個要點（勿抄原文）",
  "risks": "改寫時要注意的風險／禁忌"
}

【貼文】
%s
`, text))
}

func parseViralAnalysisJSON(raw string) (*domain.ViralAnalysis, bool) {
	s := strings.TrimSpace(raw)
	s = strings.TrimPrefix(s, "```json")
	s = strings.TrimPrefix(s, "```")
	s = strings.TrimSuffix(s, "```")
	s = strings.TrimSpace(s)
	// 取第一個 { … 最後一個 }
	if i := strings.Index(s, "{"); i >= 0 {
		if j := strings.LastIndex(s, "}"); j > i {
			s = s[i : j+1]
		}
	}
	var parsed struct {
		Hooks     string `json:"hooks"`
		Structure string `json:"structure"`
		Emotion   string `json:"emotion"`
		Summary   string `json:"summary"`
		CTA       string `json:"cta"`
		Copyable  string `json:"copyable"`
		Risks     string `json:"risks"`
	}
	if err := json.Unmarshal([]byte(s), &parsed); err != nil {
		return nil, false
	}
	if strings.TrimSpace(parsed.Hooks) == "" && strings.TrimSpace(parsed.Structure) == "" && strings.TrimSpace(parsed.Summary) == "" {
		return nil, false
	}
	return &domain.ViralAnalysis{
		Hooks: strings.TrimSpace(parsed.Hooks), Structure: strings.TrimSpace(parsed.Structure),
		Emotion: strings.TrimSpace(parsed.Emotion), Summary: strings.TrimSpace(parsed.Summary),
		CTA: strings.TrimSpace(parsed.CTA), Copyable: strings.TrimSpace(parsed.Copyable),
		Risks: strings.TrimSpace(parsed.Risks),
	}, true
}

func ruleViralAnalysis(text string) *domain.ViralAnalysis {
	hasQ := strings.ContainsAny(text, "？?")
	hasPain := strings.ContainsAny(text, "卡煩雷痛難") || strings.Contains(text, "怎麼") || strings.Contains(text, "不會")
	hooks := "開場用生活情境，降低防備"
	if hasQ {
		hooks = "用真心疑問收尾／開場，降低回覆門檻"
	} else if hasPain {
		hooks = "先丟具體痛點，讀者有代入感"
	}
	emotion := "好奇／認同"
	if hasPain {
		emotion = "共感 + 一點焦慮釋放"
	}
	sum := "情境清楚"
	if hasPain {
		sum = "痛點具體"
	}
	if hasQ {
		sum += " + 好回的問題"
	}
	return &domain.ViralAnalysis{
		Hooks:     hooks,
		Structure: "情境／痛點 → 自己經驗一句 → 邀請補充（或輕 CTA）",
		Emotion:   emotion,
		Summary:   "這則有互動潛力：" + sum + "。改寫時保留結構，換成你的經驗與語氣。",
		CTA:       "軟性提問或請讀者補一句經驗",
		Copyable:  "短段落、一個明確條件、結尾問句；勿整段抄原文",
		Risks:     "勿保證效果、勿硬廣、勿嘲諷讀者",
	}
}

func formatViralFormulaDetail(va *domain.ViralAnalysis) string {
	if va == nil {
		return ""
	}
	var b strings.Builder
	if va.Summary != "" {
		b.WriteString("【為什麼可能爆】")
		b.WriteString(va.Summary)
		b.WriteString("\n")
	}
	if va.Hooks != "" {
		b.WriteString("【鉤子】")
		b.WriteString(va.Hooks)
		b.WriteString("\n")
	}
	if va.Structure != "" {
		b.WriteString("【結構】")
		b.WriteString(va.Structure)
		b.WriteString("\n")
	}
	if va.Emotion != "" {
		b.WriteString("【情緒】")
		b.WriteString(va.Emotion)
		b.WriteString("\n")
	}
	if va.Copyable != "" {
		b.WriteString("【可複製】")
		b.WriteString(va.Copyable)
		b.WriteString("\n")
	}
	if va.CTA != "" {
		b.WriteString("【CTA】")
		b.WriteString(va.CTA)
		b.WriteString("\n")
	}
	if va.Risks != "" {
		b.WriteString("【風險】")
		b.WriteString(va.Risks)
	}
	return strings.TrimSpace(b.String())
}

func (s *Service) PublishSingle(ctx context.Context, ownerUID int64, accountID, text, title string, imageURLs []string, scheduleStartAt int64, topicTag, replyControl string) (*domain.OutboxBundle, error) {
	text = strings.TrimSpace(text)
	if text == "" {
		return nil, fmt.Errorf("%w: empty text", domain.ErrValidation)
	}
	if accountID == "" {
		return nil, domain.ErrNoAccount
	}
	if s.Accounts != nil {
		acc, err := s.Accounts.Get(ctx, accountID)
		if err != nil || acc == nil || acc.OwnerUID != ownerUID || !acc.IsUsable {
			return nil, domain.ErrNoAccount
		}
	}
	now := domain.NowNano()
	start := scheduleStartAt
	if start <= 0 {
		start = now
	} else if start < now {
		// datetime-local 只有分鐘精度 + 時鐘誤差：超過 1 分鐘才擋
		const schedulePastGrace = int64(time.Minute)
		if now-start > schedulePastGrace {
			return nil, fmt.Errorf("%w: schedule_start_at is in the past", domain.ErrValidation)
		}
		start = now
	}
	// 圖必須是 Meta 可抓的 http(s)；data URL 請前端先 /media/upload
	cleanImgs := make([]string, 0, len(imageURLs))
	for _, u := range imageURLs {
		u = strings.TrimSpace(u)
		if u == "" {
			continue
		}
		if strings.HasPrefix(u, "data:") {
			return nil, fmt.Errorf("%w: image_urls must be public https URLs (upload first)", domain.ErrValidation)
		}
		if strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "http://") {
			cleanImgs = append(cleanImgs, u)
		}
	}
	tag := normalizePublishTopicTag(topicTag)
	play := &domain.Play{
		ID:            "play_" + uuid.NewString()[:12],
		OwnerUID:      ownerUID,
		Title:         title,
		Topic:         truncate(text, 80),
		Status:        domain.PlayScheduling,
		LeadAccountID: accountID,
		Steps: []domain.PlayStep{{
			ID: uuid.NewString()[:8], SortOrder: 0, Kind: domain.StepRoot,
			AccountID: accountID, Text: text, ImageURLs: cleanImgs,
		}},
		ScheduleStartAt: start,
		CreatedAt:       now,
		UpdatedAt:       now,
	}
	if play.Title == "" {
		play.Title = truncate(text, 24)
	}
	if err := s.Repo.SavePlay(ctx, play); err != nil {
		return nil, err
	}
	bundle := playToOutbox(ownerUID, play)
	// 主貼帶 topic_tag
	if tag != "" && len(bundle.Steps) > 0 {
		bundle.Steps[0].TopicTag = tag
	}
	if ctrl := threadsProvNormalizeReplyControl(replyControl); ctrl != "" && len(bundle.Steps) > 0 {
		bundle.Steps[0].ReplyControl = ctrl
	}
	if err := s.Repo.SaveOutbox(ctx, bundle); err != nil {
		return nil, err
	}
	return bundle, nil
}

// QueueExternalReply persists an immediate reply to an external Threads media ID.
func (s *Service) QueueExternalReply(ctx context.Context, ownerUID int64, accountID, replyToMediaID, text, title string) (*domain.OutboxBundle, error) {
	replyToMediaID = strings.TrimSpace(replyToMediaID)
	if replyToMediaID == "" {
		return nil, fmt.Errorf("%w: reply_to_media_id must be numeric", domain.ErrValidation)
	}
	for i := 0; i < len(replyToMediaID); i++ {
		if replyToMediaID[i] < '0' || replyToMediaID[i] > '9' {
			return nil, fmt.Errorf("%w: reply_to_media_id must be numeric", domain.ErrValidation)
		}
	}
	if accountID == "" {
		return nil, domain.ErrNoAccount
	}
	if s.Accounts != nil {
		acc, err := s.Accounts.Get(ctx, accountID)
		if err != nil || acc == nil || acc.OwnerUID != ownerUID || !acc.IsUsable {
			return nil, domain.ErrNoAccount
		}
	}

	now := domain.NowNano()
	bundle := &domain.OutboxBundle{
		ID: "outbox_" + uuid.NewString()[:12], OwnerUID: ownerUID, Title: title,
		Status: domain.OBScheduling, ReplyToMediaID: replyToMediaID, CreatedAt: now, UpdatedAt: now,
		Steps: []domain.OutboxStep{{
			ID: "obstep_" + uuid.NewString()[:10], SortOrder: 0, Kind: domain.StepReply,
			AccountID: accountID, Text: text, Status: domain.StepScheduled,
			ScheduledAt: now, ReplyTo: replyToMediaID,
		}},
	}
	if err := s.Repo.SaveOutbox(ctx, bundle); err != nil {
		return nil, err
	}
	return bundle, nil
}

func normalizePublishTopicTag(raw string) string {
	s := strings.TrimSpace(raw)
	s = strings.TrimPrefix(s, "#")
	s = strings.TrimSpace(s)
	s = strings.ReplaceAll(s, ".", "")
	s = strings.ReplaceAll(s, "&", "")
	s = strings.TrimSpace(s)
	if s == "" {
		return ""
	}
	r := []rune(s)
	if len(r) > 50 {
		s = string(r[:50])
	}
	return s
}

// ---------- OwnPosts (OP) ----------

func (s *Service) ListOwnPosts(ctx context.Context, ownerUID int64, accountID string) ([]*domain.OwnPost, error) {
	return s.Repo.ListOwnPosts(ctx, ownerUID, accountID)
}

func (s *Service) LastSyncedAt(ctx context.Context, ownerUID int64) (int64, error) {
	m, err := s.Repo.GetSyncMeta(ctx, ownerUID)
	if err != nil {
		return 0, err
	}
	return m.LastSyncedAt, nil
}

func (s *Service) SyncOwnPosts(ctx context.Context, ownerUID int64, accountID string) ([]*domain.OwnPost, error) {
	if accountID == "" {
		return nil, domain.ErrNoAccount
	}
	var acc *threadsDomain.Account
	if s.Accounts != nil {
		a, err := s.Accounts.Get(ctx, accountID)
		if err != nil || a == nil {
			return nil, domain.ErrNotFound
		}
		if a.OwnerUID != ownerUID {
			return nil, domain.ErrForbidden
		}
		if !a.IsUsable {
			return nil, domain.ErrNoAccount
		}
		acc = a
	}
	now := domain.NowNano()

	// 真 Threads Graph 同步
	if s.Media != nil && s.Accounts != nil && acc != nil {
		token, terr := s.Accounts.AccessToken(ctx, acc)
		if terr == nil && strings.TrimSpace(token) != "" && !strings.HasPrefix(token, "fake-") {
			if err := s.syncOwnPostsFromThreads(ctx, ownerUID, accountID, token); err != nil {
				return nil, err
			}
			_ = s.Repo.SetSyncMeta(ctx, &domain.SyncMeta{OwnerUID: ownerUID, LastSyncedAt: now})
			return s.Repo.ListOwnPosts(ctx, ownerUID, accountID)
		}
	}

	// 無 Media／假 token：保留本地假 seed（單元測試）
	existing, _ := s.Repo.ListOwnPosts(ctx, ownerUID, accountID)
	if len(existing) == 0 {
		for i := 0; i < 2; i++ {
			p := &domain.OwnPost{
				ID: "op_" + uuid.NewString()[:10], OwnerUID: ownerUID, AccountID: accountID,
				MediaID: "media_sync_" + uuid.NewString()[:6], Text: fmt.Sprintf("Synced post #%d from Threads", i+1),
				MediaType: "TEXT_POST", LikeCount: 3 + i, ReplyCount: 1, ViewCount: 100 + i*20,
				InsightsStatus: "ok",
				Replies: []domain.OwnPostReply{{
					ID: "or_" + uuid.NewString()[:6], Username: "fan_" + fmt.Sprint(i),
					Text: "nice!", CreatedAt: now - int64(i)*time.Hour.Nanoseconds(), ReplyStatus: "pending",
				}},
				PublishedAt: now - int64(i+1)*time.Hour.Nanoseconds(),
			}
			_ = s.Repo.SaveOwnPost(ctx, p)
		}
	} else {
		for _, p := range existing {
			p.ViewCount += 5
			p.LikeCount++
			p.InsightsStatus = "ok"
			_ = s.Repo.SaveOwnPost(ctx, p)
		}
	}
	_ = s.Repo.SetSyncMeta(ctx, &domain.SyncMeta{OwnerUID: ownerUID, LastSyncedAt: now})
	return s.Repo.ListOwnPosts(ctx, ownerUID, accountID)
}

// syncOwnPostsFromThreads — 拉 me/threads + insights + conversation，以 media_id upsert。
func (s *Service) syncOwnPostsFromThreads(ctx context.Context, ownerUID int64, accountID, accessToken string) error {
	threads, err := s.Media.ListThreads(ctx, accessToken, 25)
	if err != nil {
		return fmt.Errorf("threads list: %w", err)
	}
	// 既有本地貼文：保留分析公式欄位
	existing, _ := s.Repo.ListOwnPosts(ctx, ownerUID, accountID)
	byMedia := map[string]*domain.OwnPost{}
	for _, p := range existing {
		if p.MediaID != "" {
			byMedia[p.MediaID] = p
		}
		// 清掉早期 mock seed（Synced post #…）
		if strings.HasPrefix(p.MediaID, "media_sync_") || strings.HasPrefix(p.Text, "Synced post #") {
			_ = s.Repo.DeleteOwnPost(ctx, p.ID)
		}
	}

	insights := make([]FetchedInsights, len(threads))
	// Each slot starts as "not fetched" so a rate-limited call, a timeout, or a cancelled sync is
	// distinguishable from a post that genuinely has zero engagement.
	insightFetched := make([]bool, len(threads))
	var wg sync.WaitGroup
	limit := make(chan struct{}, 4)
	for i, th := range threads {
		if th.ID == "" {
			continue
		}
		wg.Add(1)
		go func(i int, mediaID string) {
			defer wg.Done()
			select {
			case limit <- struct{}{}:
				defer func() { <-limit }()
			case <-ctx.Done():
				return
			}
			got, err := s.Media.GetInsights(ctx, accessToken, mediaID)
			if err != nil {
				logx.Errorf("threads insights media=%s: %v", mediaID, err)
				return
			}
			insights[i], insightFetched[i] = got, true
		}(i, th.ID)
	}
	wg.Wait()

	for i, th := range threads {
		if th.ID == "" {
			continue
		}
		// 留言仍在點開時才載；insights 已用小型 worker pool 並行取得。
		ins := insights[i]

		prev := byMedia[th.ID]
		// A failed insights call must keep whatever counts we already had. Writing zeros would
		// destroy the real numbers, and they feed insights summary, benchmark and account health.
		if !insightFetched[i] {
			if prev != nil {
				ins.Likes, ins.Replies, ins.Reposts = prev.LikeCount, prev.ReplyCount, prev.RepostCount
				ins.Quotes, ins.Views, ins.Shares = prev.QuoteCount, prev.ViewCount, prev.ShareCount
			}
			ins.Status = "error"
		}
		id := "op_" + th.ID
		if prev != nil && prev.ID != "" {
			id = prev.ID
		}
		pubAt := th.PublishedAt
		if pubAt <= 0 {
			pubAt = domain.NowNano()
		}
		// 保留先前已載入的留言（點開過的不因 re-sync 清掉）
		var replies []domain.OwnPostReply
		if prev != nil && len(prev.Replies) > 0 {
			replies = append([]domain.OwnPostReply(nil), prev.Replies...)
		}
		replyControl := strings.TrimSpace(th.ReplyControl)
		if replyControl == "" && prev != nil {
			replyControl = prev.ReplyControl
		}
		p := &domain.OwnPost{
			ID: id, OwnerUID: ownerUID, AccountID: accountID,
			MediaID: th.ID, Text: th.Text, MediaType: th.MediaType,
			MediaURL: th.MediaURL, ThumbnailURL: th.ThumbnailURL,
			Permalink: th.Permalink, Shortcode: th.Shortcode, TopicTag: th.TopicTag,
			LikeCount: ins.Likes, ReplyCount: ins.Replies, RepostCount: ins.Reposts,
			QuoteCount: ins.Quotes, ViewCount: ins.Views, ShareCount: ins.Shares,
			InsightsStatus: ins.Status,
			Replies:        replies,
			PublishedAt:    pubAt,
			ReplyControl:   replyControl,
		}
		if p.MediaType == "" {
			p.MediaType = "TEXT_POST"
		}
		// 保留本地分析結果
		if prev != nil {
			p.FormulaSummary = prev.FormulaSummary
			p.Insight = prev.Insight
			p.FormulaDetail = prev.FormulaDetail
		}
		if ins.Status == "error" && ins.ErrorMsg != "" && p.Insight == "" {
			// 不寫 error 進 insight 以免覆蓋分析；僅 insights_status
		}
		if err := s.Repo.SaveOwnPost(ctx, p); err != nil {
			return err
		}
	}
	return nil
}

// LoadOwnPostReplies — 點開貼文時才拉留言（/{media}/replies 或 conversation）
func (s *Service) LoadOwnPostReplies(ctx context.Context, ownerUID int64, postID string) (*domain.OwnPost, error) {
	post, err := s.getOwnPostOwned(ctx, ownerUID, postID)
	if err != nil {
		return nil, err
	}
	if post.MediaID == "" {
		return post, nil
	}
	if s.Media == nil || s.Accounts == nil {
		return post, nil
	}
	acc, err := s.Accounts.Get(ctx, post.AccountID)
	if err != nil || acc == nil || acc.OwnerUID != ownerUID {
		return nil, domain.ErrNoAccount
	}
	token, terr := s.Accounts.AccessToken(ctx, acc)
	if terr != nil || strings.TrimSpace(token) == "" || strings.HasPrefix(token, "fake-") {
		return post, nil
	}
	convo, cerr := s.Media.ListConversation(ctx, token, post.MediaID, 50)
	if cerr != nil {
		return nil, fmt.Errorf("load replies: %w", cerr)
	}
	replies := mapFetchedRepliesWithRoot(convo, post, post.MediaID)
	post.Replies = replies
	// 若 insights 沒給 replies 數，用實際筆數補
	if post.ReplyCount < len(replies) {
		post.ReplyCount = len(replies)
	}
	if err := s.Repo.SaveOwnPost(ctx, post); err != nil {
		return nil, err
	}
	return post, nil
}

func ownPostToken(ctx context.Context, s *Service, ownerUID int64, accountID string) (string, error) {
	if s.Accounts == nil {
		return "", nil
	}
	acc, err := s.Accounts.Get(ctx, accountID)
	if err != nil || acc == nil || acc.OwnerUID != ownerUID {
		return "", domain.ErrNoAccount
	}
	token, terr := s.Accounts.AccessToken(ctx, acc)
	if terr != nil {
		return "", terr
	}
	return strings.TrimSpace(token), nil
}

func isRealThreadsToken(token string) bool {
	return token != "" && !strings.HasPrefix(token, "fake-")
}

// ManageOwnPostReply hides or unhides a top-level reply via Threads /manage_reply.
func (s *Service) ManageOwnPostReply(ctx context.Context, ownerUID int64, postID, replyID string, hide bool) (*domain.OwnPost, error) {
	post, err := s.getOwnPostOwned(ctx, ownerUID, postID)
	if err != nil {
		return nil, err
	}
	replyID = strings.TrimSpace(replyID)
	if replyID == "" {
		return nil, fmt.Errorf("%w: reply_id is required", domain.ErrValidation)
	}
	found := false
	for _, r := range post.Replies {
		if r.ID == replyID {
			found = true
			if r.IsMine {
				return nil, fmt.Errorf("%w: cannot hide your own reply", domain.ErrValidation)
			}
			if strings.TrimSpace(r.ParentReplyID) != "" {
				return nil, fmt.Errorf("%w: only top-level replies can be hidden", domain.ErrValidation)
			}
			break
		}
	}
	if !found && len(post.Replies) > 0 {
		return nil, fmt.Errorf("%w: reply not found on this post", domain.ErrValidation)
	}
	if post.MediaID != "" && replyID == post.MediaID {
		return nil, fmt.Errorf("%w: 只能隱藏別人留在這則貼文下的回覆，不能隱藏貼文本體", domain.ErrValidation)
	}
	token, terr := ownPostToken(ctx, s, ownerUID, post.AccountID)
	if terr != nil {
		return nil, terr
	}
	if mgr, ok := any(s.Media).(ThreadsReplyManager); ok && isRealThreadsToken(token) {
		if err := mgr.ManageReply(ctx, token, replyID, hide); err != nil {
			return nil, fmt.Errorf("%w: %s", domain.ErrValidation, humanizeManageReplyError(err))
		}
	} else if isRealThreadsToken(token) && s.Media != nil {
		return nil, fmt.Errorf("%w: reply management is not configured", domain.ErrValidation)
	}
	status := "NOT_HUSHED"
	if hide {
		status = "HIDDEN"
	}
	updated := false
	for i := range post.Replies {
		if post.Replies[i].ID == replyID || (hide && post.Replies[i].ParentReplyID == replyID) {
			post.Replies[i].HideStatus = status
			updated = true
		}
	}
	if !updated {
		post.Replies = append(post.Replies, domain.OwnPostReply{ID: replyID, HideStatus: status})
	}
	if err := s.Repo.SaveOwnPost(ctx, post); err != nil {
		return nil, err
	}
	return post, nil
}

// SetOwnPostReplyControl updates who can reply (Threads reply_control).
func (s *Service) SetOwnPostReplyControl(ctx context.Context, ownerUID int64, postID, control string) (*domain.OwnPost, error) {
	post, err := s.getOwnPostOwned(ctx, ownerUID, postID)
	if err != nil {
		return nil, err
	}
	control = threadsProvNormalizeReplyControl(control)
	if control == "" {
		return nil, fmt.Errorf("%w: reply_control must be everyone, accounts_you_follow, mentioned_only, parent_post_author_only, or followers_only", domain.ErrValidation)
	}
	token, terr := ownPostToken(ctx, s, ownerUID, post.AccountID)
	if terr != nil {
		return nil, terr
	}
	// Threads 官方只允許發文時帶 reply_control；對已發布 media POST 會回 code 100。
	if s.Media != nil && isRealThreadsToken(token) && strings.TrimSpace(post.MediaID) != "" {
		return nil, fmt.Errorf("%w: Threads 只能在發文時設定誰可以回覆，已發布貼文無法用 API 修改。請到創作頁發一則新貼文", domain.ErrValidation)
	}
	post.ReplyControl = control
	if err := s.Repo.SaveOwnPost(ctx, post); err != nil {
		return nil, err
	}
	return post, nil
}

func threadsProvNormalizeReplyControl(raw string) string {
	s := strings.ToLower(strings.TrimSpace(raw))
	switch s {
	case "everyone", "accounts_you_follow", "mentioned_only", "parent_post_author_only", "followers_only":
		return s
	default:
		return ""
	}
}

func humanizeManageReplyError(err error) string {
	if err == nil {
		return ""
	}
	msg := strings.ToLower(err.Error())
	if strings.Contains(msg, "unsupported post request") ||
		strings.Contains(msg, "does not support this operation") ||
		strings.Contains(msg, "code 100") ||
		strings.Contains(msg, "permission") {
		return "無法隱藏這則回覆。請用別人留在你貼文下的第一層回覆，並到帳號頁重新連 Threads（需授權 threads_manage_replies）"
	}
	return err.Error()
}

func mapFetchedReplies(convo []FetchedReply, prev *domain.OwnPost) []domain.OwnPostReply {
	root := ""
	if prev != nil {
		root = prev.MediaID
	}
	return mapFetchedRepliesWithRoot(convo, prev, root)
}

func mapFetchedRepliesWithRoot(convo []FetchedReply, prev *domain.OwnPost, rootMediaID string) []domain.OwnPostReply {
	out := make([]domain.OwnPostReply, 0, len(convo))
	localStatus := map[string]domain.OwnPostReply{}
	if prev != nil {
		for _, r := range prev.Replies {
			localStatus[r.ID] = r
		}
	}
	for _, c := range convo {
		id := c.ID
		if id == "" || (rootMediaID != "" && id == rootMediaID) {
			continue
		}
		created := c.PublishedAt
		if created <= 0 {
			created = domain.NowNano()
		}
		// parent = 根貼 media id → 第一層（parent 空）
		parent := c.ParentMediaID
		if parent == rootMediaID || parent == "" {
			parent = ""
		}
		// 預設 pending；載完後用對話樹標記（不靠 LLM）
		status := "pending"
		repliedBy := ""
		var repliedAt int64
		if old, ok := localStatus[id]; ok && old.ReplyStatus == "replied" {
			// 本機曾送出回覆的標記先保留，稍後仍用樹再算一次
			status = old.ReplyStatus
			repliedBy = old.RepliedBy
			repliedAt = old.RepliedAt
		}
		hideStatus := strings.TrimSpace(c.HideStatus)
		if old, ok := localStatus[id]; ok && hideStatus == "" {
			hideStatus = old.HideStatus
		}
		out = append(out, domain.OwnPostReply{
			ID: id, Username: c.Username, Text: c.Text, CreatedAt: created,
			LikeCount: c.LikeCount, ReplyStatus: status, RepliedBy: repliedBy, RepliedAt: repliedAt,
			ParentReplyID: parent, IsMine: c.IsMine, HideStatus: hideStatus,
		})
	}
	// 純資料：若某則留言底下有「我的」子回覆 → 標已回覆
	return markRepliedFromTree(out)
}

// markRepliedFromTree — 不靠 LLM：對話樹裡 parent=該留言 id 且 is_mine → replied
func markRepliedFromTree(replies []domain.OwnPostReply) []domain.OwnPostReply {
	// parentID → 是否有我的子回覆
	mineUnder := map[string]domain.OwnPostReply{}
	for _, r := range replies {
		if !r.IsMine {
			continue
		}
		pid := strings.TrimSpace(r.ParentReplyID)
		if pid == "" {
			continue
		}
		// 保留時間最新的一則當 replied_by 參考
		if old, ok := mineUnder[pid]; !ok || r.CreatedAt >= old.CreatedAt {
			mineUnder[pid] = r
		}
	}
	for i := range replies {
		if replies[i].IsMine {
			// 自己的留言不進「待回」列表
			replies[i].ReplyStatus = "replied"
			continue
		}
		if mine, ok := mineUnder[replies[i].ID]; ok {
			replies[i].ReplyStatus = "replied"
			if replies[i].RepliedBy == "" {
				replies[i].RepliedBy = mine.Username
			}
			if replies[i].RepliedAt == 0 {
				replies[i].RepliedAt = mine.CreatedAt
			}
		} else {
			replies[i].ReplyStatus = "pending"
			replies[i].RepliedBy = ""
			replies[i].RepliedAt = 0
		}
	}
	return replies
}

func mergeReplyStatus(prev, next []domain.OwnPostReply) []domain.OwnPostReply {
	if len(prev) == 0 {
		return next
	}
	byID := map[string]domain.OwnPostReply{}
	for _, r := range prev {
		byID[r.ID] = r
	}
	for i := range next {
		if old, ok := byID[next[i].ID]; ok && old.ReplyStatus == "replied" {
			next[i].ReplyStatus = "replied"
			next[i].RepliedBy = old.RepliedBy
			next[i].RepliedAt = old.RepliedAt
		}
	}
	return next
}

func (s *Service) GenerateReply(ctx context.Context, ownerUID int64, postID, replyID, personaID string) (_ string, err error) {
	post, err := s.getOwnPostOwned(ctx, ownerUID, postID)
	if err != nil {
		return "", err
	}

	postText := strings.TrimSpace(post.Text)
	commentText := ""
	commentUser := ""
	if replyID != "" {
		found := false
		for _, r := range post.Replies {
			if r.ID == replyID {
				commentText = strings.TrimSpace(r.Text)
				commentUser = strings.TrimSpace(r.Username)
				found = true
				break
			}
		}
		if !found {
			return "", fmt.Errorf("%w: reply not found", domain.ErrValidation)
		}
	}
	charge, err := s.billAI(ctx, ownerUID, "own post reply draft", "ownPosts.generateReply")
	if err != nil {
		return "", err
	}
	defer charge.Settle(ctx, &err)
	persona := s.loadPersonaForGen(ctx, ownerUID, personaID)
	fp := personaExpressionFingerprintBlock(persona)

	prompt := buildOwnPostReplyPrompt(fp, postText, commentUser, commentText)

	provider, model, apiKey, kerr := s.resolveUserAI(ctx, ownerUID)
	if kerr == nil && strings.TrimSpace(apiKey) != "" && !isSyntheticAIKey(apiKey) {
		if out, aerr := s.completeLLM(ctx, provider, model, apiKey, prompt); aerr == nil {
			if t := cleanGeneratedText(out); t != "" {
				return t, nil
			}
		}
	}
	// 測試路徑：FakeClient
	if s.AI != nil {
		if out, e := s.AI.Complete(ctx, "test-key", "grok-3", prompt); e == nil && out != "" {
			return cleanGeneratedText(out), nil
		}
	}
	return "", fmt.Errorf("%w: 無法產回覆草稿，請到設定填寫 AI Key 後再試", domain.ErrValidation)
}

func (s *Service) SendReply(ctx context.Context, ownerUID int64, postID, replyID, text, accountID string, imageURLs []string) (*domain.OwnPost, error) {
	post, err := s.getOwnPostOwned(ctx, ownerUID, postID)
	if err != nil {
		return nil, err
	}
	text = strings.TrimSpace(text)
	if text == "" {
		return nil, fmt.Errorf("%w: empty reply", domain.ErrValidation)
	}
	accID := accountID
	if accID == "" {
		accID = post.AccountID
	}
	username := "me"
	token := "fake-token"
	if s.Accounts != nil {
		acc, aerr := s.Accounts.Get(ctx, accID)
		if aerr != nil || acc == nil || acc.OwnerUID != ownerUID || !acc.IsUsable {
			return nil, domain.ErrNoAccount
		}
		username = acc.Username
		if t, terr := s.Accounts.AccessToken(ctx, acc); terr == nil && t != "" {
			token = t
		}
	}
	if s.Transport == nil {
		return nil, fmt.Errorf("%w: publish transport not configured", domain.ErrValidation)
	}
	// reply_to_id：回某則留言用留言 media id；回主貼用主貼 media id
	replyTo := strings.TrimSpace(post.MediaID)
	if strings.TrimSpace(replyID) != "" {
		replyTo = strings.TrimSpace(replyID)
	}
	if replyTo == "" {
		return nil, fmt.Errorf("%w: 貼文缺少 Threads media_id，請先重新同步後再回覆", domain.ErrValidation)
	}
	if strings.HasPrefix(token, "fake-") {
		return nil, fmt.Errorf("%w: 帳號 token 無效，請重新連 Threads", domain.ErrValidation)
	}
	res, perr := s.Transport.Publish(ctx, domain.PublishRequest{
		AccessToken: token, AccountID: accID, Text: text, ReplyTo: replyTo,
	})
	if perr != nil {
		// OP-05: do not mark local success；回傳可讀錯誤（Biz 400）
		return nil, fmt.Errorf("%w: %s", domain.ErrValidation, perr.Error())
	}
	now := domain.NowNano()
	if replyID != "" {
		for i := range post.Replies {
			if post.Replies[i].ID == replyID {
				post.Replies[i].ReplyStatus = "replied"
				post.Replies[i].RepliedBy = username
				post.Replies[i].RepliedAt = now
			}
		}
	}
	// 本地掛上我的回覆：parent = 被回留言 id（空＝掛在主貼下第一層）
	parent := strings.TrimSpace(replyID)
	newID := "or_" + uuid.NewString()[:8]
	if res != nil && res.MediaID != "" {
		newID = res.MediaID
	}
	post.Replies = append(post.Replies, domain.OwnPostReply{
		ID: newID, Username: username, Text: text,
		CreatedAt: now, ReplyStatus: "replied", IsMine: true, ParentReplyID: parent,
	})
	// 重新標記整樹已回覆狀態
	post.Replies = markRepliedFromTree(post.Replies)
	if post.ReplyCount < len(post.Replies) {
		post.ReplyCount = len(post.Replies)
	}
	_ = imageURLs
	if err := s.Repo.SaveOwnPost(ctx, post); err != nil {
		return nil, err
	}
	return post, nil
}

func (s *Service) AnalyzePost(ctx context.Context, ownerUID int64, postID string) (_ *domain.OwnPost, err error) {
	post, err := s.getOwnPostOwned(ctx, ownerUID, postID)
	if err != nil {
		return nil, err
	}
	if strings.TrimSpace(post.Text) == "" {
		return nil, fmt.Errorf("%w: 貼文沒有文字可分析（圖片／純媒體貼可改貼上說明再分析）", domain.ErrValidation)
	}
	charge, err := s.billAI(ctx, ownerUID, "own post structure analyze", "ownPosts.analyze")
	if err != nil {
		return nil, err
	}
	defer charge.Settle(ctx, &err)
	va, err := s.analyzeViralUnbilled(ctx, ownerUID, post.Text)
	if err != nil {
		return nil, err
	}
	post.FormulaSummary = va.Hooks
	if post.FormulaSummary == "" {
		post.FormulaSummary = va.Structure
	}
	post.Insight = va.Summary
	post.FormulaDetail = formatViralFormulaDetail(va)
	if err := s.Repo.SaveOwnPost(ctx, post); err != nil {
		return nil, err
	}
	return post, nil
}

// GenerateFromFormula is intentionally removed (OP-11).
func (s *Service) GenerateFromFormula(_ context.Context, _, _ string) error {
	return domain.ErrFormulaRemove
}

// ---------- Mentions ----------

func (s *Service) ListMentions(ctx context.Context, ownerUID int64, accountID string) ([]*domain.Mention, error) {
	return s.Repo.ListMentions(ctx, ownerUID, accountID)
}

// SyncMentions — 從 Threads Graph 拉「@我」提及並 upsert 本地 inbox。
func (s *Service) SyncMentions(ctx context.Context, ownerUID int64, accountID string) ([]*domain.Mention, error) {
	if accountID == "" {
		return nil, domain.ErrNoAccount
	}
	if s.Accounts == nil {
		return nil, domain.ErrNoAccount
	}
	acc, err := s.Accounts.Get(ctx, accountID)
	if err != nil || acc == nil {
		return nil, domain.ErrNotFound
	}
	if acc.OwnerUID != ownerUID {
		return nil, domain.ErrForbidden
	}
	if !acc.IsUsable {
		return nil, domain.ErrNoAccount
	}
	token, terr := s.Accounts.AccessToken(ctx, acc)
	if terr != nil || strings.TrimSpace(token) == "" || strings.HasPrefix(token, "fake-") {
		return nil, fmt.Errorf("%w: 帳號 token 無效，請重新連 Threads（需含 threads_manage_mentions）", domain.ErrValidation)
	}
	if s.Media == nil {
		return nil, fmt.Errorf("%w: Threads Graph 未設定，無法同步提及", domain.ErrValidation)
	}

	userID := strings.TrimSpace(acc.ThreadsUserID)
	if userID == "" {
		userID = "me"
	}
	hits, err := s.Media.ListMentions(ctx, token, userID, 40)
	if err != nil {
		return nil, fmt.Errorf("mentions sync: %w", err)
	}

	// 既有：保留 status / draft
	existing, _ := s.Repo.ListMentions(ctx, ownerUID, accountID)
	byMedia := map[string]*domain.Mention{}
	for _, m := range existing {
		if m.MediaID != "" {
			byMedia[m.MediaID] = m
		}
	}

	now := domain.NowNano()
	for _, hit := range hits {
		mediaID := strings.TrimSpace(hit.ID)
		if mediaID == "" {
			continue
		}
		prev := byMedia[mediaID]
		id := "mn_" + mediaID
		status := domain.MentionPending
		draft := ""
		created := hit.PublishedAt
		if created <= 0 {
			created = now
		}
		if prev != nil {
			id = prev.ID
			if prev.Status != "" {
				status = prev.Status
			}
			draft = prev.DraftText
			if prev.CreatedAt > 0 {
				created = prev.CreatedAt
			}
		}
		ctxSnippet := mentionContextSnippet(hit)
		m := &domain.Mention{
			ID: id, OwnerUID: ownerUID, AccountID: accountID,
			MediaID: mediaID, FromUsername: hit.Username, Text: hit.Text,
			ContextSnippet: ctxSnippet, Permalink: hit.Permalink,
			RootPostID: hit.RootPostID, ParentID: hit.ParentID,
			Status: status, DraftText: draft, CreatedAt: created,
		}
		if m.FromUsername == "" {
			m.FromUsername = "unknown"
		}
		if err := s.Repo.SaveMention(ctx, m); err != nil {
			return nil, err
		}
	}
	return s.Repo.ListMentions(ctx, ownerUID, accountID)
}

func mentionContextSnippet(hit FetchedMention) string {
	kind := "提及你"
	if hit.IsQuotePost {
		kind = "引用你"
	} else if hit.IsReply {
		kind = "回覆中 @ 你"
	}
	text := strings.TrimSpace(hit.Text)
	if len([]rune(text)) > 120 {
		text = string([]rune(text)[:120]) + "…"
	}
	if text == "" {
		return kind
	}
	return kind + " · " + text
}

func (s *Service) GenerateMentionReply(ctx context.Context, ownerUID int64, id, personaID string) (_ *domain.Mention, err error) {
	m, err := s.getMentionOwned(ctx, ownerUID, id)
	if err != nil {
		return nil, err
	}
	charge, err := s.billAI(ctx, ownerUID, "mention reply draft", "mentions.generateReply")
	if err != nil {
		return nil, err
	}
	defer charge.Settle(ctx, &err)
	persona := s.loadPersonaForGen(ctx, ownerUID, personaID)
	fp := personaExpressionFingerprintBlock(persona)
	prompt := buildMentionReplyPrompt(fp, m.FromUsername, m.Text, m.ContextSnippet)

	draft := ""
	provider, model, apiKey, kerr := s.resolveUserAI(ctx, ownerUID)
	if kerr == nil && strings.TrimSpace(apiKey) != "" && !isSyntheticAIKey(apiKey) {
		if out, aerr := s.completeLLM(ctx, provider, model, apiKey, prompt); aerr == nil {
			draft = cleanGeneratedText(out)
		}
	}
	if draft == "" && s.AI != nil {
		if out, e := s.AI.Complete(ctx, "test-key", "grok-3", prompt); e == nil {
			draft = cleanGeneratedText(out)
		}
	}
	if draft == "" {
		return nil, fmt.Errorf("%w: 無法產回覆草稿，請到設定填寫 AI Key 後再試", domain.ErrValidation)
	}
	m.DraftText = draft
	if err := s.Repo.SaveMention(ctx, m); err != nil {
		return nil, err
	}
	return m, nil
}

func (s *Service) loadPersonaForGen(ctx context.Context, ownerUID int64, personaID string) *domain.Persona {
	if personaID != "" {
		if p, err := s.GetPersona(ctx, ownerUID, personaID); err == nil {
			return p
		}
	}
	if aid, _ := s.GetActivePersonaID(ctx, ownerUID); aid != "" {
		if p, err := s.GetPersona(ctx, ownerUID, aid); err == nil {
			return p
		}
	}
	return nil
}

func personaFingerprintBlock(p *domain.Persona) string {
	if p == nil {
		return "（未選人設，用自然口語、友善、像朋友聊天）"
	}
	fp := strings.TrimSpace(p.Style.DraftText)
	if fp == "" {
		fp = serializeDraftText(p.Style.Draft)
	}
	var b strings.Builder
	if n := strings.TrimSpace(p.Name); n != "" {
		b.WriteString("名稱：")
		b.WriteString(n)
		b.WriteString("\n")
	}
	if br := strings.TrimSpace(p.Brief); br != "" {
		b.WriteString("定位：")
		b.WriteString(br)
		b.WriteString("\n")
	}
	if fp != "" {
		b.WriteString("【語言指紋】\n")
		b.WriteString(fp)
		b.WriteString("\n")
	} else if t := strings.TrimSpace(p.Style.Draft.Tone); t != "" {
		b.WriteString("語氣：")
		b.WriteString(t)
		b.WriteString("\n")
	}
	if len(p.Guard.Avoid) > 0 {
		b.WriteString("【禁止】")
		b.WriteString(strings.Join(p.Guard.Avoid, "、"))
		b.WriteString("\n")
	}
	out := strings.TrimSpace(b.String())
	if out == "" {
		return "（人設資料不足，用自然口語）"
	}
	return out
}

func personaExpressionFingerprintBlock(p *domain.Persona) string {
	if p == nil {
		return "（未選人設；依原文內容自然回應，不預設任何開頭或收尾）"
	}
	draft := p.Style.Draft
	var b strings.Builder
	write := func(label, value string) {
		value = strings.TrimSpace(value)
		if value == "" {
			return
		}
		b.WriteString(label)
		b.WriteString("：")
		b.WriteString(value)
		b.WriteString("\n")
	}
	write("角色視角", p.Brief)
	write("語氣", draft.Tone)
	write("慣用語言", draft.LanguageFingerprint)
	write("句子節奏", draft.Rhythm)
	write("標點習慣", draft.Punctuation)
	write("避免方式", draft.Avoid)
	if len(p.Guard.Avoid) > 0 {
		write("禁止", strings.Join(p.Guard.Avoid, "、"))
	}
	if len(p.Style.SamplePreviews) > 0 {
		b.WriteString("語感樣本（只觀察語氣與節奏，不複製內容或開頭）：\n")
		limit := len(p.Style.SamplePreviews)
		if limit > 3 {
			limit = 3
		}
		for _, sample := range p.Style.SamplePreviews[:limit] {
			sample = strings.TrimSpace(sample)
			if sample == "" {
				continue
			}
			if utf8.RuneCountInString(sample) > 120 {
				sample = string([]rune(sample)[:120]) + "…"
			}
			b.WriteString("- ")
			b.WriteString(sample)
			b.WriteString("\n")
		}
	}
	out := strings.TrimSpace(b.String())
	if out == "" {
		return "（人設資料不足；依原文內容自然回應，不預設任何開頭或收尾）"
	}
	return out
}

func buildOwnPostReplyPrompt(fp, postText, commentUser, commentText string) string {
	var b strings.Builder
	b.WriteString(`你正在扮演 Threads 帳號本人回覆（不是客服、不是分析師）。
任務：先理解原文和對方真正想表達的內容，再用人設的表達軌跡寫一則回覆草稿。

規則（強制）：
- 只輸出回覆正文，不要「回覆：」前綴、不要分析、不要 markdown。
- 繁體中文（台灣用語）、口語、像真人滑手機回。
- 內容優先：回應原文或留言裡至少一個具體資訊、情緒或意圖，不要只寫泛用套話。
- 人設只控制用字、語氣、節奏、標點與禁忌，不得把指紋當內容模板。
- 不預設任何開頭。不要固定從建議、共感、感謝、稱讚或問句開始，依這次內容自然切入。
- 不必每次提問或邀互動；只有語意真的需要時才問。
- 不捏造自己沒有根據的經歷，也不要重述整篇原文。
- 長度依內容自然決定，通常 15～140 字，可分段空行。

`)
	b.WriteString("【人設表達軌跡】\n")
	b.WriteString(fp)
	b.WriteString("\n\n【我的主貼】\n")
	b.WriteString(strings.TrimSpace(postText))
	b.WriteString("\n")
	if strings.TrimSpace(commentText) != "" {
		b.WriteString("\n【要回的留言】")
		if commentUser != "" {
			b.WriteString(" @")
			b.WriteString(commentUser)
		}
		b.WriteString("\n")
		b.WriteString(commentText)
		b.WriteString("\n")
	} else {
		b.WriteString("\n【任務情境】\n在自己的主貼下自然補充一個相關想法；不需要刻意邀互動。\n")
	}
	return strings.TrimSpace(b.String())
}

func buildMentionReplyPrompt(fp, fromUser, mentionText, contextSnippet string) string {
	return strings.TrimSpace(fmt.Sprintf(`
你正在扮演 Threads 帳號本人，回覆別人的「提及／@」。
任務：先理解對方提及你的具體內容與上下文，再用人設的表達軌跡寫一則回覆草稿。

規則（強制）：
- 只輸出回覆正文，不要前綴、不要分析。
- 繁體中文、口語，直接接住對方至少一個具體資訊、情緒或意圖。
- 人設只控制用字、語氣、節奏、標點與禁忌，不得把指紋當內容模板。
- 不預設任何開頭，不要固定從建議、共感、感謝、稱讚或問句開始。
- 不必每次提問或邀互動；不捏造經歷，不用與內容無關的泛用套話。
- 長度依內容自然決定，通常 15～140 字。

【人設表達軌跡】
%s

【對方 @你】
@%s：%s

【上下文】
%s
`, fp, fromUser, mentionText, strings.TrimSpace(contextSnippet)))
}

func (s *Service) MarkMentionReplied(ctx context.Context, ownerUID int64, id, text string, imageURLs []string) (*domain.Mention, error) {
	m, err := s.getMentionOwned(ctx, ownerUID, id)
	if err != nil {
		return nil, err
	}
	text = strings.TrimSpace(text)
	if text == "" {
		text = strings.TrimSpace(m.DraftText)
	}
	if text == "" {
		return nil, fmt.Errorf("%w: empty text", domain.ErrValidation)
	}
	// 真發文：reply_to = 提及 media_id（對方那則貼／回覆）
	if s.Transport != nil {
		token := "fake"
		accID := m.AccountID
		if s.Accounts != nil {
			acc, aerr := s.Accounts.Get(ctx, accID)
			if aerr != nil || acc == nil || acc.OwnerUID != ownerUID || !acc.IsUsable {
				return nil, domain.ErrNoAccount
			}
			t, terr := s.Accounts.AccessToken(ctx, acc)
			if terr != nil || strings.TrimSpace(t) == "" {
				return nil, fmt.Errorf("%w: 帳號 token 無效，請重新連 Threads", domain.ErrValidation)
			}
			token = t
		}
		replyTo := strings.TrimSpace(m.MediaID)
		if replyTo == "" {
			// 單元測試 seed 無 media_id 時允許 FakeTransport；正式路徑必須有
			if s.Accounts != nil {
				return nil, fmt.Errorf("%w: 提及缺少 media_id，請先重新同步提及", domain.ErrValidation)
			}
			replyTo = "media_test"
		}
		if s.Accounts != nil && (strings.HasPrefix(token, "fake-") || token == "fake") {
			return nil, fmt.Errorf("%w: 帳號 token 無效，請重新連 Threads", domain.ErrValidation)
		}
		_, perr := s.Transport.Publish(ctx, domain.PublishRequest{
			AccessToken: token, AccountID: accID, Text: text, ReplyTo: replyTo,
		})
		if perr != nil {
			return nil, fmt.Errorf("%w: %s", domain.ErrValidation, perr.Error())
		}
	}
	m.Status = domain.MentionReplied
	m.DraftText = text
	_ = imageURLs // 提及回覆不附圖（與我的貼文回覆一致）
	if err := s.Repo.SaveMention(ctx, m); err != nil {
		return nil, err
	}
	return m, nil
}

func (s *Service) SkipMention(ctx context.Context, ownerUID int64, id string) (*domain.Mention, error) {
	m, err := s.getMentionOwned(ctx, ownerUID, id)
	if err != nil {
		return nil, err
	}
	m.Status = domain.MentionSkipped
	if err := s.Repo.SaveMention(ctx, m); err != nil {
		return nil, err
	}
	return m, nil
}

// SeedMention for tests / sync helpers
func (s *Service) SeedMention(ctx context.Context, m *domain.Mention) error {
	if m.ID == "" {
		m.ID = "mn_" + uuid.NewString()[:10]
	}
	if m.Status == "" {
		m.Status = domain.MentionPending
	}
	if m.CreatedAt == 0 {
		m.CreatedAt = domain.NowNano()
	}
	return s.Repo.SaveMention(ctx, m)
}

// ---------- helpers ----------

// aiCharge is one reserved AI credit. Quota is reserved up front so an over-limit member is
// stopped before any work starts, but the audit event is only written once the work succeeded —
// the monthly counter is rebuilt from events if it is ever lost, so an event for a call that
// never happened would silently undo the refund.
type aiCharge struct {
	svc      *Service
	ownerUID int64
	label    string
	source   string
	mode     string
	settled  bool
}

// billAI reserves credit for one AI call. Pair it with `defer charge.Settle(ctx, &err)` on a
// named error return so that every failure path — including ones added later — refunds.
func (s *Service) billAI(ctx context.Context, ownerUID int64, label, source string) (*aiCharge, error) {
	if s == nil || s.Usage == nil {
		return &aiCharge{settled: true}, nil
	}
	mode, err := s.Usage.PrepareCall(ctx, ownerUID, usageDomain.MeterAICopy)
	if err != nil {
		return nil, err
	}
	return &aiCharge{svc: s, ownerUID: ownerUID, label: label, source: source, mode: mode}, nil
}

// Settle commits the charge when the operation succeeded, or refunds it when it failed.
func (c *aiCharge) Settle(ctx context.Context, errp *error) {
	if errp != nil && *errp != nil {
		c.Release(ctx)
		return
	}
	c.Commit(ctx)
}

// Commit writes the audit event. A failure here is logged rather than surfaced: the member
// already received the AI result, so charging them is correct and losing the event must not
// turn a successful call into an error.
func (c *aiCharge) Commit(ctx context.Context) {
	if c == nil || c.settled {
		return
	}
	c.settled = true
	if _, err := c.svc.Usage.RecordCall(ctx, c.ownerUID, usageDomain.MeterAICopy, c.mode, c.label, c.source); err != nil {
		logx.Errorf("usage record uid=%d source=%s mode=%s: %v", c.ownerUID, c.source, c.mode, err)
	}
}

// Release refunds a charge that was never committed. Safe to call more than once.
func (c *aiCharge) Release(ctx context.Context) {
	if c == nil || c.settled {
		return
	}
	c.settled = true
	// The failure being compensated for is often a cancelled request, so the refund needs a
	// context that outlives it.
	rctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
	defer cancel()
	if err := c.svc.Usage.ReleaseCall(rctx, c.ownerUID, usageDomain.MeterAICopy, c.mode); err != nil {
		logx.Errorf("usage release uid=%d source=%s mode=%s: %v", c.ownerUID, c.source, c.mode, err)
	}
}

func (s *Service) getOwnPostOwned(ctx context.Context, ownerUID int64, id string) (*domain.OwnPost, error) {
	p, err := s.Repo.GetOwnPost(ctx, id)
	if err != nil {
		return nil, err
	}
	if p.OwnerUID != ownerUID {
		return nil, domain.ErrForbidden
	}
	return p, nil
}

func (s *Service) getMentionOwned(ctx context.Context, ownerUID int64, id string) (*domain.Mention, error) {
	m, err := s.Repo.GetMention(ctx, id)
	if err != nil {
		return nil, err
	}
	if m.OwnerUID != ownerUID {
		return nil, domain.ErrForbidden
	}
	return m, nil
}

func (s *Service) ensureUsableAccounts(ctx context.Context, ownerUID int64, play *domain.Play) error {
	if s.Accounts == nil {
		// test mode without accounts — allow if lead set
		if play.LeadAccountID == "" && !underPost(play) {
			return domain.ErrNoAccount
		}
		return nil
	}
	ids := map[string]struct{}{}
	if play.LeadAccountID != "" {
		ids[play.LeadAccountID] = struct{}{}
	}
	for _, c := range play.CastAccountIDs {
		ids[c] = struct{}{}
	}
	for _, st := range play.Steps {
		ids[st.AccountID] = struct{}{}
	}
	for id := range ids {
		if id == "" {
			continue
		}
		acc, err := s.Accounts.Get(ctx, id)
		if err != nil || acc == nil || acc.OwnerUID != ownerUID || !acc.IsUsable {
			return domain.ErrNoAccount
		}
	}
	return nil
}

func underPost(p *domain.Play) bool {
	return p.TargetOwnPostID != "" || (p.TargetExternal != nil && p.TargetExternal.URL != "")
}

func validatePlay(p *domain.Play) error {
	if underPost(p) {
		if len(p.Steps) == 0 {
			return fmt.Errorf("%w: need replies", domain.ErrValidation)
		}
		speakers := speakerSet(p)
		for _, st := range p.Steps {
			if st.AccountID == "" || !speakers[st.AccountID] {
				return fmt.Errorf("%w: reply account not in speakers", domain.ErrValidation)
			}
			if strings.TrimSpace(st.Text) == "" {
				return fmt.Errorf("%w: empty step", domain.ErrValidation)
			}
		}
		return nil
	}
	if p.LeadAccountID == "" {
		return fmt.Errorf("%w: need lead", domain.ErrValidation)
	}
	if len(p.Steps) == 0 {
		return fmt.Errorf("%w: need root", domain.ErrValidation)
	}
	root := p.Steps[0]
	for _, st := range p.Steps {
		if st.Kind == domain.StepRoot {
			root = st
			break
		}
	}
	if root.Kind != domain.StepRoot {
		return fmt.Errorf("%w: first must root", domain.ErrValidation)
	}
	if root.AccountID != p.LeadAccountID {
		return fmt.Errorf("%w: root must lead", domain.ErrValidation)
	}
	speakers := speakerSet(p)
	for _, st := range p.Steps {
		if st.Kind == domain.StepReply {
			if !speakers[st.AccountID] {
				return fmt.Errorf("%w: reply account not in speakers", domain.ErrValidation)
			}
		}
	}
	return nil
}

func speakerSet(p *domain.Play) map[string]bool {
	m := map[string]bool{}
	if p.LeadAccountID != "" {
		m[p.LeadAccountID] = true
	}
	for _, c := range p.CastAccountIDs {
		if c != "" {
			m[c] = true
		}
	}
	return m
}

func playToOutbox(ownerUID int64, play *domain.Play) *domain.OutboxBundle {
	now := domain.NowNano()
	// 第一則：送出 Outbox 後立刻可發（忽略過期／過遠的 schedule_start_at 造成「排程中卡住」）。
	// 若明確設了「未來開始時間」且 > now+30s，才把第一則排到那個時間（延後開跑）。
	cursor := now
	if play.ScheduleStartAt > now+int64(30*time.Second) {
		cursor = play.ScheduleStartAt
	}
	steps := make([]domain.OutboxStep, 0, len(play.Steps))
	replyTo := ""
	if play.TargetExternal != nil {
		replyTo = play.TargetExternal.MediaID
	}
	for i, st := range play.Steps {
		if i > 0 {
			// 後續：基礎間隔 + 隨機抖動；0 = 與上一則同一排程點（worker 同 tick 依序發）
			base := st.DelayFromPreviousSec
			if base < 0 {
				base = 0
			}
			if base > 0 {
				cursor += int64(intervalSecWithJitter(base)) * int64(time.Second)
			}
		}
		kind := st.Kind
		if underPost(play) {
			kind = domain.StepReply
		}
		steps = append(steps, domain.OutboxStep{
			ID: "obstep_" + uuid.NewString()[:10], StepID: st.ID, SortOrder: st.SortOrder,
			Kind: kind, AccountID: st.AccountID, Text: st.Text,
			ImageURLs: append([]string(nil), st.ImageURLs...),
			Status:    domain.StepScheduled, ScheduledAt: cursor, ReplyTo: replyTo,
		})
	}
	title := play.Title
	if title == "" {
		title = play.Topic
	}
	return &domain.OutboxBundle{
		ID: "outbox_" + uuid.NewString()[:12], OwnerUID: ownerUID, PlayID: play.ID,
		Title: title, Status: domain.OBScheduling, Steps: steps,
		ReplyToMediaID: replyTo, CreatedAt: now, UpdatedAt: now,
	}
}

// intervalSecWithJitter — 在 base 秒上加 ±20% 抖動（至少 ±20s、最多 ±3min），且不低於 15s。
// 讓互回／串場節奏像真人，不要剛好每 300 秒一則。
func intervalSecWithJitter(baseSec int) int {
	if baseSec < 0 {
		baseSec = 0
	}
	span := baseSec / 5 // 20%
	if span < 20 {
		span = 20
	}
	if span > 180 {
		span = 180
	}
	// [base-span, base+span]
	delta := 0
	if span > 0 {
		delta = cryptoRandIntn(2*span+1) - span
	}
	out := baseSec + delta
	if out < 15 {
		out = 15
	}
	return out
}

// cryptoRandIntn — [0, n) 均勻；失敗時退回 0（不抖動）
func cryptoRandIntn(n int) int {
	if n <= 1 {
		return 0
	}
	// 用 crypto/rand 避免 math/rand 可預測
	var b [8]byte
	if _, err := rand.Read(b[:]); err != nil {
		return 0
	}
	// big-endian uint64
	v := uint64(b[0])<<56 | uint64(b[1])<<48 | uint64(b[2])<<40 | uint64(b[3])<<32 |
		uint64(b[4])<<24 | uint64(b[5])<<16 | uint64(b[6])<<8 | uint64(b[7])
	return int(v % uint64(n))
}

func findRoot(b *domain.OutboxBundle) *domain.OutboxStep {
	for i := range b.Steps {
		if b.Steps[i].Kind == domain.StepRoot {
			return &b.Steps[i]
		}
	}
	return nil
}

func findOutboxStep(b *domain.OutboxBundle, stepID string) *domain.OutboxStep {
	for i := range b.Steps {
		if b.Steps[i].ID == stepID {
			return &b.Steps[i]
		}
	}
	return nil
}

func recomputeBundle(steps []domain.OutboxStep) string {
	if len(steps) == 0 {
		return domain.OBCancelled
	}
	allPub := true
	anyFail := false
	anyActive := false
	for _, s := range steps {
		switch s.Status {
		case domain.StepPublished:
		case domain.StepFailed:
			anyFail = true
			allPub = false
		case domain.StepPublishing:
			anyActive = true
			allPub = false
		case domain.StepBlocked, domain.StepCancelled:
			allPub = false
		default:
			allPub = false
			if s.Status == domain.StepScheduled {
				// still scheduling
			}
		}
	}
	if allPub {
		return domain.OBCompleted
	}
	if anyFail {
		return domain.OBPartial
	}
	if anyActive {
		return domain.OBActive
	}
	return domain.OBScheduling
}

// cleanupEphemeralImages 刪除發文用暫存圖（temp/*；相容 other/* 舊路徑）。
// avatar/* 永不刪。best-effort，失敗只記 log。
func (s *Service) cleanupEphemeralImages(ctx context.Context, urls []string) {
	if s.Storage == nil || !s.Storage.Enabled() || len(urls) == 0 {
		return
	}
	for _, u := range urls {
		key := s.objectKeyFromPublicURL(u)
		if key == "" || !isEphemeralPublishObjectKey(key) {
			continue
		}
		if err := s.Storage.Delete(ctx, key); err != nil {
			logx.Errorf("studio cleanup image key=%s: %v", key, err)
		} else {
			logx.Infof("studio cleaned ephemeral image key=%s", key)
		}
	}
}

func isEphemeralPublishObjectKey(key string) bool {
	key = strings.TrimPrefix(key, "/")
	return strings.HasPrefix(key, "temp/") || strings.HasPrefix(key, "other/")
}

func (s *Service) objectKeyFromPublicURL(raw string) string {
	u := strings.TrimSpace(raw)
	if u == "" {
		return ""
	}
	base := strings.TrimRight(strings.TrimSpace(s.StoragePublicBase), "/")
	if base != "" {
		prefix := base + "/"
		if strings.HasPrefix(u, prefix) {
			return strings.TrimPrefix(u, prefix)
		}
	}
	// 後備：路徑含 /temp/ 或 /other/ 時取之後片段
	for _, marker := range []string{"/temp/", "/other/"} {
		if i := strings.Index(u, marker); i >= 0 {
			return strings.TrimPrefix(u[i:], "/")
		}
	}
	return ""
}

func normalizeURL(raw string) string {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return ""
	}
	u, err := url.Parse(raw)
	if err != nil {
		return strings.TrimRight(raw, "/")
	}
	if u.Scheme == "" {
		u.Scheme = "https"
	}
	u.Host = strings.ToLower(u.Host)
	u.Fragment = ""
	// strip query noise
	u.RawQuery = ""
	s := u.String()
	return strings.TrimRight(s, "/")
}

func truncate(s string, n int) string {
	r := []rune(s)
	if len(r) <= n {
		return s
	}
	return string(r[:n]) + "…"
}
