package domain

import (
	"fmt"
	"strings"
)

const (
	WatchActive   = "active"
	WatchPaused   = "paused"
	WatchArchived = "archived"

	MaxWatchTerms                 = 20
	MaxWatchExcludeTerms          = 30
	MaxTermLen                    = 60
	MinTermLen                    = 2
	WatchContextGeneric           = "generic"
	WatchContextProduct           = "product"
	PauseReasonUser               = "user"
	PauseReasonProductUnavailable = "product_unavailable"
	PauseReasonBrandUnavailable   = "brand_unavailable"
)

/*
RadarWatch 是常駐關鍵字監控。每日排程只撈 active 的。

regions 留空代表沿用服務檔案的地區 —— 這裡刻意不把服務檔案的值複製進來，
否則之後改服務檔案，舊訂閱會繼續用舊地區判定，而使用者不會知道。
*/
type RadarWatch struct {
	ID                   string   `bson:"_id" json:"id"`
	OwnerUID             int64    `bson:"owner_uid" json:"owner_uid"`
	Terms                []string `bson:"terms" json:"terms"`
	ExcludeTerms         []string `bson:"exclude_terms" json:"exclude_terms"`
	Regions              []string `bson:"regions" json:"regions"`
	Status               string   `bson:"status" json:"status"`
	ContextMode          string   `bson:"context_mode,omitempty" json:"context_mode,omitempty"`
	BrandID              string   `bson:"brand_id,omitempty" json:"brand_id,omitempty"`
	ProductID            string   `bson:"product_id,omitempty" json:"product_id,omitempty"`
	BrandNameSnapshot    string   `bson:"brand_name_snapshot,omitempty" json:"brand_name_snapshot,omitempty"`
	ProductLabelSnapshot string   `bson:"product_label_snapshot,omitempty" json:"product_label_snapshot,omitempty"`
	ContextBoundAt       int64    `bson:"context_bound_at,omitempty" json:"context_bound_at,omitempty"`
	PauseReason          string   `bson:"pause_reason,omitempty" json:"pause_reason,omitempty"`
	LastSweptAt          int64    `bson:"last_swept_at,omitempty" json:"last_swept_at,omitempty"`
	CreatedAt            int64    `bson:"created_at" json:"created_at"`
	UpdatedAt            int64    `bson:"updated_at" json:"updated_at"`

	// FirstSweepTriggered 是建立當下的一次性狀態，不落庫：只用來讓前端在
	// 使用者第一組訂閱剛好排入首巡時顯示「首巡進行中」提示。
	FirstSweepTriggered bool `bson:"-" json:"-"`
}

type WatchListFilter struct {
	Status      string
	ContextMode string
	BrandID     string
	ProductID   string
	Page        int
	PageSize    int
}

func IsWatchContext(s string) bool { return s == WatchContextGeneric || s == WatchContextProduct }

func IsWatchPauseReason(s string) bool {
	return s == "" || s == PauseReasonUser || s == PauseReasonProductUnavailable || s == PauseReasonBrandUnavailable
}

// NormalizeContext makes pre-product documents read as generic and validates
// the all-or-nothing product identity invariant.
func (w *RadarWatch) NormalizeContext() error {
	if w.ContextMode == "" {
		w.ContextMode = WatchContextGeneric
	}
	if !IsWatchContext(w.ContextMode) {
		return fmt.Errorf("%w: unknown context_mode %q", ErrValidation, w.ContextMode)
	}
	if !IsWatchPauseReason(w.PauseReason) {
		return fmt.Errorf("%w: unknown pause_reason %q", ErrValidation, w.PauseReason)
	}
	if w.ContextMode == WatchContextGeneric {
		if w.BrandID != "" || w.ProductID != "" {
			return fmt.Errorf("%w: generic watch cannot carry product IDs", ErrValidation)
		}
		return nil
	}
	if strings.TrimSpace(w.BrandID) == "" || strings.TrimSpace(w.ProductID) == "" {
		return fmt.Errorf("%w: product watch requires brand_id and product_id", ErrValidation)
	}
	if w.ContextBoundAt <= 0 {
		w.ContextBoundAt = w.UpdatedAt
	}
	return nil
}

// BindProduct is one-way: replacing a product would make historical sweeps ambiguous.
func (w *RadarWatch) BindProduct(brandID, productID, brandSnapshot, productSnapshot string, at int64) error {
	if err := w.NormalizeContext(); err != nil {
		return err
	}
	if w.ContextMode != WatchContextGeneric || w.BrandID != "" || w.ProductID != "" {
		return fmt.Errorf("%w: watch product context is already bound", ErrValidation)
	}
	if strings.TrimSpace(brandID) == "" || strings.TrimSpace(productID) == "" {
		return fmt.Errorf("%w: brand_id and product_id must be provided together", ErrValidation)
	}
	w.ContextMode, w.BrandID, w.ProductID = WatchContextProduct, strings.TrimSpace(brandID), strings.TrimSpace(productID)
	w.BrandNameSnapshot, w.ProductLabelSnapshot, w.ContextBoundAt = strings.TrimSpace(brandSnapshot), strings.TrimSpace(productSnapshot), at
	w.UpdatedAt = at
	return nil
}

