package svc

import (
	"context"
	"errors"
	"fmt"
	"strings"

	"os"
	"path/filepath"

	"apps/backend/internal/config"
	"apps/backend/internal/middleware"
	"apps/backend/internal/module/ai"
	appnotifRepo "apps/backend/internal/module/appnotif/repository"
	appnotifUC "apps/backend/internal/module/appnotif/usecase"
	billingModule "apps/backend/internal/module/billing"
	crmRepo "apps/backend/internal/module/crm/repository"
	crmUC "apps/backend/internal/module/crm/usecase"
	fsDomain "apps/backend/internal/module/filestorage/domain"
	"apps/backend/internal/module/filestorage/noop"
	"apps/backend/internal/module/filestorage/s3store"
	growthRepo "apps/backend/internal/module/growth/repository"
	growthUC "apps/backend/internal/module/growth/usecase"
	inspireRepo "apps/backend/internal/module/inspire/repository"
	inspireUC "apps/backend/internal/module/inspire/usecase"
	jobRepo "apps/backend/internal/module/job/repository"
	jobUC "apps/backend/internal/module/job/usecase"
	memberDomain "apps/backend/internal/module/member/domain"
	memberRepo "apps/backend/internal/module/member/repository"
	memberUC "apps/backend/internal/module/member/usecase"
	notifDomain "apps/backend/internal/module/notification/domain"
	notifUC "apps/backend/internal/module/notification/usecase"
	radarDomain "apps/backend/internal/module/radar/domain"
	radarRepo "apps/backend/internal/module/radar/repository"
	radarUC "apps/backend/internal/module/radar/usecase"
	scoutRepo "apps/backend/internal/module/scout/repository"
	scoutUC "apps/backend/internal/module/scout/usecase"
	"apps/backend/internal/module/search"
	studioDomain "apps/backend/internal/module/studio/domain"
	studioPublish "apps/backend/internal/module/studio/publish"
	studioRepo "apps/backend/internal/module/studio/repository"
	studioUC "apps/backend/internal/module/studio/usecase"
	threadsDomain "apps/backend/internal/module/threads/domain"
	threadsProv "apps/backend/internal/module/threads/provider"
	threadsRepo "apps/backend/internal/module/threads/repository"
	threadsUC "apps/backend/internal/module/threads/usecase"
	tokenUC "apps/backend/internal/module/token/usecase"
	usageDomain "apps/backend/internal/module/usage/domain"
	usageRepo "apps/backend/internal/module/usage/repository"
	usageUC "apps/backend/internal/module/usage/usecase"

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

type RedisHealth interface {
	PingCtx(ctx context.Context) bool
}

// ServiceContext wires goctl middleware + modules.
type ServiceContext struct {
	Config           config.Config
	Redis            RedisHealth
	AuthJWT          rest.Middleware
	AdminAuth        rest.Middleware
	StripeRawBody    rest.Middleware
	Members          memberDomain.Repository
	Auth             *memberUC.AuthService
	Token            *tokenUC.JWTIssuer
	Notification     notifDomain.UseCase // email
	Storage          fsDomain.Storage
	Threads          *threadsUC.Service
	Jobs             *jobUC.Service
	AppNotif         *appnotifUC.Service
	Usage            *usageUC.Service
	Billing          *billingModule.Service
	KeyResolver      *usageUC.SettingsResolver
	AI               ai.Client
	AIRegistry       *ai.Registry
	Search           search.Client
	Studio           *studioUC.Service
	PublishTransport studioPublish.Transport
	Inspire          *inspireUC.Service
	Scout            *scoutUC.Service
	Growth           *growthUC.Service
	Radar            *radarUC.Service
	Crm              *crmUC.Service
	ExtensionZipPath string
}

func NewServiceContext(c config.Config) *ServiceContext {
	if len(c.CacheRedis) == 0 {
		logx.Must(errString("CacheRedis is required for monc entity cache"))
	}

	settingsCodec, err := c.NewMemberSettingsCodec()
	if err != nil {
		logx.Must(err)
	}
	repo := memberRepo.NewMoncStore(c.Mongo.URI, c.Mongo.Database, c.CacheRedis, c.CacheRedis[0].RedisConf, c.Redis.Namespace, settingsCodec)
	redisClient := redis.MustNewRedis(c.CacheRedis[0].RedisConf)
	logx.Infof("mongo monc ready db=%s redis_cache=%s", c.Mongo.Database, c.CacheRedis[0].Host)

	issuer := tokenUC.NewJWTIssuer(
		c.Auth.AccessSecret,
		c.Auth.RefreshSecret,
		c.Auth.AccessExpire,
		c.Auth.RefreshExpire,
	)
	cost := c.Bcrypt.Cost
	if cost <= 0 {
		cost = 10
	}
	auth := memberUC.NewAccountService(repo, issuer, cost)
	auth.GoogleClientID = c.OAuth.GoogleClientID
	auth.LineClientID = c.OAuth.LineClientID
	auth.LineClientSecret = c.OAuth.LineClientSecret
	auth.LineRedirectURI = c.OAuth.LineRedirectURI

	notify := newNotification(c)
	store := newStorage(c)

	appN := appnotifUC.New(appnotifRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database))
	jobs := jobUC.New(jobRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database))
	jobs.Notifier = appN
	threadsSvc := newThreads(c)
	// 連帳成功 → 排程 ~30 天後 token renew job
	threadsSvc.Renew = jobs

	keyRes := &usageUC.SettingsResolver{
		Members:          repo,
		PlatformAI:       c.Platform.AIKey,
		PlatformXAI:      c.Platform.XAIKey,
		PlatformOpenCode: c.Platform.OpenCodeKey,
		PlatformExa:      c.Platform.ExaKey,
	}
	usageSvc := usageUC.New(usageRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database), keyRes)
	usageSvc.Gate = usageUC.NewRedisPlatformGate(c.CacheRedis[0].RedisConf, 120, 60, c.Redis.Namespace)
	billingRepo := billingModule.NewMongoRepository(c.Mongo.URI, c.Mongo.Database)
	billingSvc := &billingModule.Service{
		Repo: billingRepo, Enabled: c.Stripe.Enabled, WebhookSecret: c.Stripe.WebhookSecret,
		StarterPriceID: c.Stripe.StarterPriceID, ProPriceID: c.Stripe.ProPriceID,
		SuccessURL: c.Stripe.SuccessURL, CancelURL: c.Stripe.CancelURL, PortalReturnURL: c.Stripe.PortalReturnURL,
	}
	if c.Stripe.Enabled && strings.TrimSpace(c.Stripe.SecretKey) != "" {
		billingSvc.Provider = billingModule.NewStripeProvider(c.Stripe.SecretKey)
		logx.Info("billing: Stripe subscriptions enabled")
	} else {
		logx.Info("billing: Stripe disabled")
	}

	// M4 studio: mon store + publish transport（有 Meta 憑證則真發文／同步）
	var pub studioPublish.Transport
	var mediaSource studioUC.ThreadsMediaSource
	if strings.TrimSpace(c.Platform.ThreadsAppId) != "" && strings.TrimSpace(c.Platform.ThreadsAppSecret) != "" {
		pub = studioPublish.NewMeta("https://graph.threads.net")
		mediaSource = &metaMediaBridge{Meta: threadsProv.NewMeta(c.Platform.ThreadsAppId, c.Platform.ThreadsAppSecret)}
		logx.Info("studio: meta publish + own-posts sync enabled")
	} else {
		pub = studioPublish.NewUnavailable("set THREADS_APP_ID and THREADS_APP_SECRET")
		logx.Info("studio: Threads publishing disabled until credentials are configured")
	}
	modelsCache := ai.NewRedisModelsCache(c.CacheRedis[0].RedisConf, c.Redis.Namespace)
	aiRegistry := ai.NewRegistry(modelsCache, c.Redis.AIModelCacheFingerprintSecret)
	aiClient, _ := aiRegistry.Client(ai.ProviderXAI)
	searchClient := search.NewExa()
	studioSvc := studioUC.New(studioRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database), pub)
	studioSvc.Accounts = threadsSvc // *threadsUC.Service implements AccountLookup
	studioSvc.Usage = usageSvc
	studioSvc.AI = aiClient
	studioSvc.AIRegistry = aiRegistry
	studioSvc.Keys = &studioAIKeys{Members: repo, Resolver: keyRes}
	studioSvc.Media = mediaSource
	studioSvc.Storage = store
	studioSvc.StoragePublicBase = strings.TrimSpace(c.ObjectStorage.PublicBaseURL)
	// 人設分析：API 只入列 job，worker 爬取／LLM 後存檔（離開頁面不中斷）
	studioSvc.Jobs = &personaJobBridge{Jobs: jobs}

	// M5 inspire + scout（真 AI + stream）
	inspireSvc := inspireUC.New(inspireRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database))
	inspireSvc.Usage = usageSvc
	inspireSvc.AI = aiClient
	inspireSvc.AIRegistry = aiRegistry
	inspireSvc.ResolveAI = func(ctx context.Context, uid int64) (provider, model, apiKey string, err error) {
		return (&studioAIKeys{Members: repo, Resolver: keyRes}).ResolveAI(ctx, uid)
	}
	inspireSvc.Search = searchClient
	inspireSvc.ResolveKey = func(ctx context.Context, uid int64, meter string) (string, string, error) {
		return keyRes.ResolveKey(ctx, uid, meter)
	}
	scoutSvc := scoutUC.New(scoutRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database))
	scoutSvc.Settings = &devModeFromMembers{Members: repo}
	scoutSvc.AI = aiClient
	scoutSvc.AIRegistry = aiRegistry
	scoutSvc.Usage = usageSvc
	scoutSvc.ResolveAI = func(ctx context.Context, uid int64) (provider, model, apiKey string, err error) {
		return (&studioAIKeys{Members: repo, Resolver: keyRes}).ResolveAI(ctx, uid)
	}
	scoutSvc.ReplyQueue = &scoutReplyQueue{Studio: studioSvc}
	scoutSvc.Provider = scoutUC.NewExaThreadsProvider(c.Platform.ExaKey)
	scoutSvc.SessionSecret = c.Scout.SessionSecret
	scoutSvc.Crawler = scoutUC.NewHTTPCrawlerProvider(c.Scout.CrawlerEndpoint, c.Scout.CrawlerToken)
	// 人設對標帳號分析：可用 Chrome 同步的 crawler session 讀公開頁
	studioSvc.Crawler = scoutSvc
	// 靈感 chat 注入真人人設 + 品牌產品目錄
	inspireSvc.Personas = &inspirePersonaBridge{Studio: studioSvc}
	inspireSvc.Brands = &inspireBrandBridge{Scout: scoutSvc}

	growthSvc := growthUC.New(growthRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database))
	growthSvc.Notifier = &growthNotifBridge{App: appN}
	growthSvc.Bonus = usageSvc
	// Scout mark-published → outcome hook
	scoutSvc.OnOutreachPublished = func(ctx context.Context, ownerUID int64, postID, accountID string) {
		_, _ = growthSvc.RecordPublished(ctx, ownerUID, "scout_outreach", postID, accountID, 0)
	}
	// Outbox step published → outcome
	studioSvc.OnStepPublished = func(ctx context.Context, ownerUID int64, bundleID, stepID, accountID string) {
		_, _ = growthSvc.RecordPublished(ctx, ownerUID, "outbox_step", bundleID+":"+stepID, accountID, 0)
	}

	radarSvc := radarUC.New(radarRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database))
	radarSvc.Quota = &radarQuotaBridge{Usage: usageSvc}
	radarSvc.Usage = usageSvc
	radarSvc.AI = aiClient
	radarSvc.AIRegistry = aiRegistry
	radarSvc.ResolveAI = func(ctx context.Context, uid int64) (provider, model, apiKey string, err error) {
		return (&studioAIKeys{Members: repo, Resolver: keyRes}).ResolveAI(ctx, uid)
	}
	radarSvc.ResolveKey = func(ctx context.Context, uid int64, meter string) (string, string, error) {
		return keyRes.ResolveKey(ctx, uid, meter)
	}
	// 關鍵字建議拿既有海巡痛點詞當素材，不另建第二套關鍵字引擎。
	radarSvc.PainTerms = &radarPainTermBridge{Scout: scoutSvc}
	radarSvc.ProductSource = &radarProductContextBridge{Scout: scoutSvc}
	scoutSvc.RadarLifecycle = radarSvc

	crmSvc := crmUC.New(crmRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database))
	crmSvc.Growth = &crmGrowthBridge{Growth: growthSvc}
	crmSvc.RadarOpps = &crmRadarOppBridge{Radar: radarSvc}
	crmSvc.Notifier = &crmFollowUpNotifBridge{App: appN}
	radarSvc.CRM = crmSvc
	radarSvc.HitFetch = &scoutHitAdapter{Scout: scoutSvc}
	radarSvc.Notifier = radarUC.NotifierFromAppNotif(&radarSystemNotif{App: appN})
	radarSvc.Health = &radarHealthBridge{Growth: growthSvc}
	// 商機回覆一鍵送出：同一條 Outbox 佇列＋同一套 crawler media id 解析，不重造第二套送出路徑。
	radarSvc.ReplyQueue = &scoutReplyQueue{Studio: studioSvc}
	radarSvc.MediaResolver = scoutSvc
	// 每日巡走同日去重；立即巡邏另開 manual job，避免當天已成功的日巡把按鈕吞掉。
	radarSvc.SweepJobs = radarSweepJobs{jobs: jobs}

	zipPath := findExtensionZip()

	return &ServiceContext{
		Config:           c,
		Redis:            redisClient,
		Members:          repo,
		Auth:             auth,
		Token:            issuer,
		Notification:     notify,
		Storage:          store,
		Threads:          threadsSvc,
		Jobs:             jobs,
		AppNotif:         appN,
		Usage:            usageSvc,
		Billing:          billingSvc,
		KeyResolver:      keyRes,
		AI:               aiClient,
		AIRegistry:       aiRegistry,
		Search:           searchClient,
		Studio:           studioSvc,
		PublishTransport: pub,
		Inspire:          inspireSvc,
		Scout:            scoutSvc,
		Growth:           growthSvc,
		Radar:            radarSvc,
		Crm:              crmSvc,
		ExtensionZipPath: zipPath,
		AuthJWT:          middleware.NewAuthJWTMiddleware(issuer, repo).Handle,
		AdminAuth:        middleware.NewAdminAuthMiddleware().Handle,
		StripeRawBody:    middleware.NewStripeRawBodyMiddleware().Handle,
	}
}

