package usecase

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"strings"
	"time"
	"unicode/utf8"

	"apps/backend/internal/module/ai"
	"apps/backend/internal/module/inspire/domain"
	"apps/backend/internal/module/search"
	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"
)

type Service struct {
	Repo  domain.Repository
	Usage *usageUC.Service
	AI    ai.Client // tests / fallback
	// AIRegistry real xai / opencode-go
	AIRegistry *ai.Registry
	// ResolveAI returns provider, model, apiKey（會員設定）
	ResolveAI func(ctx context.Context, uid int64) (provider, model, apiKey string, err error)
	Search    search.Client
	// ResolveKey returns api key for meter (optional; tests can leave nil and skip external)
	ResolveKey func(ctx context.Context, uid int64, meter string) (mode, apiKey string, err error)
	// Persona / 品牌產品 — 真資料注入 prompt（可空；空則略過該段）
	Personas PersonaSource
	Brands   BrandCatalogSource
}

func New(repo domain.Repository) *Service {
	return &Service{Repo: repo}
}

// ListTrends — 只讀快取／種子，不網搜、不扣點。找靈感請走 RefreshTrends（手動）。
func (s *Service) ListTrends(ctx context.Context, ownerUID int64) ([]*domain.TrendItem, error) {
	list, err := s.Repo.ListTrends(ctx, ownerUID)
	if err != nil {
		return nil, err
	}
	if len(list) == 0 {
		// 空庫給示意種子，標 seed；不打外網
		list = defaultTrends(ownerUID)
		_ = s.Repo.SaveTrends(ctx, ownerUID, list)
	}
	return list, nil
}

// RefreshTrends — 手動「找靈感」：取得真實結果後才扣點並覆蓋快取。
// 搜尋失敗時保留舊內容；不可用示意種子冒充本次刷新成功。
func (s *Service) RefreshTrends(ctx context.Context, ownerUID int64) (_ []*domain.TrendItem, err error) {
	list := s.tryLiveTrends(ctx, ownerUID)
	if len(list) == 0 {
		return nil, fmt.Errorf("找不到新的可用話題，已保留原本內容")
	}
	charge, err := s.bill(ctx, ownerUID, usageDomain.MeterWebSearch, "找靈感話題", "inspire.refreshTrends")
	if err != nil {
		return nil, err
	}
	defer charge.Settle(ctx, &err)
	if err := s.Repo.SaveTrends(ctx, ownerUID, list); err != nil {
		return nil, err
	}
	return list, nil
}

// tryLiveTrends：網搜「關於 Threads 本週在聊什麼」的報導／彙整，再抽出短話題名。
// 注意：沒有官方 Threads 熱搜 API；這是話題靈感，不是平台即時榜。
func (s *Service) tryLiveTrends(ctx context.Context, ownerUID int64) []*domain.TrendItem {
	if s.Search == nil {
		return nil
	}
	key := ""
	if s.ResolveKey != nil {
		if _, k, err := s.ResolveKey(ctx, ownerUID, usageDomain.MeterWebSearch); err == nil {
			key = strings.TrimSpace(k)
		}
	}
	if key == "" || strings.HasPrefix(key, "fake") {
		return nil
	}
	// 計費在 RefreshTrends 已做；此處只搜尋

	previous := map[string]struct{}{}
	var excluded []string
	if cached, err := s.Repo.ListTrends(ctx, ownerUID); err == nil {
		for _, item := range cached {
			if item == nil {
				continue
			}
			label := strings.TrimSpace(item.Label)
			if label == "" {
				continue
			}
			previous[strings.ToLower(label)] = struct{}{}
			if len(excluded) < 6 {
				excluded = append(excluded, label)
			}
		}
	}
	nowTW := time.Now().In(time.FixedZone("Asia/Taipei", 8*60*60))
	freshness := nowTW.Format("2006-01-02 15:04")
	excludeHint := strings.Join(excluded, "、")
	queries := []string{
		fmt.Sprintf("%s 台灣 Threads 最近正在討論的新話題，不要重複：%s", freshness, excludeHint),
		fmt.Sprintf("%s Threads 台灣最新貼文熱門關鍵字與生活討論，不要：%s", freshness, excludeHint),
		fmt.Sprintf("%s 台灣社群近 24 小時新話題 Threads 趨勢，不要重複：%s", freshness, excludeHint),
	}
	seen := map[string]struct{}{}
	now := domain.NowNano()
	out := make([]*domain.TrendItem, 0, 12)
	for _, q := range queries {
		hits, err := s.Search.Search(ctx, key, q, 10)
		if err != nil || len(hits) == 0 {
			continue
		}
		for _, h := range hits {
			if isMetaThreadsArticle(h) {
				// 仍可從內文抽「紅什麼」段落，但略過整篇當 label
			}
			for _, label := range extractTopicLabels(h) {
				keyNorm := strings.ToLower(strings.TrimSpace(label))
				if keyNorm == "" {
					continue
				}
				if _, old := previous[keyNorm]; old {
					continue
				}
				if _, ok := seen[keyNorm]; ok {
					continue
				}
				if isNoiseTopicLabel(label) {
					continue
				}
				seen[keyNorm] = struct{}{}
				summary := strings.TrimSpace(h.Snippet)
				if summary == "" {
					summary = strings.TrimSpace(h.Title)
				}
				rank := len(out) + 1
				out = append(out, &domain.TrendItem{
					ID:          "tr_" + uuid.NewString()[:10],
					OwnerUID:    ownerUID,
					Kind:        "threads_tag",
					Label:       label,
					Summary:     truncate(summary, 120),
					Heat:        101 - rank, // 越前面的搜尋結果熱度越高，符合前端倒序排列
					Keywords:    []string{label},
					Samples:     []string{truncate(summary, 80)},
					SourceLabel: "web",
					ObservedAt:  now,
				})
				if len(out) >= 12 {
					return out
				}
			}
		}
		if len(out) >= 8 {
			break
		}
	}
	return out
}

// isMetaThreadsArticle — 談「Threads 功能／趨勢榜上線」的新聞，不是島民在聊的話題本身。
func isMetaThreadsArticle(h search.Hit) bool {
	blob := strings.ToLower(h.Title + " " + h.Snippet)
	meta := []string{
		"趨勢話題", "trending topics", "新功能上線", "功能開放", "功能測試",
		"setn", "三立", "必看", "選題sop", "qsearch", "趨勢功能",
	}
	for _, m := range meta {
		if strings.Contains(blob, m) {
			return true
		}
	}
	return false
}

