package usecase

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"math"
	"net/url"
	"sort"
	"strings"
	"unicode"

	"apps/backend/internal/module/growth/domain"

	"github.com/zeromicro/go-zero/core/logx"
)

// --- Playbooks ---

func (s *Service) PublishPlaybook(ctx context.Context, ownerUID int64, kind, title, niche, body string, anonymous bool) (*domain.Playbook, error) {
	kind = strings.ToLower(strings.TrimSpace(kind))
	title = strings.TrimSpace(title)
	body = strings.TrimSpace(body)
	if title == "" || body == "" {
		return nil, fmt.Errorf("%w: title and body required", domain.ErrValidation)
	}
	switch kind {
	case domain.PlaybookBrief, domain.PlaybookPersona, domain.PlaybookPlay:
	default:
		return nil, fmt.Errorf("%w: kind must be brief|persona|play", domain.ErrValidation)
	}
	now := domain.NowNano()
	p := &domain.Playbook{
		ID: domain.NewID(), OwnerUID: ownerUID, Kind: kind, Title: title,
		Niche: strings.TrimSpace(niche), Body: body, Anonymous: anonymous,
		CreatedAt: now, UpdatedAt: now,
	}
	if err := s.Repo.SavePlaybook(ctx, p); err != nil {
		return nil, err
	}
	return p, nil
}

func (s *Service) ListPlaybooks(ctx context.Context, viewerUID int64, kind, niche string, mine bool, page, pageSize int) ([]*domain.Playbook, int64, error) {
	ownerOnly := int64(0)
	if mine {
		ownerOnly = viewerUID
	}
	return s.Repo.ListPlaybooks(ctx, kind, niche, ownerOnly, page, pageSize)
}

func (s *Service) GetPlaybook(ctx context.Context, id string) (*domain.Playbook, error) {
	return s.Repo.GetPlaybook(ctx, id)
}

func (s *Service) ImportPlaybook(ctx context.Context, viewerUID int64, id string) (*domain.Playbook, error) {
	p, err := s.Repo.GetPlaybook(ctx, id)
	if err != nil {
		return nil, err
	}
	_ = s.Repo.IncPlaybookImport(ctx, id)
	// return a personal copy metadata (caller may store locally)
	now := domain.NowNano()
	copyPB := &domain.Playbook{
		ID: domain.NewID(), OwnerUID: viewerUID, Kind: p.Kind,
		Title: p.Title + "（引用）", Niche: p.Niche, Body: p.Body,
		Anonymous: false, CreatedAt: now, UpdatedAt: now,
	}
	if err := s.Repo.SavePlaybook(ctx, copyPB); err != nil {
		return nil, err
	}
	return copyPB, nil
}

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

func PlaybookAuthorLabel(p *domain.Playbook, viewerUID int64) string {
	if p == nil {
		return ""
	}
	if p.OwnerUID == viewerUID {
		return "我"
	}
	if p.Anonymous {
		return "匿名島民"
	}
	return fmt.Sprintf("島民#%d", p.OwnerUID%10000)
}

// --- Free tools ---

func StyleQuiz(samples string) (tone, rhythm, hooks, avoid, summary string, tags []string) {
	text := strings.TrimSpace(samples)
	if text == "" {
		return "平實", "中等", "問題開頭", "避免空泛口號", "請貼上幾則你的貼文以分析風格。", []string{"待補充"}
	}
	runes := []rune(text)
	excl := strings.Count(text, "！") + strings.Count(text, "!")
	q := strings.Count(text, "？") + strings.Count(text, "?")
	avgLen := 0
	lines := strings.FieldsFunc(text, func(r rune) bool { return r == '\n' || r == '。' })
	if len(lines) > 0 {
		total := 0
		for _, ln := range lines {
			total += len([]rune(ln))
		}
		avgLen = total / len(lines)
	}
	tone = "溫和敘事"
	if excl > 3 {
		tone = "熱情有力"
	} else if q > 3 {
		tone = "對話邀請"
	}
	rhythm = "中短句"
	if avgLen > 40 {
		rhythm = "長句鋪陳"
	} else if avgLen < 15 {
		rhythm = "短句節奏"
	}
	hooks = "經驗分享"
	if q > 0 {
		hooks = "提問開場"
	}
	avoid = "避免罐頭 AI 腔與過度標點"
	if excl > 5 {
		avoid = "注意驚嘆號密度，避免像廣告"
	}
	tags = []string{tone, rhythm, hooks}
	summary = fmt.Sprintf("從約 %d 字樣本看：語氣偏「%s」，節奏「%s」，常用「%s」。", len(runes), tone, rhythm, hooks)
	return
}