// devModeFromMembers adapts member settings for scout path selection.
type devModeFromMembers struct {
	Members memberDomain.Repository
}

type scoutReplyQueue struct{ Studio *studioUC.Service }

type radarProductContextBridge struct{ Scout *scoutUC.Service }

func (b *radarProductContextBridge) GetBrand(ctx context.Context, ownerUID int64, id string) (*radarUC.ProductBrand, error) {
	brand, err := b.Scout.GetBrand(ctx, ownerUID, id)
	if err != nil {
		return nil, err
	}
	return &radarUC.ProductBrand{ID: brand.ID, OwnerUID: brand.OwnerUID, DisplayName: brand.DisplayName, TargetAudience: brand.TargetAudience, UpdatedAt: brand.UpdatedAt}, nil
}
func (b *radarProductContextBridge) GetProduct(ctx context.Context, ownerUID int64, id string) (*radarUC.ProductCatalogProduct, error) {
	product, err := b.Scout.GetProduct(ctx, ownerUID, id)
	if err != nil {
		return nil, err
	}
	return &radarUC.ProductCatalogProduct{ID: product.ID, OwnerUID: product.OwnerUID, BrandID: product.BrandID, Label: product.Label, ProductContext: product.ProductContext, PainPoints: product.PainPoints, MatchTags: product.MatchTags, ProviderCapabilityTerms: product.ProviderCapabilityTerms, ProviderExcludeTerms: product.ProviderExcludeTerms, UpdatedAt: product.UpdatedAt}, nil
}

