package usecase

import (
	"context"
	"encoding/json"
	"fmt"
	"net/url"
	"sort"
	"strings"
	"time"
	"unicode/utf8"

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

// SettingsReader for dev_mode
type SettingsReader interface {
	DevModeEnabled(ctx context.Context, uid int64) (bool, error)
}

type ReplyQueue interface {
	QueueExternalReply(ctx context.Context, ownerUID int64, accountID, replyToMediaID, text, title string) (outboxID string, err error)
}

// OutreachPublishedHook fires after mark-published / successful outreach send path.
type OutreachPublishedHook func(ctx context.Context, ownerUID int64, postID, accountID string)

// ProductWatchLifecycle is a narrow bridge to Radar; Scout owns product CRUD,
// while Radar owns watch state and historical sweep semantics.
type ProductWatchLifecycle interface {
	PauseProductWatches(ctx context.Context, ownerUID int64, productID string) (int, error)
}

type Service struct {
	Repo     domain.Repository
	Settings SettingsReader
	// Transport is retained only for test construction compatibility. Scout never publishes directly.
	Transport     studioPublish.Transport
	AI            ai.Client // drafts 不用；話題關鍵字 AI 擴充可走此 fallback
	AIRegistry    *ai.Registry
	ResolveAI     func(ctx context.Context, uid int64) (provider, model, apiKey string, err error)
	Usage         *usageUC.Service // 可空：單元測試不扣點
	ReplyQueue    ReplyQueue
	Provider      ThreadSearchProvider
	Crawler       ChromeCrawlerProvider
	SessionSecret string
	// OnOutreachPublished optional growth-loop outcome hook.
	OnOutreachPublished OutreachPublishedHook
	RadarLifecycle      ProductWatchLifecycle
}

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

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

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

func (s *Service) CreateBrand(ctx context.Context, ownerUID int64, name, brief string) (*domain.Brand, error) {
	now := domain.NowNano()
	if name == "" {
		name = "未命名品牌"
	}
	b := &domain.Brand{
		ID: "br_" + uuid.NewString()[:10], OwnerUID: ownerUID,
		DisplayName: name, Brief: brief, CreatedAt: now, UpdatedAt: now,
	}
	if err := s.Repo.SaveBrand(ctx, b); err != nil {
		return nil, err
	}
	aid, _ := s.Repo.GetActiveBrandID(ctx, ownerUID)
	if aid == "" {
		_ = s.Repo.SetActiveBrandID(ctx, ownerUID, b.ID)
	}
	return b, nil
}

func (s *Service) SaveBrand(ctx context.Context, ownerUID int64, b *domain.Brand) (*domain.Brand, error) {
	now := domain.NowNano()
	if b.ID == "" {
		return s.CreateBrand(ctx, ownerUID, b.DisplayName, b.Brief)
	}
	ex, err := s.GetBrand(ctx, ownerUID, b.ID)
	if err != nil {
		return nil, err
	}
	b.OwnerUID = ownerUID
	b.CreatedAt = ex.CreatedAt
	b.UpdatedAt = now
	if err := s.Repo.SaveBrand(ctx, b); err != nil {
		return nil, err
	}
	return b, nil
}

func (s *Service) RemoveBrand(ctx context.Context, ownerUID int64, id string) error {
	if _, err := s.GetBrand(ctx, ownerUID, id); err != nil {
		return err
	}
	prods, _ := s.Repo.ListProducts(ctx, ownerUID, id)
	if len(prods) > 0 {
		return domain.ErrHasProducts
	}
	if err := s.Repo.DeleteBrand(ctx, id); err != nil {
		return err
	}
	aid, _ := s.Repo.GetActiveBrandID(ctx, ownerUID)
	if aid == id {
		list, _ := s.Repo.ListBrands(ctx, ownerUID)
		next := ""
		if len(list) > 0 {
			next = list[0].ID
		}
		_ = s.Repo.SetActiveBrandID(ctx, ownerUID, next)
	}
	return nil
}

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

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

func (s *Service) ListProducts(ctx context.Context, ownerUID int64, brandID string) ([]*domain.Product, error) {
	return s.Repo.ListProducts(ctx, ownerUID, brandID)
}

func (s *Service) ListAllProducts(ctx context.Context, ownerUID int64) ([]*domain.Product, error) {
	return s.Repo.ListProducts(ctx, ownerUID, "")
}

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

func (s *Service) SaveProduct(ctx context.Context, ownerUID int64, p *domain.Product) (*domain.Product, error) {
	now := domain.NowNano()
	if p.BrandID == "" {
		return nil, fmt.Errorf("%w: brand_id required", domain.ErrValidation)
	}
	if _, err := s.GetBrand(ctx, ownerUID, p.BrandID); err != nil {
		return nil, err
	}
	if p.ID == "" {
		p.ID = "prd_" + uuid.NewString()[:10]
		p.CreatedAt = now
	} else {
		ex, err := s.GetProduct(ctx, ownerUID, p.ID)
		if err == nil {
			p.CreatedAt = ex.CreatedAt
		} else if err != domain.ErrNotFound {
			return nil, err
		} else {
			p.CreatedAt = now
		}
	}
	p.OwnerUID = ownerUID
	p.UpdatedAt = now
	if p.Label == "" {
		p.Label = "未命名產品"
	}
	if err := s.Repo.SaveProduct(ctx, p); err != nil {
		return nil, err
	}
	return p, nil
}

func (s *Service) RemoveProduct(ctx context.Context, ownerUID int64, id string) error {
	if _, err := s.GetProduct(ctx, ownerUID, id); err != nil {
		return err
	}
	if s.RadarLifecycle != nil {
		if _, err := s.RadarLifecycle.PauseProductWatches(ctx, ownerUID, id); err != nil {
			return err
		}
	}
	return s.Repo.DeleteProduct(ctx, id)
}

func (s *Service) ImportProductFromURL(_ context.Context, raw string) (*domain.ImportDraft, error) {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return nil, fmt.Errorf("%w: empty url", domain.ErrValidation)
	}
	u, err := url.Parse(raw)
	if err != nil || (u.Host == "" && !strings.HasPrefix(raw, "http")) {
		return nil, fmt.Errorf("%w: bad url", domain.ErrValidation)
	}
	host := ""
	if u != nil {
		host = u.Host
	}
	label := "匯入商品"
	if host != "" {
		label = host + " 商品"
	}
	return &domain.ImportDraft{
		Label: label,
		// 痛點／標籤要用「飼主正在煩惱的話」，不是產品賣點；匯入後請再改成真痛點
		ProductContext: "從 " + raw + " 推估的產品情境（請改寫成實際賣點與使用場景）",
		PainPoints:     []string{"不知道怎麼選", "用了沒感覺", "擔心不適合／有副作用"},
		MatchTags:      []string{"求推薦", "有沒有人用過", "怎麼辦"},
		PlacementURL:   raw,
		SourceNote:     "importProductFromUrl · " + host,
	}, nil
}