func isNoiseTopicLabel(label string) bool {
	l := strings.TrimSpace(label)
	if l == "" {
		return true
	}
	n := len([]rune(l))
	if n < 2 || n > 18 {
		return true
	}
	low := strings.ToLower(l)
	// 整段疑問句／感嘆句當 chip 很假
	if strings.HasSuffix(l, "？") || strings.HasSuffix(l, "?") ||
		strings.HasSuffix(l, "！") || strings.HasSuffix(l, "!") {
		if n > 8 {
			return true
		}
	}
	if strings.HasPrefix(l, "為何") || strings.HasPrefix(l, "為什麼") ||
		strings.HasPrefix(l, "讓你") || strings.HasPrefix(l, "你還") {
		return true
	}
	noise := []string{
		"threads", "meta", "meta.ai", "@meta.ai", "趨勢", "熱門話題", "功能", "上線", "測試", "報導",
		"記者", "新聞", "sop", "qsearch", "台灣", "本週", "一週", "脆報",
		"必看", "整理", "秒懂", "全文", "gq", "setn", "合法違規",
	}
	for _, w := range noise {
		if low == w || low == "#"+w || strings.Contains(low, "meta.ai") {
			return true
		}
	}
	// 整句新聞標題残留
	if strings.ContainsAny(l, "｜|/") || strings.Contains(l, "http") {
		return true
	}
	// 含書名號整句
	if strings.Contains(l, "「") || strings.Contains(l, "」") {
		return true
	}
	return false
}

// extractTopicLabels 從搜尋結果抽出短話題（hashtag、「」、清理後的短語）。
func extractTopicLabels(h search.Hit) []string {
	title := strings.TrimSpace(h.Title)
	snippet := strings.TrimSpace(h.Snippet)
	blob := title + "\n" + snippet
	var out []string
	seen := map[string]struct{}{}
	add := func(s string) {
		s = strings.TrimSpace(s)
		s = strings.Trim(s, "「」『』\"' #　")
		s = strings.TrimSpace(s)
		if s == "" || isNoiseTopicLabel(s) {
			return
		}
		// 統一短標
		s = truncate(s, 16)
		k := strings.ToLower(s)
		if _, ok := seen[k]; ok {
			return
		}
		seen[k] = struct{}{}
		out = append(out, s)
	}

	// 1) #hashtag
	for _, src := range []string{title, snippet} {
		for i := 0; i < len(src); i++ {
			if src[i] != '#' {
				continue
			}
			rest := src[i:]
			end := len(rest)
			for j, r := range rest {
				if j == 0 {
					continue
				}
				if r == ' ' || r == '\n' || r == '\t' || r == '，' || r == '。' || r == ',' || r == '.' || r == '｜' || r == '|' {
					end = j
					break
				}
			}
			add(rest[:end])
		}
	}

	// 2) 「…」／『…』
	extractQuoted(blob, '「', '」', add)
	extractQuoted(blob, '『', '』', add)

	// 3) 標題裡「紅什麼／夯什麼」冒號後的片段
	for _, sep := range []string{"紅什麼：", "紅什麼:", "夯什麼：", "夯什麼:", "熱議：", "熱議:"} {
		if i := strings.Index(title, sep); i >= 0 {
			rest := title[i+len(sep):]
			// 用？！。切短
			for _, cut := range []string{"？", "?", "！", "!", "。", "｜", "|"} {
				if j := strings.Index(rest, cut); j > 0 {
					rest = rest[:j]
					break
				}
			}
			// 再拆「、」
			for _, part := range splitTopicParts(rest) {
				add(part)
			}
		}
	}

	// 4) 非 meta 文：用清理後的短標題當一個話題
	if !isMetaThreadsArticle(h) {
		clean := cleanNewsTitle(title)
		if clean != "" && len([]rune(clean)) <= 16 {
			add(clean)
		} else if clean != "" {
			// 取前段關鍵
			for _, part := range splitTopicParts(clean) {
				add(part)
				if len(out) >= 4 {
					break
				}
			}
		}
	}

	return out
}

func extractQuoted(s string, open, close rune, add func(string)) {
	runes := []rune(s)
	for i := 0; i < len(runes); i++ {
		if runes[i] != open {
			continue
		}
		for j := i + 1; j < len(runes); j++ {
			if runes[j] == close {
				add(string(runes[i+1 : j]))
				i = j
				break
			}
		}
	}
}

func splitTopicParts(s string) []string {
	s = strings.TrimSpace(s)
	if s == "" {
		return nil
	}
	// 統一分隔
	for _, sep := range []string{"、", "／", "/", "·", "•", "，", ",", "跟", "與", "和"} {
		s = strings.ReplaceAll(s, sep, "|")
	}
	var parts []string
	for _, p := range strings.Split(s, "|") {
		p = strings.TrimSpace(p)
		if p != "" {
			parts = append(parts, p)
		}
	}
	return parts
}

func cleanNewsTitle(title string) string {
	t := strings.TrimSpace(title)
	// 去【】前綴
	for {
		if !strings.HasPrefix(t, "【") {
			break
		}
		if i := strings.Index(t, "】"); i >= 0 {
			t = strings.TrimSpace(t[i+len("】"):])
			continue
		}
		break
	}
	// 去站名
	for _, sep := range []string{"｜", "|", " - ", " — "} {
		if i := strings.Index(t, sep); i > 0 {
			t = strings.TrimSpace(t[:i])
		}
	}
	t = strings.TrimSpace(t)
	return t
}

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

func (s *Service) SaveElement(ctx context.Context, ownerUID int64, e *domain.Element) (*domain.Element, error) {
	if strings.TrimSpace(e.Title) == "" && strings.TrimSpace(e.Body) == "" {
		return nil, fmt.Errorf("%w: empty element", domain.ErrValidation)
	}
	now := domain.NowNano()
	if e.ID == "" {
		e.ID = "el_" + uuid.NewString()[:10]
		e.CreatedAt = now
	} else {
		ex, err := s.Repo.GetElement(ctx, e.ID)
		if err == nil {
			if ex.OwnerUID != ownerUID {
				return nil, domain.ErrForbidden
			}
			e.CreatedAt = ex.CreatedAt
		} else if err != domain.ErrNotFound {
			return nil, err
		} else {
			e.CreatedAt = now
		}
	}
	e.OwnerUID = ownerUID
	e.UpdatedAt = now
	if e.Kind == "" {
		e.Kind = "snippet"
	}
	if err := s.Repo.SaveElement(ctx, e); err != nil {
		return nil, err
	}
	return e, nil
}

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

func (s *Service) GetSession(ctx context.Context, ownerUID int64) (*domain.Session, error) {
	sess, err := s.Repo.GetSession(ctx, ownerUID)
	if err == domain.ErrNotFound {
		return s.CreateSession(ctx, ownerUID, "")
	}
	return sess, err
}

func (s *Service) GetSessionByID(ctx context.Context, ownerUID int64, id string) (*domain.Session, error) {
	id = strings.TrimSpace(id)
	if id == "" {
		return s.GetSession(ctx, ownerUID)
	}
	sess, err := s.Repo.GetSessionByID(ctx, ownerUID, id)
	if err != nil {
		return nil, err
	}
	return sess, nil
}

// ListSessionSummaries — 列表（最新在前；標記 active）。
func (s *Service) ListSessionSummaries(ctx context.Context, ownerUID int64) ([]domain.SessionSummary, error) {
	list, err := s.Repo.ListSessions(ctx, ownerUID)
	if err != nil {
		return nil, err
	}
	activeID, _ := s.Repo.GetActiveSessionID(ctx, ownerUID)
	if activeID == "" && len(list) > 0 {
		activeID = list[0].ID
	}
	out := make([]domain.SessionSummary, 0, len(list))
	for _, sess := range list {
		out = append(out, sessionToSummary(sess, sess.ID == activeID))
	}
	return out, nil
}