func IsWatchStatus(s string) bool {
	switch s {
	case WatchActive, WatchPaused, WatchArchived:
		return true
	}
	return false
}

/*
CanTransitionWatch 實作 spec §3.1：active ↔ paused 可往返，兩者都能封存，
archived 是終態。

終態不可逆是刻意的：封存後歷史商機與統計都還留著，如果允許復活，
「這批統計是哪個訂閱在什麼期間跑出來的」就會失去單一解釋。要再監控同一組
關鍵字請建新的訂閱。
*/
func CanTransitionWatch(from, to string) bool {
	if from == to {
		return true
	}
	switch from {
	case WatchActive:
		return to == WatchPaused || to == WatchArchived
	case WatchPaused:
		return to == WatchActive || to == WatchArchived
	default:
		return false
	}
}

func (w *RadarWatch) Transition(to string) error {
	if !IsWatchStatus(to) {
		return fmt.Errorf("%w: unknown watch status %q", ErrValidation, to)
	}
	if !CanTransitionWatch(w.Status, to) {
		if w.Status == WatchArchived {
			return fmt.Errorf("%w: archived watch cannot become %s; create a new watch instead", ErrValidation, to)
		}
		return fmt.Errorf("%w: cannot change watch from %s to %s", ErrValidation, w.Status, to)
	}
	if to == WatchActive && (w.PauseReason == PauseReasonProductUnavailable || w.PauseReason == PauseReasonBrandUnavailable) {
		return fmt.Errorf("%w: unavailable product watch must be archived and recreated", ErrValidation)
	}
	w.Status = to
	if to == WatchActive {
		w.PauseReason = ""
	}
	if to == WatchPaused && w.PauseReason == "" {
		w.PauseReason = PauseReasonUser
	}
	w.UpdatedAt = NowNano()
	return nil
}

/*
Normalize 正規化關鍵字並驗證。

term 一律 lower-case 存放：Threads 搜尋不分大小寫，若不正規化，「Wedding」與
「wedding」會被當成兩個 term，之後 T556 的關鍵字轉換率就會把同一個詞拆成兩列。
*/
func (w *RadarWatch) Normalize() error {
	if w.OwnerUID <= 0 {
		return fmt.Errorf("%w: owner_uid required", ErrValidation)
	}

	terms, err := normalizeTerms(w.Terms, "terms", MaxWatchTerms)
	if err != nil {
		return err
	}
	if len(terms) == 0 {
		return fmt.Errorf("%w: terms required", ErrValidation)
	}
	w.Terms = terms

	excludes, err := normalizeTerms(w.ExcludeTerms, "exclude_terms", MaxWatchExcludeTerms)
	if err != nil {
		return err
	}
	w.ExcludeTerms = excludes

	// 同一個詞同時要與不要，等於這個訂閱永遠不會命中任何東西。
	excludeSet := map[string]bool{}
	for _, e := range excludes {
		excludeSet[e] = true
	}
	for _, t := range terms {
		if excludeSet[t] {
			return fmt.Errorf("%w: %q is in both terms and exclude_terms", ErrValidation, t)
		}
	}

	regions := make([]string, 0, len(w.Regions))
	seen := map[string]bool{}
	for _, r := range w.Regions {
		code := strings.ToUpper(strings.TrimSpace(r))
		if code == "" {
			continue
		}
		if !IsServiceAreaCode(code) {
			return fmt.Errorf("%w: regions contains unknown code %q", ErrValidation, code)
		}
		if seen[code] {
			continue
		}
		seen[code] = true
		regions = append(regions, code)
	}
	w.Regions = regions

	if w.Status == "" {
		w.Status = WatchActive
	}
	if !IsWatchStatus(w.Status) {
		return fmt.Errorf("%w: unknown watch status %q", ErrValidation, w.Status)
	}
	if err := w.NormalizeContext(); err != nil {
		return err
	}
	return nil
}

func normalizeTerms(in []string, field string, max int) ([]string, error) {
	if field == "terms" {
		out := ExpandSearchTerms(in, max)
		if len(out) > max {
			return nil, fmt.Errorf("%w: %s exceeds %d items", ErrValidation, field, max)
		}
		return out, nil
	}
	out := make([]string, 0, len(in))
	seen := map[string]bool{}
	for _, raw := range in {
		// 全形空白也要收掉：中文輸入法很容易打出來，而它不會命中任何東西。
		t := strings.TrimSpace(strings.ReplaceAll(raw, "\u3000", " "))
		t = strings.Join(strings.Fields(t), " ")
		if t == "" {
			continue
		}
		t = strings.ToLower(t)
		if len([]rune(t)) < MinTermLen {
			return nil, fmt.Errorf("%w: %s contains a term shorter than %d characters (%q)", ErrValidation, field, MinTermLen, t)
		}
		if len([]rune(t)) > MaxTermLen {
			return nil, fmt.Errorf("%w: %s contains a term longer than %d characters", ErrValidation, field, MaxTermLen)
		}
		if seen[t] {
			continue
		}
		seen[t] = true
		out = append(out, t)
	}
	if len(out) > max {
		return nil, fmt.Errorf("%w: %s exceeds %d items", ErrValidation, field, max)
	}
	return out, nil
}