func PainKeywords(productBrief, audience string) (keywords, pains, scan []string, summary string) {
	brief := strings.TrimSpace(productBrief)
	if brief == "" {
		return []string{"請描述產品"}, []string{"痛點待填"}, []string{"關鍵字"}, "請填產品簡述。"
	}
	// rule-based extraction: split CJK/words
	tokens := tokenizeTC(brief)
	for _, t := range tokens {
		if len([]rune(t)) < 2 {
			continue
		}
		keywords = append(keywords, t)
		if len(keywords) >= 12 {
			break
		}
	}
	pains = []string{
		"不知道怎麼選",
		"用了沒感覺",
		"怕踩雷",
		"預算有限",
	}
	if audience != "" {
		pains = append([]string{audience + "常遇到的困擾"}, pains...)
	}
	scan = append([]string{}, keywords...)
	for _, p := range pains {
		scan = append(scan, p)
	}
	if len(scan) > 16 {
		scan = scan[:16]
	}
	summary = fmt.Sprintf("依產品描述抽出 %d 個掃描詞、%d 個痛點方向，可直接貼進海巡 brief。", len(keywords), len(pains))
	return
}

func tokenizeTC(s string) []string {
	var cur strings.Builder
	var out []string
	flush := func() {
		t := strings.TrimSpace(cur.String())
		cur.Reset()
		if t != "" {
			out = append(out, t)
		}
	}
	for _, r := range s {
		if unicode.Is(unicode.Han, r) {
			cur.WriteRune(r)
			if len([]rune(cur.String())) >= 4 {
				flush()
			}
			continue
		}
		if unicode.IsLetter(r) || unicode.IsDigit(r) {
			cur.WriteRune(r)
			continue
		}
		flush()
	}
	flush()
	// also bigrams from han
	han := make([]rune, 0)
	for _, r := range s {
		if unicode.Is(unicode.Han, r) {
			han = append(han, r)
		}
	}
	for i := 0; i+1 < len(han); i++ {
		out = append(out, string(han[i:i+2]))
	}
	// dedupe preserve order
	seen := map[string]bool{}
	var uniq []string
	for _, t := range out {
		if seen[t] {
			continue
		}
		seen[t] = true
		uniq = append(uniq, t)
	}
	return uniq
}

// --- UTM ---

// validateRedirectTarget keeps /u/{code} from becoming an open redirect that can point anywhere.
// A HasPrefix("http") check is not enough: it lets through "httpx://", scheme-relative "//evil",
// and anything unparseable, all of which the browser resolves differently than we assume.
func validateRedirectTarget(dest string) (string, error) {
	dest = strings.TrimSpace(dest)
	u, err := url.Parse(dest)
	if err != nil {
		return "", fmt.Errorf("%w: destination_url is not a valid URL", domain.ErrValidation)
	}
	if u.Scheme != "http" && u.Scheme != "https" {
		return "", fmt.Errorf("%w: destination_url must be http or https", domain.ErrValidation)
	}
	if u.Host == "" {
		return "", fmt.Errorf("%w: destination_url must include a host", domain.ErrValidation)
	}
	return u.String(), nil
}

