package usecase

import (
	"context"
	"fmt"
	"strings"

	"apps/backend/internal/module/radar/domain"
	usageDomain "apps/backend/internal/module/usage/domain"
)

// ThreadHit is one raw search result from the dual-path fetch layer.
type ThreadHit struct {
	URL         string
	Title       string
	Snippet     string
	PublishedAt int64
}

// ThreadSearcher is the API-path search (Exa etc.).
type ThreadSearcher interface {
	SearchThreads(ctx context.Context, terms []string, limit int) ([]ThreadHit, error)
}

// ChromeSearcher is the crawler path.
type ChromeSearcher interface {
	SearchChrome(ctx context.Context, storageState string, terms []string, limit int) ([]ThreadHit, error)
}

// DevModeReader reports whether the member uses the crawler path.
type DevModeReader interface {
	DevModeEnabled(ctx context.Context, ownerUID int64) (bool, error)
}

// CrawlerSessionReader returns decrypted Playwright storage state.
type CrawlerSessionReader interface {
	CrawlerSessionToken(ctx context.Context, ownerUID int64) (string, error)
}

// HitFetcher is the preferred injected dual-path entry (scout.SearchHitsOnly adapter).
type HitFetcher interface {
	SearchHits(ctx context.Context, ownerUID int64, terms []string, limit int) (hits []ThreadHit, path string, err error)
}

// HitFetcherFunc adapts a function to HitFetcher.
type HitFetcherFunc func(ctx context.Context, ownerUID int64, terms []string, limit int) ([]ThreadHit, string, error)

func (f HitFetcherFunc) SearchHits(ctx context.Context, ownerUID int64, terms []string, limit int) ([]ThreadHit, string, error) {
	return f(ctx, ownerUID, terms, limit)
}

/*
FetchCandidates runs dual-path fetch for a watch.

dev_mode=false → path=api; true + session → path=crawler.
exclude_terms filtered after fetch. Meter: web_search / radar.sweep for API path.
*/
func (s *Service) FetchCandidates(ctx context.Context, ownerUID int64, w *domain.RadarWatch, limit int) (cands []*domain.CandidatePost, path string, credits int, err error) {
	if w == nil {
		return nil, "", 0, fmt.Errorf("%w: watch required", domain.ErrValidation)
	}
	terms := domain.ExpandSearchTerms(w.Terms, domain.MaxWatchTerms)
	if len(terms) == 0 {
		return nil, "", 0, fmt.Errorf("%w: watch has no terms", domain.ErrValidation)
	}
	if limit <= 0 {
		limit = 20
	}

	var hits []ThreadHit
	if s.HitFetch != nil {
		hits, path, err = s.HitFetch.SearchHits(ctx, ownerUID, terms, limit)
	} else {
		hits, path, err = s.fetchViaProviders(ctx, ownerUID, terms, limit)
	}
	if err != nil {
		return nil, path, 0, err
	}

	// Bill API path search once per sweep (crawler uses member session — still record sweep credit as web_search when platform path).
	if path == domain.SweepPathAPI || path == "api" {
		charge, berr := s.bill(ctx, ownerUID, usageDomain.MeterWebSearch, "雷達巡檢抓取", "radar.sweep")
		if berr != nil {
			return nil, path, 0, berr
		}
		// Commit immediately — hits already returned.
		charge.Commit(ctx)
		credits = usageDomain.MeterCost(usageDomain.MeterWebSearch)
	}
	if path == "" {
		path = domain.SweepPathAPI
	}
	if path == "api" {
		path = domain.SweepPathAPI
	}
	if path == "crawler" {
		path = domain.SweepPathCrawler
	}

	exclude := map[string]bool{}
	for _, e := range w.ExcludeTerms {
		exclude[strings.ToLower(strings.TrimSpace(e))] = true
	}

	out := make([]*domain.CandidatePost, 0, len(hits))
	for _, h := range hits {
		permalink := strings.TrimSpace(h.URL)
		text := strings.TrimSpace(h.Snippet)
		if text == "" {
			text = strings.TrimSpace(h.Title)
		}
		if text == "" && permalink != "" {
			text = permalink
		}
		if text == "" || permalink == "" {
			continue
		}
		blob := strings.ToLower(text + " " + h.Title)
		skip := false
		for ex := range exclude {
			if ex != "" && strings.Contains(blob, ex) {
				skip = true
				break
			}
		}
		if skip {
			continue
		}
		term := matchingWatchTerm(blob, terms)
		class := classifyCandidate(blob)
		out = append(out, &domain.CandidatePost{
			ExternalID:     permalink,
			Permalink:      permalink,
			AuthorHandle:   authorFromURL(permalink),
			Text:           text,
			Title:          h.Title,
			PostedAt:       h.PublishedAt,
			MatchedTerm:    term,
			Classification: class,
		})
	}
	return out, path, credits, nil
}

func (s *Service) fetchViaProviders(ctx context.Context, ownerUID int64, terms []string, limit int) ([]ThreadHit, string, error) {
	path := domain.SweepPathAPI
	devMode := false
	if s.DevMode != nil {
		if d, err := s.DevMode.DevModeEnabled(ctx, ownerUID); err == nil {
			devMode = d
		}
	}
	if devMode {
		path = domain.SweepPathCrawler
		if s.CrawlerSession == nil || s.Chrome == nil {
			return nil, path, fmt.Errorf("crawler path unavailable: session or chrome not configured")
		}
		state, err := s.CrawlerSession.CrawlerSessionToken(ctx, ownerUID)
		if err != nil || state == "" {
			return nil, path, fmt.Errorf("crawler path unavailable: no browser session")
		}
		hits, err := s.Chrome.SearchChrome(ctx, state, terms, limit)
		return hits, path, err
	}
	if s.Search == nil {
		return nil, path, fmt.Errorf("api path unavailable: search provider not configured")
	}
	hits, err := s.Search.SearchThreads(ctx, terms, limit)
	return hits, path, err
}

func matchingWatchTerm(text string, terms []string) string {
	for _, t := range terms {
		t = strings.TrimSpace(t)
		if t != "" && strings.Contains(text, strings.ToLower(t)) {
			return t
		}
	}
	if len(terms) > 0 {
		return terms[0]
	}
	return ""
}

func authorFromURL(raw string) string {
	raw = strings.TrimSpace(raw)
	// https://www.threads.net/@handle/post/...
	if i := strings.Index(raw, "/@"); i >= 0 {
		rest := raw[i+2:]
		if j := strings.IndexAny(rest, "/?"); j >= 0 {
			return rest[:j]
		}
		return rest
	}
	return ""
}

func classifyCandidate(lower string) string {
	if hasAnySub(lower, "giveaway", "抽獎", "crypto", "賺錢", "互追") {
		return "noise"
	}
	if hasAnySub(lower, "dm me", "私訊我", "服務洽詢", "立即購買", "限時優惠", "業配", "團購") {
		return "provider_offer"
	}
	if hasAnySub(lower, "公告", "開幕", "報名", "活動資訊") {
		return "announcement"
	}
	if hasAnySub(lower, "推薦", "求推", "有沒有推薦", "求推薦") {
		return "seeking_recommendation"
	}
	if strings.Contains(lower, "?") || strings.Contains(lower, "？") || hasAnySub(lower, "怎麼", "如何", "請問", "求助") {
		return "seeking_help"
	}
	return "discussion"
}

func hasAnySub(text string, signals ...string) bool {
	for _, s := range signals {
		if strings.Contains(text, s) {
			return true
		}
	}
	return false
}
