package usecase

import (
	"context"
	"encoding/json"
	"fmt"
	"net/url"
	"strings"
	"time"

	"apps/backend/internal/module/ai"
	"apps/backend/internal/module/scout/domain"
	studioPublish "apps/backend/internal/module/studio/publish"
	threadsDomain "apps/backend/internal/module/threads/domain"

	"github.com/google/uuid"
)

// SettingsReader for dev_mode
type SettingsReader interface {
	DevModeEnabled(ctx context.Context, uid int64) (bool, error)
}

type ReplyQueue interface {
	QueueExternalReply(ctx context.Context, ownerUID int64, accountID, replyToMediaID, text, title string) (outboxID string, err error)
}

// OutreachPublishedHook fires after mark-published / successful outreach send path.
type OutreachPublishedHook func(ctx context.Context, ownerUID int64, postID, accountID string)

type Service struct {
	Repo     domain.Repository
	Settings SettingsReader
	// Transport is retained only for test construction compatibility. Scout never publishes directly.
	Transport     studioPublish.Transport
	AI            ai.Client // Retained for service wiring; Scout drafts never call AI.
	ReplyQueue    ReplyQueue
	Provider      ThreadSearchProvider
	Crawler       ChromeCrawlerProvider
	SessionSecret string
	// OnOutreachPublished optional growth-loop outcome hook.
	OnOutreachPublished OutreachPublishedHook
}

func New(repo domain.Repository) *Service {
	return &Service{Repo: repo, Provider: newDefaultExaThreadsProvider()}
}

func (s *Service) ListBrands(ctx context.Context, ownerUID int64) ([]*domain.Brand, error) {
	return s.Repo.ListBrands(ctx, ownerUID)
}

func (s *Service) GetBrand(ctx context.Context, ownerUID int64, id string) (*domain.Brand, error) {
	b, err := s.Repo.GetBrand(ctx, id)
	if err != nil {
		return nil, err
	}
	if b.OwnerUID != ownerUID {
		return nil, domain.ErrForbidden
	}
	return b, nil
}

func (s *Service) CreateBrand(ctx context.Context, ownerUID int64, name, brief string) (*domain.Brand, error) {
	now := domain.NowNano()
	if name == "" {
		name = "未命名品牌"
	}
	b := &domain.Brand{
		ID: "br_" + uuid.NewString()[:10], OwnerUID: ownerUID,
		DisplayName: name, Brief: brief, CreatedAt: now, UpdatedAt: now,
	}
	if err := s.Repo.SaveBrand(ctx, b); err != nil {
		return nil, err
	}
	aid, _ := s.Repo.GetActiveBrandID(ctx, ownerUID)
	if aid == "" {
		_ = s.Repo.SetActiveBrandID(ctx, ownerUID, b.ID)
	}
	return b, nil
}

func (s *Service) SaveBrand(ctx context.Context, ownerUID int64, b *domain.Brand) (*domain.Brand, error) {
	now := domain.NowNano()
	if b.ID == "" {
		return s.CreateBrand(ctx, ownerUID, b.DisplayName, b.Brief)
	}
	ex, err := s.GetBrand(ctx, ownerUID, b.ID)
	if err != nil {
		return nil, err
	}
	b.OwnerUID = ownerUID
	b.CreatedAt = ex.CreatedAt
	b.UpdatedAt = now
	if err := s.Repo.SaveBrand(ctx, b); err != nil {
		return nil, err
	}
	return b, nil
}

func (s *Service) RemoveBrand(ctx context.Context, ownerUID int64, id string) error {
	if _, err := s.GetBrand(ctx, ownerUID, id); err != nil {
		return err
	}
	prods, _ := s.Repo.ListProducts(ctx, ownerUID, id)
	if len(prods) > 0 {
		return domain.ErrHasProducts
	}
	if err := s.Repo.DeleteBrand(ctx, id); err != nil {
		return err
	}
	aid, _ := s.Repo.GetActiveBrandID(ctx, ownerUID)
	if aid == id {
		list, _ := s.Repo.ListBrands(ctx, ownerUID)
		next := ""
		if len(list) > 0 {
			next = list[0].ID
		}
		_ = s.Repo.SetActiveBrandID(ctx, ownerUID, next)
	}
	return nil
}

