package domain

import (
	"fmt"
	"strings"
	"time"
)

// Opportunity status — spec §3.2.
const (
	OppJudging   = "judging"
	OppQualified = "qualified"
	OppRejected  = "rejected"
	OppAccepted  = "accepted"
	OppDismissed = "dismissed"
)

// ReviewState is the inbox workflow and deliberately remains separate from
// Opportunity status (accepted still belongs to CRM).
const (
	ReviewPending   = "pending"
	ReviewCompleted = "completed"
	ReviewRemoved   = "removed"
)

const (
	RemovalPainMismatch  = "pain_mismatch"
	RemovalProviderAd    = "provider_or_ad"
	RemovalStale         = "stale"
	RemovalAlreadySolved = "already_solved"
	RemovalDuplicate     = "duplicate"
	RemovalOther         = "other"
	RemovalLegacy        = "legacy_unknown"
)

// Intent band thresholds are locked by spec §3.3 (80 / 50). Do not change here.
const (
	BandHigh = "high"
	BandMid  = "mid"
	BandLow  = "low"

	BandHighMinScore = 80
	BandMidMinScore  = 50
)

// Region match is three-state; unknown is not mismatch (OP-04).
const (
	RegionMatch    = "match"
	RegionMismatch = "mismatch"
	RegionUnknown  = "unknown"
)

// Opportunity sources.
const (
	OppSourceThreads = "threads"
	// OppSourceManualImport：使用者貼 Threads／Facebook 貼文網址或 CSV 批次匯入（spec §4.11 P1）。
	OppSourceManualImport = "manual_import"
	OppSourceScoutPromote = "scout_promote"
)

// Five judge dimensions — reasons[] must include every one (OP-06).
const (
	DimAuthenticity = "authenticity"
	DimIntent       = "intent"
	DimRegion       = "region"
	DimFreshness    = "freshness"
	DimFit          = "fit"
)

// RequiredReasonDimensions is the fixed set that must all be present to persist.
var RequiredReasonDimensions = []string{
	DimAuthenticity,
	DimIntent,
	DimRegion,
	DimFreshness,
	DimFit,
}

// OpportunityReason is one scored dimension of the five-question judge.
type OpportunityReason struct {
	Dimension string `bson:"dimension" json:"dimension"`
	Score     int    `bson:"score" json:"score"`
	Reason    string `bson:"reason" json:"reason"`
}

// OpportunityOverride records a human band/status correction for later calibration.
type OpportunityOverride struct {
	FromBand   string `bson:"from_band,omitempty" json:"from_band,omitempty"`
	ToBand     string `bson:"to_band,omitempty" json:"to_band,omitempty"`
	FromStatus string `bson:"from_status,omitempty" json:"from_status,omitempty"`
	ToStatus   string `bson:"to_status,omitempty" json:"to_status,omitempty"`
	ActorUID   int64  `bson:"actor_uid" json:"actor_uid"`
	At         int64  `bson:"at" json:"at"`
}

type ProductPrimaryOverride struct {
	ActorUID int64  `bson:"actor_uid" json:"actor_uid"`
	Reason   string `bson:"reason" json:"reason"`
	At       int64  `bson:"at" json:"at"`
}