func sessionToSummary(sess *domain.Session, active bool) domain.SessionSummary {
	preview := ""
	if n := len(sess.Messages); n > 0 {
		preview = truncate(sess.Messages[n-1].Text, 48)
	}
	title := strings.TrimSpace(sess.Title)
	if title == "" {
		// 推首則 user 文
		for _, m := range sess.Messages {
			if m.Role == "user" && strings.TrimSpace(m.Text) != "" {
				title = truncate(m.Text, 24)
				break
			}
		}
	}
	if title == "" {
		title = "新對話"
	}
	return domain.SessionSummary{
		ID:           sess.ID,
		Title:        title,
		Preview:      preview,
		MessageCount: len(sess.Messages),
		UpdatedAt:    sess.UpdatedAt,
		CreatedAt:    sess.CreatedAt,
		Active:       active,
	}
}

// CreateSession — 開新對話並設為 active（舊的保留）。
func (s *Service) CreateSession(ctx context.Context, ownerUID int64, title string) (*domain.Session, error) {
	sess := emptySession(ownerUID)
	sess.Title = strings.TrimSpace(title)
	if err := s.Repo.SaveSession(ctx, sess); err != nil {
		return nil, err
	}
	if err := s.Repo.SetActiveSessionID(ctx, ownerUID, sess.ID); err != nil {
		return nil, err
	}
	return sess, nil
}

// ActivateSession — 切換目前作用中的對話。
func (s *Service) ActivateSession(ctx context.Context, ownerUID int64, id string) (*domain.Session, error) {
	sess, err := s.Repo.GetSessionByID(ctx, ownerUID, strings.TrimSpace(id))
	if err != nil {
		return nil, err
	}
	sess.UpdatedAt = domain.NowNano()
	if err := s.Repo.SaveSession(ctx, sess); err != nil {
		return nil, err
	}
	if err := s.Repo.SetActiveSessionID(ctx, ownerUID, sess.ID); err != nil {
		return nil, err
	}
	return sess, nil
}

// DeleteSession — 刪除指定對話；若刪的是 active，自動切最近或建新。
func (s *Service) DeleteSession(ctx context.Context, ownerUID int64, id string) (*domain.Session, error) {
	id = strings.TrimSpace(id)
	if id == "" {
		return nil, fmt.Errorf("%w: empty session id", domain.ErrValidation)
	}
	if err := s.Repo.DeleteSessionByID(ctx, ownerUID, id); err != nil {
		return nil, err
	}
	activeID, _ := s.Repo.GetActiveSessionID(ctx, ownerUID)
	if activeID == id || activeID == "" {
		// 切到剩餘最近一則，或建新
		list, err := s.Repo.ListSessions(ctx, ownerUID)
		if err != nil {
			return nil, err
		}
		if len(list) == 0 {
			return s.CreateSession(ctx, ownerUID, "")
		}
		return s.ActivateSession(ctx, ownerUID, list[0].ID)
	}
	return s.GetSession(ctx, ownerUID)
}

func (s *Service) SaveSession(ctx context.Context, ownerUID int64, sess *domain.Session) (*domain.Session, error) {
	sess.OwnerUID = ownerUID
	sess.UpdatedAt = domain.NowNano()
	if sess.ID == "" {
		sess.ID = "sess_" + uuid.NewString()[:8]
	}
	if sess.CreatedAt == 0 {
		sess.CreatedAt = sess.UpdatedAt
	}
	if err := s.Repo.SaveSession(ctx, sess); err != nil {
		return nil, err
	}
	return sess, nil
}

// ClearSession — 相容舊 API：開新對話並切過去（舊 session 保留，可從列表選回）。
func (s *Service) ClearSession(ctx context.Context, ownerUID int64) (*domain.Session, error) {
	return s.CreateSession(ctx, ownerUID, "")
}

// PromptBlock is one labeled chunk of the assembled prompt (for readable preview).
type PromptBlock struct {
	Title string
	Body  string
}

// PromptPreviewResult is the real payload that would be sent to the LLM (no call, no bill).
type PromptPreviewResult struct {
	Prompt      string
	Mode        string
	Sections    []string
	Blocks      []PromptBlock
	Fingerprint string // sha256 hex[:16] of Prompt — same algo as chat done
	CharCount   int
	RuneCount   int
	Pinned      []PromptPinned
	Note        string
}

type PromptPinned struct {
	ID    string
	Kind  string
	Title string
	Body  string
}

// ChatOutcome — session + the exact prompt that was sent to the LLM.
type ChatOutcome struct {
	Session     *domain.Session
	Prompt      string
	Fingerprint string
	Sections    []string
	Blocks      []PromptBlock
	CharCount   int
	RuneCount   int
}

func promptFingerprint(prompt string) string {
	sum := sha256.Sum256([]byte(prompt))
	return hex.EncodeToString(sum[:])[:16]
}

func promptStats(prompt string) (chars, runes int, fp string) {
	return len(prompt), utf8.RuneCountInString(prompt), promptFingerprint(prompt)
}

// PreviewPrompt builds the exact same prompt as Chat/ChatStream would, without billing or AI.
func (s *Service) PreviewPrompt(ctx context.Context, ownerUID int64, message string, pinnedIDs []string, mode, personaID, sessionID, material string) (*PromptPreviewResult, error) {
	message = strings.TrimSpace(message)
	material = strings.TrimSpace(material)
	if mode != "chat" && mode != "generate" {
		mode = "chat"
	}
	sess, err := s.resolveSession(ctx, ownerUID, sessionID)
	if err != nil {
		return nil, err
	}
	// Mirror chatInternal: append this-turn user message into a copy before build.
	userLog := message
	if mode == "generate" {
		userLog = generateUserLogMessage(message, material)
	}
	previewSess := *sess
	previewSess.Messages = append(append([]domain.ChatMessage{}, sess.Messages...), domain.ChatMessage{
		ID: "preview", Role: "user", Text: userLog, CreatedAt: domain.NowNano(),
	})
	previewSess.PinnedElementIDs = pinnedIDs
	// 預覽不寫回 DB，但用同一套摘要規則
	maybeRefreshSessionSummary(&previewSess)

	var pinned []PromptPinned
	for _, id := range pinnedIDs {
		if e, eerr := s.Repo.GetElement(ctx, id); eerr == nil && e != nil && e.OwnerUID == ownerUID {
			pinned = append(pinned, PromptPinned{
				ID: e.ID, Kind: e.Kind, Title: e.Title, Body: e.Body,
			})
		}
	}

	persona := s.resolvePersonaSnap(ctx, ownerUID, personaID)
	lang := ai.ResponseLanguageFrom(ctx)
	if pl := inferPersonaLanguage(persona); pl != "" {
		lang = pl
	}
	prompt, blocks, sections := s.buildInspirePrompt(ctx, ownerUID, pinnedIDs, message, mode, personaID, material, "", &previewSess, persona, lang)
	chars, runes, fp := promptStats(prompt)
	out := &PromptPreviewResult{
		Prompt:      prompt,
		Mode:        mode,
		Sections:    sections,
		Blocks:      blocks,
		Fingerprint: fp,
		CharCount:   chars,
		RuneCount:   runes,
		Pinned:      pinned,
		Note:        "此為送出前實際組裝的完整 prompt（與 Chat／ChatStream 同一 buildInspirePrompt）。指紋（fingerprint）與字數可用來對照送出後回傳值；未呼叫 AI、未扣額度。",
	}
	if mode == "chat" && message == "" {
		out.Note += " 訊息為空；真送出會被拒絕。"
	}
	return out, nil
}

