package job

import (
	"context"
	"fmt"
	"strings"
	"sync"

	"haixun-backend/internal/library/clock"
	app "haixun-backend/internal/library/errors"
	"haixun-backend/internal/library/errors/code"
	libkg "haixun-backend/internal/library/knowledge"
	"haixun-backend/internal/library/placement"
	libprompt "haixun-backend/internal/library/prompt"
	"haixun-backend/internal/library/websearch"
	"haixun-backend/internal/model/ai/domain/enum"
	domai "haixun-backend/internal/model/ai/domain/usecase"
	aiusecase "haixun-backend/internal/model/ai/usecase"
	brandentity "haixun-backend/internal/model/brand/domain/entity"
	branddomain "haixun-backend/internal/model/brand/domain/usecase"
	jobdom "haixun-backend/internal/model/job/domain/usecase"
	kgusecase "haixun-backend/internal/model/knowledge_graph/domain/usecase"
	placementusecase "haixun-backend/internal/model/placement/usecase"
	topicdomain "haixun-backend/internal/model/placement_topic/domain/usecase"
	threadsaccountdomain "haixun-backend/internal/model/threads_account/domain/usecase"
)

type ExpandGraphDeps struct {
	Jobs           jobdom.UseCase
	Brand          branddomain.UseCase
	PlacementTopic topicdomain.UseCase
	KnowledgeGraph kgusecase.UseCase
	ThreadsAccount threadsaccountdomain.UseCase
	Placement      placementusecase.UseCase
	AI             aiusecase.UseCase
}

func RegisterExpandGraphHandler(runner *Runner, deps ExpandGraphDeps) {
	if runner == nil {
		return
	}
	runner.RegisterStepHandler("expand", func(ctx context.Context, step StepContext) error {
		return runExpandGraph(ctx, step, deps)
	})
}

func brandIDFromPayload(payload map[string]any) string {
	brandID := stringField(payload, "brand_id")
	if brandID == "" {
		brandID = stringField(payload, "persona_id")
	}
	return brandID
}