/*
Opportunity 是經五問判定後的商機。

同一 owner 下 external_id 唯一：跨 watch 命中同一貼文只留一筆，新觸發 term
併入 matched_terms，不重跑判定（SW-06）。
*/
type Opportunity struct {
	ID                       string                  `bson:"_id" json:"id"`
	OwnerUID                 int64                   `bson:"owner_uid" json:"owner_uid"`
	WatchID                  string                  `bson:"watch_id,omitempty" json:"watch_id,omitempty"`
	Source                   string                  `bson:"source" json:"source"`
	SourceScoutPostID        string                  `bson:"source_scout_post_id,omitempty" json:"source_scout_post_id,omitempty"`
	ExternalID               string                  `bson:"external_id" json:"external_id"`
	Permalink                string                  `bson:"permalink" json:"permalink"`
	AuthorHandle             string                  `bson:"author_handle" json:"author_handle"`
	Text                     string                  `bson:"text" json:"text"`
	PostedAt                 int64                   `bson:"posted_at" json:"posted_at"`
	Status                   string                  `bson:"status" json:"status"`
	IntentScore              int                     `bson:"intent_score" json:"intent_score"`
	IntentBand               string                  `bson:"intent_band" json:"intent_band"`
	Reasons                  []OpportunityReason     `bson:"reasons" json:"reasons"`
	RegionDetected           string                  `bson:"region_detected,omitempty" json:"region_detected,omitempty"`
	RegionMatch              string                  `bson:"region_match" json:"region_match"`
	FreshnessHours           int                     `bson:"freshness_hours" json:"freshness_hours"`
	MatchedService           string                  `bson:"matched_service,omitempty" json:"matched_service,omitempty"`
	MatchedTerms             []string                `bson:"matched_terms" json:"matched_terms"`
	RejectReason             string                  `bson:"reject_reason,omitempty" json:"reject_reason,omitempty"`
	Override                 *OpportunityOverride    `bson:"override,omitempty" json:"override,omitempty"`
	ContactID                string                  `bson:"contact_id,omitempty" json:"contact_id,omitempty"`
	PrimaryBrandID           string                  `bson:"primary_brand_id,omitempty" json:"primary_brand_id,omitempty"`
	PrimaryProductID         string                  `bson:"primary_product_id,omitempty" json:"primary_product_id,omitempty"`
	PrimaryBrandName         string                  `bson:"primary_brand_name,omitempty" json:"primary_brand_name,omitempty"`
	PrimaryProductLabel      string                  `bson:"primary_product_label,omitempty" json:"primary_product_label,omitempty"`
	PrimaryProductFitScore   int                     `bson:"primary_product_fit_score,omitempty" json:"primary_product_fit_score,omitempty"`
	PrimaryProductFitBand    string                  `bson:"primary_product_fit_band,omitempty" json:"primary_product_fit_band,omitempty"`
	PrimaryProductOverridden bool                    `bson:"primary_product_overridden,omitempty" json:"primary_product_overridden,omitempty"`
	PrimaryProductOverride   *ProductPrimaryOverride `bson:"primary_product_override,omitempty" json:"primary_product_override,omitempty"`
	ProductMatches           []*ProductMatch         `bson:"product_matches,omitempty" json:"product_matches,omitempty"`
	ReviewState              string                  `bson:"review_state,omitempty" json:"review_state,omitempty"`
	PreviousReviewState      string                  `bson:"previous_review_state,omitempty" json:"previous_review_state,omitempty"`
	RemovalReason            string                  `bson:"removal_reason,omitempty" json:"removal_reason,omitempty"`
	RemovalNote              string                  `bson:"removal_note,omitempty" json:"removal_note,omitempty"`
	RemovedAt                int64                   `bson:"removed_at,omitempty" json:"removed_at,omitempty"`
	RemovedBy                int64                   `bson:"removed_by,omitempty" json:"removed_by,omitempty"`
	LastMatchedAt            int64                   `bson:"last_matched_at,omitempty" json:"last_matched_at,omitempty"`
	PriorityScore            int                     `bson:"priority_score,omitempty" json:"priority_score,omitempty"`
	PriorityBand             string                  `bson:"priority_band,omitempty" json:"priority_band,omitempty"`
	PainFitScore             int                     `bson:"pain_fit_score,omitempty" json:"pain_fit_score,omitempty"`
	DemandIntentScore        int                     `bson:"demand_intent_score,omitempty" json:"demand_intent_score,omitempty"`
	EvidenceQualityScore     int                     `bson:"evidence_quality_score,omitempty" json:"evidence_quality_score,omitempty"`
	FreshnessScore           int                     `bson:"freshness_score,omitempty" json:"freshness_score,omitempty"`
	DemandEvidence           []string                `bson:"demand_evidence,omitempty" json:"demand_evidence,omitempty"`
	DemandInputVersion       string                  `bson:"demand_input_version,omitempty" json:"demand_input_version,omitempty"`
	DemandMapVersion         int64                   `bson:"demand_map_version,omitempty" json:"demand_map_version,omitempty"`
	CreatedAt                int64                   `bson:"created_at" json:"created_at"`
	UpdatedAt                int64                   `bson:"updated_at" json:"updated_at"`
}

// OpportunityListFilter 支援 band／status／watch／日期區間。
// Status 與 Statuses 擇一：Statuses 非空時用 $in；否則 Status 做單值比對。
type OpportunityListFilter struct {
	Band         string
	Status       string
	Statuses     []string // 多狀態；$in（今日頁：qualified／accepted／dismissed）
	WatchID      string
	CreatedFrom  int64 // inclusive, unix ns; 0 = no lower bound
	CreatedTo    int64 // exclusive, unix ns; 0 = no upper bound
	PostedFrom   int64
	PostedTo     int64
	Page         int
	PageSize     int
	BrandID      string
	ProductID    string
	FitBand      string
	MatchState   string
	Sort         string
	ReviewState  string
	TimeScope    string
	PriorityBand string
}

type ReviewStatePatch struct {
	State                  string
	RemovalReason          string
	RemovalNote            string
	DuplicateOpportunityID string
	RemovedBy              int64
}