func (s *Service) CreateUtmLink(ctx context.Context, ownerUID int64, dest, outcomeID, label string) (*domain.UtmLink, error) {
	dest, err := validateRedirectTarget(dest)
	if err != nil {
		return nil, err
	}
	code := domain.NewID()[:10]
	u := &domain.UtmLink{
		ID: domain.NewID(), Code: code, OwnerUID: ownerUID,
		DestinationURL: dest, OutcomeID: strings.TrimSpace(outcomeID),
		Label: strings.TrimSpace(label), CreatedAt: domain.NowNano(),
	}
	if err := s.Repo.SaveUtmLink(ctx, u); err != nil {
		return nil, err
	}
	return u, nil
}

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

func (s *Service) TrackUtmClick(ctx context.Context, code string) (dest string, err error) {
	u, err := s.Repo.IncUtmClick(ctx, code)
	if err != nil {
		return "", err
	}
	if u.OutcomeID != "" {
		if e, gerr := s.Repo.GetOutcome(ctx, u.OutcomeID); gerr == nil && e != nil {
			e.ClickCount++
			if e.Kind == domain.KindReach || e.Kind == "" {
				e.Kind = domain.KindClick
				e.Confidence = domain.ConfidenceConfirmed
				e.Status = domain.StatusConfirmed
			}
			e.UpdatedAt = domain.NowNano()
			if serr := s.Repo.SaveOutcome(ctx, e); serr != nil {
				// 點擊已經發生，redirect 照走；但歸因掉了要看得見，否則成效數字會默默偏低。
				logx.WithContext(ctx).Errorf("utm click attribution not saved: outcome=%s err=%v", e.ID, serr)
			}
		}
	}
	// 舊資料是在只檢查 "http" 前綴時寫入的，出門前再驗一次。
	return validateRedirectTarget(u.DestinationURL)
}

// --- Benchmark ---

// NormalizeNiche 讓利基桶有機會對得上：大小寫與前後空白不該切出不同的桶。
func NormalizeNiche(niche string) string {
	niche = strings.ToLower(strings.TrimSpace(niche))
	if niche == "" {
		return "general"
	}
	return niche
}

func (s *Service) ContributeBenchmark(ctx context.Context, ownerUID int64, niche string, engRate, avgViews float64) error {
	niche = NormalizeNiche(niche)
	h := sha256.Sum256([]byte(fmt.Sprintf("%d|%s", ownerUID, niche)))
	return s.Repo.UpsertBenchmarkSample(ctx, &domain.BenchmarkSample{
		Niche: niche, EngRate: engRate, AvgViews: avgViews,
		OwnerHash: hex.EncodeToString(h[:8]), UpdatedAt: domain.NowNano(),
	})
}

func (s *Service) GetBenchmark(ctx context.Context, niche string, yourEng, yourViews float64) (sampleSize int, medianEng, medianViews float64, available bool, hint string) {
	niche = NormalizeNiche(niche)
	list, err := s.Repo.ListBenchmarkSamples(ctx, niche, 500)
	if err != nil || len(list) < 5 {
		return len(list), 0, 0, false, "樣本不足 5，暫不顯示中位數（多貢獻幾次成效後再看）"
	}
	engs := make([]float64, 0, len(list))
	views := make([]float64, 0, len(list))
	for _, s0 := range list {
		engs = append(engs, s0.EngRate)
		views = append(views, s0.AvgViews)
	}
	medianEng = medianFloat(engs)
	medianViews = medianFloat(views)
	hint = "約在中位附近"
	if yourEng > 0 {
		if yourEng >= medianEng*1.2 {
			hint = "你高於同利基中位數"
		} else if yourEng <= medianEng*0.8 {
			hint = "你低於同利基中位數，可調整題材／開頭"
		}
	}
	return len(list), medianEng, medianViews, true, hint
}