func runExpandGraph(ctx context.Context, step StepContext, deps ExpandGraphDeps) error {
	payload := step.Run.Payload
	tenantID := stringField(payload, "tenant_id")
	ownerUID := stringField(payload, "owner_uid")
	seed := stringField(payload, "seed_query")
	supplemental := boolField(payload, "supplemental")

	if tenantID == "" || ownerUID == "" {
		return fmt.Errorf("expand-graph payload missing tenant_id or owner_uid")
	}
	if seed == "" {
		return fmt.Errorf("expand-graph payload missing seed_query")
	}

	scope, err := resolvePlacementScope(ctx, deps.Brand, deps.PlacementTopic, tenantID, ownerUID, payload)
	if err != nil {
		return err
	}
	brand := scope.Brand
	brandID := scope.CatalogBrand
	if brandID == "" {
		return fmt.Errorf("expand-graph payload missing brand_id or topic_id")
	}

	productBrief := strings.TrimSpace(brand.ProductBrief)
	if formatted := placement.ProductBriefFromContext(brand.ProductContext); formatted != "" {
		productBrief = formatted
	}

	research, err := deps.Placement.ResearchSettings(ctx, tenantID, ownerUID)
	if err != nil {
		return err
	}
	memberCtx, err := deps.ThreadsAccount.ResolveMemberPlacementContext(ctx, tenantID, ownerUID, research)
	if err != nil {
		return err
	}
	webClient := websearch.New(memberCtx.WebSearchConfig())

	credential, err := deps.ThreadsAccount.ResolveMemberAiCredential(ctx, tenantID, ownerUID)
	if err != nil {
		return err
	}
	providerID, err := aiusecase.MapWorkerProvider(credential.Provider)
	if err != nil {
		return err
	}

	updateProgress := func(summary string, percentage int) {
		_ = step.Heartbeat(ctx)
		_, _ = deps.Jobs.UpdateProgress(ctx, jobdom.UpdateProgressRequest{
			JobID:      step.JobID,
			WorkerID:   step.WorkerID,
			Phase:      "expand",
			Summary:    summary,
			Percentage: percentage,
		})
	}

	bootstrap := boolField(payload, "bootstrap")
	regenerateMap := boolField(payload, "regenerate_map")
	expandStrategy := libkg.ParseExpandStrategy(stringField(payload, "expand_strategy"))
	needResearchMap := bootstrap || regenerateMap || brand.ResearchMap.IsEmpty()
	prefetchedBrave := []libkg.BraveSource{}
	var prefetchQueries []string

	if needResearchMap && expandStrategy.RequiresWebSearch() {
		updateProgress("平行產生研究地圖與蒐集參考資料…", 5)
		var mapErr error
		var wg sync.WaitGroup
		wg.Add(2)

		go func() {
			defer wg.Done()
			mapErr = ensureResearchMap(ctx, step, deps, brand, productBrief, providerID, credential, updateProgress)
		}()

		go func() {
			defer wg.Done()
			prefetchPlan := kgPlanInput(brand, seed, productBrief, nil, false, expandStrategy)
			prefetchQueries = libkg.PlanBootstrapQueries(prefetchPlan)
			if len(prefetchQueries) == 0 {
				return
			}
			sources, err := runWebKnowledgeExpand(ctx, webClient, memberCtx, prefetchQueries, expandStrategy, func(i, total int) {
				pct := 8 + ((i + 1) * 12 / max(total, 1))
				updateProgress(fmt.Sprintf("預先蒐集參考資料 %d/%d…", i+1, total), pct)
			}, func() error {
				cancelled, _ := deps.Jobs.IsCancelRequested(ctx, step.JobID)
				if cancelled {
					return errJobCancelled
				}
				return ctx.Err()
			})
			if err == nil {
				prefetchedBrave = sources
				return
			}
			prefetchQueries = nil
		}()

		wg.Wait()
		if mapErr != nil {
			return mapErr
		}
		brand, err = reloadScopeBrand(ctx, deps, tenantID, ownerUID, scope)
		if err != nil {
			return err
		}
	} else if needResearchMap {
		updateProgress("產生研究地圖…", 5)
		if err := ensureResearchMap(ctx, step, deps, brand, productBrief, providerID, credential, updateProgress); err != nil {
			return err
		}
		brand, err = reloadScopeBrand(ctx, deps, tenantID, ownerUID, scope)
		if err != nil {
			return err
		}
	}

	var existing *kgusecase.GraphSummary
	if supplemental {
		existing, _ = deps.KnowledgeGraph.Get(ctx, tenantID, ownerUID, brandID)
	}

	braveSources := []libkg.BraveSource{}
	var systemPrompt string
	var userPrompt string

	switch expandStrategy {
	case libkg.ExpandStrategyLLM:
		updateProgress("整理延伸知識…", 20)
		systemPrompt, err = libprompt.KnowledgeGraphLLMSystem()
		if err != nil {
			return app.For(code.AI).SysInternal("knowledge graph llm prompt load failed")
		}
		topicCtx := placement.PlacementTopicContextFromBrand(brand, productBrief)
		kgVars := topicCtx.PromptLines()
		kgVars["seed"] = seed
		kgVars["product_brief_line"] = libkg.OptionalPromptLine("產品簡述", productBrief)
		kgVars["target_audience_line"] = libkg.OptionalPromptLine("目標受眾", brand.TargetAudience)
		kgVars["persona_line"] = libkg.OptionalPromptLine("主題目標", brand.Brief)
		kgVars["research_pillars_line"] = libkg.BulletPromptLine(
			"內容支柱（延伸知識要往這些方向廣泛展開）", brand.ResearchMap.Pillars)
		kgVars["research_questions_line"] = libkg.BulletPromptLine(
			"受眾提問方向（可衍生成更多周邊節點）", brand.ResearchMap.Questions)
		userPrompt, err = libprompt.KnowledgeGraphLLMUser(kgVars)
		if err != nil {
			return app.For(code.AI).SysInternal("knowledge graph llm user prompt load failed")
		}
	default:
		updateProgress("蒐集參考資料…", 10)

		l1Labels := []string{}
		if existing != nil {
			l1Labels = libkg.L1LabelsFromNodes(existing.Nodes)
		}
		planIn := kgPlanInput(brand, seed, productBrief, l1Labels, supplemental, expandStrategy)
		queries := libkg.PlanQueries(planIn)
		if len(prefetchedBrave) > 0 {
			queries = libkg.QueriesExcept(queries, prefetchQueries)
		}

		updateProgress(fmt.Sprintf("蒐集參考資料（%d 項查詢）…", len(queries)+len(prefetchQueries)), 25)

		var moreBrave []libkg.BraveSource
		if len(queries) > 0 {
			moreBrave, err = runWebKnowledgeExpand(ctx, webClient, memberCtx, queries, expandStrategy, func(i, total int) {
				pct := 25 + ((i + 1) * 30 / max(total, 1))
				updateProgress(fmt.Sprintf("蒐集參考資料 %d/%d…", len(prefetchQueries)+i+1, len(prefetchQueries)+total), pct)
			}, func() error {
				cancelled, _ := deps.Jobs.IsCancelRequested(ctx, step.JobID)
				if cancelled {
					return errJobCancelled
				}
				return ctx.Err()
			})
			if err != nil {
				return err
			}
		}
		braveSources = libkg.MergeBraveSources(prefetchedBrave, moreBrave)
		if len(braveSources) == 0 {
			return app.For(code.Setting).SvcThirdParty("暫時無法取得參考資料，請稍後重試")
		}

		updateProgress("整理延伸知識…", 60)

		systemPrompt, err = libprompt.KnowledgeGraphSystem()
		if err != nil {
			return app.For(code.AI).SysInternal("knowledge graph prompt load failed")
		}
		userPrompt, err = libkg.BuildUserPrompt(kgSynthInput(brand, seed, productBrief, braveSources))
		if err != nil {
			return app.For(code.AI).SysInternal("knowledge graph user prompt load failed")
		}
	}
	genReq := placement.ResearchGenerateRequest(domai.GenerateRequest{
		Provider: providerID,
		Model:    credential.Model,
		Credential: domai.Credential{
			APIKey: credential.APIKey,
		},
		System: systemPrompt,
		Messages: []domai.Message{
			{Role: "user", Content: userPrompt},
		},
	})
	result, err := deps.AI.GenerateText(ctx, genReq)
	if err != nil {
		return err
	}

	graph, err := libkg.ParseSynthOutput(result.Text, libkg.SynthInput{
		Seed:           seed,
		ProductBrief:   productBrief,
		TargetAudience: brand.TargetAudience,
	}, braveSources)
	if err != nil {
		return app.For(code.AI).SvcThirdParty("延伸知識產生失敗，請重試：" + err.Error())
	}
	if libkg.GraphTooThin(graph) {
		retryReq := placement.ResearchGenerateRequest(domai.GenerateRequest{
			Provider: providerID,
			Model:    credential.Model,
			Credential: domai.Credential{
				APIKey: credential.APIKey,
			},
			System: systemPrompt,
			Messages: []domai.Message{
				{Role: "user", Content: userPrompt},
				{Role: "assistant", Content: result.Text},
				{Role: "user", Content: libkg.KnowledgeGraphRetryUserPrompt()},
			},
		})
		if retryResult, retryErr := deps.AI.GenerateText(ctx, retryReq); retryErr == nil {
			if retryGraph, parseErr := libkg.ParseSynthOutput(retryResult.Text, libkg.SynthInput{
				Seed:           seed,
				ProductBrief:   productBrief,
				TargetAudience: brand.TargetAudience,
			}, braveSources); parseErr == nil && !libkg.GraphTooThin(retryGraph) {
				graph = retryGraph
			}
		}
	}

	if supplemental && existing != nil {
		graph = mergeGraphs(existing, graph, braveSources)
	}

	needsBreadthExpand := !supplemental &&
		(libkg.GraphNeedsBreadth(graph) || graph.PainTagCount < libkg.MinPainTagCandidates())
	if needsBreadthExpand {
		updateProgress(fmt.Sprintf("擴充延伸知識廣度（目前 %d 節點）…", len(graph.Nodes)), 75)
		planIn := kgPlanInput(brand, seed, productBrief, libkg.L1LabelsFromNodes(graph.Nodes), true, expandStrategy)
		if expandStrategy.UsesSupplementalBrave() {
			suppQueries := libkg.PlanQueries(planIn)
			extraSources, err := runWebKnowledgeExpand(ctx, webClient, memberCtx, suppQueries, expandStrategy, nil, func() error {
				cancelled, _ := deps.Jobs.IsCancelRequested(ctx, step.JobID)
				if cancelled {
					return errJobCancelled
				}
				return ctx.Err()
			})
			if err != nil {
				return err
			}
			braveSources = append(braveSources, extraSources...)
		}
		suppInstruction, err := libprompt.KnowledgeGraphSupplemental()
		if err != nil {
			return app.For(code.AI).SysInternal("knowledge graph supplemental prompt load failed")
		}
		suppUserPrompt, err := libkg.BuildUserPrompt(kgSynthInput(brand, seed, productBrief, braveSources))
		if err != nil {
			return err
		}
		breadthPrompt := libkg.KnowledgeGraphBreadthUserPrompt(len(graph.Nodes))
		suppResult, err := deps.AI.GenerateText(ctx, placement.ResearchGenerateRequest(domai.GenerateRequest{
			Provider: providerID,
			Model:    credential.Model,
			Credential: domai.Credential{
				APIKey: credential.APIKey,
			},
			System: systemPrompt,
			Messages: []domai.Message{
				{Role: "user", Content: suppUserPrompt + "\n\n" + suppInstruction + "\n\n" + breadthPrompt},
			},
		}))
		if err == nil {
			if patched, parseErr := libkg.ParseSynthOutput(suppResult.Text, libkg.SynthInput{Seed: seed}, braveSources); parseErr == nil {
				graph = mergeGraphs(&kgusecase.GraphSummary{
					Seed:  graph.Seed,
					Nodes: graph.Nodes,
					Edges: graph.Edges,
				}, patched, braveSources)
			}
		}
	}

	if libkg.GraphNeedsBootstrap(graph) {
		updateProgress(fmt.Sprintf("從研究地圖補齊延伸知識（目前 %d 節點）…", len(graph.Nodes)), 85)
		libkg.SupplementGraphFromResearchMap(&graph, seed, brand.ResearchMap.Pillars, brand.ResearchMap.Questions)
	}
	patrolInput := libkg.PatrolTagInputFromBrand(brand, productBrief)
	libkg.DeriveSearchTagsFromGraph(&graph, patrolInput)

	updateProgress("整理海巡關鍵字…", 88)
	if err := syncAutoPatrolKeywords(ctx, deps, tenantID, ownerUID, scope, graph.Nodes, productBrief); err != nil {
		return err
	}
	if scope.TopicID != "" {
		topic, topicErr := deps.PlacementTopic.Get(ctx, tenantID, ownerUID, scope.TopicID)
		if topicErr == nil && topic != nil {
			brand = placementTopicAsBrand(scope, topic)
		}
	} else {
		brand, err = deps.Brand.Get(ctx, tenantID, ownerUID, brandID)
		if err != nil {
			return err
		}
	}

	updateProgress("儲存研究地圖…", 90)

	graph.BraveSources = braveSources
	now := clock.NowUnixNano()
	saved, err := deps.KnowledgeGraph.Upsert(ctx, kgusecase.UpsertRequest{
		TenantID:       tenantID,
		OwnerUID:       ownerUID,
		BrandID:        brandID,
		TopicID:        scope.TopicID,
		Seed:           graph.Seed,
		Nodes:          graph.Nodes,
		Edges:          graph.Edges,
		BraveSources:   graph.BraveSources,
		ExpandStrategy: expandStrategy.String(),
		PainTagCount:   graph.PainTagCount,
		GeneratedAt:    now,
	})
	if err != nil {
		return err
	}

	if err := syncResearchMapSources(ctx, deps, tenantID, ownerUID, scope, expandStrategy.String(), braveSources); err != nil {
		return err
	}

	nextRoute := "/research?brand=" + brandID
	if scope.TopicID != "" {
		nextRoute = "/placement/topics/" + scope.TopicID + "/research-map"
	}
	handoff := map[string]any{
		"flow":                      "placement",
		"brand_id":                  brandID,
		"topic_id":                  scope.TopicID,
		"pain_tag_count":            saved.PainTagCount,
		"summary":                   fmt.Sprintf("圖譜 %d 節點，痛點候選 %d", len(saved.Nodes), saved.PainTagCount),
		"next_route":                nextRoute,
		"needs_supplemental_expand": saved.PainTagCount < libkg.MinPainTagCandidates() || len(saved.Nodes) < libkg.MinBreadthGraphNodes(),
		"search_source_mode":        string(memberCtx.SearchSourceMode),
		"dev_mode":                  memberCtx.DevMode,
	}
	_, err = deps.Jobs.CompleteRun(ctx, jobdom.CompleteRunRequest{
		JobID:    step.JobID,
		WorkerID: step.WorkerID,
		Result: map[string]any{
			"graph_id":           saved.ID,
			"seed":               saved.Seed,
			"pain_tag_count":     saved.PainTagCount,
			"node_count":         len(saved.Nodes),
			"search_source_mode": string(memberCtx.SearchSourceMode),
			"handoff":            handoff,
		},
	})
	return err
}