func (s *Service) GetActiveBrandID(ctx context.Context, ownerUID int64) (string, error) {
	return s.Repo.GetActiveBrandID(ctx, ownerUID)
}

func (s *Service) SetActiveBrandID(ctx context.Context, ownerUID int64, id string) error {
	if id != "" {
		if _, err := s.GetBrand(ctx, ownerUID, id); err != nil {
			return err
		}
	}
	return s.Repo.SetActiveBrandID(ctx, ownerUID, id)
}

func (s *Service) ListProducts(ctx context.Context, ownerUID int64, brandID string) ([]*domain.Product, error) {
	return s.Repo.ListProducts(ctx, ownerUID, brandID)
}

func (s *Service) ListAllProducts(ctx context.Context, ownerUID int64) ([]*domain.Product, error) {
	return s.Repo.ListProducts(ctx, ownerUID, "")
}

func (s *Service) GetProduct(ctx context.Context, ownerUID int64, id string) (*domain.Product, error) {
	p, err := s.Repo.GetProduct(ctx, id)
	if err != nil {
		return nil, err
	}
	if p.OwnerUID != ownerUID {
		return nil, domain.ErrForbidden
	}
	return p, nil
}

func (s *Service) SaveProduct(ctx context.Context, ownerUID int64, p *domain.Product) (*domain.Product, error) {
	now := domain.NowNano()
	if p.BrandID == "" {
		return nil, fmt.Errorf("%w: brand_id required", domain.ErrValidation)
	}
	if _, err := s.GetBrand(ctx, ownerUID, p.BrandID); err != nil {
		return nil, err
	}
	if p.ID == "" {
		p.ID = "prd_" + uuid.NewString()[:10]
		p.CreatedAt = now
	} else {
		ex, err := s.GetProduct(ctx, ownerUID, p.ID)
		if err == nil {
			p.CreatedAt = ex.CreatedAt
		} else if err != domain.ErrNotFound {
			return nil, err
		} else {
			p.CreatedAt = now
		}
	}
	p.OwnerUID = ownerUID
	p.UpdatedAt = now
	if p.Label == "" {
		p.Label = "未命名產品"
	}
	if err := s.Repo.SaveProduct(ctx, p); err != nil {
		return nil, err
	}
	return p, nil
}

func (s *Service) RemoveProduct(ctx context.Context, ownerUID int64, id string) error {
	if _, err := s.GetProduct(ctx, ownerUID, id); err != nil {
		return err
	}
	return s.Repo.DeleteProduct(ctx, id)
}

func (s *Service) ImportProductFromURL(_ context.Context, raw string) (*domain.ImportDraft, error) {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return nil, fmt.Errorf("%w: empty url", domain.ErrValidation)
	}
	u, err := url.Parse(raw)
	if err != nil || (u.Host == "" && !strings.HasPrefix(raw, "http")) {
		return nil, fmt.Errorf("%w: bad url", domain.ErrValidation)
	}
	host := ""
	if u != nil {
		host = u.Host
	}
	label := "匯入商品"
	if host != "" {
		label = host + " 商品"
	}
	return &domain.ImportDraft{
		Label:          label,
		ProductContext: "從 " + raw + " 推估的產品情境（live 可接真爬頁）",
		PainPoints:     []string{"找不到適合的", "價格猶豫", "不確定是否適合自己"},
		MatchTags:      []string{"好物", "推薦", "踩雷"},
		PlacementURL:   raw,
		SourceNote:     "importProductFromUrl · " + host,
	}, nil
}

