package usecase

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"strings"

	"apps/backend/internal/module/radar/domain"
	usageDomain "apps/backend/internal/module/usage/domain"

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

// productSuggestPrompt keeps the product path traceable: the model may suggest
// wording, but every item has to point back to one concrete catalog field.
func productSuggestPrompt(p *ProductContextSnapshot, limit int) string {
	var b strings.Builder
	b.WriteString("你是產品需求巡邏的關鍵字助理。請找正在描述需求、困擾或求助的人，不要找品牌宣傳或同業廣告。\n")
	b.WriteString("只根據以下產品上下文產出短搜尋詞，不要只用品牌名或產品名：\n")
	f := func(label string, values []string) {
		if len(values) == 0 {
			return
		}
		b.WriteString(label + "：" + strings.Join(values, "、") + "\n")
	}
	b.WriteString("品牌（僅供語境）：「" + strings.TrimSpace(p.BrandName) + "」\n")
	b.WriteString("產品（僅供語境）：「" + strings.TrimSpace(p.ProductLabel) + "」\n")
	f("目標受眾", []string{p.TargetAudience})
	f("痛點", p.PainPoints)
	f("情境", []string{p.ProductContext})
	f("標籤", p.MatchTags)
	f("可提供能力", p.ProviderCapabilityTerms)
	f("排除詞", p.ProviderExcludeTerms)
	b.WriteString(fmt.Sprintf("最多輸出 %d 則 JSON 陣列，每則格式：", limit))
	b.WriteString(`[{"term":"短詞","reason":"為什麼能找到需求者","usage":"include 或 exclude","basis_kind":"audience／pain／context／tag／capability／exclude","basis_text":"對應的原始上下文"}]`)
	b.WriteString("\n規則：include 每則都必須有 basis_kind、basis_text；短詞最多兩個 token、中文每 token 2–4 字、去空格後最多 12 字；禁止標點、引號、AND/OR、emoji、#。exclude 的 basis_kind 必須是 exclude。品牌名與產品名不能是唯一理由。\n")
	return b.String()
}

/*
suggestPrompt 組建議關鍵字的提示。

有服務檔案就帶服務項目、價格、案例、地區、禁語；沒有也能給通用求助短詞。
*/
func suggestPrompt(p *domain.ServiceProfile, limit int, extra []string) string {
	var b strings.Builder
	b.WriteString("你是台灣本地服務業的行銷助理。提出可用於社群平台搜尋的關鍵字，")
	b.WriteString("目標是找到「正在找這類服務的人」發的貼文，不是找同業的宣傳文。\n\n")
	if p == nil {
		b.WriteString("使用者尚未填服務檔案。請產出台灣 Threads 上常見的求助／求推薦短搜尋詞。\n")
	} else {
		b.WriteString("服務項目：\n")
		for _, s := range p.Services {
			b.WriteString("- " + s.Name)
			if s.PriceMin > 0 || s.PriceMax > 0 {
				b.WriteString(fmt.Sprintf("（價格區間 %.0f–%.0f %s）", s.PriceMin, s.PriceMax, s.Currency))
			}
			b.WriteString("\n")
		}

		if len(p.ServiceAreas) > 0 {
			labels := make([]string, 0, len(p.ServiceAreas))
			for _, code := range p.ServiceAreas {
				if label := domain.ServiceAreaLabel(code); label != "" {
					labels = append(labels, label)
				}
			}
			b.WriteString("服務地區：" + strings.Join(labels, "、") + "\n")
		}
		if p.RemoteOk {
			b.WriteString("可遠端服務。\n")
		}
		if len(p.Cases) > 0 {
			b.WriteString("代表案例：\n")
			for _, c := range p.Cases {
				b.WriteString("- " + c.Title)
				if c.Summary != "" {
					b.WriteString("：" + c.Summary)
				}
				b.WriteString("\n")
			}
		}
		if len(p.Faq) > 0 {
			b.WriteString("客戶常問：\n")
			for _, f := range p.Faq {
				b.WriteString("- " + f.Question + "\n")
			}
		}
		if len(p.Forbidden) > 0 {
			// 禁語是回覆生成的硬性過濾詞，順手也不該出現在關鍵字裡。
			b.WriteString("不可使用的字詞：" + strings.Join(p.Forbidden, "、") + "\n")
		}
		if p.ToneNote != "" {
			b.WriteString("語氣備註：" + p.ToneNote + "\n")
		}
	}
	if len(extra) > 0 {
		// 既有痛點關鍵字工具的產出當素材，不另建第二套關鍵字引擎（T514 決策）。
		b.WriteString("既有痛點關鍵字（可參考、可調整）：" + strings.Join(extra, "、") + "\n")
	}

	b.WriteString(fmt.Sprintf("\n請輸出最多 %d 則建議，只輸出 JSON 陣列，不要有其他文字。每則格式：\n", limit))
	b.WriteString(`[{"term":"關鍵字","reason":"為什麼這個詞能找到有需求的人（一句話）","usage":"include 或 exclude"}]`)
	b.WriteString("\n規則：\n")
	b.WriteString("1. include 是要搜尋的詞；exclude 是要排除的詞（例如同業叫賣、徵才、二手轉讓）。\n")
	b.WriteString("2. 用台灣的實際說法，包含口語求助句式：求推薦、有人知道、請問、怎麼辦、哪裡買。\n")
	b.WriteString("3. 每則都要有理由，理由講人話，不要覆述關鍵字本身。\n")
	b.WriteString("4. 不要輸出價格數字或聯絡方式。\n")
	b.WriteString("5. 【Threads 短詞硬約束｜include 必守】每則 term 最多 2 個詞（半形空格分隔）；")
	b.WriteString("中文每詞 2–4 字；整組去掉空格後 ≤12 字；禁止標點、引號、AND/OR、-、emoji、#。\n")
	b.WriteString("6. 每個服務意圖給 3–5 組短變體（例：「保母 求推薦」「到府保母」「台北 保母」），不要長句。\n")
	return b.String()
}