func runWebKnowledgeExpand(
	ctx context.Context,
	client websearch.Client,
	member placement.MemberContext,
	queries []string,
	strategy libkg.ExpandStrategy,
	onProgress func(i, total int),
	heartbeat func() error,
) ([]libkg.BraveSource, error) {
	if client == nil || !client.Enabled() {
		return nil, app.For(code.Setting).InputMissingRequired(placement.WebSearchKeyRequiredMessage(placement.ResearchSettings{
			WebSearchProvider: member.WebSearchProvider,
			BraveAPIKey:       member.BraveAPIKey,
			ExaAPIKey:         member.ExaAPIKey,
		}))
	}
	if len(queries) == 0 {
		if strategy == libkg.ExpandStrategyHybrid {
			return nil, nil
		}
		return nil, app.For(code.Setting).InputMissingRequired("沒有可執行的網路搜尋查詢")
	}
	cfg := libkg.DefaultBraveCollectConfig()
	out := libkg.CollectWebSources(ctx, client, libkg.BraveSearchLocale{
		Country:      member.BraveCountry,
		SearchLang:   member.BraveSearchLang,
		UserLocation: member.ExaUserLocation,
	}, queries, cfg, onProgress, heartbeat)
	if len(out) == 0 {
		return nil, app.For(code.Setting).SvcThirdParty("暫時無法取得參考資料，請稍後重試")
	}
	return out, nil
}