func (s *Service) PrepareBrief(ctx context.Context, ownerUID int64, intent, brandID, productID, purpose string, deep bool) (*domain.RunBrief, error) {
	_ = deep
	intent = strings.TrimSpace(intent)
	if intent == "" {
		return nil, fmt.Errorf("%w: intent required", domain.ErrValidation)
	}
	mode := domain.ModeTheme
	if purpose == "activity" {
		mode = domain.ModeActivity
	}
	brief := &domain.RunBrief{
		Intent: intent, Mode: mode, BrandID: brandID, ProductID: productID,
		Pains: []string{}, Tags: []string{}, Periphery: []string{}, ScanTerms: []string{},
	}
	if productID != "" {
		p, err := s.GetProduct(ctx, ownerUID, productID)
		if err == nil {
			mode = domain.ModeProduct
			if purpose == "activity" {
				mode = domain.ModeActivity
			}
			brief.Mode = mode
			brief.ProductLabel = p.Label
			brief.ProductContext = p.ProductContext
			brief.Pains = append([]string(nil), p.PainPoints...)
			brief.Tags = append([]string(nil), p.MatchTags...)
			brief.BrandID = p.BrandID
			brief.PlacementNote = "軟性經驗分享，避免硬廣"
		}
	}
	if len(brief.Pains) == 0 {
		brief.Pains = []string{intent, "相關困擾"}
	}
	if len(brief.Tags) == 0 {
		brief.Tags = tokenize(intent)
	}
	brief.Periphery = []string{"使用情境", "替代方案", "成分／規格"}
	brief.ScanTerms = planScanTerms(brief)
	brief.ThemeLabel = truncate(intent, 36)
	brief.ThemeKey = mode + "|" + productID + "|" + truncate(intent, 48)
	brief.ResponseStance = "先共鳴再給建議"
	return brief, nil
}

func (s *Service) RunScanFromBrief(ctx context.Context, ownerUID int64, brief *domain.RunBrief) ([]*domain.Post, error) {
	if brief == nil {
		return nil, fmt.Errorf("%w: nil brief", domain.ErrValidation)
	}
	if len(brief.ScanTerms) == 0 {
		return nil, fmt.Errorf("%w: need scan_terms", domain.ErrValidation)
	}
	path := domain.PathAPI
	devMode := false
	if s.Settings != nil {
		if d, err := s.Settings.DevModeEnabled(ctx, ownerUID); err == nil {
			devMode = d
		}
	}
	if devMode {
		storageState, err := s.GetCrawlerSessionToken(ctx, ownerUID)
		if err != nil {
			return nil, domain.ErrNoCrawlerSession
		}
		path = domain.PathCrawler
		if s.Crawler == nil {
			return nil, fmt.Errorf("Chrome crawler is not configured")
		}
		hits, err := s.Crawler.SearchChrome(ctx, storageState, brief.ScanTerms, 20)
		if err != nil {
			return nil, err
		}
		return s.persistSearchHits(ctx, ownerUID, brief, path, hits)
	}
	if s.Provider == nil {
		return nil, fmt.Errorf("scout search provider is not configured")
	}
	hits, err := s.Provider.SearchThreads(ctx, brief.ScanTerms, 5)
	if err != nil {
		return nil, err
	}
	return s.persistSearchHits(ctx, ownerUID, brief, path, hits)
}

func (s *Service) persistSearchHits(ctx context.Context, ownerUID int64, brief *domain.RunBrief, path string, hits []ThreadSearchResult) ([]*domain.Post, error) {
	now := domain.NowNano()
	out := make([]*domain.Post, 0, len(hits))
	for i, hit := range hits {
		text := strings.TrimSpace(hit.Snippet)
		if text == "" {
			continue
		}
		permalink := canonicalPermalink(hit.URL)
		if permalink == "" {
			continue
		}
		term := matchingTerm(text+" "+hit.Title, brief.ScanTerms)
		classified := classifyPost(brief.Mode, text+" "+hit.Title, brief.ScanTerms)
		if classified.classification == domain.ClassificationNoise {
			continue
		}
		p := &domain.Post{
			ID: permalinkID(ownerUID, permalink), ExternalID: permalink, Permalink: permalink,
			OwnerUID: ownerUID, BrandID: brief.BrandID, Author: authorFromThreadsURL(permalink), Text: text,
			SearchTag: term, Opportunity: "", OutreachStatus: domain.OutreachNew,
			Score: classified.score, Classification: classified.classification, MatchedProductID: brief.ProductID, MatchedProductLabel: brief.ProductLabel,
			MatchReason: classified.reason, ScoutMode: brief.Mode, IntentSnippet: brief.Intent,
			ThemeKey: brief.ThemeKey, ThemeLabel: brief.ThemeLabel, ScanPath: path,
			CreatedAt: now - int64(i)*1000,
		}
		if err := s.Repo.SavePost(ctx, p); err != nil {
			return nil, err
		}
		out = append(out, p)
	}
	return out, nil
}