func (s *Service) Chat(ctx context.Context, ownerUID int64, message string, pinnedIDs []string, mode, personaID, sessionID, material string, useWeb bool) (*ChatOutcome, error) {
	return s.chatInternal(ctx, ownerUID, message, pinnedIDs, mode, personaID, sessionID, material, useWeb, nil)
}

// ChatStream — 真 AI stream；onDelta 每收到一段正文就回呼（可 SSE）。結束後 session 已寫入。
func (s *Service) ChatStream(ctx context.Context, ownerUID int64, message string, pinnedIDs []string, mode, personaID, sessionID, material string, useWeb bool, onDelta func(chunk string) error) (*ChatOutcome, error) {
	return s.chatInternal(ctx, ownerUID, message, pinnedIDs, mode, personaID, sessionID, material, useWeb, onDelta)
}

func (s *Service) resolveSession(ctx context.Context, ownerUID int64, sessionID string) (*domain.Session, error) {
	sessionID = strings.TrimSpace(sessionID)
	if sessionID == "" {
		return s.GetSession(ctx, ownerUID)
	}
	return s.GetSessionByID(ctx, ownerUID, sessionID)
}

// generateUserLogMessage — 寫進 session 的短紀錄，不把整包素材塞進對話泡泡。
func generateUserLogMessage(notes, material string) string {
	notes = strings.TrimSpace(notes)
	if notes == "" {
		notes = "用人設寫成貼文"
	}
	mat := strings.TrimSpace(material)
	preview := truncate(mat, 48)
	if preview == "" {
		return "【產文】" + notes
	}
	return "【產文】" + notes + "｜素材：" + preview
}

func (s *Service) chatInternal(ctx context.Context, ownerUID int64, message string, pinnedIDs []string, mode, personaID, sessionID, material string, useWeb bool, onDelta func(chunk string) error) (_ *ChatOutcome, err error) {
	message = strings.TrimSpace(message)
	material = strings.TrimSpace(material)
	if mode != "chat" && mode != "generate" {
		mode = "chat"
	}
	if mode == "generate" {
		if message == "" {
			message = "整理這段對話，寫成一則可發布的 Threads 貼文"
		}
	} else if message == "" {
		return nil, fmt.Errorf("%w: empty message", domain.ErrValidation)
	}
	sess, err := s.resolveSession(ctx, ownerUID, sessionID)
	if err != nil {
		return nil, err
	}
	if mode == "generate" && material == "" {
		material = ComposeDraftMaterial(sess, message)
		if strings.TrimSpace(material) == "" {
			return nil, fmt.Errorf("%w: 先聊幾句，再整理成貼文", domain.ErrValidation)
		}
	}
	label := "inspire chat"
	if mode == "generate" {
		label = "inspire rewrite"
	}
	charge, err := s.bill(ctx, ownerUID, usageDomain.MeterAICopy, label, "inspire.chat")
	if err != nil {
		return nil, err
	}
	defer charge.Settle(ctx, &err)
	// 回覆語言：人設指紋／範例優先，其次會員 UI 語系（ctx 已由 Auth 注入）
	persona := s.resolvePersonaSnap(ctx, ownerUID, personaID)
	if mode == "generate" && (persona == nil || persona.Status != "ready") {
		return nil, fmt.Errorf("%w: 請先選擇已完成分析的人設", domain.ErrValidation)
	}
	lang := ai.ResponseLanguageFrom(ctx)
	if pl := inferPersonaLanguage(persona); pl != "" {
		lang = pl
	}
	ctx = ai.WithResponseLanguage(ctx, lang)
	if mode == "generate" {
		if message == "整理這段對話，寫成一則可發布的 Threads 貼文" || message == "" {
			if lang == "en" {
				message = "Turn this conversation into one publish-ready Threads post"
			} else {
				message = "整理這段對話，寫成一則可發布的 Threads 貼文"
			}
		}
	}
	webContext := ""
	if mode == "chat" && useWeb {
		webContext, err = s.searchPromptContext(ctx, ownerUID, message)
		if err != nil {
			return nil, err
		}
	}

	now := domain.NowNano()
	sess.PinnedElementIDs = pinnedIDs
	userLog := message
	if mode == "generate" {
		userLog = generateUserLogMessage(message, material)
	}
	// 自動標題：首則有意義 user 訊息
	if strings.TrimSpace(sess.Title) == "" {
		if mode == "generate" {
			sess.Title = truncate(material, 24)
		} else {
			sess.Title = truncate(message, 24)
		}
	}
	sess.Messages = append(sess.Messages, domain.ChatMessage{
		ID: "msg_" + uuid.NewString()[:8], Role: "user", Text: userLog, CreatedAt: now,
	})
	// 舊對話摺進摘要（每 N 則才重算）；prompt 只帶摘要 + 最近幾則
	maybeRefreshSessionSummary(sess)

	prompt, blocks, sections := s.buildInspirePrompt(ctx, ownerUID, pinnedIDs, message, mode, personaID, material, webContext, sess, persona, lang)
	chars, runes, fp := promptStats(prompt)
	// 靈感：小輸出預算 + 精簡 prompt → 首字與完成都更快
	llmCtx := ai.WithMaxTokens(ctx, inspireMaxTokens(mode))
	llmCtx = ai.WithTemperature(llmCtx, 0.85)
	reply, aerr := s.runInspireLLM(llmCtx, ownerUID, prompt, onDelta)
	if aerr != nil {
		return nil, aerr
	}
	if reply == "" {
		return nil, fmt.Errorf("%w: AI 回傳空白，請換模型後重試", domain.ErrValidation)
	}

	asst := domain.ChatMessage{
		ID: "msg_" + uuid.NewString()[:8], Role: "assistant", Text: reply, CreatedAt: domain.NowNano(),
	}
	if mode == "generate" {
		body := strings.TrimSpace(reply)
		if i := strings.Index(body, "] "); i >= 0 && strings.HasPrefix(body, "[") {
			body = strings.TrimSpace(body[i+2:])
		}
		if body == "" {
			body = material
		}
		asst.Draft = &domain.ChatDraft{Title: truncate(material, 24), Body: body}
		if lang == "en" {
			asst.Text = "Draft rewritten in persona voice — ready to use."
		} else {
			asst.Text = "已依人設改寫成草稿，可直接用。"
		}
	}
	sess.Messages = append(sess.Messages, asst)
	// 助手回完後再檢查是否該摺摘要（不另打 AI）
	maybeRefreshSessionSummary(sess)
	sess.UpdatedAt = domain.NowNano()
	if err := s.Repo.SaveSession(ctx, sess); err != nil {
		return nil, err
	}
	// 聊過的 session 設為 active
	_ = s.Repo.SetActiveSessionID(ctx, ownerUID, sess.ID)
	return &ChatOutcome{
		Session: sess, Prompt: prompt, Fingerprint: fp,
		Sections: sections, Blocks: blocks, CharCount: chars, RuneCount: runes,
	}, nil
}