func kgPlanInput(
	brand *branddomain.BrandSummary,
	seed, productBrief string,
	l1Labels []string,
	supplemental bool,
	strategy libkg.ExpandStrategy,
) libkg.PlanInput {
	return libkg.PlanInput{
		Seed:           seed,
		TargetAudience: brand.TargetAudience,
		ProductBrief:   productBrief,
		Pillars:        brand.ResearchMap.Pillars,
		Questions:      brand.ResearchMap.Questions,
		PatrolKeywords: brand.ResearchMap.PatrolKeywords,
		L1Labels:       l1Labels,
		Supplemental:   supplemental,
		Strategy:       strategy,
	}
}

func kgSynthInput(
	brand *branddomain.BrandSummary,
	seed, productBrief string,
	sources []libkg.BraveSource,
) libkg.SynthInput {
	topicCtx := placement.PlacementTopicContextFromBrand(brand, productBrief)
	return libkg.SynthInput{
		BrandDisplayName:  topicCtx.BrandDisplayName,
		TopicName:         topicCtx.TopicName,
		ProductLabel:      topicCtx.ProductDisplayName(),
		Goals:             topicCtx.Goals,
		Seed:              seed,
		ProductBrief:      productBrief,
		TargetAudience:    brand.TargetAudience,
		Persona:           brand.Brief,
		ResearchPillars:   brand.ResearchMap.Pillars,
		ResearchQuestions: brand.ResearchMap.Questions,
		Sources:           sources,
	}
}

