package placement

import (
	"strings"
	"time"
	"unicode"

	libkg "haixun-backend/internal/library/knowledge"
)

const (
	minPostBodyProductFit         = 50
	minPostBodyProductFitDiscover = 28
	minPostProductFitPatrol       = 58
	minPostProductFitDiscover     = 34
	minPostProductFitRelevant     = 62
	minPostProductFitRelaxedFinal = 38
	minPostProductFitIngestFinal  = 28
	minPostPillarAnchor           = 1
	patrolMaxPostAgeDays          = IdealMaxPostAgeDays
	relevanceMaxPostAgeDays       = 14
)

// PostScanContext carries research map + product signals for post-level filtering.
type PostScanContext struct {
	MatchTags       []string
	ProductName     string
	ProductFeatures string
	AudienceSummary string
	TargetAudience  string
	Questions       []string
	Pillars         []string
	Exclusions      []string
}

func PostScanContextFromPatrolInput(in libkg.PatrolTagInput, exclusions []string) PostScanContext {
	return PostScanContext{
		MatchTags:       append([]string{}, in.MatchTags...),
		ProductName:     strings.TrimSpace(in.ProductName),
		ProductFeatures: strings.TrimSpace(in.ProductFeatures),
		AudienceSummary: strings.TrimSpace(in.AudienceSummary),
		TargetAudience:  strings.TrimSpace(in.TargetAudience),
		Questions:       append([]string{}, in.Questions...),
		Pillars:         append([]string{}, in.Pillars...),
		Exclusions:      ExpandExclusionTokens(exclusions),
	}
}

func ScorePostProductFit(text, searchTag string, ctx PostScanContext) int {
	text = strings.ToLower(strings.TrimSpace(text))
	if text == "" {
		return 0
	}
	tagScore := ScoreProductForTag(searchTag, ctx.MatchTags)
	if tagScore == 0 && ctx.ProductName != "" {
		tagScore = ScoreProductForTag(searchTag, []string{ctx.ProductName})
	}
	postScore := ScoreProductForTag(text, ctx.MatchTags)
	if postScore == 0 && ctx.ProductName != "" {
		postScore = scoreTextAgainstPhrase(text, ctx.ProductName)
	}
	if ctx.ProductFeatures != "" {
		postScore = maxInt(postScore, scoreTextAgainstPhrase(text, ctx.ProductFeatures))
	}
	for _, pillar := range ctx.Pillars {
		postScore = maxInt(postScore, scoreTextAgainstPhrase(text, pillar)/2)
	}
	combined := postScore
	if tagScore > 0 && postScore > 0 {
		combined = (tagScore*2 + postScore*3) / 5
	} else if postScore > 0 {
		combined = postScore
	} else if tagScore > 0 {
		combined = tagScore / 2
	}
	if HasCategoryConflict(text, ctx) {
		return minInt(combined, 25)
	}
	if HasTangentialTopicMismatch(text, ctx) {
		return minInt(combined, 20)
	}
	intentScore := LocalIntentScore(text, ctx)
	if intentScore > combined {
		combined = (combined*2 + intentScore*3) / 5
	}
	return combined
}

// PassesDiscoverFilters is the crawl-stage gate: collect candidates from Search/Threads API.
// Stricter ranking happens at finalize.
func PassesDiscoverFilters(text, searchTag, postedAt string, dimension QueryDimension, tagFit, recencyDays int, ctx PostScanContext) bool {
	text = strings.TrimSpace(text)
	if text == "" {
		return false
	}
	if MatchesExclusion(text, ctx.Exclusions) {
		return false
	}
	if HasCategoryConflict(text, ctx) {
		return false
	}
	if HasTangentialTopicMismatch(text, ctx) {
		return false
	}
	if !HasPainPointDemand(text) && !HasPlacementIntent(text) {
		return false
	}
	if !PassesPostAge(postedAt, dimension, recencyDays) {
		return false
	}
	bodyFit := ScorePostBodyProductFit(text, ctx)
	effectiveFit := bodyFit
	if tagFit > 0 && bodyFit > 0 {
		effectiveFit = (tagFit + bodyFit*2) / 3
	} else if tagFit > 0 && HasPlacementIntent(text) {
		effectiveFit = tagFit / 2
	}
	if bodyFit < minPostBodyProductFitDiscover {
		if !(HasPlacementIntent(text) && effectiveFit >= minPostProductFitIngestFinal) {
			return false
		}
	}
	return effectiveFit >= minPostProductFitDiscover ||
		(HasPlacementIntent(text) && effectiveFit >= minPostProductFitIngestFinal)
}