func (q *scoutReplyQueue) QueueExternalReply(ctx context.Context, ownerUID int64, accountID, replyToMediaID, text, title string) (string, error) {
	bundle, err := q.Studio.QueueExternalReply(ctx, ownerUID, accountID, replyToMediaID, text, title)
	if err != nil {
		return "", err
	}
	return bundle.ID, nil
}

func (d *devModeFromMembers) DevModeEnabled(ctx context.Context, uid int64) (bool, error) {
	if d == nil || d.Members == nil {
		return false, nil
	}
	st, err := d.Members.GetSettings(ctx, uid)
	if err != nil {
		return false, err
	}
	if st == nil {
		return false, nil
	}
	return st.DevModeEnabled, nil
}

func findExtensionZip() string {
	cands := make([]string, 0, 5)
	if executable, err := os.Executable(); err == nil {
		cands = append(cands, filepath.Join(filepath.Dir(executable), "..", "web", "downloads", "haixun-threads-sync.zip"))
	}
	cands = append(cands,
		"../web/public/downloads/haixun-threads-sync.zip",
		"../../apps/web/public/downloads/haixun-threads-sync.zip",
		"apps/web/public/downloads/haixun-threads-sync.zip",
		"/home/daniel/thread-master/apps/web/public/downloads/haixun-threads-sync.zip",
	)
	for _, c := range cands {
		if st, err := os.Stat(c); err == nil && st.Size() > 0 {
			abs, _ := filepath.Abs(c)
			logx.Infof("extension zip: %s (%d bytes)", abs, st.Size())
			return abs
		}
	}
	logx.Error("extension zip not found — ST-16 download will 404")
	return ""
}