func mergeGraphs(existing *kgusecase.GraphSummary, incoming libkg.Graph, extraSources []libkg.BraveSource) libkg.Graph {
	if existing == nil {
		return incoming
	}
	merged := libkg.Graph{
		Seed:         existing.Seed,
		Nodes:        append([]libkg.Node{}, existing.Nodes...),
		Edges:        append([]libkg.Edge{}, existing.Edges...),
		BraveSources: append([]libkg.BraveSource{}, existing.BraveSources...),
	}
	seenLabel := map[string]struct{}{}
	for _, node := range merged.Nodes {
		seenLabel[strings.ToLower(strings.TrimSpace(node.Label))] = struct{}{}
	}
	for _, node := range incoming.Nodes {
		key := strings.ToLower(strings.TrimSpace(node.Label))
		if _, ok := seenLabel[key]; ok {
			continue
		}
		seenLabel[key] = struct{}{}
		merged.Nodes = append(merged.Nodes, node)
	}
	edgeSeen := map[string]struct{}{}
	for _, edge := range merged.Edges {
		edgeSeen[edge.From+"->"+edge.To] = struct{}{}
	}
	for _, edge := range incoming.Edges {
		key := edge.From + "->" + edge.To
		if _, ok := edgeSeen[key]; ok {
			continue
		}
		edgeSeen[key] = struct{}{}
		merged.Edges = append(merged.Edges, edge)
	}
	merged.BraveSources = append(merged.BraveSources, extraSources...)
	return merged
}