func PassesPostScanFilters(text, searchTag, postedAt string, dimension QueryDimension, tagFit int, ctx PostScanContext) bool {
	text = strings.TrimSpace(text)
	if text == "" {
		return false
	}
	if MatchesExclusion(text, ctx.Exclusions) {
		return false
	}
	if HasCategoryConflict(text, ctx) {
		return false
	}
	if HasTangentialTopicMismatch(text, ctx) {
		return false
	}
	if !HasPainPointDemand(text) {
		return false
	}
	if !PassesPostAge(postedAt, dimension, 0) {
		return false
	}
	bodyFit := ScorePostBodyProductFit(text, ctx)
	if bodyFit < minPostBodyProductFit {
		return false
	}
	if !ProductSolvesPostPain(text, ctx) {
		return false
	}
	effectiveFit := bodyFit
	if tagFit > 0 && bodyFit > 0 {
		effectiveFit = (tagFit + bodyFit*2) / 3
	}
	minFit := minPostProductFitPatrol
	if dimension == QueryRelevance {
		minFit = minPostProductFitRelevant
	}
	return effectiveFit >= minFit
}

// ScorePostBodyProductFit scores only the post body against the product offer (ignores search tag).
func ScorePostBodyProductFit(text string, ctx PostScanContext) int {
	return ScorePostProductFit(text, "", ctx)
}

// ProductSolvesPostPain checks pain in the post aligns with what the product can solve.
func ProductSolvesPostPain(text string, ctx PostScanContext) bool {
	if HasCategoryConflict(text, ctx) {
		return false
	}
	if HasTangentialTopicMismatch(text, ctx) {
		return false
	}
	if matchesTopicAnchor(text, ctx) {
		return true
	}
	bodyFit := ScorePostBodyProductFit(text, ctx)
	if bodyFit < minPostBodyProductFit {
		return false
	}
	for _, q := range ctx.Questions {
		if tokenHits(text, q) >= 2 && bodyFit >= minPostBodyProductFit {
			return true
		}
	}
	if ctx.ProductName != "" && scoreTextAgainstPhrase(text, ctx.ProductName) >= 55 {
		return true
	}
	for _, tag := range ctx.MatchTags {
		if scoreTextAgainstPhrase(text, tag) >= 55 {
			return true
		}
	}
	return LocalIntentScore(text, ctx) >= minPostBodyProductFit
}

// PostSolvedByProduct is the final flag for UI: demand + fit + no mismatch.
func PostSolvedByProduct(text string, effectiveFit int, ctx PostScanContext) bool {
	return effectiveFit >= minPostProductFitPatrol &&
		HasPainPointDemand(text) &&
		ProductSolvesPostPain(text, ctx) &&
		!HasCategoryConflict(text, ctx) &&
		!HasTangentialTopicMismatch(text, ctx)
}

// PassesRelaxedFinalFilters keeps demand + basic fit when strict pass yields zero results.
func PassesRelaxedFinalFilters(text string, effectiveFit int, ctx PostScanContext) bool {
	text = strings.TrimSpace(text)
	if text == "" {
		return false
	}
	if MatchesExclusion(text, ctx.Exclusions) {
		return false
	}
	if HasCategoryConflict(text, ctx) {
		return false
	}
	if HasTangentialTopicMismatch(text, ctx) {
		return false
	}
	if !HasPainPointDemand(text) && !HasPlacementIntent(text) {
		return false
	}
	return effectiveFit >= minPostProductFitRelaxedFinal
}