func matchingTerm(text string, terms []string) string {
	for _, term := range terms {
		if term = strings.TrimSpace(term); term != "" && strings.Contains(strings.ToLower(text), strings.ToLower(term)) {
			return term
		}
	}
	return ""
}

func authorFromThreadsURL(raw string) string {
	u, err := url.Parse(raw)
	if err != nil {
		return ""
	}
	for _, segment := range strings.Split(u.Path, "/") {
		segment = strings.TrimPrefix(strings.TrimSpace(segment), "@")
		if segment != "" {
			return segment
		}
	}
	return ""
}

func (s *Service) ListPosts(ctx context.Context, ownerUID int64, brandID string) ([]*domain.Post, error) {
	return s.Repo.ListPosts(ctx, ownerUID, brandID)
}

func (s *Service) DraftOutreach(ctx context.Context, ownerUID int64, postID, personaID string) (*domain.Post, error) {
	_ = personaID
	p, err := s.getPostOwned(ctx, ownerUID, postID)
	if err != nil {
		return nil, err
	}
	draft := "嗨 @" + p.Author + "，看到你提到「" + p.SearchTag + "」，我也遇過類似情況。若你願意，想聽聽你後來怎麼處理。"
	p.DraftText = draft
	p.OutreachStatus = domain.OutreachDrafted
	if err := s.Repo.SavePost(ctx, p); err != nil {
		return nil, err
	}
	return p, nil
}

func (s *Service) SkipOutreach(ctx context.Context, ownerUID int64, postID string) (*domain.Post, error) {
	p, err := s.getPostOwned(ctx, ownerUID, postID)
	if err != nil {
		return nil, err
	}
	p.OutreachStatus = domain.OutreachSkipped
	if err := s.Repo.SavePost(ctx, p); err != nil {
		return nil, err
	}
	return p, nil
}

func (s *Service) MarkPublished(ctx context.Context, ownerUID int64, postID string) (*domain.Post, error) {
	p, err := s.getPostOwned(ctx, ownerUID, postID)
	if err != nil {
		return nil, err
	}
	p.OutreachStatus = domain.OutreachPublished
	if err := s.Repo.SavePost(ctx, p); err != nil {
		return nil, err
	}
	// growth-loop: brand learning bump when brand known
	if p.BrandID != "" {
		if b, berr := s.Repo.GetBrand(ctx, p.BrandID); berr == nil && b != nil && b.OwnerUID == ownerUID {
			b.LearningVersion++
			b.LearnedFromPostsCount++
			b.LastLearnedAt = domain.NowNano()
			b.LearningSummary = fmt.Sprintf("海巡外展累積 %d 則 · 知識 v%d", b.LearnedFromPostsCount, b.LearningVersion)
			b.UpdatedAt = domain.NowNano()
			_ = s.Repo.SaveBrand(ctx, b)
		}
	}
	if s.OnOutreachPublished != nil {
		s.OnOutreachPublished(ctx, ownerUID, postID, "")
	}
	return p, nil
}

