package usecase

import (
	"strings"

	"apps/backend/internal/module/scout/domain"
)

type CandidateDecision string

const (
	CandidateEligible   CandidateDecision = "eligible"
	CandidateDuplicate  CandidateDecision = "duplicate"
	CandidateIrrelevant CandidateDecision = "irrelevant"
)

// CandidateStats describes mutually exclusive decisions made by one
// evaluator. Searched is incremented for every raw candidate presented to it.
type CandidateStats struct {
	Searched   int
	Duplicate  int
	Irrelevant int
	Eligible   int
}

type CandidateEvaluation struct {
	Decision       CandidateDecision
	Identity       string
	Permalink      string
	Text           string
	SearchTag      string
	PostedAt       int64
	Classification string
	Score          int
	Reason         string
	TopicMatch     TopicMatch
}

// CandidateEvaluator is the single relevance gate used before persistence.
// Historical identity checks are intentionally left to T042; this evaluator
// only handles canonical duplicates in the current candidate set.
type CandidateEvaluator struct {
	brief          *domain.RunBrief
	signature      TopicSignature
	seen           map[string]struct{}
	historicalSeen func(identity string) (bool, error)
	stats          CandidateStats
}

func NewCandidateEvaluator(brief *domain.RunBrief) *CandidateEvaluator {
	if brief == nil {
		brief = &domain.RunBrief{}
	}
	signature := NewTopicSignature(brief.Intent, brief.ScanTerms)
	// Provider/demand briefs intentionally replace Intent with a product label;
	// their approved pain/capability terms are the real evidence. Keep their
	// established mode-specific gates until those modes get their own signature
	// contract, while activity/product/theme retain the original intent gate.
	if brief.Mode == domain.ModeProvider || brief.Mode == domain.ModeDemand {
		signature = TopicSignature{}
	}
	return &CandidateEvaluator{brief: brief, signature: signature, seen: make(map[string]struct{})}
}

func (e *CandidateEvaluator) Stats() CandidateStats {
	if e == nil {
		return CandidateStats{}
	}
	return e.stats
}

// SetHistoricalSeenLookup injects the owner-scoped repository check. The
// callback receives the canonical identity, never a raw provider URL.
func (e *CandidateEvaluator) SetHistoricalSeenLookup(check func(identity string) (bool, error)) {
	if e != nil {
		e.historicalSeen = check
	}
}

func (e *CandidateEvaluator) Evaluate(hit ThreadSearchResult) CandidateEvaluation {
	return e.EvaluateAt(hit, domain.NowNano())
}