/*
PainTermSource 提供既有痛點關鍵字工具的產出當 prompt 素材（可空）。

刻意做成可選：接不上時建議品質下降，但功能不會壞，也不會冒出第二套關鍵字引擎。
*/
type PainTermSource interface {
	PainTerms(ctx context.Context, ownerUID int64) ([]string, error)
}

/*
SuggestWatchTerms 回關鍵字建議（RW-03）。

服務檔案是加分項，不是門檻。AI 不可用時改走通用短詞，避免「建議關鍵字」變成下一扇牆。
不自動寫入任何 watch：使用者逐條採用才有意義。
計費走既有 ai_copy meter，source 標 radar.suggest（spec §5.5）；fallback 不扣點。
*/
func (s *Service) SuggestWatchTerms(ctx context.Context, ownerUID int64, limit int) (_ []domain.WatchTermSuggestion, err error) {
	if ownerUID <= 0 {
		return nil, fmt.Errorf("%w: owner_uid required", domain.ErrValidation)
	}
	if limit <= 0 {
		limit = domain.DefaultSuggestions
	}
	if limit > domain.MaxSuggestions {
		limit = domain.MaxSuggestions
	}

	profile, err := s.Repo.GetServiceProfile(ctx, ownerUID)
	if err != nil && !errors.Is(err, domain.ErrNotFound) {
		return nil, err
	}
	if errors.Is(err, domain.ErrNotFound) {
		profile = nil
	}

	var extra []string
	if s.PainTerms != nil {
		if terms, perr := s.PainTerms.PainTerms(ctx, ownerUID); perr != nil {
			// 素材拿不到只影響建議品質，不該讓整個請求失敗。
			logx.Errorf("radar suggest: pain terms unavailable uid=%d: %v", ownerUID, perr)
		} else {
			extra = terms
		}
	}

	charge, err := s.bill(ctx, ownerUID, usageDomain.MeterAICopy, "雷達關鍵字建議", "radar.suggest")
	if err != nil {
		return nil, err
	}
	defer charge.Settle(ctx, &err)

	raw, aiErr := s.completeAI(ctx, ownerUID, suggestPrompt(profile, limit, extra))
	var out []domain.WatchTermSuggestion
	if aiErr == nil {
		out = domain.CleanSuggestions(parseSuggestions(raw), limit)
	} else {
		logx.Errorf("radar suggest: AI unavailable uid=%d: %v; using fallback", ownerUID, aiErr)
	}
	if len(out) == 0 {
		out = genericSuggestFallback(profile, extra, limit)
	}
	if len(out) == 0 {
		if aiErr != nil {
			return nil, aiErr
		}
		return nil, fmt.Errorf("%w: AI 沒有回傳可用的關鍵字建議，請稍後再試", domain.ErrValidation)
	}
	if aiErr != nil {
		// 沒真正用到模型：退點，避免「系統自己給的詞還收一次」。
		charge.Release(ctx)
	}
	return out, nil
}

