package usecase

import (
	"context"
	"fmt"
	"strings"

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

// ExploreResult is the outcome of an on-demand explore run (no watch subscription).
type ExploreResult struct {
	SweepID        string
	HitCount       int
	JudgedCount    int
	CreatedCount   int
	MatchedCount   int
	MergedCount    int
	TruncatedCount int
	CreditsUsed    int
}

/*
ExploreOpportunities runs an immediate search → five-question judge pipeline
for a short list of Threads-searchable terms (contract B / plan T3).

Reuses FetchCandidates path (HitFetch fan-out) and ProcessCandidates (quota,
dedupe, reasons). Does not bypass daily opportunity caps. Not async.
*/
func (s *Service) ExploreOpportunities(ctx context.Context, ownerUID int64, rawTerms []string) (*ExploreResult, error) {
	return s.exploreOpportunities(ctx, ownerUID, rawTerms, "", "")
}

func (s *Service) ExploreProductOpportunities(ctx context.Context, ownerUID int64, rawTerms []string, brandID, productID string) (*ExploreResult, error) {
	brandID, productID = strings.TrimSpace(brandID), strings.TrimSpace(productID)
	if (brandID == "") != (productID == "") {
		return nil, fmt.Errorf("%w: brand_id and product_id must be provided together", domain.ErrValidation)
	}
	if brandID == "" {
		return s.exploreOpportunities(ctx, ownerUID, rawTerms, "", "")
	}
	if _, err := s.LoadProductContext(ctx, ownerUID, brandID, productID); err != nil {
		return nil, err
	}
	return s.exploreOpportunities(ctx, ownerUID, rawTerms, brandID, productID)
}

func (s *Service) exploreOpportunities(ctx context.Context, ownerUID int64, rawTerms []string, brandID, productID string) (*ExploreResult, error) {
	if ownerUID <= 0 {
		return nil, fmt.Errorf("%w: owner_uid required", domain.ErrValidation)
	}
	terms, err := validateExploreTerms(rawTerms)
	if err != nil {
		return nil, err
	}

	// Profile optional at gate — judge tolerates nil profile (same as manual import).
	profile, _ := s.Repo.GetServiceProfile(ctx, ownerUID)

	// Synthetic watch so FetchCandidates / ProcessCandidates can carry terms.
	w := &domain.RadarWatch{
		ID:       "",
		OwnerUID: ownerUID,
		Terms:    terms,
		Status:   domain.WatchActive,
	}
	var productContext *ProductContextSnapshot
	if brandID != "" {
		product, err := s.LoadProductContext(ctx, ownerUID, brandID, productID)
		if err != nil {
			return nil, err
		}
		productContext = product
		w.ContextMode, w.BrandID, w.ProductID = domain.WatchContextProduct, brandID, productID
		w.BrandNameSnapshot, w.ProductLabelSnapshot = product.BrandName, product.ProductLabel
	}
	if productContext != nil {
		if dm, derr := s.GetDemandMap(ctx, ownerUID, productID); derr == nil {
			if plan, perr := BuildQueryPlan(dm, productContext.ProductLabel); perr == nil && plan != nil {
				w = mergeFetchWatch(w, plan)
			}
		}
	}

	// Cap hits for sync explore (≤20); AI judge still subject to daily quota inside ProcessCandidates.
	const exploreHitLimit = 20
	cands, path, fetchCredits, ferr := s.FetchCandidates(ctx, ownerUID, w, exploreHitLimit)
	if ferr != nil {
		return nil, ferr
	}

	sw, err := s.BeginSweepRecord(ctx, ownerUID, "", "", path)
	if err != nil {
		return nil, err
	}
	if path != "" {
		_ = s.setSweepPath(ctx, sw.ID, path)
	}

	rawHitCount := len(cands)
	cands, prefilter := PrefilterCandidates(cands, w, productContext)
	_, _ = s.Repo.UpdateSweep(ctx, sw.ID, domain.SweepDelta{
		HitCount:     rawHitCount,
		CreditsUsed:  fetchCredits,
		CreditSearch: fetchCredits,
		DedupedCount: prefilter.Deduped, PrefilterPassCount: prefilter.Pass,
		PrefilterReviewCount: prefilter.Review, PrefilterRejectedCount: prefilter.Rejected,
	})

	created, _, judged, truncated, failed, judgeCredits, perr := s.ProcessCandidates(
		ctx, ownerUID, w, profile, sw.ID, cands, nil,
	)
	if perr != nil {
		reason := "判定流程失敗：" + perr.Error()
		end := domain.NowNano()
		_, _ = s.Repo.UpdateSweep(ctx, sw.ID, domain.SweepDelta{FailedReason: &reason, EndedAt: end})
		return nil, perr
	}

	end := domain.NowNano()
	var failPtr *string
	if failed > 0 && created == 0 && judged > 0 {
		r := fmt.Sprintf("%d 筆判定失敗", failed)
		failPtr = &r
	}
	sw, _ = s.Repo.UpdateSweep(ctx, sw.ID, domain.SweepDelta{
		CreditsUsed:  judgeCredits,
		CreditJudge:  judgeCredits,
		EndedAt:      end,
		FailedReason: failPtr,
	})

	credits := fetchCredits + judgeCredits
	if sw != nil {
		// UpdateSweep may have accumulated credits differently; prefer stored total when available.
		if sw.CreditsUsed > 0 {
			credits = sw.CreditsUsed
		}
	}

	return &ExploreResult{
		SweepID:        sw.ID,
		HitCount:       len(cands),
		JudgedCount:    judged,
		CreatedCount:   created,
		MatchedCount:   sw.MatchEvaluatedCount,
		MergedCount:    sw.MatchMergedCount,
		TruncatedCount: truncated,
		CreditsUsed:    credits,
	}, nil
}

func validateExploreTerms(raw []string) ([]string, error) {
	if len(raw) == 0 {
		return nil, fmt.Errorf("%w: terms required (1–%d)", domain.ErrValidation, domain.MaxExploreTerms)
	}
	if len(raw) > domain.MaxExploreTerms {
		return nil, fmt.Errorf("%w: at most %d terms", domain.ErrValidation, domain.MaxExploreTerms)
	}
	out := make([]string, 0, len(raw))
	seen := map[string]bool{}
	for _, r := range raw {
		term := domain.NormalizeSearchTerm(r)
		if term == "" {
			return nil, fmt.Errorf("%w: empty term", domain.ErrValidation)
		}
		if !domain.IsThreadsSearchable(term) {
			return nil, fmt.Errorf("%w: term %q is not Threads-searchable (≤2 tokens, CJK 2–4 chars, ≤12 total, no punctuation)", domain.ErrValidation, term)
		}
		key := strings.ToLower(term)
		if seen[key] {
			continue
		}
		seen[key] = true
		out = append(out, term)
	}
	if len(out) == 0 {
		return nil, fmt.Errorf("%w: terms required", domain.ErrValidation)
	}
	return out, nil
}