func newThreads(c config.Config) *threadsUC.Service {
	repo := threadsRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database)
	secret := c.Auth.AccessSecret
	web := strings.TrimRight(c.PublicWebBase, "/")
	if web == "" {
		web = "http://127.0.0.1:5173"
	}
	port := c.Port
	if port == 0 {
		port = 8888
	}
	// Meta 拒絕 http://127.0.0.1 redirect_uri（error 1349187）。
	// 優先 PublicAPIBase → 與 PublicWebBase 同 host（nginx 反代 /api）→ 本機 loopback。
	apiBase := strings.TrimRight(c.PublicAPIBase, "/")
	if apiBase == "" && strings.HasPrefix(web, "https://") {
		apiBase = web
	}
	if apiBase == "" {
		apiBase = fmt.Sprintf("http://127.0.0.1:%d", port)
	}

	var provider threadsDomain.Provider
	if strings.TrimSpace(c.Platform.ThreadsAppId) != "" && strings.TrimSpace(c.Platform.ThreadsAppSecret) != "" {
		provider = threadsProv.NewMeta(c.Platform.ThreadsAppId, c.Platform.ThreadsAppSecret)
		logx.Infof("threads oauth: meta provider callback=%s/api/v1/threads-accounts/oauth/callback", apiBase)
		if strings.HasPrefix(apiBase, "http://") {
			logx.Error("threads oauth: callback is HTTP — Meta will block (use https PublicAPIBase / PublicWebBase)")
		}
	} else {
		provider = threadsProv.NewUnavailable("set THREADS_APP_ID and THREADS_APP_SECRET")
		logx.Info("threads oauth disabled until credentials are configured")
	}
	svc := threadsUC.New(repo, provider, secret)
	svc.APIPublicBase = apiBase
	svc.WebBase = web
	svc.CallbackPath = "/api/v1/threads-accounts/oauth/callback"
	return svc
}

func newStorage(c config.Config) fsDomain.Storage {
	os := c.ObjectStorage
	if strings.TrimSpace(os.Endpoint) == "" {
		logx.Error("object storage: Endpoint empty — uploads disabled (MinIO/S3 required)")
		return noop.New()
	}
	s, err := s3store.New(s3store.Config{
		Endpoint: os.Endpoint, Region: os.Region, Bucket: os.Bucket,
		AccessKey: os.AccessKey, SecretKey: os.SecretKey,
		UsePathStyle: os.UsePathStyle, PublicBaseURL: os.PublicBaseURL,
	})
	if err != nil {
		logx.Errorf("object storage MinIO/S3 init failed: %v — uploads disabled", err)
		return noop.New()
	}
	logx.Infof("object storage: minio/s3 endpoint=%s bucket=%s", os.Endpoint, os.Bucket)
	return s
}