func (s *Service) PrepareBrief(ctx context.Context, ownerUID int64, intent, brandID, productID, purpose string, deep bool) (*domain.RunBrief, error) {
	intent = strings.TrimSpace(intent)
	if intent == "" && purpose != "provider" && purpose != "demand" {
		return nil, fmt.Errorf("%w: intent required", domain.ErrValidation)
	}

	// 話題靈感：真正「產」關鍵字（規則變體 + 可選 AI），不再只拆使用者原句。
	if purpose == "activity" {
		return s.prepareActivityBrief(ctx, ownerUID, intent, deep)
	}

	mode := domain.ModeTheme
	brief := &domain.RunBrief{
		Intent: intent, Mode: mode, BrandID: brandID, ProductID: productID,
		Pains: []string{}, Tags: []string{}, Periphery: []string{}, ScanTerms: []string{},
	}
	if productID != "" {
		p, err := s.GetProduct(ctx, ownerUID, productID)
		if err != nil && purpose == "provider" {
			return nil, err
		}
		if err == nil {
			mode = domain.ModeProduct
			brief.Mode = mode
			brief.ProductLabel = p.Label
			brief.ProductContext = p.ProductContext
			brief.Pains = append([]string(nil), p.PainPoints...)
			brief.Tags = append([]string(nil), p.MatchTags...)
			brief.BrandID = p.BrandID
			brief.PlacementNote = "軟性經驗分享，避免硬廣"
		}
	}
	if purpose == "provider" {
		if productID == "" {
			return nil, fmt.Errorf("%w: product required for provider matching", domain.ErrValidation)
		}
		p, err := s.GetProduct(ctx, ownerUID, productID)
		if err != nil {
			return nil, err
		}
		pains, capabilities, excludes := providerMatchingFields(p)
		brief.Mode = domain.ModeProvider
		brief.BrandID = p.BrandID
		brief.ProductLabel = p.Label
		brief.ProductContext = p.ProductContext
		brief.Pains = pains
		brief.Tags = capabilities
		brief.Periphery = excludes
		brief.Intent = p.Label
		brief.ResponseStance = "找可驗證的解法提供者，不推產品"
		brief.ScanTerms = planScanTerms(brief)
		brief.ThemeLabel = truncate(p.Label+" 解法媒合", 36)
		brief.ThemeKey = brief.Mode + "|" + productID
		return brief, nil
	}
	if purpose == "demand" {
		if productID == "" {
			return nil, fmt.Errorf("%w: product required for demand matching", domain.ErrValidation)
		}
		p, err := s.GetProduct(ctx, ownerUID, productID)
		if err != nil {
			return nil, err
		}
		pains, excludes := demandMatchingFields(p)
		brief.Mode = domain.ModeDemand
		brief.BrandID = p.BrandID
		brief.ProductLabel = p.Label
		brief.ProductContext = p.ProductContext
		brief.Pains = pains
		brief.Periphery = excludes
		brief.Intent = p.Label
		brief.ResponseStance = "找正在求助的需求貼文，再決定如何回應"
		brief.ScanTerms = planScanTerms(brief)
		brief.ThemeLabel = truncate(p.Label+" 需求痛點", 36)
		brief.ThemeKey = brief.Mode + "|" + productID
		return brief, nil
	}
	if len(brief.Pains) == 0 {
		// 預設用「求助／求推」語感，避免把產品賣點當搜尋詞
		brief.Pains = []string{intent}
	}
	if len(brief.Tags) == 0 {
		brief.Tags = tokenize(intent)
	}
	// periphery 只當作業備註，不進 scan_terms（否則會污染 Exa 查詢）
	brief.Periphery = []string{"使用情境", "替代方案", "成分／規格"}
	brief.ScanTerms = planScanTerms(brief)
	brief.ThemeLabel = truncate(intent, 36)
	brief.ThemeKey = mode + "|" + productID + "|" + truncate(intent, 48)
	brief.ResponseStance = "先共鳴再給建議"
	return brief, nil
}

func (s *Service) prepareActivityBrief(ctx context.Context, ownerUID int64, intent string, deep bool) (*domain.RunBrief, error) {
	ruleTerms := planActivityTerms(intent)
	// 預設就試 AI 產詞；deep 預留給前端「再想一輪」等同義，不額外收費路徑分叉。
	_ = deep
	aiTerms, err := s.suggestActivityTermsAI(ctx, ownerUID, intent, 8)
	if err != nil {
		// AI 不可用只降級規則，不擋話題工坊。
		logx.Infof("scout activity terms AI unavailable uid=%d: %v", ownerUID, err)
	}
	terms := mergeActivityTerms(aiTerms, ruleTerms, 8)
	if len(terms) == 0 {
		// 最後防線：至少給可搜的核或原句截斷
		if c := compactCore(intent); c != "" {
			terms = []string{c}
		} else {
			terms = []string{truncateRunes(intent, 4)}
		}
	}
	return &domain.RunBrief{
		Intent:         intent,
		Mode:           domain.ModeActivity,
		Pains:          []string{intent},
		Tags:           extractTopicCores(intent),
		Periphery:      []string{},
		ScanTerms:      terms,
		ThemeLabel:     truncate(intent, 36),
		ThemeKey:       domain.ModeActivity + "||" + truncate(intent, 48),
		ResponseStance: "先共鳴再給建議",
	}, nil
}