func IsReviewState(s string) bool {
	return s == ReviewPending || s == ReviewCompleted || s == ReviewRemoved
}

func IsRemovalReason(s string) bool {
	switch s {
	case RemovalPainMismatch, RemovalProviderAd, RemovalStale, RemovalAlreadySolved, RemovalDuplicate, RemovalOther, RemovalLegacy:
		return true
	default:
		return false
	}
}

// BandFromScore maps intent_score → band with locked 80／50 thresholds.
func BandFromScore(score int) string {
	if score >= BandHighMinScore {
		return BandHigh
	}
	if score >= BandMidMinScore {
		return BandMid
	}
	return BandLow
}

func IsOppStatus(s string) bool {
	switch s {
	case OppJudging, OppQualified, OppRejected, OppAccepted, OppDismissed:
		return true
	}
	return false
}

func IsIntentBand(s string) bool {
	switch s {
	case BandHigh, BandMid, BandLow:
		return true
	}
	return false
}

func IsRegionMatch(s string) bool {
	switch s {
	case RegionMatch, RegionMismatch, RegionUnknown:
		return true
	}
	return false
}

func IsOppSource(s string) bool {
	switch s {
	case OppSourceThreads, OppSourceManualImport, OppSourceScoutPromote:
		return true
	}
	return false
}

/*
CanTransitionOpportunity 實作 spec §3.2。

accepted／dismissed 為使用者終態；rejected → qualified 僅經覆寫。
judging 之後不可回到 judging。
*/
func CanTransitionOpportunity(from, to string) bool {
	if from == to {
		return true
	}
	switch from {
	case OppJudging:
		return to == OppQualified || to == OppRejected
	case OppQualified:
		return to == OppAccepted || to == OppDismissed
	case OppRejected:
		return to == OppQualified
	default:
		return false
	}
}

func (o *Opportunity) Transition(to string) error {
	if !IsOppStatus(to) {
		return fmt.Errorf("%w: unknown opportunity status %q", ErrValidation, to)
	}
	if !CanTransitionOpportunity(o.Status, to) {
		return fmt.Errorf("%w: cannot change opportunity from %s to %s", ErrValidation, o.Status, to)
	}
	o.Status = to
	o.UpdatedAt = NowNano()
	return nil
}

// ApplyBandFromScore sets IntentBand from IntentScore (locked thresholds).
func (o *Opportunity) ApplyBandFromScore() {
	o.IntentBand = BandFromScore(o.IntentScore)
}

/*
ValidateReasons 要求五維度齊全、每條有非空白人話理由（OP-06）。

缺任一維度 → 明確錯誤，repository 不得寫入。
*/
func ValidateReasons(reasons []OpportunityReason) error {
	if len(reasons) == 0 {
		return fmt.Errorf("%w: reasons required (need all five dimensions)", ErrValidation)
	}
	seen := map[string]bool{}
	for _, r := range reasons {
		dim := strings.TrimSpace(r.Dimension)
		if dim == "" {
			return fmt.Errorf("%w: reasons entry missing dimension", ErrValidation)
		}
		if !isReasonDimension(dim) {
			return fmt.Errorf("%w: unknown reason dimension %q", ErrValidation, dim)
		}
		if seen[dim] {
			return fmt.Errorf("%w: duplicate reason dimension %q", ErrValidation, dim)
		}
		if strings.TrimSpace(r.Reason) == "" {
			return fmt.Errorf("%w: reasons[%s] needs a human-readable reason", ErrValidation, dim)
		}
		seen[dim] = true
	}
	for _, dim := range RequiredReasonDimensions {
		if !seen[dim] {
			return fmt.Errorf("%w: reasons missing dimension %q (need all five)", ErrValidation, dim)
		}
	}
	return nil
}

func isReasonDimension(s string) bool {
	switch s {
	case DimAuthenticity, DimIntent, DimRegion, DimFreshness, DimFit:
		return true
	}
	return false
}

// NormalizeMatchedTerms lower-cases, trims, and de-duplicates terms.
func NormalizeMatchedTerms(in []string) []string {
	out := make([]string, 0, len(in))
	seen := map[string]bool{}
	for _, raw := range in {
		t := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(raw, "\u3000", " ")))
		t = strings.Join(strings.Fields(t), " ")
		if t == "" || seen[t] {
			continue
		}
		seen[t] = true
		out = append(out, t)
	}
	return out
}

// MergeMatchedTerms returns a∪b with stable order (a first, then new from b).
func MergeMatchedTerms(a, b []string) []string {
	return NormalizeMatchedTerms(append(append([]string{}, a...), b...))
}