func stringField(payload map[string]any, key string) string {
	if payload == nil {
		return ""
	}
	raw, ok := payload[key]
	if !ok || raw == nil {
		return ""
	}
	switch v := raw.(type) {
	case string:
		return strings.TrimSpace(v)
	default:
		return strings.TrimSpace(fmt.Sprint(v))
	}
}

func boolField(payload map[string]any, key string) bool {
	if payload == nil {
		return false
	}
	raw, ok := payload[key]
	if !ok || raw == nil {
		return false
	}
	switch v := raw.(type) {
	case bool:
		return v
	case string:
		return strings.EqualFold(strings.TrimSpace(v), "true")
	default:
		return false
	}
}

func max(a, b int) int {
	if a > b {
		return a
	}
	return b
}

func syncAutoPatrolKeywords(
	ctx context.Context,
	deps ExpandGraphDeps,
	tenantID, ownerUID string,
	scope *placementScope,
	nodes []libkg.Node,
	productBrief string,
) error {
	if scope == nil || scope.Brand == nil {
		return nil
	}
	fresh := scope.Brand
	if scope.TopicID != "" {
		topic, err := deps.PlacementTopic.Get(ctx, tenantID, ownerUID, scope.TopicID)
		if err != nil {
			return err
		}
		fresh = placementTopicAsBrand(scope, topic)
	} else {
		loaded, err := deps.Brand.Get(ctx, tenantID, ownerUID, scope.CatalogBrand)
		if err != nil {
			return err
		}
		fresh = loaded
	}
	patrolInput := libkg.PatrolTagInputFromBrand(fresh, productBrief)
	tags := libkg.CollectPatrolTagsFromGraph(patrolInput, nodes)
	if len(tags) == 0 {
		return nil
	}
	entityMap := brandentity.ResearchMap{
		AudienceSummary: fresh.ResearchMap.AudienceSummary,
		ContentGoal:     fresh.ResearchMap.ContentGoal,
		Questions:       fresh.ResearchMap.Questions,
		Pillars:         fresh.ResearchMap.Pillars,
		Exclusions:      fresh.ResearchMap.Exclusions,
		ResearchItems:   fresh.ResearchMap.ResearchItems,
		ExpandStrategy:  fresh.ResearchMap.ExpandStrategy,
		PatrolKeywords:  tags,
	}
	return updatePlacementResearchMap(ctx, deps, tenantID, ownerUID, scope, entityMap, nil)
}