/*
suggestActivityTermsAI 請模型依意圖產生 Threads 短詞；失敗回 error，呼叫端降級規則。
計費：有 Usage 時走 ai_copy／source=scout.topic.suggest；無 Usage（測試）不扣點。
*/
func (s *Service) suggestActivityTermsAI(ctx context.Context, ownerUID int64, intent string, limit int) (_ []string, err error) {
	if s == nil || (s.ResolveAI == nil && s.AI == nil) {
		return nil, fmt.Errorf("ai not configured")
	}
	if limit <= 0 {
		limit = 8
	}

	// 預留點數（Usage 可空）
	var charged bool
	var mode string
	if s.Usage != nil {
		m, berr := s.Usage.PrepareCall(ctx, ownerUID, usageDomain.MeterAICopy)
		if berr != nil {
			return nil, berr
		}
		mode = m
		charged = true
		defer func() {
			if !charged {
				return
			}
			if err != nil {
				_ = s.Usage.ReleaseCall(ctx, ownerUID, usageDomain.MeterAICopy, mode)
				return
			}
			if _, rerr := s.Usage.RecordCall(ctx, ownerUID, usageDomain.MeterAICopy, mode, "話題關鍵字建議", "scout.topic.suggest"); rerr != nil {
				logx.Errorf("scout topic suggest record uid=%d: %v", ownerUID, rerr)
			}
		}()
	}

	raw, cerr := s.completeActivityAI(ctx, ownerUID, activityTermsPrompt(intent, limit))
	if cerr != nil {
		err = cerr
		return nil, err
	}
	parsed := filterThreadsSearchableTerms(parseActivityAITerms(raw))
	if len(parsed) == 0 {
		err = fmt.Errorf("ai returned no searchable terms")
		return nil, err
	}
	return capTerms(parsed, limit), nil
}

func (s *Service) completeActivityAI(ctx context.Context, ownerUID int64, prompt string) (string, error) {
	if s.ResolveAI != nil && s.AIRegistry != nil {
		provider, model, apiKey, rerr := s.ResolveAI(ctx, ownerUID)
		if rerr != nil {
			return "", rerr
		}
		if strings.TrimSpace(apiKey) == "" || strings.HasPrefix(strings.ToLower(apiKey), "fake") {
			return "", fmt.Errorf("ai key empty")
		}
		c, cerr := s.AIRegistry.Client(provider)
		if cerr != nil {
			return "", cerr
		}
		return c.Complete(ctx, apiKey, model, prompt)
	}
	// 單元測試路徑：未接 ResolveAI 時才用注入的 AI client
	if s.AI != nil {
		return s.AI.Complete(ctx, "test-key", "grok-3", prompt)
	}
	return "", fmt.Errorf("ai not configured")
}

// providerMatchingFields keeps existing products usable: their established match
// tags become matching terms until the more specific provider terms are added.
func providerMatchingFields(p *domain.Product) (pains, capabilities, excludes []string) {
	pains = nonEmptyTerms(p.PainPoints)
	if len(pains) == 0 {
		pains = nonEmptyTerms(p.MatchTags)
	}
	capabilities = nonEmptyTerms(p.ProviderCapabilityTerms)
	if len(capabilities) == 0 {
		capabilities = nonEmptyTerms(p.MatchTags)
	}
	if label := strings.TrimSpace(p.Label); label != "" {
		if len(pains) == 0 {
			pains = []string{label}
		}
		if len(capabilities) == 0 {
			capabilities = []string{label}
		}
	}
	excludes = nonEmptyTerms(p.ProviderExcludeTerms)
	if label := strings.TrimSpace(p.Label); label != "" && !containsTerm(pains, label) && !containsTerm(capabilities, label) {
		excludes = dedupeTerms(excludes, []string{label})
	}
	return pains, capabilities, excludes
}

func containsTerm(terms []string, want string) bool {
	for _, term := range terms {
		if strings.EqualFold(strings.TrimSpace(term), want) {
			return true
		}
	}
	return false
}

func demandMatchingFields(p *domain.Product) (pains, excludes []string) {
	pains = nonEmptyTerms(p.PainPoints)
	if len(pains) == 0 {
		pains = nonEmptyTerms(p.MatchTags)
	}
	if len(pains) == 0 && strings.TrimSpace(p.Label) != "" {
		pains = []string{strings.TrimSpace(p.Label)}
	}
	excludes = nonEmptyTerms(p.ProviderExcludeTerms)
	return pains, excludes
}