func medianFloat(xs []float64) float64 {
	if len(xs) == 0 {
		return 0
	}
	sort.Float64s(xs)
	mid := len(xs) / 2
	if len(xs)%2 == 0 {
		return (xs[mid-1] + xs[mid]) / 2
	}
	return xs[mid]
}

// --- Workspace members ---

func (s *Service) AddWorkspaceMember(ctx context.Context, actorUID, memberUID int64, workspaceID, role string) (*domain.WorkspaceMember, error) {
	w, err := s.Repo.GetWorkspace(ctx, workspaceID)
	if err != nil {
		return nil, err
	}
	if w.OwnerUID != actorUID {
		ok, r, _ := s.Repo.IsWorkspaceMember(ctx, workspaceID, actorUID)
		if !ok || r != domain.WSRoleOwner {
			return nil, domain.ErrForbidden
		}
	}
	if memberUID <= 0 {
		return nil, fmt.Errorf("%w: member_uid required", domain.ErrValidation)
	}
	role = strings.ToLower(strings.TrimSpace(role))
	if role == "" {
		role = domain.WSRoleReviewer
	}
	switch role {
	case domain.WSRoleEditor, domain.WSRoleReviewer, domain.WSRoleOwner:
	default:
		return nil, fmt.Errorf("%w: invalid role", domain.ErrValidation)
	}
	m := &domain.WorkspaceMember{
		ID: domain.NewID(), WorkspaceID: workspaceID, UID: memberUID, Role: role, JoinedAt: domain.NowNano(),
	}
	if err := s.Repo.SaveWorkspaceMember(ctx, m); err != nil {
		return nil, err
	}
	return m, nil
}

func (s *Service) ListWorkspaceMembers(ctx context.Context, actorUID int64, workspaceID string) ([]*domain.WorkspaceMember, error) {
	ok, _, err := s.Repo.IsWorkspaceMember(ctx, workspaceID, actorUID)
	if err != nil {
		return nil, err
	}
	w, werr := s.Repo.GetWorkspace(ctx, workspaceID)
	if werr != nil {
		return nil, werr
	}
	if !ok && w.OwnerUID != actorUID {
		return nil, domain.ErrForbidden
	}
	list, err := s.Repo.ListWorkspaceMembers(ctx, workspaceID)
	if err != nil {
		return nil, err
	}
	// ensure owner present
	hasOwner := false
	for _, m := range list {
		if m.UID == w.OwnerUID {
			hasOwner = true
			break
		}
	}
	if !hasOwner {
		list = append([]*domain.WorkspaceMember{{
			ID: "owner", WorkspaceID: workspaceID, UID: w.OwnerUID, Role: domain.WSRoleOwner, JoinedAt: w.CreatedAt,
		}}, list...)
	}
	return list, nil
}

func (s *Service) RemoveWorkspaceMember(ctx context.Context, actorUID, targetUID int64, workspaceID string) error {
	w, err := s.Repo.GetWorkspace(ctx, workspaceID)
	if err != nil {
		return err
	}
	if w.OwnerUID != actorUID {
		return domain.ErrForbidden
	}
	if targetUID == w.OwnerUID {
		return fmt.Errorf("%w: cannot remove owner", domain.ErrValidation)
	}
	return s.Repo.RemoveWorkspaceMember(ctx, workspaceID, targetUID)
}

func (s *Service) UpdateWorkspaceBranding(ctx context.Context, ownerUID int64, id, footer, brandName string, hidePowered *bool) (*domain.Workspace, error) {
	w, err := s.Repo.GetWorkspace(ctx, id)
	if err != nil {
		return nil, err
	}
	if w.OwnerUID != ownerUID {
		return nil, domain.ErrForbidden
	}
	if footer != "" {
		w.FooterText = footer
	}
	if brandName != "" {
		w.BrandName = brandName
	}
	if hidePowered != nil {
		w.HidePoweredBy = *hidePowered
	}
	w.UpdatedAt = domain.NowNano()
	if err := s.Repo.SaveWorkspace(ctx, w); err != nil {
		return nil, err
	}
	return w, nil
}