func genericSuggestFallback(p *domain.ServiceProfile, extra []string, limit int) []domain.WatchTermSuggestion {
	raw := make([]domain.WatchTermSuggestion, 0, limit+8)
	add := func(term, reason, usage string) {
		term = strings.TrimSpace(term)
		reason = strings.TrimSpace(reason)
		if term == "" || reason == "" {
			return
		}
		raw = append(raw, domain.WatchTermSuggestion{Term: term, Reason: reason, Usage: usage})
	}
	if p != nil {
		for _, item := range p.Services {
			name := strings.TrimSpace(item.Name)
			if name == "" {
				continue
			}
			for _, term := range domain.SearchableTermVariants(name, true) {
				add(term, "依你填的服務「"+name+"」找正在問的人", domain.SuggestUsageInclude)
			}
		}
	}
	for _, term := range extra {
		basis := strings.TrimSpace(term)
		if basis == "" {
			continue
		}
		for _, v := range domain.SearchableTermVariants(basis, true) {
			add(v, "依你已有的痛點詞「"+basis+"」", domain.SuggestUsageInclude)
		}
	}
	add("求推薦", "台灣 Threads 上找服務最常這樣問", domain.SuggestUsageInclude)
	add("有人知道", "口語求助句，能找到正在發問的人", domain.SuggestUsageInclude)
	add("求建議", "正在比較或猶豫的人常這樣寫", domain.SuggestUsageInclude)
	add("哪裡找", "明確在找店家或服務的人", domain.SuggestUsageInclude)
	add("怎麼辦", "遇到問題在求助的人常這樣寫", domain.SuggestUsageInclude)
	add("徵才", "招募文不是客人", domain.SuggestUsageExclude)
	add("接案", "同業供給文，不是需求", domain.SuggestUsageExclude)
	return domain.CleanSuggestions(raw, limit)
}

// SuggestProductWatchTerms suggests demand terms from a paired Brand/Product
// snapshot. If the AI provider is unavailable, the catalog's own structured
// fields are used as a deterministic, traceable fallback.
func (s *Service) SuggestProductWatchTerms(ctx context.Context, ownerUID int64, brandID, productID string, limit int) (_ []domain.WatchTermSuggestion, err error) {
	if ownerUID <= 0 {
		return nil, fmt.Errorf("%w: owner_uid required", domain.ErrValidation)
	}
	brandID = strings.TrimSpace(brandID)
	productID = strings.TrimSpace(productID)
	if brandID == "" || productID == "" {
		return nil, fmt.Errorf("%w: paired brand_id and product_id required", domain.ErrValidation)
	}
	if limit <= 0 {
		limit = domain.DefaultSuggestions
	}
	if limit > domain.MaxSuggestions {
		limit = domain.MaxSuggestions
	}
	ctxSnapshot, err := s.LoadProductContext(ctx, ownerUID, brandID, productID)
	if err != nil {
		return nil, err
	}

	charge, err := s.bill(ctx, ownerUID, usageDomain.MeterAICopy, "產品需求詞建議", "radar.suggest")
	if err != nil {
		return nil, err
	}
	defer charge.Settle(ctx, &err)

	var out []domain.WatchTermSuggestion
	if raw, aiErr := s.completeAI(ctx, ownerUID, productSuggestPrompt(ctxSnapshot, limit)); aiErr == nil {
		out = cleanProductSuggestions(domain.CleanSuggestions(parseSuggestions(raw), limit), limit)
		if len(out) == 0 {
			logx.Errorf("radar product suggest: AI returned no traceable terms uid=%d; using catalog fallback", ownerUID)
		}
	} else {
		logx.Errorf("radar product suggest: AI unavailable uid=%d: %v; using catalog fallback", ownerUID, aiErr)
	}
	if len(out) == 0 {
		out = productSuggestionFallback(ctxSnapshot, limit)
	}
	if len(out) == 0 {
		return nil, fmt.Errorf("%w: 產品上下文沒有可搜尋的需求詞，請先補充痛點、情境、標籤或能力", domain.ErrValidation)
	}
	return out, nil
}