// SearchHitsOnly runs the dual-path Threads search without persisting Scout posts.
// Radar reuses this so the crawl split (api vs crawler via dev_mode) stays one code path (RG-01).
//
// Terms are searched one-by-one (fan-out) then deduped by canonical permalink — joining all
// terms into a single query dilutes Threads/Exa recall (daily radar sweep bug).
func (s *Service) SearchHitsOnly(ctx context.Context, ownerUID int64, terms []string, limit int) (hits []ThreadSearchResult, path string, err error) {
	terms = nonEmptyTerms(terms)
	if len(terms) == 0 {
		return nil, "", fmt.Errorf("%w: need search terms", domain.ErrValidation)
	}
	if limit <= 0 {
		limit = 10
	}
	if limit > 40 {
		limit = 40
	}
	// per-query budget mirrors RunScanFromBrief / fanOutSearch.
	perQuery := 8
	if len(terms) == 1 {
		perQuery = 20
	} else if len(terms) >= 6 {
		perQuery = 5
	}
	if perQuery > limit {
		perQuery = limit
	}
	path = domain.PathAPI
	devMode := false
	if s.Settings != nil {
		if d, derr := s.Settings.DevModeEnabled(ctx, ownerUID); derr == nil {
			devMode = d
		}
	}
	if devMode {
		var storageState string
		var serr error
		if s.Repo != nil {
			storageState, serr = s.GetCrawlerSessionToken(ctx, ownerUID)
		} else {
			serr = domain.ErrNoCrawlerSession
		}
		if serr != nil || strings.TrimSpace(storageState) == "" {
			if s.Provider == nil {
				return nil, domain.PathCrawler, domain.ErrNoCrawlerSession
			}
			logx.Infof("scout SearchHitsOnly: no crawler session uid=%d; falling back to api", ownerUID)
		} else if s.Crawler == nil {
			if s.Provider == nil {
				return nil, domain.PathCrawler, fmt.Errorf("Chrome crawler is not configured")
			}
			logx.Infof("scout SearchHitsOnly: crawler not configured uid=%d; falling back to api", ownerUID)
		} else {
			path = domain.PathCrawler
			hits, err = fanOutSearch(ctx, terms, perQuery, func(ctx context.Context, q string, n int) ([]ThreadSearchResult, error) {
				return s.Crawler.SearchChrome(ctx, storageState, []string{q}, n)
			})
			if err == nil {
				hits = fillSearchHitsToTarget(ctx, s, terms, hits, limit, path, storageState)
				return capHits(hits, limit), path, nil
			}
			if s.Provider == nil {
				return nil, path, err
			}
			logx.Errorf("scout SearchHitsOnly: crawler failed uid=%d: %v; falling back to api", ownerUID, err)
		}
	}
	if s.Provider == nil {
		return nil, path, fmt.Errorf("scout search provider is not configured")
	}
	path = domain.PathAPI
	hits, err = fanOutSearch(ctx, terms, perQuery, func(ctx context.Context, q string, n int) ([]ThreadSearchResult, error) {
		return s.Provider.SearchThreads(ctx, []string{q}, n)
	})
	if err != nil {
		return nil, path, err
	}
	hits = fillSearchHitsToTarget(ctx, s, terms, hits, limit, path, "")
	return capHits(hits, limit), path, nil
}

func capHits(hits []ThreadSearchResult, limit int) []ThreadSearchResult {
	if limit > 0 && len(hits) > limit {
		return hits[:limit]
	}
	return hits
}

func (s *Service) RunScanFromBrief(ctx context.Context, ownerUID int64, brief *domain.RunBrief) ([]*domain.Post, error) {
	return s.runScanFromBrief(ctx, ownerUID, brief, "")
}

// RunScanForRun executes the same search pipeline without making results
// visible through the legacy posts collection. Results are staged under the
// running run; PublishRun opens the visibility barrier after the whole scan.
func (s *Service) RunScanForRun(ctx context.Context, ownerUID int64, runID string, brief *domain.RunBrief) ([]*domain.Post, error) {
	if runID == "" {
		return nil, domain.ErrValidation
	}
	posts, err := s.runScanFromBrief(ctx, ownerUID, brief, runID)
	if err != nil {
		return nil, err
	}
	if err := s.StageRunPosts(ctx, ownerUID, runID, posts); err != nil {
		return nil, err
	}
	return posts, nil
}

func (s *Service) runScanFromBrief(ctx context.Context, ownerUID int64, brief *domain.RunBrief, runID string) ([]*domain.Post, error) {
	if brief == nil {
		return nil, fmt.Errorf("%w: nil brief", domain.ErrValidation)
	}
	terms := nonEmptyTerms(brief.ScanTerms)
	if len(terms) == 0 {
		return nil, fmt.Errorf("%w: need scan_terms", domain.ErrValidation)
	}
	if brief.Mode == domain.ModeProvider {
		p, err := s.GetProduct(ctx, ownerUID, brief.ProductID)
		if err != nil {
			return nil, err
		}
		pains, capabilities, excludes := providerMatchingFields(p)
		brief.BrandID = p.BrandID
		brief.ProductLabel = p.Label
		brief.Pains = pains
		brief.Tags = capabilities
		brief.Periphery = excludes
		terms = filterProviderScanTerms(terms, p)
		if len(terms) == 0 {
			return nil, fmt.Errorf("%w: provider scan terms cannot be product/category exclusions", domain.ErrValidation)
		}
	}
	if brief.Mode == domain.ModeDemand {
		p, err := s.GetProduct(ctx, ownerUID, brief.ProductID)
		if err != nil {
			return nil, err
		}
		brief.BrandID = p.BrandID
		brief.ProductLabel = p.Label
		brief.Pains, brief.Periphery = demandMatchingFields(p)
	}
	path := domain.PathAPI
	devMode := false
	if s.Settings != nil {
		if d, err := s.Settings.DevModeEnabled(ctx, ownerUID); err == nil {
			devMode = d
		}
	}
	// 每條關鍵字獨立搜尋再合併（使用者已審過的 scan_terms）
	perQuery := 8
	if len(terms) == 1 {
		perQuery = 20
	} else if len(terms) >= 6 {
		perQuery = 5
	}
	target := brief.TargetCount
	if target < 0 {
		target = 0
	}
	if target > 40 {
		target = 40
	}
	// 有目標時略抬高 per-query，減少第一輪就差很多則。
	if target > 0 {
		needPerTerm := (target + len(terms) - 1) / len(terms)
		if needPerTerm > perQuery {
			perQuery = needPerTerm
		}
		if perQuery > 20 {
			perQuery = 20
		}
	}

	var err error
	var storageState string
	if devMode {
		storageState, err = s.GetCrawlerSessionToken(ctx, ownerUID)
		if err != nil {
			return nil, domain.ErrNoCrawlerSession
		}
		path = domain.PathCrawler
		if s.Crawler == nil {
			return nil, fmt.Errorf("Chrome crawler is not configured")
		}
	} else {
		if s.Provider == nil {
			return nil, fmt.Errorf("scout search provider is not configured")
		}
	}
	pipeline, err := s.searchEligiblePipeline(ctx, ownerUID, terms, brief, target, path, storageState, perQuery)
	if err != nil {
		return nil, err
	}
	return s.persistSearchHitsWithRun(ctx, ownerUID, brief, path, runID, pipeline.Hits)
}