// EnsureReviewAllowsAutoSendP2 — owner or any reviewer/editor/member may have approved
func (s *Service) CanReviewInWorkspace(ctx context.Context, uid int64, workspaceID string) bool {
	w, err := s.Repo.GetWorkspace(ctx, workspaceID)
	if err != nil {
		return false
	}
	if w.OwnerUID == uid {
		return true
	}
	ok, role, _ := s.Repo.IsWorkspaceMember(ctx, workspaceID, uid)
	return ok && (role == domain.WSRoleOwner || role == domain.WSRoleReviewer || role == domain.WSRoleEditor)
}

// ExportReportPDF returns minimal valid PDF bytes
func ExportReportPDF(title, body string) []byte {
	// Escape PDF strings
	esc := func(s string) string {
		s = strings.ReplaceAll(s, "\\", "\\\\")
		s = strings.ReplaceAll(s, "(", "\\(")
		s = strings.ReplaceAll(s, ")", "\\)")
		return s
	}
	// simple multi-line text
	lines := strings.Split(body, "\n")
	if len(lines) > 40 {
		lines = lines[:40]
	}
	var content strings.Builder
	content.WriteString("BT /F1 14 Tf 50 780 Td (" + esc(title) + ") Tj\n")
	content.WriteString("/F1 10 Tf 0 -24 Td\n")
	for i, ln := range lines {
		if i > 0 {
			content.WriteString("0 -14 Td\n")
		}
		// PDF standard fonts poor for CJK — keep ASCII fallback note
		safe := toPDFSafe(ln)
		content.WriteString("(" + esc(safe) + ") Tj\n")
	}
	content.WriteString("ET")
	stream := content.String()
	objs := []string{
		"1 0 obj<< /Type /Catalog /Pages 2 0 R >>endobj\n",
		"2 0 obj<< /Type /Pages /Kids [3 0 R] /Count 1 >>endobj\n",
		"3 0 obj<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources<< /Font<< /F1 5 0 R >> >> >>endobj\n",
		fmt.Sprintf("4 0 obj<< /Length %d >>stream\n%s\nendstream endobj\n", len(stream), stream),
		"5 0 obj<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>endobj\n",
	}
	var pdf strings.Builder
	pdf.WriteString("%PDF-1.4\n")
	offsets := []int{0}
	for _, o := range objs {
		offsets = append(offsets, pdf.Len())
		pdf.WriteString(o)
	}
	xref := pdf.Len()
	pdf.WriteString(fmt.Sprintf("xref\n0 %d\n", len(offsets)))
	pdf.WriteString("0000000000 65535 f \n")
	for i := 1; i < len(offsets); i++ {
		pdf.WriteString(fmt.Sprintf("%010d 00000 n \n", offsets[i]))
	}
	pdf.WriteString(fmt.Sprintf("trailer<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(offsets), xref))
	return []byte(pdf.String())
}

func toPDFSafe(s string) string {
	var b strings.Builder
	for _, r := range s {
		if r < 128 && r >= 32 {
			b.WriteRune(r)
		} else if r == '\t' {
			b.WriteByte(' ')
		} else {
			// replace non-latin with ?
			b.WriteByte('?')
		}
	}
	out := b.String()
	if strings.Trim(out, "? ") == "" && s != "" {
		return "[Chinese content — open markdown export for full text]"
	}
	return out
}

func Round2(v float64) float64 {
	return math.Round(v*100) / 100
}

// RoundRate 用在互動率這種比率（0.0234）。比率不能走 Round2 —— 那會輾成 0.02，
// 前端 *100 後所有人都變成整數百分比。保留 4 位小數 = 百分比的 2 位。
func RoundRate(v float64) float64 {
	return math.Round(v*10000) / 10000
}