func (e *CandidateEvaluator) EvaluateAt(hit ThreadSearchResult, now int64) CandidateEvaluation {
	if e == nil {
		return CandidateEvaluation{Decision: CandidateIrrelevant, Reason: "evaluator unavailable"}
	}
	e.stats.Searched++
	text := strings.TrimSpace(hit.Snippet)
	if text == "" {
		// Some providers return a title without highlights/text. It is still
		// usable evidence when the title carries the complete topic phrase.
		text = strings.TrimSpace(hit.Title)
	}
	permalink := canonicalPermalink(hit.URL)
	identity := canonicalPostIdentity(hit.URL)
	result := CandidateEvaluation{
		Decision:  CandidateIrrelevant,
		Identity:  identity,
		Permalink: permalink,
		Text:      text,
		PostedAt:  hit.PublishedAt,
	}
	if text == "" || permalink == "" || identity == "" {
		e.stats.Irrelevant++
		result.Reason = "missing canonical URL or post text"
		return result
	}
	if _, exists := e.seen[identity]; exists {
		e.stats.Duplicate++
		result.Decision = CandidateDuplicate
		result.Reason = "duplicate canonical identity: " + identity
		return result
	}
	if e.historicalSeen != nil {
		seen, err := e.historicalSeen(identity)
		if err != nil {
			e.stats.Irrelevant++
			result.Reason = "historical identity lookup failed"
			return result
		}
		if seen {
			// Historical duplicates are terminal. Mark them locally so a provider
			// returning the same post twice does not repeat the repository lookup.
			e.seen[identity] = struct{}{}
			e.stats.Duplicate++
			result.Decision = CandidateDuplicate
			result.Reason = "duplicate historical identity: " + identity
			return result
		}
	}
	body := text + " " + strings.TrimSpace(hit.Title)
	if len(e.signature.Concepts) > 0 {
		result.TopicMatch = e.signature.Match(body)
		if !result.TopicMatch.Matched {
			e.stats.Irrelevant++
			result.Reason = result.TopicMatch.Reason
			return result
		}
	} else {
		// Compatibility for briefs that only carry approved scan terms. A
		// signature with no concepts still must not admit an empty query or turn
		// a generic anchor such as "推薦" into a topic.
		if e.brief.Mode == domain.ModeActivity && len(semanticTopicParts(e.brief.Intent)) == 0 {
			var hasTopicPart bool
			for _, term := range e.brief.ScanTerms {
				if len(semanticTopicParts(term)) > 0 {
					hasTopicPart = true
					break
				}
			}
			if !hasTopicPart {
				e.stats.Irrelevant++
				result.Reason = "no approved topic core matched"
				return result
			}
		}
		matched := matchingSearchTerm(body, e.brief.ScanTerms)
		if matched == "" {
			e.stats.Irrelevant++
			result.Reason = "no approved topic core matched"
			return result
		}
		result.TopicMatch = TopicMatch{Matched: true, MatchedCores: []string{matched}, Reason: "matched core: " + matched}
	}

	classified := classifyPost(e.brief.Mode, body, e.brief.ScanTerms)
	if e.brief.Mode == domain.ModeProvider {
		classified = classifyProvider(body, e.brief.Pains, e.brief.Tags, e.brief.Periphery)
	}
	if e.brief.Mode == domain.ModeDemand {
		classified = classifyDemand(body, e.brief.Pains, e.brief.Periphery)
	}
	if classified.classification == domain.ClassificationNoise ||
		((e.brief.Mode == domain.ModeProduct || e.brief.Mode == domain.ModeTheme) && classified.classification == domain.ClassificationProviderOffer) ||
		(e.brief.Mode == domain.ModeProvider && classified.classification != domain.ClassificationProviderDirect && classified.classification != domain.ClassificationProviderRecommended) {
		e.stats.Irrelevant++
		result.Reason = classified.reason
		return result
	}

	term := strings.TrimSpace(hit.MatchedQuery)
	if term == "" || !textMatchesSearchTerm(body, term) {
		term = result.TopicMatch.MatchedCores[0]
	}
	score, reason := classified.score, classified.reason
	if result.TopicMatch.Reason != "" {
		reason += "; " + result.TopicMatch.Reason
	}
	if hit.Track == "both" {
		boost := 6
		if e.brief.Mode == domain.ModeActivity {
			boost = 35
		}
		score = minInt(100, score+boost)
		reason += "; track: both"
	} else if hit.Track == "recent" {
		boost := 3
		if e.brief.Mode == domain.ModeActivity {
			boost = 8
		}
		score = minInt(100, score+boost)
		reason += "; track: recent"
	} else if hit.Track == "top" {
		if e.brief.Mode == domain.ModeActivity {
			score = minInt(100, score+12)
		}
		reason += "; track: top"
	}
	if hit.SerpRank > 0 {
		reason += "; serp_rank: " + itoaASCII(hit.SerpRank)
	}
	if hit.PublishedAt > 0 && isSoftAged(hit.PublishedAt, defaultScoutSoftAgeDays) {
		score = maxInt(1, score-12)
		reason += "; soft_aged"
	}
	// Only an eligible candidate occupies the local identity. Providers often
	// return a sparse/short first hit followed by a richer snippet for the same
	// post; rejecting the first hit must not prevent the second from being
	// evaluated. This is deliberately after every relevance and classification
	// gate, while historical duplicates were marked above as terminal.
	e.seen[identity] = struct{}{}
	if now <= 0 {
		now = domain.NowNano()
	}
	_ = now // reserved for the run-level created_at assignment
	result.Decision = CandidateEligible
	result.SearchTag = term
	result.Classification = classified.classification
	result.Score = score
	result.Reason = reason
	e.stats.Eligible++
	return result
}