func inspireMaxTokens(mode string) int {
	// 主貼不設產品字數上限；技術預算給足，若仍達 length 由 transport 續寫。
	if mode == "generate" {
		return 8192
	}
	return 2048
}

// 對話視窗：只帶最近幾則原文；更舊的摺進 ContextSummary
const (
	inspireKeepRecentChat     = 5 // 發想要有足夠上下文
	inspireKeepRecentGenerate = 4
	inspireSummarizeEvery     = 6
	inspireSummaryMaxRunes    = 480
	inspireHistoryLineChat    = 120
	inspireHistoryLineGen     = 90
	inspirePinBodyChat        = 280
	inspirePinBodyGenerate    = 280
	inspirePromptCapChat      = 3200
	inspirePromptCapGenerate  = 3600
)

// maybeRefreshSessionSummary 把「窗口外」舊訊息摺成摘要。
// 不做額外 LLM 呼叫（免費、快）；清空 session 時摘要一併清掉。
func maybeRefreshSessionSummary(sess *domain.Session) {
	if sess == nil {
		return
	}
	n := len(sess.Messages)
	keep := inspireKeepRecentChat
	if n <= keep {
		return
	}
	olderEnd := n - keep
	if olderEnd <= 0 {
		return
	}
	// 已覆蓋到 olderEnd 前綴 → 不必重算
	if sess.SummaryMsgCount >= olderEnd && strings.TrimSpace(sess.ContextSummary) != "" {
		return
	}
	// 距上次摘要未滿 N 則新舊差 → 暫不重算（沿用舊摘要 + 視窗）
	if sess.SummaryMsgCount > 0 && (olderEnd-sess.SummaryMsgCount) < inspireSummarizeEvery {
		return
	}
	older := sess.Messages[:olderEnd]
	sess.ContextSummary = compactHistorySummary(older, inspireSummaryMaxRunes)
	sess.SummaryMsgCount = olderEnd
}

func compactHistorySummary(msgs []domain.ChatMessage, maxRunes int) string {
	if len(msgs) == 0 {
		return ""
	}
	var parts []string
	// 從舊到新取重點；過長則只留頭尾
	for _, m := range msgs {
		role := "使用者"
		if m.Role == "assistant" {
			role = "助手"
		}
		t := strings.TrimSpace(m.Text)
		if t == "" && m.Draft != nil {
			t = strings.TrimSpace(m.Draft.Body)
		}
		if t == "" {
			continue
		}
		parts = append(parts, role+"："+truncate(t, 60))
	}
	if len(parts) == 0 {
		return ""
	}
	// 太長：保留前 3 + 後 3
	if len(parts) > 8 {
		head := parts[:3]
		tail := parts[len(parts)-3:]
		parts = append(head, append([]string{"…"}, tail...)...)
	}
	return truncate(strings.Join(parts, " / "), maxRunes)
}

// ComposeDraftMaterial — 從 session 規則組裝「待改寫內容」（免費、不呼叫 AI）。
func ComposeDraftMaterial(sess *domain.Session, extra string) string {
	var parts []string
	if sess != nil {
		if t := strings.TrimSpace(sess.Title); t != "" && t != "新對話" {
			parts = append(parts, "主題："+t)
		}
		if sum := strings.TrimSpace(sess.ContextSummary); sum != "" {
			parts = append(parts, "前情重點："+sum)
		}
		// 最近訊息（略過產文短紀錄）
		msgs := sess.Messages
		start := 0
		if len(msgs) > 10 {
			start = len(msgs) - 10
		}
		for _, m := range msgs[start:] {
			t := strings.TrimSpace(m.Text)
			if strings.HasPrefix(t, "【產文】") {
				continue
			}
			if m.Role == "user" && t != "" {
				parts = append(parts, "我想："+truncate(t, 160))
			} else if m.Role == "assistant" {
				if m.Draft != nil && strings.TrimSpace(m.Draft.Body) != "" {
					parts = append(parts, "先前草稿："+truncate(m.Draft.Body, 200))
				} else if t != "" && t != "已依人設改寫成草稿，可直接用。" && t != "已產出草稿，可直接用。" {
					parts = append(parts, "討論："+truncate(t, 200))
				}
			}
		}
	}
	if e := strings.TrimSpace(extra); e != "" {
		parts = append(parts, "補充："+e)
	}
	return strings.TrimSpace(strings.Join(parts, "\n\n"))
}

func (s *Service) resolvePersonaSnap(ctx context.Context, ownerUID int64, personaID string) *PersonaSnapshot {
	if s.Personas == nil {
		return nil
	}
	p, err := s.Personas.ResolvePersona(ctx, ownerUID, personaID)
	if err != nil {
		return nil
	}
	return p
}

// inferPersonaLanguage — 從人設指紋／範例推輸出語言；空=沿用會員 UI 語系。
func inferPersonaLanguage(p *PersonaSnapshot) string {
	if p == nil {
		return ""
	}
	var texts []string
	for _, t := range []string{p.DraftText, p.Voice, p.Brief, p.Name} {
		if strings.TrimSpace(t) != "" {
			texts = append(texts, t)
		}
	}
	for _, sm := range p.DimSummaries {
		if strings.TrimSpace(sm) != "" {
			texts = append(texts, sm)
		}
	}
	return ai.InferScriptLanguage(texts...)
}

func inspireSystemRules(mode, lang string) string {
	en := ai.NormalizeResponseLanguage(lang) == "en"
	if mode == "generate" {
		if en {
			return strings.Join([]string{
				"Turn 【Conversation material】 into one complete, publish-ready Threads post.",
				"First identify the clearest insight and emotional center. Then shape a strong, natural reading arc before applying the persona's wording, rhythm, and punctuation.",
				"Use proven engagement principles without templates: a content-specific entry, concrete detail, meaningful turn, and an ending earned by the idea. Never force a question or CTA.",
				"Output ONLY the post body. No analysis, title, markdown, or outline. Use whatever length the content needs to finish its thought; do not pad or cut it short. Do not invent facts or experiences.",
			}, "\n")
		}
		return strings.Join([]string{
			"把【對話素材】整理成一則完整、可直接發布的 Threads 正文。",
			"先找出整段對話最清楚的觀點與情緒核心，安排自然且有推進的閱讀軌跡，再套用【人設】的用字、節奏、標點與情緒表達。",
			"運用高互動貼文原則但不套模板：依內容選切入點、保留具體細節、形成有意義的轉折，結尾由觀點自然落下；不要硬加問句或 CTA。",
			"只輸出正文，不要分析、標題、markdown、大綱。依內容需要自然展開，把文章完整講完，不設固定字數，也不要灌水。不得捏造素材沒有的事實或個人經歷。",
		}, "\n")
	}
	// chat：正常發想對話，不限 Threads 字數、不強壓極短（完整討論）
	if en {
		return strings.Join([]string{
			"You are a Threads ideation partner, not a final-copy writer.",
			"Chat naturally and help brainstorm: topic, angle, emotion, hooks, examples, taboos.",
			"Reply as long as needed for good ideation (no post character limit here).",
			"You may offer options, questions, and partial lines; do not force a final publish-ready post unless asked.",
			"When consensus is clear, you may summarize as “Current consensus: …”.",
			"Language: match the persona (fingerprint/samples).",
			"Do not cite brands unless pinned.",
		}, "\n")
	}
	return strings.Join([]string{
		"你是 Threads 發想搭子，不是定稿寫手。",
		"用正常聊天方式一起發想：主題、角度、情緒、鉤子、例子、禁忌都可以展開談。",
		"發想階段不限字數、不必刻意壓短；把討論講完整比較重要。",
		"可以給方向、選項、半成品句子；除非用戶要求，不要硬寫成最終可貼文定稿。",
		"共識清楚時可用「目前共識：…」整理。",
		"語言與【人設】一致（指紋／範例）；不要被 UI 語系帶跑。",
		"未套用品牌勿引用。",
	}, "\n")
}