func syncResearchMapSources(
	ctx context.Context,
	deps ExpandGraphDeps,
	tenantID, ownerUID string,
	scope *placementScope,
	expandStrategy string,
	sources []libkg.BraveSource,
) error {
	if expandStrategy == "" || scope == nil || scope.Brand == nil {
		return nil
	}
	items := make([]brandentity.ResearchItem, 0, len(sources))
	for _, src := range sources {
		if strings.TrimSpace(src.URL) == "" && strings.TrimSpace(src.Snippet) == "" {
			continue
		}
		items = append(items, brandentity.ResearchItem{
			Title:   src.Title,
			URL:     src.URL,
			Snippet: src.Snippet,
			Query:   src.Query,
		})
	}
	fresh, err := reloadScopeBrand(ctx, deps, tenantID, ownerUID, scope)
	if err != nil {
		return err
	}
	if fresh == nil {
		return nil
	}
	entityMap := brandentity.ResearchMap{
		AudienceSummary: fresh.ResearchMap.AudienceSummary,
		ContentGoal:     fresh.ResearchMap.ContentGoal,
		Questions:       fresh.ResearchMap.Questions,
		Pillars:         fresh.ResearchMap.Pillars,
		Exclusions:      fresh.ResearchMap.Exclusions,
		ResearchItems:   items,
		ExpandStrategy:  expandStrategy,
		PatrolKeywords:  fresh.ResearchMap.PatrolKeywords,
	}
	return updatePlacementResearchMap(ctx, deps, tenantID, ownerUID, scope, entityMap, nil)
}