func newNotification(c config.Config) notifDomain.UseCase {
	base := strings.TrimRight(c.PublicWebBase, "/")
	if base == "" {
		base = "http://127.0.0.1:5173"
	}
	brand := notifDomain.Brand{
		Name: c.Brand.Name, Link: base, LogoURL: c.Brand.LogoURL, Copyright: c.Brand.Copyright,
	}
	if brand.Name == "" {
		brand.Name = "Harbor Desk"
	}
	if brand.LogoURL == "" {
		brand.LogoURL = base + "/brand-mark.svg"
	}
	if brand.Copyright == "" {
		brand.Copyright = "© Harbor Desk"
	}
	cfg := notifUC.Config{
		Sender:        c.Mail.Sender,
		DevExposeCode: c.Mail.DevExposeCode,
		Brand:         brand,
	}
	if c.Mail.SMTP.Host != "" {
		cfg.Providers = []notifDomain.EmailDelivery{
			notifUC.NewSMTPDelivery(notifUC.SMTPConfig{
				Host: c.Mail.SMTP.Host, Port: c.Mail.SMTP.Port,
				User: c.Mail.SMTP.User, Password: c.Mail.SMTP.Password,
			}),
		}
		logx.Infof("notification mail: smtp host=%s port=%d logo=%s expose_code=%v",
			c.Mail.SMTP.Host, c.Mail.SMTP.Port, brand.LogoURL, c.Mail.DevExposeCode)
	} else {
		cfg.Providers = []notifDomain.EmailDelivery{notifUC.NewLogDelivery()}
		logx.Info("notification mail: log-only (set Mail.SMTP.Host to send real email)")
	}
	return notifUC.NewService(cfg)
}

type errString string

func (e errString) Error() string { return string(e) }

// metaMediaBridge adapts threads MetaProvider → studio.ThreadsMediaSource
type metaMediaBridge struct {
	Meta *threadsProv.MetaProvider
}

func (b *metaMediaBridge) ListThreads(ctx context.Context, accessToken string, limit int) ([]studioUC.FetchedThread, error) {
	list, err := b.Meta.ListThreads(ctx, accessToken, limit)
	if err != nil {
		return nil, err
	}
	out := make([]studioUC.FetchedThread, 0, len(list))
	for _, t := range list {
		pub := int64(0)
		if !t.Timestamp.IsZero() {
			pub = t.Timestamp.UnixNano()
		}
		out = append(out, studioUC.FetchedThread{
			ID: t.ID, Text: t.Text, MediaType: t.MediaType, MediaURL: t.MediaURL,
			ThumbnailURL: t.ThumbnailURL, Permalink: t.Permalink, Shortcode: t.Shortcode,
			TopicTag: t.TopicTag, Username: t.Username, PublishedAt: pub,
			ReplyControl: t.ReplyControl,
		})
	}
	return out, nil
}

func (b *metaMediaBridge) GetInsights(ctx context.Context, accessToken, mediaID string) (studioUC.FetchedInsights, error) {
	ins, err := b.Meta.GetInsights(ctx, accessToken, mediaID)
	if err != nil {
		return studioUC.FetchedInsights{Status: "error", ErrorMsg: err.Error()}, nil
	}
	return studioUC.FetchedInsights{
		Views: ins.Views, Likes: ins.Likes, Replies: ins.Replies, Reposts: ins.Reposts,
		Quotes: ins.Quotes, Shares: ins.Shares, Status: ins.Status, ErrorMsg: ins.ErrorMsg,
	}, nil
}

func (b *metaMediaBridge) ListConversation(ctx context.Context, accessToken, mediaID string, limit int) ([]studioUC.FetchedReply, error) {
	list, err := b.Meta.ListConversation(ctx, accessToken, mediaID, limit)
	if err != nil {
		return nil, nil
	}
	out := make([]studioUC.FetchedReply, 0, len(list))
	for _, r := range list {
		pub := int64(0)
		if !r.Timestamp.IsZero() {
			pub = r.Timestamp.UnixNano()
		}
		out = append(out, studioUC.FetchedReply{
			ID: r.ID, Text: r.Text, Username: r.Username, ParentMediaID: r.ParentMediaID,
			PublishedAt: pub, IsMine: r.IsMine, LikeCount: r.LikeCount, HideStatus: r.HideStatus,
		})
	}
	return out, nil
}

func (b *metaMediaBridge) ManageReply(ctx context.Context, accessToken, replyID string, hide bool) error {
	return b.Meta.ManageReply(ctx, accessToken, replyID, hide)
}

func (b *metaMediaBridge) SetReplyControl(ctx context.Context, accessToken, mediaID, control string) error {
	return b.Meta.SetReplyControl(ctx, accessToken, mediaID, control)
}