// fillSearchHitsToTarget tops up hits when the first fan-out misses the daily goal.
// 1) Same path with higher per-query  2) If primary is crawler, secondary is search Provider.
func fillSearchHitsToTarget(
	ctx context.Context,
	s *Service,
	terms []string,
	hits []ThreadSearchResult,
	target int,
	primaryPath string,
	crawlerState string,
) []ThreadSearchResult {
	if target <= 0 || len(hits) >= target {
		return hits
	}
	// Stage A: same path, boost per-query (cap 20).
	boost := 20
	need := target - len(hits)
	if need < boost {
		// still request up to boost so sparse terms can contribute
		_ = need
	}
	var more []ThreadSearchResult
	var err error
	if primaryPath == domain.PathCrawler && s.Crawler != nil && crawlerState != "" {
		more, err = fanOutSearch(ctx, terms, boost, func(ctx context.Context, q string, limit int) ([]ThreadSearchResult, error) {
			return s.Crawler.SearchChrome(ctx, crawlerState, []string{q}, limit)
		})
	} else if s.Provider != nil {
		more, err = fanOutSearch(ctx, terms, boost, func(ctx context.Context, q string, limit int) ([]ThreadSearchResult, error) {
			return s.Provider.SearchThreads(ctx, []string{q}, limit)
		})
	}
	if err == nil && len(more) > 0 {
		hits = mergeHitsDedupe(hits, more)
	}
	if len(hits) >= target {
		return hits
	}
	// Stage B: crawler primary → top up with search provider (Exa / Threads-domain search).
	if primaryPath == domain.PathCrawler && s.Provider != nil {
		topup, terr := fanOutSearch(ctx, terms, boost, func(ctx context.Context, q string, limit int) ([]ThreadSearchResult, error) {
			return s.Provider.SearchThreads(ctx, []string{q}, limit)
		})
		if terr == nil && len(topup) > 0 {
			hits = mergeHitsDedupe(hits, topup)
		}
	}
	return hits
}

func mergeHitsDedupe(groups ...[]ThreadSearchResult) []ThreadSearchResult {
	seen := make(map[string]struct{})
	var out []ThreadSearchResult
	for _, group := range groups {
		for _, hit := range group {
			permalink := canonicalPermalink(hit.URL)
			key := canonicalPostIdentity(hit.URL)
			if key == "" {
				key = permalink
			}
			if key == "" {
				continue
			}
			if _, ok := seen[key]; ok {
				continue
			}
			seen[key] = struct{}{}
			if permalink != "" {
				hit.URL = permalink
			}
			out = append(out, hit)
		}
	}
	return out
}

func filterProviderScanTerms(terms []string, p *domain.Product) []string {
	var out []string
	for _, term := range terms {
		term = strings.TrimSpace(term)
		if term == "" || strings.EqualFold(term, p.Label) || hasMatchingTerm(strings.ToLower(term), p.ProviderExcludeTerms) {
			continue
		}
		out = append(out, term)
	}
	return dedupeTerms(out)
}

// fanOutSearch runs one search per query term and dedupes by canonical permalink.
func fanOutSearch(ctx context.Context, terms []string, perQuery int, search func(context.Context, string, int) ([]ThreadSearchResult, error)) ([]ThreadSearchResult, error) {
	if perQuery < 1 {
		perQuery = 5
	}
	seen := make(map[string]struct{})
	out := make([]ThreadSearchResult, 0, len(terms)*perQuery)
	var firstErr error
	for _, term := range terms {
		hits, err := search(ctx, term, perQuery)
		if err != nil {
			if firstErr == nil {
				firstErr = err
			}
			if isCrawlerSessionDead(err) {
				break
			}
			continue
		}
		for _, hit := range hits {
			permalink := canonicalPermalink(hit.URL)
			identity := canonicalPostIdentity(hit.URL)
			if identity == "" || permalink == "" {
				continue
			}
			if _, ok := seen[identity]; ok {
				continue
			}
			seen[identity] = struct{}{}
			hit.URL = permalink
			// 記住是哪條 query 命中，方便 search_tag
			if hit.MatchedQuery == "" {
				hit.MatchedQuery = term
			}
			out = append(out, hit)
		}
	}
	if len(out) == 0 && firstErr != nil {
		return nil, firstErr
	}
	return out, nil
}

func (s *Service) persistSearchHits(ctx context.Context, ownerUID int64, brief *domain.RunBrief, path string, hits []ThreadSearchResult) ([]*domain.Post, error) {
	return s.persistSearchHitsWithRun(ctx, ownerUID, brief, path, "", hits)
}

func (s *Service) persistSearchHitsWithRun(ctx context.Context, ownerUID int64, brief *domain.RunBrief, path, runID string, hits []ThreadSearchResult) ([]*domain.Post, error) {
	now := domain.NowNano()
	// Provider 原始順序只用於 deterministic created_at；最終結果由
	// evaluator score 排序，不能再被來源的時間／track 順序覆蓋。
	hasTrack := hitsHaveTrack(hits)
	out := make([]*domain.Post, 0, len(hits))
	evaluator := NewCandidateEvaluator(brief)
	evaluator.SetHistoricalSeenLookup(func(identity string) (bool, error) {
		return s.Repo.HasSeenIdentity(ctx, ownerUID, identity)
	})
	for i, hit := range hits {
		evaluation := evaluator.EvaluateAt(hit, now)
		if evaluation.Decision != CandidateEligible {
			continue
		}
		text := evaluation.Text
		permalink := evaluation.Permalink
		postedAt := evaluation.PostedAt
		// created_at：保 crawler 回傳序（i 越小越前）；有發文時間仍寫 PostedAt 供 UI
		createdAt := now - int64(i)*1000
		if postedAt > 0 && !hasTrack {
			// 非 crawler 路徑仍用發文時間當 created 序
			createdAt = postedAt
		}
		p := &domain.Post{
			ID: permalinkID(ownerUID, permalink), ExternalID: permalink, Permalink: permalink,
			OwnerUID: ownerUID, RunID: runID, BrandID: brief.BrandID, Author: authorFromThreadsURL(permalink), Text: text,
			SearchTag: evaluation.SearchTag, Opportunity: "", OutreachStatus: domain.OutreachNew,
			Score: evaluation.Score, Classification: evaluation.Classification, MatchedProductID: brief.ProductID, MatchedProductLabel: brief.ProductLabel,
			MatchReason: evaluation.Reason, ScoutMode: brief.Mode, IntentSnippet: brief.Intent,
			ThemeKey: brief.ThemeKey, ThemeLabel: brief.ThemeLabel, ScanPath: path,
			PostedAt: postedAt, CreatedAt: createdAt,
		}
		if runID == "" {
			if err := s.Repo.SavePost(ctx, p); err != nil {
				return nil, err
			}
		}
		out = append(out, p)
	}
	// Score is the primary quality order. Posted/created time and ID only make
	// equal scores deterministic; an old but highly relevant post can therefore
	// still be returned ahead of a newer weak match.
	sortPostsByScore(out)
	return out, nil
}

