package usecase

import (
	"sort"
	"strings"
	"unicode"
	"unicode/utf8"
)

// TopicConcept is one necessary concept group in a topic. A candidate must
// match at least one core or approved alias from every group.
type TopicConcept struct {
	Name    string
	Cores   []string
	Aliases []string
}

// TopicSignature is the hard relevance contract for one scan. Search
// modifiers and conversation anchors are intentionally not concepts.
type TopicSignature struct {
	Intent   string
	Concepts []TopicConcept
	Anchors  []string
}

// TopicMatch is deliberately small: callers need the decision, matched
// concepts for diagnostics, and missing concepts for shortfall reasons.
type TopicMatch struct {
	Matched       bool
	MatchedCores  []string
	MissingGroups []string
	Reason        string
}

// NewTopicSignature builds a signature from the original intent and the
// approved search terms. Approved terms may expose an explicit alias (for
// example "鬼滅" for "鬼滅之刃") or split a compact intent into concepts (for
// example "台北 市集" for "台北週末市集").
func NewTopicSignature(intent string, approvedTerms []string) TopicSignature {
	intent = normalizeTopicTerm(intent)
	sig := TopicSignature{Intent: intent, Anchors: append([]string(nil), topicAnchors...)}
	intentParts := semanticTopicParts(intent)
	naturalIntent := len(intentParts) == 1 && isNaturalTopicIntent(intent)
	if naturalIntent {
		if derived := deriveNaturalTopicParts(intent, approvedTerms); len(derived) > 0 {
			intentParts = derived
		}
	}
	approvedParts := make([]string, 0)
	approvedTermParts := make([][]string, 0, len(approvedTerms))
	for _, term := range approvedTerms {
		parts := semanticTopicParts(term)
		if len(parts) == 0 {
			continue
		}
		approvedTermParts = append(approvedTermParts, parts)
		approvedParts = append(approvedParts, parts...)
	}
	approvedParts = uniqueTopicTerms(approvedParts)

	// A compact long phrase such as 台北週末市集 is often not present
	// contiguously in a post. When approved terms explicitly split it, use the
	// split concepts instead of making the unsplittable phrase mandatory. Named
	// entities (鬼滅之刃) remain one concept because their approved form is
	// still a single term.
	splitApproved := !naturalIntent && len(intentParts) == 1 && len(approvedParts) > 1 &&
		!containsTopicTerm(approvedParts, intentParts[0])
	keepIntentParts := !splitApproved
	if keepIntentParts {
		for _, part := range intentParts {
			sig.addConcept(part, "")
		}
	}
	for _, termParts := range approvedTermParts {
		// A multi-concept query ("後端 外包") is a conjunction, not an
		// alias declaration. Treating each token as an alias would let a post
		// mentioning only "後端" pass a signature that requires "後端工程師".
		explicitAlias := len(termParts) == 1
		for _, part := range termParts {
			if isTopicAnchor(part) {
				continue
			}
			matched := false
			for i := range sig.Concepts {
				concept := &sig.Concepts[i]
				for _, core := range concept.Cores {
					if topicEquivalent(part, core) {
						matched = true
						continue
					}
					// Work-intent vocabulary is a bounded synonym group. A
					// user asking for 接案 should still see a post that says
					// 外包, but this never turns arbitrary query fragments into
					// aliases for a role or named entity.
					if isWorkTopicTerm(core) && isWorkTopicTerm(part) {
						concept.addAlias(part)
						matched = true
						continue
					}
					// Only a single-token approved term explicitly grants an
					// alias. Never infer aliases from a conjunction's fragments.
					if explicitAlias && topicContains(core, part) && topicRuneCount(part) >= 2 {
						concept.addAlias(part)
						matched = true
					}
				}
			}
			if !matched && splitApproved {
				sig.addConcept(part, "")
			}
		}
	}
	// No usable concept means the input contained only generic anchors. It is
	// safer to reject every candidate than to turn "推薦" into a topic.
	return sig
}

// BuildTopicSignature is kept as a descriptive alias for callers that prefer
// a builder-style name.
func BuildTopicSignature(intent string, approvedTerms []string) TopicSignature {
	return NewTopicSignature(intent, approvedTerms)
}

func (s *TopicSignature) addConcept(core, alias string) {
	core = normalizeTopicTerm(core)
	if core == "" || isTopicAnchor(core) {
		return
	}
	for i := range s.Concepts {
		if topicEquivalent(s.Concepts[i].Name, core) {
			if alias != "" {
				s.Concepts[i].addAlias(alias)
			}
			return
		}
	}
	c := TopicConcept{Name: core, Cores: []string{core}}
	if alias != "" {
		c.addAlias(alias)
	}
	s.Concepts = append(s.Concepts, c)
}