func (b *metaMediaBridge) ListMentions(ctx context.Context, accessToken, threadsUserID string, limit int) ([]studioUC.FetchedMention, error) {
	list, err := b.Meta.ListMentions(ctx, accessToken, threadsUserID, limit)
	if err != nil {
		return nil, err
	}
	out := make([]studioUC.FetchedMention, 0, len(list))
	for _, m := range list {
		pub := int64(0)
		if !m.Timestamp.IsZero() {
			pub = m.Timestamp.UnixNano()
		}
		out = append(out, studioUC.FetchedMention{
			ID: m.ID, Text: m.Text, Username: m.Username, Permalink: m.Permalink,
			RootPostID: m.RootPostID, ParentID: m.ParentID,
			IsReply: m.IsReply, IsQuotePost: m.IsQuotePost, PublishedAt: pub,
		})
	}
	return out, nil
}

func (b *metaMediaBridge) ListProfilePosts(ctx context.Context, accessToken, username string, limit int) ([]studioUC.FetchedThread, error) {
	list, err := b.Meta.ListProfilePosts(ctx, accessToken, username, limit)
	if err != nil {
		return nil, err
	}
	out := make([]studioUC.FetchedThread, 0, len(list))
	for _, t := range list {
		pub := int64(0)
		if !t.Timestamp.IsZero() {
			pub = t.Timestamp.UnixNano()
		}
		out = append(out, studioUC.FetchedThread{
			ID: t.ID, Text: t.Text, MediaType: t.MediaType, MediaURL: t.MediaURL,
			ThumbnailURL: t.ThumbnailURL, Permalink: t.Permalink, Shortcode: t.Shortcode,
			TopicTag: t.TopicTag, Username: t.Username, PublishedAt: pub,
			ReplyControl: t.ReplyControl,
		})
	}
	return out, nil
}

type radarSweepJobs struct {
	jobs *jobUC.Service
}

func (a radarSweepJobs) ScheduleRadarSweep(ctx context.Context, ownerUID int64, watchID string, runAt int64) (string, error) {
	if a.jobs == nil {
		return "", fmt.Errorf("jobs not configured")
	}
	j, err := a.jobs.ScheduleRadarSweep(ctx, ownerUID, watchID, runAt)
	if err != nil {
		return "", err
	}
	return j.ID, nil
}

func (a radarSweepJobs) ScheduleManualRadarSweep(ctx context.Context, ownerUID int64, watchID string, runAt int64) (string, error) {
	if a.jobs == nil {
		return "", fmt.Errorf("jobs not configured")
	}
	j, err := a.jobs.ScheduleManualRadarSweep(ctx, ownerUID, watchID, runAt)
	if err != nil {
		return "", err
	}
	return j.ID, nil
}

// personaJobBridge adapts job.Service → studio.PersonaAnalyzeScheduler (jobID only).
type personaJobBridge struct {
	Jobs *jobUC.Service
}

func (b *personaJobBridge) SchedulePersonaAnalyzeAccount(ctx context.Context, ownerUID int64, personaID, username, lang string) (string, error) {
	if b == nil || b.Jobs == nil {
		return "", fmt.Errorf("jobs not configured")
	}
	j, err := b.Jobs.SchedulePersonaAnalyzeAccount(ctx, ownerUID, personaID, username, lang)
	if err != nil {
		return "", err
	}
	return j.ID, nil
}

func (b *personaJobBridge) SchedulePersonaAnalyzeText(ctx context.Context, ownerUID int64, personaID, rawText, sourceLabel, lang string) (string, error) {
	if b == nil || b.Jobs == nil {
		return "", fmt.Errorf("jobs not configured")
	}
	j, err := b.Jobs.SchedulePersonaAnalyzeText(ctx, ownerUID, personaID, rawText, sourceLabel, lang)
	if err != nil {
		return "", err
	}
	return j.ID, nil
}

// studioAIKeys adapts member settings + usage key resolver for persona LLM.
type studioAIKeys struct {
	Members  memberDomain.Repository
	Resolver *usageUC.SettingsResolver
}

func (k *studioAIKeys) ResolveAI(ctx context.Context, ownerUID int64) (provider, model, apiKey string, err error) {
	st, err := k.Members.GetSettings(ctx, ownerUID)
	if err != nil {
		return "", "", "", err
	}
	if st == nil {
		st = memberDomain.DefaultSettings(ownerUID)
	}
	// 完全依會員 AI 設定，不寫死／不替換模型 id
	provider = ai.NormalizeProvider(st.Provider)
	model = strings.TrimSpace(st.Model)
	if model == "" {
		return provider, "", "", fmt.Errorf("請到設定選擇 AI 模型")
	}
	if k.Resolver != nil {
		_, key, rerr := k.Resolver.ResolveKey(ctx, ownerUID, usageDomain.MeterAICopy)
		if rerr != nil && !errors.Is(rerr, usageDomain.ErrNoKey) {
			return provider, model, "", rerr
		}
		if rerr == nil && strings.TrimSpace(key) != "" {
			return provider, model, key, nil
		}
	}
	// BYOK direct
	if key := st.KeyForProvider(provider); key != "" {
		return provider, model, key, nil
	}
	return provider, model, "", fmt.Errorf("no AI key（請到設定填寫 Key，或確認平台已配置）")
}

// inspirePersonaBridge — studio 人設 → 靈感 prompt 完整快照
type inspirePersonaBridge struct {
	Studio *studioUC.Service
}