func sortPostsByScore(posts []*domain.Post) {
	sort.SliceStable(posts, func(i, j int) bool {
		a, b := posts[i], posts[j]
		if a.Score != b.Score {
			return a.Score > b.Score
		}
		// 有發文時間者優先，其次新到舊；ID 收尾保證結果穩定可重現
		if (a.PostedAt > 0) != (b.PostedAt > 0) {
			return a.PostedAt > 0
		}
		if a.PostedAt > 0 && a.PostedAt != b.PostedAt {
			return a.PostedAt > b.PostedAt
		}
		if a.CreatedAt != b.CreatedAt {
			return a.CreatedAt > b.CreatedAt
		}
		return a.ID > b.ID
	})
}

func hitsHaveTrack(hits []ThreadSearchResult) bool {
	for _, h := range hits {
		if h.Track != "" {
			return true
		}
	}
	return false
}

// searchAnchors 是產詞常用的口語後綴，不能單獨當「主題相關」依據。
var searchAnchors = map[string]bool{
	"求推薦": true, "推薦": true, "分享": true, "心得": true,
	"活動": true, "怎麼辦": true, "詢問": true, "討論": true,
	"有人知道": true, "請問": true,
	"熱門": true, "最新": true, "近期": true,
}

// textMatchesSearchTerm：正文須含查詢的主題核（非口語錨點）。
// 「外包 求推薦」→ 必須含「外包」；純「求推薦」才允許只命中錨點。
func textMatchesSearchTerm(text, term string) bool {
	body := normalizeSignal(text)
	if body == "" || strings.TrimSpace(term) == "" {
		return false
	}
	tokens := strings.Fields(strings.TrimSpace(term))
	if len(tokens) == 0 {
		tokens = []string{strings.TrimSpace(term)}
	}
	var content, anchors []string
	for _, tok := range tokens {
		t := strings.ToLower(strings.TrimSpace(tok))
		if t == "" || utf8.RuneCountInString(t) < 2 {
			continue
		}
		if searchAnchors[t] {
			anchors = append(anchors, t)
		} else {
			content = append(content, strings.ReplaceAll(t, " ", ""))
		}
	}
	// 有主題核：每個主題核都要命中；錨點只是搜尋修飾，不要求正文出現。
	// 「後端 外包」不能因為只提到「外包」就混入其他職種。
	if len(content) > 0 {
		for _, c := range content {
			if !strings.Contains(body, c) {
				return false
			}
		}
		return true
	}
	// 只有錨點：放寬（使用者刻意只搜「求推薦」）
	for _, a := range anchors {
		if strings.Contains(body, strings.ReplaceAll(a, " ", "")) {
			return true
		}
	}
	// 無可用 token：不誤殺
	return true
}

func matchingSearchTerm(text string, terms []string) string {
	for _, term := range terms {
		if term = strings.TrimSpace(term); term != "" && textMatchesSearchTerm(text, term) {
			return term
		}
	}
	return ""
}

func maxInt(a, b int) int {
	if a > b {
		return a
	}
	return b
}

func minInt(a, b int) int {
	if a < b {
		return a
	}
	return b
}

func sortHitsByTrackAndPostedAt(hits []ThreadSearchResult) {
	trackRank := func(t string) int {
		switch t {
		case "both":
			return 0
		case "recent":
			return 1
		case "top":
			return 2
		default:
			return 3
		}
	}
	sort.SliceStable(hits, func(i, j int) bool {
		ri, rj := trackRank(hits[i].Track), trackRank(hits[j].Track)
		if ri != rj {
			return ri < rj
		}
		// 同軌內新到舊；沒有發布時間的排最後
		ai, aj := hits[i].PublishedAt, hits[j].PublishedAt
		if ai == 0 || aj == 0 {
			return ai != 0
		}
		return ai > aj
	})
}

func authorFromThreadsURL(raw string) string {
	u, err := url.Parse(raw)
	if err != nil {
		return ""
	}
	for _, segment := range strings.Split(u.Path, "/") {
		segment = strings.TrimPrefix(strings.TrimSpace(segment), "@")
		if segment != "" {
			return segment
		}
	}
	return ""
}

func (s *Service) ListPosts(ctx context.Context, ownerUID int64, brandID string) ([]*domain.Post, error) {
	posts, err := s.Repo.ListPosts(ctx, ownerUID, brandID)
	if err != nil {
		return nil, err
	}
	return dedupePostsByIdentity(posts), nil
}

// dedupePostsByIdentity hides legacy duplicates that were stored before IDs
// were based on Threads post shortcodes. It is read-only: no user history is deleted.
func dedupePostsByIdentity(posts []*domain.Post) []*domain.Post {
	seen := make(map[string]int, len(posts))
	out := make([]*domain.Post, 0, len(posts))
	for _, post := range posts {
		if post == nil {
			continue
		}
		key := canonicalPostIdentity(post.Permalink)
		if key == "" {
			key = canonicalPostIdentity(post.ExternalID)
		}
		if key == "" {
			key = "id:" + post.ID
		}
		if idx, ok := seen[key]; ok {
			if preferScoutPost(post, out[idx]) {
				out[idx] = post
			}
			continue
		}
		seen[key] = len(out)
		out = append(out, post)
	}
	sortPostsByScore(out)
	return out
}