// buildInspirePrompt 組裝真實送 AI 的全文。
// chat：發想；generate：對【待改寫內容】做人設改寫。
// persona 可預先 resolve；lang = zh-TW | en（人設優先）。
func (s *Service) buildInspirePrompt(ctx context.Context, ownerUID int64, pinnedIDs []string, message, mode, personaID, material, webContext string, sess *domain.Session, persona *PersonaSnapshot, lang string) (string, []PromptBlock, []string) {
	var blocks []PromptBlock
	var sections []string
	add := func(title, body string) {
		body = strings.TrimSpace(body)
		if body == "" {
			return
		}
		blocks = append(blocks, PromptBlock{Title: title, Body: body})
		sections = append(sections, title)
	}

	lang = ai.NormalizeResponseLanguage(lang)
	add("系統規則", inspireSystemRules(mode, lang))

	if pBlock := formatPersonaPromptBlock(persona, mode, lang); pBlock != "" {
		add("人設", pBlock)
	} else if pBlock := s.personaPromptBlock(ctx, ownerUID, personaID, mode); pBlock != "" {
		// fallback if snap nil
		add("人設", pBlock)
	}

	// 品牌／產品：只有 pin 才帶；chat 截更短以加快
	pinCap := inspirePinBodyGenerate
	if mode == "chat" {
		pinCap = inspirePinBodyChat
	}
	var ctxParts []string
	var pinnedBrandIDs []string
	for _, id := range pinnedIDs {
		if e, eerr := s.Repo.GetElement(ctx, id); eerr == nil && e != nil && e.OwnerUID == ownerUID {
			kind := strings.TrimSpace(e.Kind)
			body := strings.TrimSpace(e.Body)
			if len([]rune(body)) > pinCap {
				body = string([]rune(body)[:pinCap]) + "…"
			}
			if kind != "" {
				ctxParts = append(ctxParts, fmt.Sprintf("[%s] %s\n%s", kind, e.Title, body))
			} else {
				ctxParts = append(ctxParts, e.Title+":\n"+body)
			}
			if kind == "brand" || strings.HasPrefix(e.ID, "el_brand_") {
				bid := strings.TrimSpace(e.RefID)
				if bid == "" && strings.HasPrefix(e.ID, "el_brand_") {
					bid = strings.TrimPrefix(e.ID, "el_brand_")
				}
				if bid != "" {
					pinnedBrandIDs = append(pinnedBrandIDs, bid)
				}
			}
		}
	}
	if cat := s.brandCatalogBlock(ctx, ownerUID, pinnedBrandIDs); cat != "" {
		add("品牌與產品", truncate(cat, 900))
	}
	if len(ctxParts) > 0 {
		joined := strings.Join(ctxParts, "\n\n")
		if len([]rune(joined)) > 1200 {
			joined = string([]rune(joined)[:1200]) + "…"
		}
		add("套用元素", joined)
	}

	if mode == "generate" {
		mat := strings.TrimSpace(material)
		if mat == "" {
			mat = ComposeDraftMaterial(sess, message)
		}
		add("對話素材", truncate(mat, 2400))
		notes := strings.TrimSpace(message)
		if notes == "" {
			notes = "用人設寫成 Threads 正文"
		}
		add("改寫指示", notes)
	} else {
		if strings.TrimSpace(webContext) != "" {
			add("Exa 查詢資料", truncate(webContext, 1400))
		}
		if sess != nil {
			if sum := strings.TrimSpace(sess.ContextSummary); sum != "" {
				add("前情摘要", sum)
			}
		}
		if sess != nil && len(sess.Messages) > 0 {
			hist := sess.Messages
			if len(hist) > 0 && hist[len(hist)-1].Role == "user" {
				last := strings.TrimSpace(hist[len(hist)-1].Text)
				if last == strings.TrimSpace(message) || strings.HasPrefix(last, "【產文】") {
					hist = hist[:len(hist)-1]
				}
			}
			keep := inspireKeepRecentChat - 1
			if keep < 2 {
				keep = 2
			}
			start := 0
			if len(hist) > keep {
				start = len(hist) - keep
			}
			if start < len(hist) {
				var hb strings.Builder
				for _, m := range hist[start:] {
					role := "使用者"
					if m.Role == "assistant" {
						role = "助手"
					}
					t := strings.TrimSpace(m.Text)
					if t == "" && m.Draft != nil {
						t = m.Draft.Body
					}
					if strings.HasPrefix(t, "【產文】") {
						continue
					}
					t = truncate(t, inspireHistoryLineChat)
					hb.WriteString(role)
					hb.WriteString("：")
					hb.WriteString(t)
					hb.WriteString("\n")
				}
				if s := strings.TrimSpace(hb.String()); s != "" {
					add("最近對話", s)
				}
			}
		}
		add("本輪", message)
	}

	var b strings.Builder
	for i, blk := range blocks {
		if i > 0 {
			b.WriteString("\n")
		}
		b.WriteString("【")
		b.WriteString(blk.Title)
		b.WriteString("】\n")
		b.WriteString(blk.Body)
		b.WriteString("\n")
	}
	full := b.String()
	capN := inspirePromptCapGenerate
	if mode == "chat" {
		capN = inspirePromptCapChat
	}
	if len([]rune(full)) > capN {
		full = string([]rune(full)[:capN]) + "…\n"
	}
	return full, blocks, sections
}

func (s *Service) searchPromptContext(ctx context.Context, ownerUID int64, query string) (_ string, err error) {
	query = strings.TrimSpace(query)
	if query == "" {
		return "", nil
	}
	charge, err := s.bill(ctx, ownerUID, usageDomain.MeterWebSearch, "inspire web search", "inspire.chat")
	if err != nil {
		return "", err
	}
	defer charge.Settle(ctx, &err)
	key := "fake"
	if s.ResolveKey != nil {
		if _, resolved, err := s.ResolveKey(ctx, ownerUID, usageDomain.MeterWebSearch); err == nil && resolved != "" {
			key = resolved
		}
	}
	var hits []search.Hit
	if s.Search != nil {
		found, err := s.Search.Search(ctx, key, query, 5)
		if err != nil {
			return "", err
		}
		hits = found
	} else {
		hits = []search.Hit{{Title: "About " + query, URL: "https://example.com", Snippet: "snippet"}}
	}
	var b strings.Builder
	for _, hit := range hits {
		title := strings.TrimSpace(hit.Title)
		snippet := strings.TrimSpace(hit.Snippet)
		url := strings.TrimSpace(hit.URL)
		if title == "" && snippet == "" {
			continue
		}
		b.WriteString("- ")
		b.WriteString(title)
		if snippet != "" {
			b.WriteString("：")
			b.WriteString(truncate(snippet, 240))
		}
		if url != "" {
			b.WriteString("\n  來源：")
			b.WriteString(url)
		}
		b.WriteString("\n")
	}
	if b.Len() == 0 {
		return "沒有找到可用資料。", nil
	}
	b.WriteString("只把以上資料當討論依據；區分來源事實與推論，不要捏造來源。")
	return strings.TrimSpace(b.String()), nil
}