func ensureResearchMap(
	ctx context.Context,
	step StepContext,
	deps ExpandGraphDeps,
	brand *branddomain.BrandSummary,
	productBrief string,
	providerID enum.ProviderID,
	credential *threadsaccountdomain.WorkerAiCredential,
	updateProgress func(string, int),
) error {
	tenantID := stringField(step.Run.Payload, "tenant_id")
	ownerUID := stringField(step.Run.Payload, "owner_uid")
	if tenantID == "" || ownerUID == "" || brand == nil {
		return fmt.Errorf("research map: missing actor or brand")
	}

	topicCtx := placement.PlacementTopicContextFromBrand(brand, productBrief)
	mapInput := topicCtx.ToResearchMapInput()

	updateProgress("分析主題脈絡…", 6)
	analysisResult, err := deps.AI.GenerateText(ctx, placement.ResearchGenerateRequest(domai.GenerateRequest{
		Provider: providerID,
		Model:    credential.Model,
		Credential: domai.Credential{
			APIKey: credential.APIKey,
		},
		System: placement.BuildResearchMapAnalysisSystemPrompt(),
		Messages: []domai.Message{
			{Role: "user", Content: placement.BuildResearchMapAnalysisUserPrompt(mapInput)},
		},
	}))
	if err != nil {
		return err
	}

	finalPrompt := placement.BuildResearchMapFinalizeUserPrompt(mapInput, analysisResult.Text)
	updateProgress("產出研究地圖…", 7)
	result, err := deps.AI.GenerateText(ctx, placement.ResearchGenerateRequest(domai.GenerateRequest{
		Provider: providerID,
		Model:    credential.Model,
		Credential: domai.Credential{
			APIKey: credential.APIKey,
		},
		System: placement.BuildResearchMapSystemPrompt(),
		Messages: []domai.Message{
			{Role: "user", Content: finalPrompt},
		},
	}))
	if err != nil {
		return err
	}

	parsed, err := placement.ParseResearchMapOutput(result.Text)
	if err != nil {
		return app.For(code.AI).SvcThirdParty("研究地圖產生失敗，請重試：" + err.Error())
	}
	if placement.ResearchMapTooThin(parsed) {
		if retryResult, retryErr := deps.AI.GenerateText(ctx, placement.ResearchGenerateRequest(domai.GenerateRequest{
			Provider: providerID,
			Model:    credential.Model,
			Credential: domai.Credential{
				APIKey: credential.APIKey,
			},
			System: placement.BuildResearchMapSystemPrompt(),
			Messages: []domai.Message{
				{Role: "user", Content: finalPrompt},
				{Role: "assistant", Content: result.Text},
				{Role: "user", Content: placement.ResearchMapRetryUserPrompt()},
			},
		})); retryErr == nil {
			if retryParsed, parseErr := placement.ParseResearchMapOutput(retryResult.Text); parseErr == nil && !placement.ResearchMapTooThin(retryParsed) {
				parsed = retryParsed
			}
		}
	}

	entityMap := brandentity.ResearchMap{
		AudienceSummary: parsed.AudienceSummary,
		ContentGoal:     parsed.ContentGoal,
		Questions:       parsed.Questions,
		Pillars:         parsed.Pillars,
		Exclusions:      parsed.Exclusions,
		PatrolKeywords:  libkg.SanitizePatrolKeywordList(parsed.PatrolKeywords),
	}
	targetAudience := strings.TrimSpace(brand.TargetAudience)
	if targetAudience == "" {
		targetAudience = parsed.AudienceSummary
	}
	topicID := topicIDFromPayload(step.Run.Payload)
	scope := &placementScope{
		TopicID:      topicID,
		CatalogBrand: brand.ID,
		Brand:        brand,
	}
	if err := updatePlacementResearchMap(ctx, deps, tenantID, ownerUID, scope, entityMap, &targetAudience); err != nil {
		return err
	}
	updateProgress("研究地圖已就緒", 8)
	return nil
}

func updatePlacementResearchMap(
	ctx context.Context,
	deps ExpandGraphDeps,
	tenantID, ownerUID string,
	scope *placementScope,
	entityMap brandentity.ResearchMap,
	targetAudience *string,
) error {
	if scope == nil {
		return nil
	}
	if scope.TopicID != "" {
		patch := topicdomain.TopicPatch{ResearchMap: &entityMap}
		_, err := deps.PlacementTopic.Update(ctx, topicdomain.UpdateRequest{
			TenantID: tenantID,
			OwnerUID: ownerUID,
			TopicID:  scope.TopicID,
			Patch:    patch,
		})
		return err
	}
	patch := branddomain.BrandPatch{ResearchMap: &entityMap}
	if targetAudience != nil && strings.TrimSpace(*targetAudience) != "" {
		patch.TargetAudience = targetAudience
	}
	_, err := deps.Brand.Update(ctx, branddomain.UpdateRequest{
		TenantID: tenantID,
		OwnerUID: ownerUID,
		BrandID:  scope.CatalogBrand,
		Patch:    patch,
	})
	return err
}