func (c *TopicConcept) addAlias(alias string) {
	alias = normalizeTopicTerm(alias)
	if alias == "" || isTopicAnchor(alias) || topicEquivalent(alias, c.Name) {
		return
	}
	for _, existing := range c.Aliases {
		if topicEquivalent(existing, alias) {
			return
		}
	}
	c.Aliases = append(c.Aliases, alias)
}

// Match applies the all-concept hard gate. Matching is case-insensitive and
// whitespace-insensitive, which handles CJK and Latin terms consistently.
func (s TopicSignature) Match(text string) TopicMatch {
	body := normalizeTopicText(text)
	match := TopicMatch{Matched: len(s.Concepts) > 0}
	for _, concept := range s.Concepts {
		matched := ""
		candidates := append(append([]string(nil), concept.Cores...), concept.Aliases...)
		for _, candidate := range candidates {
			candidate = normalizeTopicText(candidate)
			if candidate != "" && strings.Contains(body, candidate) {
				matched = candidate
				break
			}
		}
		if matched == "" {
			match.Matched = false
			match.MissingGroups = append(match.MissingGroups, concept.Name)
			continue
		}
		match.MatchedCores = append(match.MatchedCores, matched)
	}
	if len(match.MatchedCores) > 0 {
		match.Reason = "matched core: " + strings.Join(match.MatchedCores, ", ")
	}
	if len(match.MissingGroups) > 0 {
		if match.Reason != "" {
			match.Reason += "; "
		}
		match.Reason += "missing core: " + strings.Join(match.MissingGroups, ", ")
	}
	return match
}

func (s TopicSignature) Matches(text string) bool { return s.Match(text).Matched }

var topicAnchors = []string{
	"求推薦", "推薦", "分享", "心得", "活動", "怎麼辦", "詢問", "討論",
	"有人知道", "請問", "請問一下", "求助", "有沒有", "有沒有人", "有人也",
	"熱門", "最新", "近期",
}

func isTopicAnchor(term string) bool {
	term = normalizeTopicText(term)
	for _, anchor := range topicAnchors {
		if term == normalizeTopicText(anchor) {
			return true
		}
	}
	return false
}

func semanticTopicParts(raw string) []string {
	raw = normalizeTopicTerm(raw)
	if raw == "" {
		return nil
	}
	fields := strings.FieldsFunc(raw, func(r rune) bool {
		return unicode.IsSpace(r) || strings.ContainsRune(",，、/|·。！？!?：:；;（）()【】[]", r)
	})
	parts := make([]string, 0, len(fields))
	for _, field := range fields {
		field = normalizeTopicTerm(field)
		if field == "" || isTopicAnchor(field) || isTopicModifier(field) {
			continue
		}
		parts = append(parts, field)
	}
	return uniqueTopicTerms(parts)
}

// Natural activity input is often a sentence ("想找後端工程師接案"), while
// approved search terms are short variants. Treating that whole sentence as
// one mandatory contiguous core makes every real post fail the gate. Derive
// concepts from the approved terms instead, but keep the gate conjunctive.
func isNaturalTopicIntent(intent string) bool {
	if len(splitTitleCores(intent)) > 0 {
		return false
	}
	if topicRuneCount(intent) > 8 {
		return true
	}
	body := normalizeTopicText(intent)
	for _, marker := range naturalSentenceMarkers {
		if strings.Contains(body, normalizeTopicText(marker)) {
			return true
		}
	}
	for _, marker := range activityWorkMarkers {
		if strings.Contains(body, normalizeTopicText(marker)) {
			return true
		}
	}
	return false
}

var naturalTopicFillers = []string{
	"想找", "想看", "想問", "我想", "我要", "需要", "可以", "有沒有", "有人",
	"適合", "什麼", "怎麼", "如何", "最近", "換季", "週末", "這週", "本週",
	"真的", "求推薦", "推薦", "分享", "心得", "活動", "討論", "請問", "哪裡", "哪家", "附近", "有", "找", "拍", "看看",
}

var naturalSentenceMarkers = []string{
	"想找", "想看", "想問", "我想", "我要", "需要", "可以", "有沒有", "有人",
	"適合", "什麼", "怎麼", "如何", "最近", "換季", "真的", "求推薦", "推薦", "分享", "心得", "活動", "討論", "請問", "哪裡", "哪家", "附近", "有", "找", "拍", "看看",
}