/*
ValidateForWrite 在首次寫入／完整更新前檢查契約欄位。

Upsert 命中既有列時只併 term，不走這條（既有資料已通過判定）。
*/
func (o *Opportunity) ValidateForWrite() error {
	if o.OwnerUID <= 0 {
		return fmt.Errorf("%w: owner_uid required", ErrValidation)
	}
	if strings.TrimSpace(o.ExternalID) == "" {
		return fmt.Errorf("%w: external_id required", ErrValidation)
	}
	if o.Source == "" {
		o.Source = OppSourceThreads
	}
	if !IsOppSource(o.Source) {
		return fmt.Errorf("%w: unknown opportunity source %q", ErrValidation, o.Source)
	}
	if o.Status == "" {
		o.Status = OppJudging
	}
	if !IsOppStatus(o.Status) {
		return fmt.Errorf("%w: unknown opportunity status %q", ErrValidation, o.Status)
	}
	// judging 尚未出分時允許缺 reasons；一旦進入 qualified／rejected 必須五條齊全。
	if o.Status != OppJudging {
		if err := ValidateReasons(o.Reasons); err != nil {
			return err
		}
	} else if len(o.Reasons) > 0 {
		// 若呼叫端已帶 reasons，仍驗一次，避免半成品入庫。
		if err := ValidateReasons(o.Reasons); err != nil {
			return err
		}
	}
	if o.RegionMatch == "" {
		o.RegionMatch = RegionUnknown
	}
	if !IsRegionMatch(o.RegionMatch) {
		return fmt.Errorf("%w: unknown region_match %q", ErrValidation, o.RegionMatch)
	}
	if o.IntentBand == "" && (o.Status == OppQualified || o.Status == OppRejected || o.Status == OppAccepted || o.Status == OppDismissed) {
		o.ApplyBandFromScore()
	}
	if o.IntentBand != "" && !IsIntentBand(o.IntentBand) {
		return fmt.Errorf("%w: unknown intent_band %q", ErrValidation, o.IntentBand)
	}
	o.MatchedTerms = NormalizeMatchedTerms(o.MatchedTerms)
	seenProducts := map[string]bool{}
	for _, match := range o.ProductMatches {
		if err := match.ValidateForWrite(); err != nil {
			return err
		}
		if seenProducts[match.ProductID] {
			return fmt.Errorf("%w: duplicate product match %q", ErrValidation, match.ProductID)
		}
		seenProducts[match.ProductID] = true
	}
	if o.PrimaryProductID != "" {
		var primary *ProductMatch
		for _, match := range o.ProductMatches {
			if match.ProductID == o.PrimaryProductID {
				primary = match
				break
			}
		}
		if primary == nil {
			return fmt.Errorf("%w: primary product must exist in product_matches", ErrValidation)
		}
		if (!primary.Eligible || primary.Excluded) && o.PrimaryProductOverride == nil {
			return fmt.Errorf("%w: weak or excluded primary requires override", ErrValidation)
		}
	}
	return nil
}

// UTCDayBounds returns [start, end) unix ns for the UTC calendar day that contains at.
func UTCDayBounds(at int64) (start, end int64) {
	if at <= 0 {
		at = NowNano()
	}
	t := time.Unix(0, at).UTC()
	day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
	return day.UnixNano(), day.Add(24 * time.Hour).UnixNano()
}

// DisplayLocation is the product default timezone (Harbor Desk is Taipei-first).
func DisplayLocation() *time.Location {
	return time.FixedZone("Asia/Taipei", 8*60*60)
}

// LocalDayBounds returns [start, end) unix ns for the local calendar day that contains at.
func LocalDayBounds(at int64, loc *time.Location) (start, end int64) {
	if loc == nil {
		loc = DisplayLocation()
	}
	if at <= 0 {
		at = NowNano()
	}
	t := time.Unix(0, at).In(loc)
	day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
	return day.UnixNano(), day.Add(24 * time.Hour).UnixNano()
}

// InInboxTimeScope is true when a row belongs on today/7d.
// Posted-in-range is the primary meaning; a just-finished sweep must still
// surface older or undated posts via last_matched_at / created_at, otherwise
// the default pending+today inbox looks empty after patrol.
func InInboxTimeScope(postedAt, lastMatchedAt, createdAt, from, to int64) bool {
	if from <= 0 && to <= 0 {
		return true
	}
	in := func(ts int64) bool {
		if ts <= 0 {
			return false
		}
		if from > 0 && ts < from {
			return false
		}
		if to > 0 && ts >= to {
			return false
		}
		return true
	}
	if in(postedAt) {
		return true
	}
	discovered := lastMatchedAt
	if discovered <= 0 {
		discovered = createdAt
	}
	return in(discovered)
}