// PassesIngestFallbackFilters keeps basic searchable posts when strict/relaxed gates yield zero.
func PassesIngestFallbackFilters(text string, effectiveFit int, ctx PostScanContext) bool {
	text = strings.TrimSpace(text)
	if text == "" {
		return false
	}
	if MatchesExclusion(text, ctx.Exclusions) {
		return false
	}
	if HasCategoryConflict(text, ctx) {
		return false
	}
	if !HasPlacementIntent(text) && !HasPainPointDemand(text) {
		return false
	}
	return effectiveFit >= minPostProductFitIngestFinal
}

func matchesTopicAnchor(text string, ctx PostScanContext) bool {
	text = strings.ToLower(text)
	hits := 0
	for _, pillar := range ctx.Pillars {
		if tokenHits(text, pillar) > 0 {
			hits++
		}
	}
	for _, q := range ctx.Questions {
		if tokenHits(text, q) >= 2 {
			hits++
		}
	}
	if hits >= minPostPillarAnchor {
		return true
	}
	for _, tag := range ctx.MatchTags {
		if tokenHits(text, tag) > 0 {
			return true
		}
	}
	if ctx.ProductName != "" && tokenHits(text, ctx.ProductName) > 0 {
		return true
	}
	for _, phrase := range []string{ctx.AudienceSummary, ctx.TargetAudience} {
		if tokenHits(text, phrase) >= 2 {
			return true
		}
	}
	return false
}

func PassesPostAge(postedAt string, dimension QueryDimension, recencyDays int) bool {
	postedAt = strings.TrimSpace(postedAt)
	if postedAt == "" {
		// Web Search snippets often lack dates; do not drop at ingest.
		return true
	}
	maxDays := patrolMaxPostAgeDays
	switch dimension {
	case QueryRelevance:
		maxDays = relevanceMaxPostAgeDays
	case QueryRecency:
		if recencyDays > 0 {
			maxDays = recencyDays
		}
	}
	return IsPostWithinMaxAge(postedAt, maxDays, time.Now())
}

func IsPostWithinMaxAge(postedAt string, maxDays int, now time.Time) bool {
	if maxDays <= 0 {
		return true
	}
	ts, ok := ParsePostedAt(postedAt)
	if !ok {
		return true
	}
	if now.IsZero() {
		now = time.Now()
	}
	cutoff := now.AddDate(0, 0, -maxDays)
	return !ts.Before(cutoff)
}

func ParsePostedAt(raw string) (time.Time, bool) {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return time.Time{}, false
	}
	layouts := []string{
		time.RFC3339,
		"2006-01-02T15:04:05Z07:00",
		"2006-01-02 15:04:05",
		"2006-01-02",
		"2006/01/02",
	}
	for _, layout := range layouts {
		if ts, err := time.Parse(layout, raw); err == nil {
			return ts, true
		}
	}
	return time.Time{}, false
}

func ExpandExclusionTokens(exclusions []string) []string {
	seen := map[string]struct{}{}
	out := []string{}
	add := func(token string) {
		token = strings.TrimSpace(strings.ToLower(token))
		if len([]rune(token)) < 2 {
			return
		}
		if _, ok := seen[token]; ok {
			return
		}
		seen[token] = struct{}{}
		out = append(out, token)
	}
	for _, rule := range exclusions {
		rule = strings.TrimSpace(rule)
		if rule == "" {
			continue
		}
		add(rule)
		for _, token := range tokenizePhrase(rule) {
			add(token)
		}
		for _, synonym := range exclusionSynonyms(rule) {
			add(synonym)
		}
	}
	return out
}

func exclusionSynonyms(rule string) []string {
	rule = strings.ToLower(rule)
	out := []string{}
	for _, group := range exclusionCategoryGroups {
		if strings.Contains(rule, group.key) {
			out = append(out, group.tokens...)
		}
	}
	return out
}

var exclusionCategoryGroups = []struct {
	key    string
	tokens []string
}{
	{key: "洗碗", tokens: []string{"洗碗", "洗碗精", "洗碗機", "碗盤", "廚房清潔", "油污"}},
	{key: "洗衣", tokens: []string{"洗衣", "洗衣精", "洗衣粉", "衣物", "衣服清洗", "污漬"}},
	{key: "沐浴", tokens: []string{"沐浴", "沐浴乳", "沐浴露", "洗澡"}},
	{key: "洗髮", tokens: []string{"洗髮", "洗髮精", "頭皮", "髮品"}},
	{key: "寵物", tokens: []string{"寵物", "貓砂", "狗糧", "貓咪", "狗狗"}},
	{key: "廚房", tokens: []string{"廚房", "抽油煙機", "鍋具"}},
}