func (s *Service) SendOutreach(ctx context.Context, ownerUID int64, postID, text, accountID string) (*domain.Post, error) {
	p, err := s.getPostOwned(ctx, ownerUID, postID)
	if err != nil {
		return nil, err
	}
	text = strings.TrimSpace(text)
	if text == "" {
		text = p.DraftText
	}
	if text == "" {
		return nil, fmt.Errorf("%w: empty outreach text", domain.ErrValidation)
	}
	if s.ReplyQueue == nil {
		return nil, fmt.Errorf("Scout reply Outbox is not configured")
	}
	mediaID := p.ExternalID
	// A permalink can be resolved to a real Threads media ID before the official
	// API sends. This also refreshes any legacy IDs that were not Graph media IDs.
	if strings.TrimSpace(p.Permalink) != "" && s.Crawler != nil {
		state, err := s.GetCrawlerSessionToken(ctx, ownerUID)
		if err != nil {
			return nil, domain.ErrNoCrawlerSession
		}
		mediaID, err = s.Crawler.ResolveMediaID(ctx, state, p.Permalink)
		if err != nil {
			return nil, fmt.Errorf("%w: unable to resolve the target Threads post: %v", domain.ErrValidation, err)
		}
	}
	if !isNumericMediaID(mediaID) {
		if s.Crawler == nil {
			return nil, fmt.Errorf("%w: target Threads media ID is not resolved; configure Chrome crawler", domain.ErrValidation)
		}
		state, err := s.GetCrawlerSessionToken(ctx, ownerUID)
		if err != nil {
			return nil, domain.ErrNoCrawlerSession
		}
		mediaID, err = s.Crawler.ResolveMediaID(ctx, state, p.Permalink)
		if err != nil {
			return nil, fmt.Errorf("%w: unable to resolve the target Threads post: %v", domain.ErrValidation, err)
		}
	}
	p.ExternalID = mediaID
	outboxID, err := s.ReplyQueue.QueueExternalReply(ctx, ownerUID, accountID, mediaID, text, "Scout 回覆 · "+truncate(p.Author, 30))
	if err != nil {
		return nil, err
	}
	p.DraftText = text
	p.OutboxID = outboxID
	p.OutreachStatus = domain.OutreachQueued
	if err := s.Repo.SavePost(ctx, p); err != nil {
		return nil, err
	}
	return p, nil
}

func isNumericMediaID(value string) bool {
	if value == "" {
		return false
	}
	for _, ch := range value {
		if ch < '0' || ch > '9' {
			return false
		}
	}
	return true
}

func (s *Service) RemovePost(ctx context.Context, ownerUID int64, postID string) error {
	if _, err := s.getPostOwned(ctx, ownerUID, postID); err != nil {
		return err
	}
	return s.Repo.DeletePost(ctx, postID)
}

func (s *Service) RemoveTheme(ctx context.Context, ownerUID int64, themeKey string) error {
	if err := s.Repo.DeletePostsByTheme(ctx, ownerUID, themeKey); err != nil {
		return err
	}
	// A patrol batch owns both its hits and its research snapshot.
	if err := s.Repo.DeleteHomework(ctx, ownerUID, themeKey); err != nil && err != domain.ErrNotFound {
		return err
	}
	return nil
}

func (s *Service) ListHomework(ctx context.Context, ownerUID int64) ([]*domain.Homework, error) {
	return s.Repo.ListHomework(ctx, ownerUID)
}

func (s *Service) SaveHomework(ctx context.Context, ownerUID int64, h *domain.Homework) (*domain.Homework, error) {
	if h.ThemeKey == "" {
		return nil, fmt.Errorf("%w: theme_key required", domain.ErrValidation)
	}
	h.OwnerUID = ownerUID
	if h.CreatedAt == 0 {
		h.CreatedAt = domain.NowNano()
	}
	if err := s.Repo.SaveHomework(ctx, h); err != nil {
		return nil, err
	}
	return h, nil
}

func (s *Service) GetHomework(ctx context.Context, ownerUID int64, themeKey string) (*domain.Homework, error) {
	return s.Repo.GetHomework(ctx, ownerUID, themeKey)
}

func (s *Service) RemoveHomework(ctx context.Context, ownerUID int64, themeKey string) error {
	return s.Repo.DeleteHomework(ctx, ownerUID, themeKey)
}