func preferScoutPost(candidate, current *domain.Post) bool {
	statusRank := func(status string) int {
		switch status {
		case domain.OutreachPublished:
			return 5
		case domain.OutreachQueued:
			return 4
		case domain.OutreachDrafted, domain.OutreachSkipped:
			return 3
		case domain.OutreachNew:
			return 1
		default:
			return 0
		}
	}
	if a, b := statusRank(candidate.OutreachStatus), statusRank(current.OutreachStatus); a != b {
		return a > b
	}
	if candidate.CreatedAt != current.CreatedAt {
		return candidate.CreatedAt > current.CreatedAt
	}
	return candidate.Score > current.Score
}

func (s *Service) DraftOutreach(ctx context.Context, ownerUID int64, postID, personaID string) (*domain.Post, error) {
	_ = personaID
	p, err := s.getPostOwned(ctx, ownerUID, postID)
	if err != nil {
		return nil, err
	}
	if p.ScoutMode == domain.ModeProvider {
		return nil, fmt.Errorf("%w: provider matches do not support outreach drafts", domain.ErrValidation)
	}
	draft := "嗨 @" + p.Author + "，看到你提到「" + p.SearchTag + "」，我也遇過類似情況。若你願意，想聽聽你後來怎麼處理。"
	if p.ScoutMode == domain.ModeActivity {
		draft = "嗨 @" + p.Author + "，這個「" + p.SearchTag + "」很有意思。你自己最在意哪一部分？"
	}
	p.DraftText = draft
	p.OutreachStatus = domain.OutreachDrafted
	if err := s.Repo.SavePost(ctx, p); err != nil {
		return nil, err
	}
	return p, nil
}

func (s *Service) SkipOutreach(ctx context.Context, ownerUID int64, postID string) (*domain.Post, error) {
	p, err := s.getPostOwned(ctx, ownerUID, postID)
	if err != nil {
		return nil, err
	}
	p.OutreachStatus = domain.OutreachSkipped
	if err := s.Repo.SavePost(ctx, p); err != nil {
		return nil, err
	}
	return p, nil
}

func (s *Service) MarkPublished(ctx context.Context, ownerUID int64, postID string) (*domain.Post, error) {
	p, err := s.getPostOwned(ctx, ownerUID, postID)
	if err != nil {
		return nil, err
	}
	if p.ScoutMode == domain.ModeProvider {
		return nil, fmt.Errorf("%w: provider matches cannot be marked as outreach", domain.ErrValidation)
	}
	p.OutreachStatus = domain.OutreachPublished
	if err := s.Repo.SavePost(ctx, p); err != nil {
		return nil, err
	}
	// growth-loop: brand learning bump when brand known
	if p.BrandID != "" {
		if b, berr := s.Repo.GetBrand(ctx, p.BrandID); berr == nil && b != nil && b.OwnerUID == ownerUID {
			b.LearningVersion++
			b.LearnedFromPostsCount++
			b.LastLearnedAt = domain.NowNano()
			b.LearningSummary = fmt.Sprintf("海巡外展累積 %d 則 · 知識 v%d", b.LearnedFromPostsCount, b.LearningVersion)
			b.UpdatedAt = domain.NowNano()
			_ = s.Repo.SaveBrand(ctx, b)
		}
	}
	if s.OnOutreachPublished != nil {
		s.OnOutreachPublished(ctx, ownerUID, postID, "")
	}
	return p, nil
}

func (s *Service) SendOutreach(ctx context.Context, ownerUID int64, postID, text, accountID string) (*domain.Post, error) {
	p, err := s.getPostOwned(ctx, ownerUID, postID)
	if err != nil {
		return nil, err
	}
	if p.ScoutMode == domain.ModeProvider {
		return nil, fmt.Errorf("%w: provider matches do not support outreach", domain.ErrValidation)
	}
	text = strings.TrimSpace(text)
	if text == "" {
		text = p.DraftText
	}
	if text == "" {
		return nil, fmt.Errorf("%w: empty outreach text", domain.ErrValidation)
	}
	if s.ReplyQueue == nil {
		return nil, fmt.Errorf("Scout reply Outbox is not configured")
	}
	mediaID := p.ExternalID
	// A permalink can be resolved to a real Threads media ID before the official
	// API sends. This also refreshes any legacy IDs that were not Graph media IDs.
	if strings.TrimSpace(p.Permalink) != "" && s.Crawler != nil {
		state, err := s.GetCrawlerSessionToken(ctx, ownerUID)
		if err != nil {
			return nil, domain.ErrNoCrawlerSession
		}
		mediaID, err = s.Crawler.ResolveMediaID(ctx, state, p.Permalink)
		if err != nil {
			return nil, fmt.Errorf("%w: unable to resolve the target Threads post: %v", domain.ErrValidation, err)
		}
	}
	if !isNumericMediaID(mediaID) {
		if s.Crawler == nil {
			return nil, fmt.Errorf("%w: target Threads media ID is not resolved; configure Chrome crawler", domain.ErrValidation)
		}
		state, err := s.GetCrawlerSessionToken(ctx, ownerUID)
		if err != nil {
			return nil, domain.ErrNoCrawlerSession
		}
		mediaID, err = s.Crawler.ResolveMediaID(ctx, state, p.Permalink)
		if err != nil {
			return nil, fmt.Errorf("%w: unable to resolve the target Threads post: %v", domain.ErrValidation, err)
		}
	}
	p.ExternalID = mediaID
	outboxID, err := s.ReplyQueue.QueueExternalReply(ctx, ownerUID, accountID, mediaID, text, "Scout 回覆 · "+truncate(p.Author, 30))
	if err != nil {
		return nil, err
	}
	p.DraftText = text
	p.OutboxID = outboxID
	p.OutreachStatus = domain.OutreachQueued
	if err := s.Repo.SavePost(ctx, p); err != nil {
		return nil, err
	}
	return p, nil
}

/*
ResolveMediaID turns a Threads permalink into a numeric Graph media ID via the
configured Chrome crawler. Already-numeric input is returned unchanged.

Exported so other modules (radar 商機回覆一鍵送出) can reuse the same resolver
instead of re-implementing crawler session handling.
*/
func (s *Service) ResolveMediaID(ctx context.Context, ownerUID int64, permalink string) (string, error) {
	if isNumericMediaID(permalink) {
		return permalink, nil
	}
	if s.Crawler == nil {
		return "", fmt.Errorf("%w: target Threads media ID is not resolved; configure Chrome crawler", domain.ErrValidation)
	}
	state, err := s.GetCrawlerSessionToken(ctx, ownerUID)
	if err != nil {
		return "", domain.ErrNoCrawlerSession
	}
	mediaID, err := s.Crawler.ResolveMediaID(ctx, state, permalink)
	if err != nil {
		return "", fmt.Errorf("%w: unable to resolve the target Threads post: %v", domain.ErrValidation, err)
	}
	return mediaID, nil
}