var productCategoryGroups = []struct {
	key    string
	tokens []string
}{
	{key: "洗衣", tokens: []string{"洗衣", "洗衣精", "洗衣粉", "衣物", "衣服", "污漬", "漂白"}},
	{key: "洗碗", tokens: []string{"洗碗", "洗碗精", "洗碗機", "碗盤", "廚房", "油污"}},
	{key: "沐浴", tokens: []string{"沐浴", "沐浴乳", "沐浴露", "洗澡", "沖澡"}},
	{key: "洗髮", tokens: []string{"洗髮", "洗髮精", "洗髮乳", "頭皮", "髮絲"}},
	{key: "清潔", tokens: []string{"清潔", "打掃", "去污"}},
}

func productCategories(ctx PostScanContext) []string {
	blob := strings.ToLower(ctx.ProductName + " " + ctx.ProductFeatures + " " + strings.Join(ctx.MatchTags, " "))
	found := []string{}
	for _, group := range productCategoryGroups {
		for _, token := range group.tokens {
			if strings.Contains(blob, token) {
				found = append(found, group.key)
				break
			}
		}
	}
	return found
}

func postCategories(text string) []string {
	text = strings.ToLower(text)
	found := []string{}
	for _, group := range productCategoryGroups {
		for _, token := range group.tokens {
			if strings.Contains(text, token) {
				found = append(found, group.key)
				break
			}
		}
	}
	return found
}

var genericProductCategories = map[string]struct{}{
	"清潔": {},
}

func HasCategoryConflict(text string, ctx PostScanContext) bool {
	productCats := productCategories(ctx)
	if len(productCats) == 0 {
		return false
	}
	postCats := postCategories(text)
	if len(postCats) == 0 {
		return false
	}
	productSet := map[string]struct{}{}
	for _, c := range productCats {
		productSet[c] = struct{}{}
	}
	for _, c := range postCats {
		if _, ok := genericProductCategories[c]; ok {
			continue
		}
		if _, ok := productSet[c]; !ok {
			return true
		}
	}
	return false
}

func scoreTextAgainstPhrase(text, phrase string) int {
	text = strings.ToLower(text)
	phrase = strings.TrimSpace(strings.ToLower(phrase))
	if text == "" || phrase == "" {
		return 0
	}
	if strings.Contains(text, phrase) {
		return 85
	}
	best := 0
	for _, token := range tokenizePhrase(phrase) {
		if len([]rune(token)) < 2 {
			continue
		}
		if strings.Contains(text, token) {
			best = maxInt(best, 55)
		}
	}
	return best
}

func tokenHits(text, phrase string) int {
	text = strings.ToLower(text)
	hits := 0
	for _, token := range tokenizePhrase(phrase) {
		if len([]rune(token)) < 2 {
			continue
		}
		if strings.Contains(text, token) {
			hits++
		}
	}
	return hits
}

func tokenizePhrase(phrase string) []string {
	phrase = strings.ToLower(strings.TrimSpace(phrase))
	if phrase == "" {
		return nil
	}
	repl := strings.NewReplacer("，", " ", "、", " ", "。", " ", "；", " ", "/", " ", "｜", " ", "|", " ", "（", " ", "）", " ", "(", " ", ")", " ")
	phrase = repl.Replace(phrase)
	var tokens []string
	for _, part := range strings.Fields(phrase) {
		part = strings.Trim(part, `"'「」『』：:`)
		if part != "" {
			tokens = append(tokens, part)
		}
	}
	if len(tokens) == 0 {
		return splitRunes(phrase)
	}
	return tokens
}

func splitRunes(s string) []string {
	var out []string
	var buf []rune
	flush := func() {
		if len(buf) >= 2 {
			out = append(out, string(buf))
		}
		buf = buf[:0]
	}
	for _, r := range s {
		if unicode.Is(unicode.Han, r) {
			buf = append(buf, r)
			continue
		}
		flush()
	}
	flush()
	return out
}

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