func (b *inspirePersonaBridge) ResolvePersona(ctx context.Context, ownerUID int64, personaID string) (*inspireUC.PersonaSnapshot, error) {
	if b == nil || b.Studio == nil {
		return nil, nil
	}
	if strings.TrimSpace(personaID) != "" {
		if got, err := b.Studio.GetPersona(ctx, ownerUID, personaID); err == nil && got != nil {
			return studioPersonaToSnapshot(got), nil
		}
	}
	if aid, err := b.Studio.GetActivePersonaID(ctx, ownerUID); err == nil && aid != "" {
		if got, gerr := b.Studio.GetPersona(ctx, ownerUID, aid); gerr == nil && got != nil {
			return studioPersonaToSnapshot(got), nil
		}
	}
	list, err := b.Studio.ListPersonas(ctx, ownerUID)
	if err != nil || len(list) == 0 {
		return nil, nil
	}
	return studioPersonaToSnapshot(list[0]), nil
}

func studioPersonaToSnapshot(p *studioDomain.Persona) *inspireUC.PersonaSnapshot {
	if p == nil {
		return nil
	}
	fp := strings.TrimSpace(p.Style.DraftText)
	if fp == "" {
		fp = serializePersonaDraft(p.Style.Draft)
	}
	dims := map[string]string{}
	for k, d := range p.Style.Dimensions {
		if sm := strings.TrimSpace(d.Summary); sm != "" {
			dims[k] = sm
		}
	}
	avoid := append([]string{}, p.Guard.Avoid...)
	return &inspireUC.PersonaSnapshot{
		ID: p.ID, Name: p.Name, Brief: p.Brief, Status: p.Status,
		DraftText: fp, Voice: p.Voice, GuardAvoid: avoid,
		MaxChars: p.Guard.MaxChars, BanAiTone: p.Guard.BanAiTone,
		DimSummaries: dims,
	}
}

func serializePersonaDraft(d studioDomain.PersonaDraft) string {
	type pair struct{ label, value string }
	parts := []pair{
		{"我是誰", d.Identity},
		{"語氣", d.Tone},
		{"對誰說", d.Audience},
		{"開場鉤子", d.Hooks},
		{"語言指紋", d.LanguageFingerprint},
		{"節奏", d.Rhythm},
		{"標點", d.Punctuation},
		{"內容套路", d.ContentPatterns},
		{"知識轉譯", d.KnowledgeTranslation},
		{"CTA", d.CtaStyle},
		{"像他會說的話", d.Examples},
		{"絕不怎麼說", d.Avoid},
	}
	var lines []string
	for _, p := range parts {
		v := strings.TrimSpace(p.value)
		if v == "" {
			continue
		}
		lines = append(lines, "【"+p.label+"】\n"+v)
	}
	return strings.Join(lines, "\n\n")
}

// inspireBrandBridge — scout 品牌／產品目錄
type inspireBrandBridge struct {
	Scout *scoutUC.Service
}

func (b *inspireBrandBridge) ListCatalog(ctx context.Context, ownerUID int64) ([]inspireUC.BrandSnapshot, error) {
	if b == nil || b.Scout == nil {
		return nil, nil
	}
	brands, err := b.Scout.ListBrands(ctx, ownerUID)
	if err != nil {
		return nil, err
	}
	out := make([]inspireUC.BrandSnapshot, 0, len(brands))
	for _, br := range brands {
		if br == nil {
			continue
		}
		snap := inspireUC.BrandSnapshot{
			ID: br.ID, DisplayName: br.DisplayName, Brief: br.Brief,
			Audience: br.TargetAudience, Goals: br.Goals,
		}
		prods, _ := b.Scout.ListProducts(ctx, ownerUID, br.ID)
		for _, pr := range prods {
			if pr == nil {
				continue
			}
			snap.Products = append(snap.Products, inspireUC.ProductSnapshot{
				ID: pr.ID, Label: pr.Label, Context: pr.ProductContext,
				Tags:  append([]string{}, pr.MatchTags...),
				Pains: append([]string{}, pr.PainPoints...),
			})
		}
		out = append(out, snap)
	}
	return out, nil
}

/*
radarQuotaBridge 讓 radar module 讀既有方案定義，而不必反過來依賴 usage module。

雷達上限跟著方案走，但不經 meter：AI 判定與回覆仍計既有四個 meter，
這裡只回「可以有幾個常駐訂閱、一天最多收幾筆商機」。
*/
type radarQuotaBridge struct {
	Usage *usageUC.Service
}

func (b *radarQuotaBridge) RadarQuota(ctx context.Context, ownerUID int64) (int, int, error) {
	if b == nil || b.Usage == nil {
		return 0, 0, errString("usage service unavailable for radar quota")
	}
	plan, err := b.Usage.PlanFor(ctx, ownerUID)
	if err != nil {
		return 0, 0, err
	}
	return plan.MaxActiveWatches, plan.MaxDailyOpportunities, nil
}

/*
radarPainTermBridge 把既有海巡產品的痛點關鍵字餵給雷達的關鍵字建議當素材。

只讀不寫，也不影響海巡行為；沒有品牌／產品的人就是拿不到素材，建議品質下降但功能照跑。
*/
type radarPainTermBridge struct {
	Scout *scoutUC.Service
}