func isNumericMediaID(value string) bool {
	if value == "" {
		return false
	}
	for _, ch := range value {
		if ch < '0' || ch > '9' {
			return false
		}
	}
	return true
}

func (s *Service) RemovePost(ctx context.Context, ownerUID int64, postID string) error {
	if _, err := s.getPostOwned(ctx, ownerUID, postID); err != nil {
		return err
	}
	return s.Repo.DeletePost(ctx, postID)
}

func (s *Service) RemoveTheme(ctx context.Context, ownerUID int64, themeKey string) error {
	if err := s.Repo.DeletePostsByTheme(ctx, ownerUID, themeKey); err != nil {
		return err
	}
	// A patrol batch owns both its hits and its research snapshot.
	if err := s.Repo.DeleteHomework(ctx, ownerUID, themeKey); err != nil && err != domain.ErrNotFound {
		return err
	}
	return nil
}

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

func (s *Service) SaveHomework(ctx context.Context, ownerUID int64, h *domain.Homework) (*domain.Homework, error) {
	if h.ThemeKey == "" {
		return nil, fmt.Errorf("%w: theme_key required", domain.ErrValidation)
	}
	h.OwnerUID = ownerUID
	if h.CreatedAt == 0 {
		h.CreatedAt = domain.NowNano()
	}
	if err := s.Repo.SaveHomework(ctx, h); err != nil {
		return nil, err
	}
	return h, nil
}

func (s *Service) GetHomework(ctx context.Context, ownerUID int64, themeKey string) (*domain.Homework, error) {
	return s.Repo.GetHomework(ctx, ownerUID, themeKey)
}

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

func (s *Service) SetCrawlerSession(ctx context.Context, ownerUID int64, storageState string) error {
	expiresAt, err := validateStorageState(storageState)
	if err != nil {
		return err
	}
	if s.SessionSecret == "" {
		return fmt.Errorf("%w: crawler session secret is required", domain.ErrValidation)
	}
	storageStateEnc, err := threadsDomain.Seal(s.SessionSecret, storageState)
	if err != nil {
		return err
	}
	now := domain.NowNano()
	return s.Repo.SetCrawlerSession(ctx, &domain.CrawlerSession{
		OwnerUID: ownerUID, StorageStateEnc: storageStateEnc, UpdatedAt: now, ExpiresAt: expiresAt,
	})
}

// GetCrawlerSessionToken returns decrypted Playwright storageState JSON for browser crawl.
func (s *Service) GetCrawlerSessionToken(ctx context.Context, ownerUID int64) (string, error) {
	sess, err := s.Repo.GetCrawlerSession(ctx, ownerUID)
	if err != nil {
		return "", err
	}
	if sess == nil || sess.StorageStateEnc == "" || (sess.ExpiresAt > 0 && sess.ExpiresAt <= domain.NowNano()) {
		return "", domain.ErrNoCrawlerSession
	}
	if s.SessionSecret == "" {
		return "", fmt.Errorf("%w: crawler session secret is required", domain.ErrValidation)
	}
	storageState, err := threadsDomain.Open(s.SessionSecret, sess.StorageStateEnc)
	if err != nil {
		return "", err
	}
	return storageState, nil
}

type storageStateCookie struct {
	Domain  string  `json:"domain"`
	Expires float64 `json:"expires"`
}

func validateStorageState(storageState string) (int64, error) {
	if len(storageState) > 256*1024 {
		return 0, fmt.Errorf("%w: storage state exceeds 256KB", domain.ErrValidation)
	}
	var state map[string]json.RawMessage
	if err := json.Unmarshal([]byte(storageState), &state); err != nil || state == nil {
		return 0, fmt.Errorf("%w: storage state must be a JSON object", domain.ErrValidation)
	}
	rawCookies, ok := state["cookies"]
	if !ok {
		return 0, fmt.Errorf("%w: cookies required", domain.ErrValidation)
	}
	var cookies []storageStateCookie
	if err := json.Unmarshal(rawCookies, &cookies); err != nil || len(cookies) == 0 {
		return 0, fmt.Errorf("%w: cookies must be a nonempty array", domain.ErrValidation)
	}
	var expiresAt int64
	for _, cookie := range cookies {
		domainName := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(cookie.Domain)), ".")
		if !allowedCookieDomain(domainName) {
			return 0, fmt.Errorf("%w: cookie domain %q is not allowed", domain.ErrValidation, cookie.Domain)
		}
		if cookie.Expires > 0 {
			expires := int64(cookie.Expires * float64(time.Second))
			if expiresAt == 0 || expires < expiresAt {
				expiresAt = expires
			}
		}
	}
	return expiresAt, nil
}

func allowedCookieDomain(domainName string) bool {
	for _, allowed := range []string{"threads.net", "threads.com", "instagram.com", "facebook.com"} {
		if domainName == allowed || strings.HasSuffix(domainName, "."+allowed) {
			return true
		}
	}
	return false
}

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

// TopicRemoved — SC: ScoutTopic CRUD not implemented
func (s *Service) TopicRemoved() error { return domain.ErrTopicRemoved }

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

// GetPost returns one owned scout post (promote / detail).
func (s *Service) GetPost(ctx context.Context, ownerUID int64, id string) (*domain.Post, error) {
	return s.getPostOwned(ctx, ownerUID, id)
}

func tokenize(s string) []string {
	parts := strings.FieldsFunc(s, func(r rune) bool {
		return r == ' ' || r == '、' || r == ',' || r == '/'
	})
	if len(parts) == 0 {
		return []string{s}
	}
	if len(parts) > 4 {
		parts = parts[:4]
	}
	return parts
}

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