func (s *Service) SetCrawlerSession(ctx context.Context, ownerUID int64, storageState string) error {
	expiresAt, err := validateStorageState(storageState)
	if err != nil {
		return err
	}
	if s.SessionSecret == "" {
		return fmt.Errorf("%w: crawler session secret is required", domain.ErrValidation)
	}
	storageStateEnc, err := threadsDomain.Seal(s.SessionSecret, storageState)
	if err != nil {
		return err
	}
	now := domain.NowNano()
	return s.Repo.SetCrawlerSession(ctx, &domain.CrawlerSession{
		OwnerUID: ownerUID, StorageStateEnc: storageStateEnc, UpdatedAt: now, ExpiresAt: expiresAt,
	})
}

// GetCrawlerSessionToken returns decrypted Playwright storageState JSON for browser crawl.
func (s *Service) GetCrawlerSessionToken(ctx context.Context, ownerUID int64) (string, error) {
	sess, err := s.Repo.GetCrawlerSession(ctx, ownerUID)
	if err != nil {
		return "", err
	}
	if sess == nil || sess.StorageStateEnc == "" || (sess.ExpiresAt > 0 && sess.ExpiresAt <= domain.NowNano()) {
		return "", domain.ErrNoCrawlerSession
	}
	if s.SessionSecret == "" {
		return "", fmt.Errorf("%w: crawler session secret is required", domain.ErrValidation)
	}
	storageState, err := threadsDomain.Open(s.SessionSecret, sess.StorageStateEnc)
	if err != nil {
		return "", err
	}
	return storageState, nil
}

type storageStateCookie struct {
	Domain  string  `json:"domain"`
	Expires float64 `json:"expires"`
}

func validateStorageState(storageState string) (int64, error) {
	if len(storageState) > 256*1024 {
		return 0, fmt.Errorf("%w: storage state exceeds 256KB", domain.ErrValidation)
	}
	var state map[string]json.RawMessage
	if err := json.Unmarshal([]byte(storageState), &state); err != nil || state == nil {
		return 0, fmt.Errorf("%w: storage state must be a JSON object", domain.ErrValidation)
	}
	rawCookies, ok := state["cookies"]
	if !ok {
		return 0, fmt.Errorf("%w: cookies required", domain.ErrValidation)
	}
	var cookies []storageStateCookie
	if err := json.Unmarshal(rawCookies, &cookies); err != nil || len(cookies) == 0 {
		return 0, fmt.Errorf("%w: cookies must be a nonempty array", domain.ErrValidation)
	}
	var expiresAt int64
	for _, cookie := range cookies {
		domainName := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(cookie.Domain)), ".")
		if !allowedCookieDomain(domainName) {
			return 0, fmt.Errorf("%w: cookie domain %q is not allowed", domain.ErrValidation, cookie.Domain)
		}
		if cookie.Expires > 0 {
			expires := int64(cookie.Expires * float64(time.Second))
			if expiresAt == 0 || expires < expiresAt {
				expiresAt = expires
			}
		}
	}
	return expiresAt, nil
}

func allowedCookieDomain(domainName string) bool {
	for _, allowed := range []string{"threads.net", "threads.com", "instagram.com", "facebook.com"} {
		if domainName == allowed || strings.HasSuffix(domainName, "."+allowed) {
			return true
		}
	}
	return false
}

func (s *Service) ClearCrawlerSession(ctx context.Context, ownerUID int64) error {
	return s.Repo.ClearCrawlerSession(ctx, ownerUID)
}

// TopicRemoved — SC: ScoutTopic CRUD not implemented
func (s *Service) TopicRemoved() error { return domain.ErrTopicRemoved }

func (s *Service) getPostOwned(ctx context.Context, ownerUID int64, id string) (*domain.Post, error) {
	p, err := s.Repo.GetPost(ctx, id)
	if err != nil {
		return nil, err
	}
	if p.OwnerUID != ownerUID {
		return nil, domain.ErrForbidden
	}
	return p, nil
}

func tokenize(s string) []string {
	parts := strings.FieldsFunc(s, func(r rune) bool {
		return r == ' ' || r == '、' || r == ',' || r == '/'
	})
	if len(parts) == 0 {
		return []string{s}
	}
	if len(parts) > 4 {
		parts = parts[:4]
	}
	return parts
}

func truncate(s string, n int) string {
	r := []rune(s)
	if len(r) <= n {
		return s
	}
	return string(r[:n])
}