// personaPromptBlock：聊天用輕量人設；產文帶指紋但截斷。
func (s *Service) personaPromptBlock(ctx context.Context, ownerUID int64, personaID, mode string) string {
	p := s.resolvePersonaSnap(ctx, ownerUID, personaID)
	lang := inferPersonaLanguage(p)
	if lang == "" {
		lang = ai.ResponseLanguageFrom(ctx)
	}
	return formatPersonaPromptBlock(p, mode, lang)
}

// formatPersonaPromptBlock 組人設段，並明示輸出語言。
func formatPersonaPromptBlock(p *PersonaSnapshot, mode, lang string) string {
	if p == nil {
		return ""
	}
	lang = ai.NormalizeResponseLanguage(lang)
	en := lang == "en"
	var b strings.Builder
	if en {
		b.WriteString("Output language: English (match persona samples/fingerprint; do not switch to Chinese unless material is Chinese-only).\n")
	} else {
		b.WriteString("輸出語言：繁體中文（台灣），除非人設指紋／範例明顯是其他語言——以人設為準。\n")
	}
	if n := strings.TrimSpace(p.Name); n != "" {
		if en {
			b.WriteString("Name: ")
		} else {
			b.WriteString("名稱：")
		}
		b.WriteString(n)
		b.WriteString("\n")
	}
	if br := strings.TrimSpace(p.Brief); br != "" {
		if en {
			b.WriteString("Positioning: ")
		} else {
			b.WriteString("定位：")
		}
		b.WriteString(truncate(br, 120))
		b.WriteString("\n")
	}
	if v := strings.TrimSpace(p.Voice); v != "" {
		if en {
			b.WriteString("Voice: ")
		} else {
			b.WriteString("語氣：")
		}
		b.WriteString(truncate(v, 80))
		b.WriteString("\n")
	}

	if mode == "chat" {
		if len(p.GuardAvoid) > 0 {
			if en {
				b.WriteString("Avoid: ")
			} else {
				b.WriteString("禁止：")
			}
			b.WriteString(strings.Join(p.GuardAvoid, "、"))
			b.WriteString("\n")
		}
		if p.BanAiTone {
			if en {
				b.WriteString("No AI/customer-service tone\n")
			} else {
				b.WriteString("禁止 AI 腔／客服腔\n")
			}
		}
		return strings.TrimSpace(b.String())
	}

	fp := strings.TrimSpace(p.DraftText)
	if fp != "" {
		if en {
			b.WriteString("【Language fingerprint】\n")
		} else {
			b.WriteString("【語言指紋】\n")
		}
		b.WriteString(truncate(fp, 700))
		b.WriteString("\n")
	}
	if len(p.DimSummaries) > 0 {
		var lines []string
		for _, k := range []string{"d1Tone", "d2Structure", "d4Topics"} {
			if sm := strings.TrimSpace(p.DimSummaries[k]); sm != "" {
				lines = append(lines, k+": "+truncate(sm, 80))
			}
		}
		if len(lines) > 0 {
			if en {
				b.WriteString("【Style notes】\n")
			} else {
				b.WriteString("【風格重點】\n")
			}
			b.WriteString(strings.Join(lines, "\n"))
			b.WriteString("\n")
		}
	}
	var guard []string
	if len(p.GuardAvoid) > 0 {
		prefix := "禁止："
		if en {
			prefix = "Avoid: "
		}
		guard = append(guard, prefix+strings.Join(p.GuardAvoid, "、"))
	}
	if p.BanAiTone {
		if en {
			guard = append(guard, "No AI/customer-service tone")
		} else {
			guard = append(guard, "禁止 AI 腔／客服腔")
		}
	}
	if len(guard) > 0 {
		b.WriteString("【護欄】\n")
		b.WriteString(strings.Join(guard, "\n"))
		b.WriteString("\n")
	}
	return strings.TrimSpace(b.String())
}

// brandCatalogBlock 只輸出 allowIDs 內的品牌（空 = 不帶任何品牌）。
func (s *Service) brandCatalogBlock(ctx context.Context, ownerUID int64, allowIDs []string) string {
	if s.Brands == nil || len(allowIDs) == 0 {
		return ""
	}
	want := map[string]struct{}{}
	for _, id := range allowIDs {
		id = strings.TrimSpace(id)
		if id != "" {
			want[id] = struct{}{}
		}
	}
	if len(want) == 0 {
		return ""
	}
	list, err := s.Brands.ListCatalog(ctx, ownerUID)
	if err != nil || len(list) == 0 {
		return ""
	}
	var b strings.Builder
	n := 0
	for _, br := range list {
		if _, ok := want[br.ID]; !ok {
			continue
		}
		if n > 0 {
			b.WriteString("\n")
		}
		n++
		name := strings.TrimSpace(br.DisplayName)
		if name == "" {
			name = br.ID
		}
		b.WriteString("■ 品牌：")
		b.WriteString(name)
		b.WriteString("\n")
		if t := strings.TrimSpace(br.Brief); t != "" {
			b.WriteString("簡介：")
			b.WriteString(t)
			b.WriteString("\n")
		}
		if t := strings.TrimSpace(br.Audience); t != "" {
			b.WriteString("受眾：")
			b.WriteString(t)
			b.WriteString("\n")
		}
		if t := strings.TrimSpace(br.Goals); t != "" {
			b.WriteString("目標：")
			b.WriteString(t)
			b.WriteString("\n")
		}
		if len(br.Products) == 0 {
			b.WriteString("（此品牌尚無產品）\n")
			continue
		}
		for _, pr := range br.Products {
			label := strings.TrimSpace(pr.Label)
			if label == "" {
				label = pr.ID
			}
			b.WriteString("  · 產品：")
			b.WriteString(label)
			b.WriteString("\n")
			if t := strings.TrimSpace(pr.Context); t != "" {
				b.WriteString("    說明：")
				b.WriteString(t)
				b.WriteString("\n")
			}
			if len(pr.Tags) > 0 {
				b.WriteString("    標籤：")
				b.WriteString(strings.Join(pr.Tags, "、"))
				b.WriteString("\n")
			}
			if len(pr.Pains) > 0 {
				b.WriteString("    痛點：")
				b.WriteString(strings.Join(pr.Pains, "、"))
				b.WriteString("\n")
			}
		}
	}
	return strings.TrimSpace(b.String())
}