func (b *radarPainTermBridge) PainTerms(ctx context.Context, ownerUID int64) ([]string, error) {
	if b == nil || b.Scout == nil {
		return nil, nil
	}
	products, err := b.Scout.ListAllProducts(ctx, ownerUID)
	if err != nil {
		return nil, err
	}
	out := make([]string, 0, 16)
	seen := map[string]bool{}
	for _, p := range products {
		for _, pain := range p.PainPoints {
			pain = strings.TrimSpace(pain)
			if pain == "" || seen[pain] {
				continue
			}
			seen[pain] = true
			out = append(out, pain)
			// prompt 素材夠用就好，塞太多會擠掉服務檔案本身的資訊。
			if len(out) >= 30 {
				return out, nil
			}
		}
	}
	return out, nil
}

type growthNotifBridge struct {
	App *appnotifUC.Service
}

func (b *growthNotifBridge) Notify(ctx context.Context, ownerUID int64, title, body, kind, refType, refID string) error {
	if b == nil || b.App == nil {
		return nil
	}
	// Reuse job-state channel for system notes via NotifyJobState with synthetic id.
	return b.App.NotifyJobState(ctx, ownerUID, "growth:"+refType+":"+refID, "growth", "succeeded", title+": "+body, 100)
}

type radarSystemNotif struct{ App *appnotifUC.Service }

func (b *radarSystemNotif) InsertSystem(ctx context.Context, ownerUID int64, title, body, refType, refID string) error {
	if b == nil || b.App == nil {
		return nil
	}
	return b.App.NotifyJobState(ctx, ownerUID, refType+":"+refID, "radar", "failed", title+": "+body, 0)
}

type crmFollowUpNotifBridge struct{ App *appnotifUC.Service }

func (b *crmFollowUpNotifBridge) NotifyFollowUp(ctx context.Context, ownerUID int64, contactID, followUpID string) error {
	if b == nil || b.App == nil {
		return nil
	}
	return b.App.NotifyJobState(ctx, ownerUID, "followup:"+followUpID, "radar_followup", "succeeded",
		"待追蹤到期：請回訪聯絡人", 100)
}

type crmRadarOppBridge struct{ Radar *radarUC.Service }

func (b *crmRadarOppBridge) GetOpportunity(ctx context.Context, id string) (*radarDomain.Opportunity, error) {
	if b == nil || b.Radar == nil {
		return nil, radarDomain.ErrNotFound
	}
	return b.Radar.Repo.GetOpportunity(ctx, id)
}

func (b *crmRadarOppBridge) ListOpportunities(
	ctx context.Context, ownerUID int64, f radarDomain.OpportunityListFilter,
) ([]*radarDomain.Opportunity, int64, error) {
	if b == nil || b.Radar == nil {
		return nil, 0, radarDomain.ErrNotFound
	}
	return b.Radar.Repo.ListOpportunities(ctx, ownerUID, f)
}

type radarHealthBridge struct{ Growth *growthUC.Service }

func (b *radarHealthBridge) WorstLevel(ctx context.Context, ownerUID int64) (level string, advice string, err error) {
	if b == nil || b.Growth == nil {
		return "ok", "", nil
	}
	list, err := b.Growth.ListHealth(ctx, ownerUID)
	if err != nil {
		return "", "", err
	}
	level = "ok"
	for _, h := range list {
		if h == nil {
			continue
		}
		switch h.Level {
		case "throttle":
			return "throttle", h.Advice, nil
		case "warn":
			level = "warn"
			if advice == "" {
				advice = h.Advice
			}
		}
	}
	return level, advice, nil
}

type crmGrowthBridge struct{ Growth *growthUC.Service }

func (b *crmGrowthBridge) RecordConversion(ctx context.Context, ownerUID int64, contactID string, amount float64, currency, note string) (string, error) {
	e, err := b.Growth.RecordPublished(ctx, ownerUID, "radar_opportunity", contactID, "", 0)
	if err != nil {
		return "", err
	}
	out, err := b.Growth.ReportConversion(ctx, ownerUID, e.ID, amount, note, currency)
	if err != nil {
		return e.ID, err
	}
	return out.ID, nil
}

func (b *crmGrowthBridge) AmendConversion(ctx context.Context, ownerUID int64, outcomeID string, amount float64, currency, note string) error {
	_, err := b.Growth.UpdateConversion(ctx, ownerUID, outcomeID, amount, note, currency)
	return err
}

type scoutHitAdapter struct{ Scout *scoutUC.Service }

func (a *scoutHitAdapter) SearchHits(ctx context.Context, ownerUID int64, terms []string, limit int) ([]radarUC.ThreadHit, string, error) {
	if a == nil || a.Scout == nil {
		return nil, "", fmt.Errorf("scout not configured")
	}
	hits, path, err := a.Scout.SearchHitsOnly(ctx, ownerUID, terms, limit)
	if err != nil {
		return nil, path, err
	}
	out := make([]radarUC.ThreadHit, 0, len(hits))
	for _, h := range hits {
		out = append(out, radarUC.ThreadHit{URL: h.URL, Title: h.Title, Snippet: h.Snippet, PublishedAt: h.PublishedAt})
	}
	return out, path, nil
}