func cleanProductSuggestions(in []domain.WatchTermSuggestion, limit int) []domain.WatchTermSuggestion {
	out := make([]domain.WatchTermSuggestion, 0, len(in))
	for _, item := range in {
		item.BasisKind = strings.ToLower(strings.TrimSpace(item.BasisKind))
		item.BasisText = strings.TrimSpace(item.BasisText)
		if !domain.IsSuggestionBasisKind(item.BasisKind) || item.BasisText == "" {
			continue
		}
		if item.Usage == domain.SuggestUsageInclude && item.BasisKind == domain.SuggestBasisExclude {
			continue
		}
		if item.Usage == domain.SuggestUsageExclude && item.BasisKind != domain.SuggestBasisExclude {
			continue
		}
		out = append(out, item)
		if len(out) >= limit {
			break
		}
	}
	return out
}

type productSuggestionField struct {
	kind, label string
	terms       []string
	usage       string
}

func productSuggestionFallback(p *ProductContextSnapshot, limit int) []domain.WatchTermSuggestion {
	fields := []productSuggestionField{
		{domain.SuggestBasisPain, "痛點", p.PainPoints, domain.SuggestUsageInclude},
		{domain.SuggestBasisTag, "標籤", p.MatchTags, domain.SuggestUsageInclude},
		{domain.SuggestBasisCapability, "能力", p.ProviderCapabilityTerms, domain.SuggestUsageInclude},
		{domain.SuggestBasisAudience, "受眾", []string{p.TargetAudience}, domain.SuggestUsageInclude},
		{domain.SuggestBasisContext, "情境", []string{p.ProductContext}, domain.SuggestUsageInclude},
		{domain.SuggestBasisExclude, "排除詞", p.ProviderExcludeTerms, domain.SuggestUsageExclude},
	}
	out := make([]domain.WatchTermSuggestion, 0, limit)
	seen := map[string]bool{}
	for _, field := range fields {
		for _, raw := range field.terms {
			basis := strings.TrimSpace(raw)
			if basis == "" {
				continue
			}
			for _, term := range productSearchTermVariants(basis, field.usage == domain.SuggestUsageInclude) {
				key := strings.ToLower(term + "\x00" + field.usage)
				if seen[key] {
					continue
				}
				seen[key] = true
				reason := fmt.Sprintf("依產品設定的%s「%s」，可找相關需求貼文", field.label, basis)
				if field.usage == domain.SuggestUsageExclude {
					reason = fmt.Sprintf("產品設定將「%s」列為排除詞，避免混入非目標貼文", basis)
				}
				out = append(out, domain.WatchTermSuggestion{Term: term, Reason: reason, Usage: field.usage, BasisKind: field.kind, BasisText: basis})
				if len(out) >= limit {
					return out
				}
			}
		}
	}
	return out
}

// productSearchTermVariants keeps fallback terms short without inventing
// product names. Exact short fields win; longer CJK fields yield small windows.
func productSearchTermVariants(raw string, include bool) []string {
	return domain.SearchableTermVariants(raw, include)
}

/*
parseSuggestions 容忍模型在 JSON 前後多寫字或包上 code fence。

只截第一個 `[` 到最後一個 `]`：模型偶爾會加開場白，硬要求純 JSON 會讓整個功能
在那些回應上直接壞掉，而這裡的資料形狀很簡單，容忍不會引入歧義。
*/
func parseSuggestions(raw string) []domain.WatchTermSuggestion {
	start := strings.Index(raw, "[")
	end := strings.LastIndex(raw, "]")
	if start < 0 || end <= start {
		return nil
	}
	var list []domain.WatchTermSuggestion
	if err := json.Unmarshal([]byte(raw[start:end+1]), &list); err != nil {
		return nil
	}
	return list
}

func (s *Service) completeAI(ctx context.Context, ownerUID int64, prompt string) (string, error) {
	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") {
			if c, cerr := s.AIRegistry.Client(provider); cerr == nil {
				return c.Complete(ctx, apiKey, model, prompt)
			}
		}
	}
	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
			}
		}
		return s.AI.Complete(ctx, key, "grok-3", prompt)
	}
	return "", fmt.Errorf("%w: 請到設定填寫 AI Key", domain.ErrValidation)
}