func (s *Service) runInspireLLM(ctx context.Context, ownerUID int64, prompt string, onDelta func(string) error) (string, error) {
	// 真 AI：Registry + 會員 provider/model/key
	if s.ResolveAI != nil && s.AIRegistry != nil {
		provider, model, apiKey, err := s.ResolveAI(ctx, ownerUID)
		if err == nil && strings.TrimSpace(apiKey) != "" && !strings.HasPrefix(strings.ToLower(apiKey), "fake") {
			c, cerr := s.AIRegistry.Client(provider)
			if cerr == nil {
				if onDelta != nil {
					return c.CompleteStream(ctx, apiKey, model, prompt, onDelta)
				}
				return c.Complete(ctx, apiKey, model, prompt)
			}
		}
	}
	// 測試 FakeClient
	if s.AI != nil {
		key := "test-key"
		if s.ResolveKey != nil {
			if _, k, rerr := s.ResolveKey(ctx, ownerUID, usageDomain.MeterAICopy); rerr == nil && k != "" {
				key = k
			}
		}
		if onDelta != nil {
			return s.AI.CompleteStream(ctx, key, "grok-3", prompt, onDelta)
		}
		return s.AI.Complete(ctx, key, "grok-3", prompt)
	}
	return "", fmt.Errorf("%w: 請到設定填寫 AI Key", domain.ErrValidation)
}

func (s *Service) ResearchSearch(ctx context.Context, ownerUID int64, query string) (_ []*domain.ResearchHit, err error) {
	query = strings.TrimSpace(query)
	if query == "" {
		return nil, fmt.Errorf("%w: empty query", domain.ErrValidation)
	}
	charge, err := s.bill(ctx, ownerUID, usageDomain.MeterWebSearch, "research search", "research.search")
	if err != nil {
		return nil, err
	}
	defer charge.Settle(ctx, &err)
	key := "fake"
	if s.ResolveKey != nil {
		if _, k, err := s.ResolveKey(ctx, ownerUID, usageDomain.MeterWebSearch); err == nil && k != "" {
			key = k
		}
	}
	var raw []search.Hit
	if s.Search != nil {
		h, err := s.Search.Search(ctx, key, query, 5)
		if err != nil {
			return nil, err
		}
		raw = h
	} else {
		raw = []search.Hit{{Title: "About " + query, URL: "https://example.com", Snippet: "snippet"}}
	}
	out := make([]*domain.ResearchHit, 0, len(raw))
	for i, h := range raw {
		out = append(out, &domain.ResearchHit{
			ID: "rh_" + uuid.NewString()[:8], Title: h.Title, Snippet: h.Snippet, URL: h.URL,
			Summary: h.Snippet, LearnPoints: []string{"重點 " + fmt.Sprint(i+1), "可引用觀點"},
			ReplyHooks: []string{"你也覺得" + query + "嗎？"}, SourceLabel: "exa",
		})
	}
	return out, nil
}

// GenerateImage has no image backend yet. It used to return a random avatar URL from a public
// placeholder service while charging a real ai_image credit, which billed members for a picture
// nobody asked for. Until a real generator is wired in, this reports the gap instead.
func (s *Service) GenerateImage(_ context.Context, _ int64, prompt string) (*domain.GeneratedImage, error) {
	if strings.TrimSpace(prompt) == "" {
		return nil, fmt.Errorf("%w: empty prompt", domain.ErrValidation)
	}
	return nil, fmt.Errorf("%w: 圖片生成尚未接上生成服務", domain.ErrNotImplemented)
}

// Legacy removed APIs
func (s *Service) LegacyRemoved() error { return domain.ErrRemoved }

// inspireCharge is one reserved credit; see Settle/Release for the commit-on-success contract.
type inspireCharge struct {
	svc     *Service
	uid     int64
	meter   string
	mode    string
	label   string
	source  string
	settled bool
}

// bill reserves credit for one metered call. Pair it with `defer charge.Settle(ctx, &err)` on a
// named error return so every failure path refunds rather than charging for nothing.
func (s *Service) bill(ctx context.Context, uid int64, meter, label, source string) (*inspireCharge, error) {
	if s == nil || s.Usage == nil {
		return &inspireCharge{settled: true}, nil
	}
	mode, err := s.Usage.PrepareCall(ctx, uid, meter)
	if err != nil {
		return nil, err
	}
	return &inspireCharge{svc: s, uid: uid, meter: meter, mode: mode, label: label, source: source}, nil
}

func (c *inspireCharge) Settle(ctx context.Context, errp *error) {
	if errp != nil && *errp != nil {
		c.Release(ctx)
		return
	}
	c.Commit(ctx)
}

// Commit writes the audit event. The member already got their result, so a failure here is
// logged rather than turned into an error.
func (c *inspireCharge) Commit(ctx context.Context) {
	if c == nil || c.settled {
		return
	}
	c.settled = true
	if _, err := c.svc.Usage.RecordCall(ctx, c.uid, c.meter, c.mode, c.label, c.source); err != nil {
		logx.Errorf("usage record uid=%d meter=%s source=%s: %v", c.uid, c.meter, c.source, err)
	}
}

func (c *inspireCharge) Release(ctx context.Context) {
	if c == nil || c.settled {
		return
	}
	c.settled = true
	rctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
	defer cancel()
	if err := c.svc.Usage.ReleaseCall(rctx, c.uid, c.meter, c.mode); err != nil {
		logx.Errorf("usage release uid=%d meter=%s source=%s: %v", c.uid, c.meter, c.source, err)
	}
}

func emptySession(ownerUID int64) *domain.Session {
	now := domain.NowNano()
	return &domain.Session{
		ID:               "sess_" + uuid.NewString()[:8],
		OwnerUID:         ownerUID,
		Title:            "",
		Messages:         []domain.ChatMessage{},
		PinnedElementIDs: []string{},
		ContextSummary:   "",
		SummaryMsgCount:  0,
		CreatedAt:        now,
		UpdatedAt:        now,
	}
}

func defaultTrends(ownerUID int64) []*domain.TrendItem {
	now := domain.NowNano()
	// 僅 live 失敗時的後備；SourceLabel=seed，UI 應標示「示意」而非 Threads 即時榜
	return []*domain.TrendItem{
		{ID: "tr_seed_1", OwnerUID: ownerUID, Kind: "threads_tag", Label: "敏感肌換季", Summary: "示意話題（網搜未就緒）", Heat: 100,
			Keywords: []string{"敏感肌"}, Samples: []string{"換季又紅了…"}, SourceLabel: "seed", ObservedAt: now},
		{ID: "tr_seed_2", OwnerUID: ownerUID, Kind: "web_keyword", Label: "遠端上班", Summary: "示意話題（網搜未就緒）", Heat: 90,
			Keywords: []string{"遠端"}, Samples: []string{"居家辦公第三年"}, SourceLabel: "seed", ObservedAt: now},
		{ID: "tr_seed_3", OwnerUID: ownerUID, Kind: "news", Label: "無香洗髮", Summary: "示意話題（網搜未就緒）", Heat: 80,
			Keywords: []string{"無香"}, Samples: []string{"終於找到不刺鼻的"}, SourceLabel: "seed", ObservedAt: now},
	}
}

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