func deriveNaturalTopicParts(intent string, approvedTerms []string) []string {
	body := normalizeTopicText(intent)
	type candidate struct {
		term  string
		count int
	}
	candidates := make([]candidate, 0, 8)
	index := make(map[string]int)
	add := func(raw string) {
		raw = cleanNaturalTopicPart(raw)
		key := normalizeTopicText(raw)
		if key == "" || topicRuneCount(raw) < 2 || !strings.Contains(body, key) {
			return
		}
		if i, ok := index[key]; ok {
			candidates[i].count++
			return
		}
		index[key] = len(candidates)
		candidates = append(candidates, candidate{term: raw, count: 1})
	}
	for _, region := range activityRegions {
		if strings.Contains(body, normalizeTopicText(region)) {
			add(region)
			if i, ok := index[normalizeTopicText(region)]; ok {
				candidates[i].count = 2
			}
		}
	}
	// Preserve explicit work/role vocabulary from the user's sentence even if
	// they unchecked the corresponding generated query variant.
	knownIntentTerms := append([]string{}, activityWorkMarkers...)
	knownIntentTerms = append(knownIntentTerms, activityRoleSpecialties...)
	knownIntentTerms = append(knownIntentTerms, "工程師")
	for _, term := range knownIntentTerms {
		if strings.Contains(body, normalizeTopicText(term)) {
			add(term)
			if i, ok := index[normalizeTopicText(term)]; ok {
				candidates[i].count = 2
			}
		}
	}
	for _, term := range approvedTerms {
		for _, part := range semanticTopicParts(term) {
			add(part)
		}
	}

	// Prefer compact, repeated concepts over one-off sliding-window fragments
	// generated from a sentence. Known work vocabulary may legitimately occur
	// in only one approved conjunction (for example 接案).
	out := make([]string, 0, len(candidates))
	for _, c := range candidates {
		if c.count < 2 && !isKnownNaturalTopicTerm(c.term) {
			continue
		}
		out = append(out, c.term)
	}
	if len(out) == 0 {
		for _, part := range semanticTopicParts(intent) {
			if cleaned := cleanNaturalTopicPart(part); cleaned != "" {
				out = append(out, cleaned)
			}
		}
	}
	return pruneContainedTopicParts(uniqueTopicTerms(out))
}

func cleanNaturalTopicPart(raw string) string {
	raw = normalizeTopicTerm(raw)
	for _, filler := range naturalTopicFillers {
		raw = strings.ReplaceAll(raw, filler, "")
	}
	for _, modifier := range []string{"熱門", "最新", "近期"} {
		raw = strings.ReplaceAll(raw, modifier, "")
	}
	return normalizeTopicTerm(raw)
}

func isKnownNaturalTopicTerm(term string) bool {
	term = normalizeTopicText(term)
	if term == "工程師" {
		return true
	}
	for _, group := range [][]string{activityWorkMarkers, activityRoleSpecialties, activityRegions} {
		for _, known := range group {
			if topicEquivalent(term, known) {
				return true
			}
		}
	}
	return false
}

func isWorkTopicTerm(term string) bool {
	term = normalizeTopicText(term)
	for _, known := range activityWorkMarkers {
		if normalizeTopicText(known) == term {
			return true
		}
	}
	return false
}

func pruneContainedTopicParts(parts []string) []string {
	keep := make([]string, 0, len(parts))
	for _, part := range parts {
		partKey := normalizeTopicText(part)
		contained := false
		for _, other := range parts {
			otherKey := normalizeTopicText(other)
			if otherKey == partKey || topicRuneCount(other) <= topicRuneCount(part) {
				continue
			}
			if strings.Contains(otherKey, partKey) && !isKnownNaturalTopicTerm(part) {
				contained = true
				break
			}
		}
		if !contained {
			keep = append(keep, part)
		}
	}
	return keep
}

func isTopicModifier(term string) bool {
	term = normalizeTopicText(term)
	return term == "熱門" || term == "最新" || term == "近期"
}

func normalizeTopicTerm(raw string) string {
	raw = strings.ReplaceAll(raw, "\u3000", " ")
	return strings.Join(strings.Fields(strings.TrimSpace(raw)), " ")
}

func normalizeTopicText(raw string) string {
	return strings.ReplaceAll(strings.ToLower(normalizeTopicTerm(raw)), " ", "")
}

func uniqueTopicTerms(in []string) []string {
	seen := make(map[string]struct{}, len(in))
	out := make([]string, 0, len(in))
	for _, term := range in {
		term = normalizeTopicTerm(term)
		key := normalizeTopicText(term)
		if key == "" {
			continue
		}
		if _, ok := seen[key]; ok {
			continue
		}
		seen[key] = struct{}{}
		out = append(out, term)
	}
	return out
}

func containsTopicTerm(terms []string, want string) bool {
	want = normalizeTopicText(want)
	for _, term := range terms {
		if normalizeTopicText(term) == want {
			return true
		}
	}
	return false
}

func topicEquivalent(a, b string) bool { return normalizeTopicText(a) == normalizeTopicText(b) }

func topicContains(container, part string) bool {
	return strings.Contains(normalizeTopicText(container), normalizeTopicText(part))
}

func topicRuneCount(term string) int { return utf8.RuneCountInString(normalizeTopicText(term)) }

// Keep deterministic output if a caller serializes concepts for diagnostics.
func (s TopicSignature) SortConcepts() {
	sort.SliceStable(s.Concepts, func(i, j int) bool { return s.Concepts[i].Name < s.Concepts[j].Name })
}
