package service

import (
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"sort"
	"strconv"
	"strings"
	"time"
	"unicode"
	"unicode/utf8"

	"juhe-factory/api/internal/billing"
	"juhe-factory/api/internal/model"
	"juhe-factory/api/internal/provider/apimart"
	queuepkg "juhe-factory/api/internal/queue"
	"juhe-factory/api/internal/security"

	"github.com/google/uuid"
	"github.com/hibiken/asynq"
	"gorm.io/gorm"
	"gorm.io/gorm/clause"
)

var (
	validEra = map[string]bool{
		"modern_city": true, "ancient_history": true, "ancient_fantasy": true,
		"ancient_xianxia": true, "future_scifi": true, "other": true,
	}
	validLocalization = map[string]bool{
		"china": true, "uk_us": true, "korea": true, "japan": true, "france": true,
		"russia": true, "vietnam": true, "thailand": true, "india": true,
	}
)

func decodeJSONObject(value any) (map[string]any, error) {
	if value == nil {
		return map[string]any{}, nil
	}
	if object, ok := value.(map[string]any); ok {
		return object, nil
	}
	var encoded []byte
	switch raw := value.(type) {
	case []byte:
		encoded = raw
	case json.RawMessage:
		encoded = raw
	case string:
		encoded = []byte(raw)
	default:
		var err error
		encoded, err = json.Marshal(raw)
		if err != nil {
			return nil, err
		}
	}
	object := map[string]any{}
	if len(encoded) == 0 {
		return object, nil
	}
	if err := json.Unmarshal(encoded, &object); err != nil {
		return nil, err
	}
	return object, nil
}

type Creative struct {
	DB         *gorm.DB
	Queue      *asynq.Client
	Encryptor  *security.Encryptor
	HTTPClient *http.Client
}

type ProjectInput struct {
	ProjectType     string                   `json:"project_type"`
	Name            string                   `json:"name"`
	StyleID         string                   `json:"style_id"`
	EraType         string                   `json:"era_type"`
	CustomEra       *string                  `json:"custom_era"`
	AspectRatio     string                   `json:"aspect_ratio"`
	Localization    *string                  `json:"localization"`
	ShortDramaType  string                   `json:"short_drama_type"`
	PlayCount       string                   `json:"play_count"`
	AudienceProfile string                   `json:"audience_profile"`
	Producer        string                   `json:"producer"`
	CastMembers     []ProjectCastMemberInput `json:"cast_members"`
}

type ProjectCastMemberInput struct {
	Name          string  `json:"name"`
	FollowerCount *string `json:"follower_count"`
}

type EpisodeInput struct {
	EpisodeNo int    `json:"episode_no"`
	Name      string `json:"name"`
}

func (s *Creative) ListProjects(userID uuid.UUID, projectType, keyword string) ([]map[string]any, error) {
	items := make([]map[string]any, 0)
	query := s.DB.Table("creative_projects p").
		Select(`p.id,p.project_type,p.name,p.era_type,p.custom_era,p.aspect_ratio,p.localization,
			p.short_drama_type,p.play_count,p.audience_profile,p.producer,p.cast_members::text AS cast_members,p.updated_at,
			ps.name AS style_name,
			CASE WHEN p.project_type='video_redraw' THEN coalesce((
				SELECT result.public_url
				FROM episode_storyboards first_storyboard
				JOIN generation_outputs active_output ON active_output.id=first_storyboard.active_output_id
				JOIN media_assets result ON result.id=active_output.media_asset_id AND result.deleted_at IS NULL
				WHERE first_storyboard.project_id=p.id AND first_storyboard.deleted_at IS NULL
				ORDER BY first_storyboard.sequence_no LIMIT 1
			),project_cover.public_url) ELSE project_cover.public_url END AS cover_url,
			CASE WHEN p.project_type='video_redraw' THEN coalesce((
				SELECT result.mime_type
				FROM episode_storyboards first_storyboard
				JOIN generation_outputs active_output ON active_output.id=first_storyboard.active_output_id
				JOIN media_assets result ON result.id=active_output.media_asset_id AND result.deleted_at IS NULL
				WHERE first_storyboard.project_id=p.id AND first_storyboard.deleted_at IS NULL
				ORDER BY first_storyboard.sequence_no LIMIT 1
			),project_cover.mime_type) ELSE project_cover.mime_type END AS cover_mime_type,
			CASE WHEN p.project_type='premium_drama' THEN (
				SELECT candidate_media.public_url
				FROM project_episodes first_episode
				JOIN episode_storyboards first_storyboard ON first_storyboard.id=(
					SELECT storyboard.id
					FROM episode_storyboards storyboard
					WHERE storyboard.episode_id=first_episode.id AND storyboard.deleted_at IS NULL
					ORDER BY storyboard.sequence_no LIMIT 1
				)
				JOIN generation_tasks candidate_task ON candidate_task.storyboard_id=first_storyboard.id AND candidate_task.task_type='video_generation'
				JOIN generation_outputs candidate_output ON candidate_output.task_id=candidate_task.id
					AND (first_storyboard.active_output_id=candidate_output.id OR coalesce(candidate_output.metadata->>'candidate','false')='true')
				JOIN media_assets candidate_media ON candidate_media.id=candidate_output.media_asset_id AND candidate_media.deleted_at IS NULL
				WHERE first_episode.id=(
					SELECT episode.id FROM project_episodes episode
					WHERE episode.project_id=p.id AND episode.deleted_at IS NULL
					ORDER BY episode.episode_no LIMIT 1
				)
				ORDER BY candidate_output.created_at DESC
				LIMIT 1
			) END AS cover_video_url,
			count(DISTINCT e.id) FILTER (WHERE e.deleted_at IS NULL) AS episode_count,
			CASE WHEN p.project_type='video_redraw' THEN (
				SELECT count(*) FROM generation_tasks task WHERE task.project_id=p.id AND task.status IN ('pending_submission','submitting','submitted','processing','result_ready','downloading','cancel_requested')
			) ELSE count(DISTINCT e.id) FILTER (WHERE e.deleted_at IS NULL AND e.status IN ('analyzing','generating')) END AS processing_count`).
		Joins("JOIN project_styles ps ON ps.id=p.style_id").
		Joins("LEFT JOIN media_assets project_cover ON project_cover.id=p.cover_asset_id AND project_cover.deleted_at IS NULL").
		Joins("LEFT JOIN project_episodes e ON e.project_id=p.id").
		Where("p.user_id=? AND p.deleted_at IS NULL", userID)
	if projectType != "" {
		query = query.Where("p.project_type=?", projectType)
	}
	if keyword = strings.TrimSpace(keyword); keyword != "" {
		query = query.Where("p.name::text ILIKE ?", "%"+keyword+"%")
	}
	if err := query.Group("p.id,ps.name,project_cover.public_url,project_cover.mime_type").Order("p.updated_at DESC").Find(&items).Error; err != nil {
		return nil, err
	}
	if err := decodeProjectCastMembers(items); err != nil {
		return nil, err
	}
	return items, nil
}

func (s *Creative) CreateProject(userID uuid.UUID, input ProjectInput) (*model.CreativeProject, error) {
	input = normalizeCreateProjectInput(input)
	if normalizedProjectType(input.ProjectType) == "video_redraw" && strings.TrimSpace(input.StyleID) == "" {
		if err := s.DB.Table("project_styles").Select("id").Where("deleted_at IS NULL").Order("sort_order,name").Limit(1).Scan(&input.StyleID).Error; err != nil {
			return nil, err
		}
		if strings.TrimSpace(input.StyleID) == "" {
			return nil, errors.New("暂无可用视觉风格")
		}
	}
	if err := validateProjectInput(input); err != nil {
		return nil, err
	}
	styleID, err := uuid.Parse(input.StyleID)
	if err != nil {
		return nil, errors.New("项目风格无效")
	}
	var styleCount int64
	if err := s.DB.Table("project_styles").Where("id=? AND deleted_at IS NULL", styleID).Count(&styleCount).Error; err != nil || styleCount == 0 {
		return nil, errors.New("项目风格不可用")
	}
	project := &model.CreativeProject{
		UserID: userID, ProjectType: normalizedProjectType(input.ProjectType), Name: strings.TrimSpace(input.Name),
		StyleID: styleID, EraType: input.EraType, CustomEra: cleanOptional(input.CustomEra),
		AspectRatio: input.AspectRatio, Localization: cleanOptional(input.Localization),
		AudioSource: "video_audio", RedrawStatus: "draft",
	}
	if project.ProjectType == "video_redraw" {
		project.ShortDramaType = cleanOptionalString(input.ShortDramaType)
		project.PlayCount = cleanOptionalString(input.PlayCount)
		project.AudienceProfile = cleanOptionalString(input.AudienceProfile)
		project.Producer = cleanOptionalString(input.Producer)
		project.CastMembers = marshalProjectCastMembers(input.CastMembers)
	}
	if err := s.DB.Transaction(func(tx *gorm.DB) error {
		if err := tx.Create(project).Error; err != nil {
			return err
		}
		return tx.Exec(`INSERT INTO project_model_configs(id,project_id,purpose,model_type,model_id,prompt_id,settings)
			SELECT gen_random_uuid(),?,purpose,model_type,model_id,prompt_id,settings
			FROM user_model_configs WHERE user_id=? AND project_type=?`, project.ID, userID, project.ProjectType).Error
	}); err != nil {
		return nil, err
	}
	return project, nil
}

func normalizeCreateProjectInput(input ProjectInput) ProjectInput {
	if strings.TrimSpace(input.ProjectType) == "" && input.Localization == nil {
		input.ProjectType = "premium_drama"
	}
	if normalizedProjectType(input.ProjectType) == "video_redraw" {
		if strings.TrimSpace(input.EraType) == "" {
			input.EraType = "modern_city"
		}
		if strings.TrimSpace(input.AspectRatio) == "" {
			input.AspectRatio = "9:16"
		}
		if input.Localization == nil || strings.TrimSpace(*input.Localization) == "" {
			localization := "china"
			input.Localization = &localization
		}
	}
	return input
}

func (s *Creative) UpdateProject(userID, projectID uuid.UUID, input ProjectInput) error {
	if err := s.DB.Table("creative_projects").Where("id=? AND user_id=? AND deleted_at IS NULL", projectID, userID).Pluck("project_type", &input.ProjectType).Error; err != nil {
		return err
	}
	if err := validateProjectInput(input); err != nil {
		return err
	}
	styleID, err := uuid.Parse(input.StyleID)
	if err != nil {
		return errors.New("项目风格无效")
	}
	updates := map[string]any{
		"name": strings.TrimSpace(input.Name), "style_id": styleID, "era_type": input.EraType,
		"custom_era": cleanOptional(input.CustomEra), "aspect_ratio": input.AspectRatio,
		"localization": cleanOptional(input.Localization),
	}
	if normalizedProjectType(input.ProjectType) == "video_redraw" {
		updates["short_drama_type"] = cleanOptionalString(input.ShortDramaType)
		updates["play_count"] = cleanOptionalString(input.PlayCount)
		updates["audience_profile"] = cleanOptionalString(input.AudienceProfile)
		updates["producer"] = cleanOptionalString(input.Producer)
		updates["cast_members"] = gorm.Expr("?::jsonb", string(marshalProjectCastMembers(input.CastMembers)))
	}
	result := s.DB.Table("creative_projects").Where("id=? AND user_id=? AND deleted_at IS NULL", projectID, userID).Updates(updates)
	if result.Error != nil {
		return result.Error
	}
	if result.RowsAffected == 0 {
		return gorm.ErrRecordNotFound
	}
	return nil
}

func validateProjectInput(input ProjectInput) error {
	if strings.TrimSpace(input.Name) == "" || len([]rune(strings.TrimSpace(input.Name))) > 100 {
		return errors.New("项目名称必须为 1 至 100 个字符")
	}
	if !validEra[input.EraType] {
		return errors.New("项目时代无效")
	}
	if input.EraType == "other" && (input.CustomEra == nil || strings.TrimSpace(*input.CustomEra) == "") {
		return errors.New("请输入自定义时代")
	}
	if input.AspectRatio != "9:16" && input.AspectRatio != "16:9" {
		return errors.New("项目尺寸无效")
	}
	projectType := normalizedProjectType(input.ProjectType)
	if projectType != "video_redraw" && projectType != "premium_drama" {
		return errors.New("项目类型无效")
	}
	if projectType == "video_redraw" && (input.Localization == nil || !validLocalization[strings.TrimSpace(*input.Localization)]) {
		return errors.New("本土化地区无效")
	}
	if projectType == "video_redraw" {
		if len([]rune(strings.TrimSpace(input.ShortDramaType))) > 100 {
			return errors.New("短剧类型不能超过 100 个字符")
		}
		if len([]rune(strings.TrimSpace(input.PlayCount))) > 100 {
			return errors.New("播放量不能超过 100 个字符")
		}
		if len([]rune(strings.TrimSpace(input.AudienceProfile))) > 2000 {
			return errors.New("受众群体画像不能超过 2000 个字符")
		}
		if len([]rune(strings.TrimSpace(input.Producer))) > 200 {
			return errors.New("出品方不能超过 200 个字符")
		}
		if len(input.CastMembers) > 50 {
			return errors.New("主演不能超过 50 位")
		}
		for _, member := range input.CastMembers {
			if strings.TrimSpace(member.Name) == "" || len([]rune(strings.TrimSpace(member.Name))) > 100 {
				return errors.New("主演姓名必须为 1 至 100 个字符")
			}
			if member.FollowerCount != nil && len([]rune(strings.TrimSpace(*member.FollowerCount))) > 100 {
				return errors.New("主演粉丝量不能超过 100 个字符")
			}
		}
	}
	if projectType == "premium_drama" && input.Localization != nil && strings.TrimSpace(*input.Localization) != "" {
		return errors.New("短剧创作项目不需要本土化地区")
	}
	return nil
}

func normalizedProjectType(value string) string {
	value = strings.TrimSpace(value)
	if value == "" {
		return "video_redraw"
	}
	return value
}

func cleanOptional(value *string) *string {
	if value == nil || strings.TrimSpace(*value) == "" {
		return nil
	}
	cleaned := strings.TrimSpace(*value)
	return &cleaned
}

func cleanOptionalString(value string) *string {
	return cleanOptional(&value)
}

func marshalProjectCastMembers(members []ProjectCastMemberInput) json.RawMessage {
	cleaned := make([]ProjectCastMemberInput, 0, len(members))
	for _, member := range members {
		member.Name = strings.TrimSpace(member.Name)
		member.FollowerCount = cleanOptional(member.FollowerCount)
		cleaned = append(cleaned, member)
	}
	encoded, _ := json.Marshal(cleaned)
	return encoded
}

func decodeProjectCastMembers(projects []map[string]any) error {
	for _, project := range projects {
		value := project["cast_members"]
		if value == nil {
			project["cast_members"] = []ProjectCastMemberInput{}
			continue
		}
		var encoded []byte
		switch raw := value.(type) {
		case []byte:
			encoded = raw
		case json.RawMessage:
			encoded = raw
		case string:
			encoded = []byte(raw)
		default:
			var err error
			encoded, err = json.Marshal(raw)
			if err != nil {
				return err
			}
		}
		members := make([]ProjectCastMemberInput, 0)
		if len(encoded) > 0 {
			if err := json.Unmarshal(encoded, &members); err != nil {
				return err
			}
		}
		project["cast_members"] = members
	}
	return nil
}

func (s *Creative) GetProject(userID, projectID uuid.UUID) (map[string]any, error) {
	project := map[string]any{}
	err := s.DB.Table("creative_projects p").
		Select("p.id,p.project_type,p.name,p.style_id,ps.name AS style_name,ps.image_url AS style_image_url,p.era_type,p.custom_era,p.aspect_ratio,p.localization,p.short_drama_type,p.play_count,p.audience_profile,p.producer,p.cast_members::text AS cast_members,p.cover_asset_id,cover.public_url AS cover_url,p.created_at,p.updated_at").
		Joins("JOIN project_styles ps ON ps.id=p.style_id").
		Joins("LEFT JOIN media_assets cover ON cover.id=p.cover_asset_id").
		Where("p.id=? AND p.user_id=? AND p.deleted_at IS NULL", projectID, userID).Take(&project).Error
	if err != nil {
		return nil, err
	}
	if err := decodeProjectCastMembers([]map[string]any{project}); err != nil {
		return nil, err
	}
	configs := make([]map[string]any, 0)
	if err := s.DB.Table("project_model_configs c").Select("c.id,c.purpose,c.model_type,c.model_id,m.name AS model_name,ch.name AS channel_name,c.prompt_id,c.settings").Joins("JOIN models m ON m.id=c.model_id").Joins("JOIN channels ch ON ch.id=m.channel_id").Where("c.project_id=?", projectID).Find(&configs).Error; err != nil {
		return nil, err
	}
	for _, config := range configs {
		settings, err := decodeJSONObject(config["settings"])
		if err != nil {
			return nil, err
		}
		config["settings"] = settings
		if fmt.Sprint(config["model_type"]) == "video" {
			if capabilities, ok := apimart.VideoCapabilities(fmt.Sprint(config["model_name"])); ok {
				config["capabilities"] = capabilities
			}
		}
	}
	episodes, err := s.ListEpisodes(userID, projectID)
	if err != nil {
		return nil, err
	}
	project["model_configs"] = configs
	project["episodes"] = episodes
	if fmt.Sprint(project["project_type"]) == "premium_drama" && len(episodes) > 0 {
		project["cover_video_url"] = episodes[0]["cover_video_url"]
	}
	return project, nil
}

func (s *Creative) DeleteProject(userID, projectID uuid.UUID) ([]string, error) {
	media := newDeletionMediaSet()
	err := s.DB.Transaction(func(tx *gorm.DB) error {
		var project model.CreativeProject
		if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
			Where("id=? AND user_id=? AND deleted_at IS NULL", projectID, userID).Take(&project).Error; err != nil {
			return err
		}
		var active int64
		if err := tx.Table("generation_tasks").Where("project_id=? AND status IN ?", projectID, activeTaskStatuses).Count(&active).Error; err != nil {
			return err
		}
		if active > 0 {
			return errors.New("项目存在排队中或处理中的任务，暂时不能删除")
		}
		if err := tx.Table("drama_parse_tasks").Where("project_id=? AND status IN ?", projectID, []string{"queued", "running", "retry_wait", "cancel_requested"}).Count(&active).Error; err != nil {
			return err
		}
		if active > 0 {
			return errors.New("项目存在进行中的剧本解析任务，暂时不能删除")
		}
		projectPrefix := fmt.Sprintf("juyou_ran/video-redraw/projects/%s/%%", projectID)
		if err := collectMediaByObjectKey(tx, media, projectPrefix); err != nil {
			return err
		}
		queries := []*gorm.DB{
			tx.Table("media_assets media").Select("DISTINCT media.id,media.object_key").Joins("JOIN creative_projects project ON project.cover_asset_id=media.id").Where("project.id=?", projectID),
			tx.Table("media_assets media").Select("DISTINCT media.id,media.object_key").Joins("JOIN creative_projects project ON media.id=project.source_video_asset_id OR media.id=project.subtitle_asset_id").Where("project.id=?", projectID),
			tx.Table("media_assets media").Select("DISTINCT media.id,media.object_key").Joins(`JOIN project_episodes episode ON media.id=episode.cover_asset_id
				OR media.id=episode.source_video_asset_id OR media.id=episode.subtitle_asset_id`).Where("episode.project_id=?", projectID),
			tx.Table("media_assets media").Select("DISTINCT media.id,media.object_key").Joins("JOIN project_assets asset ON media.id=asset.image_asset_id OR media.id=asset.audio_asset_id").Where("asset.project_id=?", projectID),
			tx.Table("media_assets media").Select("DISTINCT media.id,media.object_key").Joins("JOIN episode_storyboards storyboard ON storyboard.thumbnail_asset_id=media.id").Where("storyboard.project_id=? OR storyboard.episode_id IN (SELECT id FROM project_episodes WHERE project_id=?)", projectID, projectID),
			tx.Table("media_assets media").Select("DISTINCT media.id,media.object_key").Joins("JOIN generation_outputs output ON output.media_asset_id=media.id").Joins("JOIN generation_tasks task ON task.id=output.task_id").Where("task.project_id=?", projectID),
		}
		for _, query := range queries {
			if err := media.addQuery(query); err != nil {
				return err
			}
		}
		if err := tx.Exec(`UPDATE episode_storyboards SET active_output_id=NULL WHERE episode_id IN
			(SELECT id FROM project_episodes WHERE project_id=?) OR project_id=?`, projectID, projectID).Error; err != nil {
			return err
		}
		if err := tx.Exec("DELETE FROM generation_outputs WHERE task_id IN (SELECT id FROM generation_tasks WHERE project_id=?)", projectID).Error; err != nil {
			return err
		}
		if err := tx.Exec("DELETE FROM generation_tasks WHERE project_id=?", projectID).Error; err != nil {
			return err
		}
		if err := tx.Exec("DELETE FROM episode_continuity_contexts WHERE episode_id IN (SELECT id FROM project_episodes WHERE project_id=?)", projectID).Error; err != nil {
			return err
		}
		if err := tx.Exec("DELETE FROM drama_parse_tasks WHERE project_id=?", projectID).Error; err != nil {
			return err
		}
		if err := tx.Exec("DELETE FROM drama_parse_batches WHERE project_id=?", projectID).Error; err != nil {
			return err
		}
		if err := tx.Exec("DELETE FROM drama_import_sessions WHERE project_id=?", projectID).Error; err != nil {
			return err
		}
		if err := tx.Exec("DELETE FROM episode_storyboards WHERE project_id=? OR episode_id IN (SELECT id FROM project_episodes WHERE project_id=?)", projectID, projectID).Error; err != nil {
			return err
		}
		if err := tx.Exec("DELETE FROM project_assets WHERE project_id=?", projectID).Error; err != nil {
			return err
		}
		if err := tx.Exec("DELETE FROM project_episodes WHERE project_id=?", projectID).Error; err != nil {
			return err
		}
		if err := tx.Exec("DELETE FROM project_model_configs WHERE project_id=?", projectID).Error; err != nil {
			return err
		}
		if err := tx.Exec("DELETE FROM creative_projects WHERE id=?", projectID).Error; err != nil {
			return err
		}
		return media.deleteRows(tx)
	})
	return media.objectKeys(), err
}

func (s *Creative) SaveModelConfigs(userID, projectID uuid.UUID, values map[string]any) error {
	return s.DB.Transaction(func(tx *gorm.DB) error {
		var projectType string
		if err := tx.Table("creative_projects").Where("id=? AND user_id=? AND deleted_at IS NULL", projectID, userID).Pluck("project_type", &projectType).Error; err != nil || projectType == "" {
			return gorm.ErrRecordNotFound
		}
		configs := []struct{ ModelType, Purpose string }{
			{ModelType: "text", Purpose: "prompt_reverse"},
			{ModelType: "image", Purpose: "image_generation"},
			{ModelType: "video", Purpose: "video_generation"},
		}
		for _, item := range configs {
			raw, ok := values[item.ModelType]
			if !ok {
				raw, ok = values[item.Purpose]
			}
			if !ok || raw == nil {
				continue
			}
			purpose := item.Purpose
			settings := map[string]any{}
			modelIDText := fmt.Sprint(raw)
			if config, ok := raw.(map[string]any); ok {
				modelIDText = fmt.Sprint(config["model_id"])
				if supplied, ok := config["settings"].(map[string]any); ok {
					for _, key := range []string{"prompt", "resolution"} {
						if value, exists := supplied[key]; exists {
							settings[key] = value
						}
					}
				}
			}
			// Aspect ratio is not a model preference: images always use 16:9.
			// Video quality belongs to this model configuration (not project settings)
			// and defaults to 480p when omitted or invalid.
			delete(settings, "aspect_ratio")
			if purpose == "video_generation" {
				resolution := strings.ToLower(strings.TrimSpace(fmt.Sprint(settings["resolution"])))
				if resolution != "480p" && resolution != "720p" && resolution != "1080p" {
					resolution = "480p"
				}
				settings["resolution"] = resolution
			}
			modelID, err := uuid.Parse(modelIDText)
			if err != nil {
				return errors.New("模型配置无效")
			}
			var available int64
			query := tx.Table("models m").Joins("JOIN channels c ON c.id=m.channel_id").Where("m.id=? AND m.model_type=? AND m.enabled=true AND m.deleted_at IS NULL AND c.enabled=true AND c.deleted_at IS NULL", modelID, item.ModelType)
			if purpose == "prompt_reverse" && projectType == "video_redraw" {
				query = query.Where("m.multimodal=true")
			}
			if err := query.Count(&available).Error; err != nil || available == 0 {
				return fmt.Errorf("%s 模型不可用", purpose)
			}
			encoded, err := json.Marshal(settings)
			if err != nil {
				return err
			}
			if err := tx.Exec(`INSERT INTO project_model_configs(id,project_id,purpose,model_type,model_id,settings) VALUES(?,?,?,?,?,?::jsonb)
				ON CONFLICT(project_id,purpose) DO UPDATE SET model_type=excluded.model_type,model_id=excluded.model_id,settings=excluded.settings`, uuid.New(), projectID, purpose, item.ModelType, modelID, string(encoded)).Error; err != nil {
				return err
			}
			if err := tx.Exec(`INSERT INTO user_model_configs(id,user_id,project_type,purpose,model_type,model_id,settings) VALUES(?,?,?,?,?,?,?::jsonb)
				ON CONFLICT(user_id,project_type,model_type) DO UPDATE SET purpose=excluded.purpose,model_id=excluded.model_id,settings=excluded.settings,updated_at=CURRENT_TIMESTAMP`, uuid.New(), userID, projectType, purpose, item.ModelType, modelID, string(encoded)).Error; err != nil {
				return err
			}
		}
		return nil
	})
}

func (s *Creative) ListUserModelPreferences(userID uuid.UUID, scope string) ([]map[string]any, error) {
	if scope != "script_analysis" {
		return nil, errors.New("模型偏好范围无效")
	}
	items := make([]map[string]any, 0)
	err := s.DB.Table("user_model_configs").
		Select("model_type,model_id,purpose,settings").
		Where("user_id=? AND project_type=?", userID, scope).
		Order("model_type").Find(&items).Error
	return items, err
}

func (s *Creative) SaveUserModelPreference(userID uuid.UUID, scope, modelType, modelIDText string) error {
	purposeByType := map[string]string{"text": "prompt_reverse"}
	purpose, ok := purposeByType[modelType]
	if scope != "script_analysis" || !ok {
		return errors.New("模型偏好范围或类型无效")
	}
	modelID, err := uuid.Parse(strings.TrimSpace(modelIDText))
	if err != nil {
		return errors.New("模型偏好无效")
	}
	query := s.DB.Table("models m").Joins("JOIN channels c ON c.id=m.channel_id").
		Where("m.id=? AND m.model_type=? AND m.enabled=true AND m.deleted_at IS NULL AND c.enabled=true AND c.deleted_at IS NULL", modelID, modelType)
	var available int64
	if err := query.Count(&available).Error; err != nil {
		return err
	}
	if available == 0 {
		return errors.New("模型不可用")
	}
	return s.DB.Exec(`INSERT INTO user_model_configs(id,user_id,project_type,purpose,model_type,model_id,settings)
		VALUES(?,?,?,?,?,?,?::jsonb)
		ON CONFLICT(user_id,project_type,model_type)
		DO UPDATE SET purpose=excluded.purpose,model_id=excluded.model_id,settings=excluded.settings,updated_at=CURRENT_TIMESTAMP`,
		uuid.New(), userID, scope, purpose, modelType, modelID, "{}").Error
}

func (s *Creative) ListEpisodes(userID, projectID uuid.UUID) ([]map[string]any, error) {
	items := make([]map[string]any, 0)
	err := s.DB.Table("project_episodes e").
		Select(`e.id,e.episode_no,e.name,e.audio_source,e.source_language,e.status,e.analysis_message,e.source_video_asset_id,e.subtitle_asset_id,e.updated_at,
			trim(coalesce(e.redraw_script,''))<>'' AS has_redraw_script,
			exists(SELECT 1 FROM episode_sources text_source WHERE text_source.episode_id=e.id) AS has_text_source,
			source.public_url AS source_video_url,source.original_name AS source_video_original_name,
			sub.original_name AS subtitle_original_name,
			(SELECT result.public_url
			 FROM episode_storyboards first_storyboard
			 JOIN generation_outputs active_output ON active_output.id=first_storyboard.active_output_id
			 JOIN media_assets result ON result.id=active_output.media_asset_id AND result.deleted_at IS NULL
			 WHERE first_storyboard.id=(
				 SELECT storyboard.id FROM episode_storyboards storyboard
				 WHERE storyboard.episode_id=e.id AND storyboard.deleted_at IS NULL
				 ORDER BY storyboard.sequence_no LIMIT 1
			 )) AS cover_url,
			(SELECT candidate_media.public_url
			 FROM episode_storyboards first_storyboard
			 JOIN generation_tasks candidate_task ON candidate_task.storyboard_id=first_storyboard.id AND candidate_task.task_type='video_generation'
			 JOIN generation_outputs candidate_output ON candidate_output.task_id=candidate_task.id
				AND (first_storyboard.active_output_id=candidate_output.id OR coalesce(candidate_output.metadata->>'candidate','false')='true')
			 JOIN media_assets candidate_media ON candidate_media.id=candidate_output.media_asset_id AND candidate_media.deleted_at IS NULL
			 WHERE first_storyboard.id=(
				 SELECT storyboard.id FROM episode_storyboards storyboard
				 WHERE storyboard.episode_id=e.id AND storyboard.deleted_at IS NULL
				 ORDER BY storyboard.sequence_no LIMIT 1
			 )
			 ORDER BY candidate_output.created_at DESC
			 LIMIT 1) AS cover_video_url,
			count(DISTINCT sb.id) FILTER (WHERE sb.deleted_at IS NULL) AS storyboard_count,
			count(DISTINCT sb.id) FILTER (WHERE sb.deleted_at IS NULL AND sb.status='completed') AS completed_storyboard_count`).
		Joins("JOIN creative_projects p ON p.id=e.project_id AND p.user_id=? AND p.deleted_at IS NULL", userID).
		Joins("LEFT JOIN media_assets source ON source.id=e.source_video_asset_id").
		Joins("LEFT JOIN media_assets sub ON sub.id=e.subtitle_asset_id").
		Joins("LEFT JOIN episode_storyboards sb ON sb.episode_id=e.id").
		Where("e.project_id=? AND e.deleted_at IS NULL", projectID).
		Group("e.id,source.public_url,source.original_name,sub.original_name").Order("e.episode_no").Find(&items).Error
	return items, err
}

func (s *Creative) CreateEpisode(userID, projectID uuid.UUID, input EpisodeInput) (*model.ProjectEpisode, error) {
	var episode model.ProjectEpisode
	err := s.DB.Transaction(func(tx *gorm.DB) error {
		var project model.CreativeProject
		if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").Where("id=? AND user_id=? AND deleted_at IS NULL", projectID, userID).Take(&project).Error; err != nil {
			return gorm.ErrRecordNotFound
		}
		episodeNo := input.EpisodeNo
		if episodeNo < 1 {
			if err := tx.Table("project_episodes").Select("coalesce(max(episode_no),0)+1").Where("project_id=? AND deleted_at IS NULL", projectID).Scan(&episodeNo).Error; err != nil {
				return err
			}
		}
		name := strings.TrimSpace(input.Name)
		if name == "" {
			name = fmt.Sprintf("第%d集", episodeNo)
		}
		exists, err := episodeNameExists(tx, projectID, nil, name)
		if err != nil {
			return err
		}
		if exists {
			return errors.New("同一项目下不可有同名剧集")
		}
		episode = model.ProjectEpisode{ProjectID: projectID, EpisodeNo: episodeNo, Name: name, AudioSource: "video_audio", Status: "draft"}
		return tx.Create(&episode).Error
	})
	return &episode, err
}

func (s *Creative) UpdateEpisodeName(userID, projectID, episodeID uuid.UUID, name string) (*model.ProjectEpisode, error) {
	name = strings.TrimSpace(name)
	if name == "" {
		return nil, errors.New("剧集名称不能为空")
	}
	if len([]rune(name)) > 160 {
		return nil, errors.New("剧集名称不能超过160个字符")
	}
	var episode model.ProjectEpisode
	err := s.DB.Transaction(func(tx *gorm.DB) error {
		if err := tx.Table("project_episodes episode").Select("episode.*").Joins("JOIN creative_projects project ON project.id=episode.project_id AND project.user_id=? AND project.deleted_at IS NULL", userID).Where("episode.id=? AND episode.project_id=? AND episode.deleted_at IS NULL", episodeID, projectID).Take(&episode).Error; err != nil {
			return gorm.ErrRecordNotFound
		}
		exists, err := episodeNameExists(tx, projectID, &episodeID, name)
		if err != nil {
			return err
		}
		if exists {
			return errors.New("同一项目下不可有同名剧集")
		}
		if err := tx.Model(&episode).Update("name", name).Error; err != nil {
			return err
		}
		episode.Name = name
		return nil
	})
	return &episode, err
}

func episodeNameExists(tx *gorm.DB, projectID uuid.UUID, excludeEpisodeID *uuid.UUID, name string) (bool, error) {
	var count int64
	query := tx.Model(&model.ProjectEpisode{}).Where("project_id=? AND deleted_at IS NULL AND lower(name)=lower(?)", projectID, strings.TrimSpace(name))
	if excludeEpisodeID != nil {
		query = query.Where("id<>?", *excludeEpisodeID)
	}
	err := query.Count(&count).Error
	return count > 0, err
}

func (s *Creative) GetWorkbench(userID, projectID, episodeID uuid.UUID) (map[string]any, error) {
	project, err := s.GetProject(userID, projectID)
	if err != nil {
		return nil, err
	}
	episode := map[string]any{}
	err = s.DB.Table("project_episodes e").Select("e.*,source.public_url AS source_video_url,source.original_name AS source_video_original_name,sub.public_url AS subtitle_url,sub.original_name AS subtitle_original_name").Joins("LEFT JOIN media_assets source ON source.id=e.source_video_asset_id").Joins("LEFT JOIN media_assets sub ON sub.id=e.subtitle_asset_id").Where("e.id=? AND e.project_id=? AND e.deleted_at IS NULL", episodeID, projectID).Take(&episode).Error
	if err != nil {
		return nil, err
	}
	assets := make([]map[string]any, 0)
	if err := s.DB.Table("project_assets a").Select("a.*,image.public_url AS image_url,image.object_key AS image_key,audio.public_url AS audio_url,audio.duration_ms AS audio_duration_ms,ref.id AS reference_image_asset_id,ref.public_url AS reference_image_url").Joins("LEFT JOIN media_assets image ON image.id=a.image_asset_id").Joins("LEFT JOIN media_assets audio ON audio.id=a.audio_asset_id").Joins("LEFT JOIN media_assets ref ON ref.id=(a.attributes->>'reference_image_asset_id')::uuid AND ref.deleted_at IS NULL").Where("a.project_id=? AND a.deleted_at IS NULL", projectID).Order("a.asset_type,a.created_at").Find(&assets).Error; err != nil {
		return nil, err
	}
	storyboards := make([]map[string]any, 0)
	if err := s.DB.Table("episode_storyboards sb").Select(`sb.*,thumb.public_url AS thumbnail_url,
			out.id AS output_id,result.id AS result_media_id,result.public_url AS result_url,result.mime_type AS result_mime,
			coalesce((SELECT count(*) FROM generation_outputs history_output JOIN generation_tasks history_task ON history_task.id=history_output.task_id WHERE history_task.storyboard_id=sb.id AND history_task.task_type='video_generation'),0) AS history_count,
			(SELECT gt.status FROM generation_tasks gt WHERE gt.storyboard_id=sb.id AND gt.task_type='video_generation' ORDER BY gt.created_at DESC LIMIT 1) AS task_status`).Joins("LEFT JOIN media_assets thumb ON thumb.id=sb.thumbnail_asset_id").Joins("LEFT JOIN generation_outputs out ON out.id=sb.active_output_id").Joins("LEFT JOIN media_assets result ON result.id=out.media_asset_id").Where("sb.episode_id=? AND sb.deleted_at IS NULL", episodeID).Order("sb.sequence_no").Find(&storyboards).Error; err != nil {
		return nil, err
	}
	return map[string]any{"project": project, "episode": episode, "assets": assets, "storyboards": storyboards}, nil
}

func (s *Creative) CreateAsset(userID, projectID uuid.UUID, assetType, name string) (*model.ProjectAsset, error) {
	if !map[string]bool{"character": true, "scene": true, "prop": true, "custom": true}[assetType] || strings.TrimSpace(name) == "" {
		return nil, errors.New("资产类型或名称无效")
	}
	var owned int64
	if err := s.DB.Table("creative_projects").Where("id=? AND user_id=? AND project_type<>'video_redraw' AND deleted_at IS NULL", projectID, userID).Count(&owned).Error; err != nil || owned == 0 {
		return nil, gorm.ErrRecordNotFound
	}
	asset := &model.ProjectAsset{ProjectID: projectID, AssetType: assetType, Name: strings.TrimSpace(name), Appearances: json.RawMessage("[]")}
	result := s.DB.Clauses(clause.OnConflict{DoNothing: true}).Create(asset)
	if result.Error != nil {
		return nil, result.Error
	}
	if result.RowsAffected == 0 {
		return nil, errors.New("同类型资产名称已存在")
	}
	return asset, nil
}

func (s *Creative) UpdateAsset(userID, projectID, assetID uuid.UUID, values map[string]any) error {
	return s.DB.Transaction(func(tx *gorm.DB) error {
		var current struct {
			Name        string
			ProjectType string
		}
		if err := tx.Table("project_assets asset").Select("asset.name,project.project_type").
			Joins("JOIN creative_projects project ON project.id=asset.project_id AND project.user_id=? AND project.deleted_at IS NULL", userID).
			Where("asset.id=? AND asset.project_id=? AND asset.deleted_at IS NULL", assetID, projectID).Take(&current).Error; err != nil {
			return err
		}
		if current.ProjectType == "video_redraw" {
			for key := range values {
				if key != "name" {
					return errors.New("剧本反推资产仅可修改名称")
				}
			}
		}
		allowed := map[string]any{}
		for _, key := range []string{"name", "description", "image_prompt", "appearances"} {
			if value, ok := values[key]; ok {
				allowed[key] = value
			}
		}
		newName := current.Name
		if value, ok := allowed["name"]; ok {
			newName = strings.TrimSpace(fmt.Sprint(value))
			if newName == "" {
				return errors.New("资产名称不能为空")
			}
			var duplicates int64
			if err := tx.Table("project_assets candidate").
				Where("candidate.project_id=? AND candidate.id<>? AND candidate.deleted_at IS NULL AND lower(candidate.name)=lower(?)", projectID, assetID, newName).
				Where("candidate.asset_type=(SELECT asset_type FROM project_assets WHERE id=? AND project_id=? AND deleted_at IS NULL)", assetID, projectID).
				Count(&duplicates).Error; err != nil {
				return err
			}
			if duplicates > 0 {
				return errors.New("同类型资产名称已存在")
			}
			allowed["name"] = newName
		}
		allowed["user_edited"] = true
		result := tx.Table("project_assets").Where("id=? AND project_id=? AND deleted_at IS NULL", assetID, projectID).Updates(allowed)
		if result.Error != nil {
			if strings.Contains(result.Error.Error(), "uq_project_assets_name_type") {
				return errors.New("同类型资产名称已存在")
			}
			return result.Error
		}
		if result.RowsAffected == 0 {
			return gorm.ErrRecordNotFound
		}
		if newName != current.Name {
			return syncAssetRename(tx, projectID, assetID, current.Name, newName)
		}
		return nil
	})
}

func syncAssetRename(tx *gorm.DB, projectID, assetID uuid.UUID, oldName, newName string) error {
	protectedNames := make([]string, 0)
	if err := tx.Table("project_assets").Where("project_id=? AND id<>? AND deleted_at IS NULL", projectID, assetID).Pluck("name", &protectedNames).Error; err != nil {
		return err
	}
	var storyboards []model.EpisodeStoryboard
	if err := tx.Select("id", "script_content", "prompt_content", "asset_refs").
		Where(`deleted_at IS NULL AND (project_id=? OR episode_id IN (
			SELECT id FROM project_episodes WHERE project_id=? AND deleted_at IS NULL
		))`, projectID, projectID).Find(&storyboards).Error; err != nil {
		return err
	}
	for _, storyboard := range storyboards {
		updates := map[string]any{}
		mentionNames, err := assetRefNames(storyboard.AssetRefs, assetID)
		if err != nil {
			return err
		}
		storyboardProtectedNames, err := otherAssetRefNames(storyboard.AssetRefs, assetID)
		if err != nil {
			return err
		}
		storyboardProtectedNames = append(storyboardProtectedNames, protectedNames...)
		mentionNames = append(mentionNames, oldName)
		scriptContent := storyboard.ScriptContent
		promptContent := storyboard.PromptContent
		seenNames := map[string]bool{}
		for _, mentionName := range mentionNames {
			mentionName = strings.TrimSpace(mentionName)
			if mentionName == "" || seenNames[mentionName] {
				continue
			}
			seenNames[mentionName] = true
			scriptContent = renameAssetMentions(scriptContent, mentionName, newName, storyboardProtectedNames)
			promptContent = renameAssetMentions(promptContent, mentionName, newName, storyboardProtectedNames)
		}
		if scriptContent != storyboard.ScriptContent {
			value := scriptContent
			updates["script_content"] = value
		}
		if promptContent != storyboard.PromptContent {
			value := promptContent
			updates["prompt_content"] = value
		}
		refs, changed, err := renameAssetRef(storyboard.AssetRefs, assetID, newName)
		if err != nil {
			return err
		}
		if changed {
			updates["asset_refs"] = gorm.Expr("?::jsonb", string(refs))
		}
		if len(updates) > 0 {
			if err := tx.Model(&model.EpisodeStoryboard{}).Where("id=?", storyboard.ID).Updates(updates).Error; err != nil {
				return err
			}
		}
	}
	return nil
}

func renameAssetMentions(content, oldName, newName string, protectedNames []string) string {
	oldName = strings.TrimSpace(oldName)
	newName = strings.TrimSpace(newName)
	if content == "" || oldName == "" || newName == "" || oldName == newName {
		return content
	}
	needle := "@" + oldName
	var result strings.Builder
	searchFrom := 0
	for searchFrom < len(content) {
		relative := strings.Index(content[searchFrom:], needle)
		if relative < 0 {
			break
		}
		index := searchFrom + relative
		result.WriteString(content[searchFrom:index])
		if matchesProtectedMention(content[index+1:], oldName, protectedNames) {
			result.WriteString(needle)
		} else {
			result.WriteString("@" + newName)
		}
		searchFrom = index + len(needle)
	}
	result.WriteString(content[searchFrom:])
	return result.String()
}

func matchesProtectedMention(contentAfterAt, oldName string, protectedNames []string) bool {
	for _, name := range protectedNames {
		name = strings.TrimSpace(name)
		if name != oldName && strings.HasPrefix(name, oldName) && strings.HasPrefix(contentAfterAt, name) {
			return true
		}
	}
	return false
}

func assetRefNames(value json.RawMessage, assetID uuid.UUID) ([]string, error) {
	if len(value) == 0 {
		return nil, nil
	}
	var refs []map[string]any
	if err := json.Unmarshal(value, &refs); err != nil {
		return nil, err
	}
	names := make([]string, 0, 1)
	for _, ref := range refs {
		if fmt.Sprint(ref["id"]) == assetID.String() {
			names = append(names, fmt.Sprint(ref["name"]))
		}
	}
	return names, nil
}

func otherAssetRefNames(value json.RawMessage, assetID uuid.UUID) ([]string, error) {
	if len(value) == 0 {
		return nil, nil
	}
	var refs []map[string]any
	if err := json.Unmarshal(value, &refs); err != nil {
		return nil, err
	}
	names := make([]string, 0, len(refs))
	for _, ref := range refs {
		if fmt.Sprint(ref["id"]) != assetID.String() {
			names = append(names, fmt.Sprint(ref["name"]))
		}
	}
	return names, nil
}

func renameAssetRef(value json.RawMessage, assetID uuid.UUID, newName string) (json.RawMessage, bool, error) {
	if len(value) == 0 {
		return value, false, nil
	}
	var refs []map[string]any
	if err := json.Unmarshal(value, &refs); err != nil {
		return nil, false, err
	}
	changed := false
	for _, ref := range refs {
		if fmt.Sprint(ref["id"]) == assetID.String() && fmt.Sprint(ref["name"]) != newName {
			ref["name"] = newName
			changed = true
		}
	}
	if !changed {
		return value, false, nil
	}
	encoded, err := json.Marshal(refs)
	return encoded, true, err
}

func (s *Creative) DeleteAsset(userID, projectID, assetID uuid.UUID) ([]string, error) {
	media := newDeletionMediaSet()
	err := s.DB.Transaction(func(tx *gorm.DB) error {
		var asset model.ProjectAsset
		if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Table("project_assets asset").Select("asset.*").
			Joins("JOIN creative_projects project ON project.id=asset.project_id AND project.user_id=? AND project.project_type<>'video_redraw' AND project.deleted_at IS NULL", userID).
			Where("asset.id=? AND asset.project_id=? AND asset.deleted_at IS NULL", assetID, projectID).Take(&asset).Error; err != nil {
			return err
		}
		assetTaskScope := "project_id=? AND task_type='image_generation' AND input_data->>'asset_id'=?"
		var active int64
		if err := tx.Model(&model.GenerationTask{}).Where(assetTaskScope+" AND status IN ?", projectID, assetID.String(), activeTaskStatuses).Count(&active).Error; err != nil {
			return err
		}
		if active > 0 {
			return errors.New("资产存在进行中的图片生成任务，暂时不能删除")
		}
		if err := media.addQuery(tx.Table("media_assets media").Select("DISTINCT media.id,media.object_key").
			Joins("JOIN generation_outputs output ON output.media_asset_id=media.id").
			Joins("JOIN generation_tasks task ON task.id=output.task_id").
			Where("task."+assetTaskScope, projectID, assetID.String())); err != nil {
			return err
		}
		if asset.ImageAssetID != nil {
			if err := media.addQuery(tx.Table("media_assets").Select("id,object_key").Where("id=?", *asset.ImageAssetID)); err != nil {
				return err
			}
		}
		if asset.AudioAssetID != nil {
			if err := media.addQuery(tx.Table("media_assets").Select("id,object_key").Where("id=?", *asset.AudioAssetID)); err != nil {
				return err
			}
		}
		assetMediaPattern := fmt.Sprintf("juyou_ran/video-redraw/projects/%s/assets/%%/%s/%%", projectID, assetID)
		if err := collectMediaByObjectKey(tx, media, assetMediaPattern); err != nil {
			return err
		}
		if err := tx.Exec(`UPDATE episode_storyboards storyboard SET
			asset_refs=COALESCE((SELECT jsonb_agg(item) FROM jsonb_array_elements(COALESCE(storyboard.asset_refs,'[]'::jsonb)) item
				WHERE item->>'id'<>?), '[]'::jsonb),
			prompt_content=replace(COALESCE(storyboard.prompt_content,''), ?, ?)
			WHERE storyboard.project_id=? OR storyboard.episode_id IN (SELECT id FROM project_episodes WHERE project_id=?)`, assetID.String(), "@"+asset.Name, asset.Name, projectID, projectID).Error; err != nil {
			return err
		}
		if err := tx.Exec("UPDATE episode_storyboards SET active_output_id=NULL WHERE active_output_id IN (SELECT output.id FROM generation_outputs output JOIN generation_tasks task ON task.id=output.task_id WHERE task."+assetTaskScope+")", projectID, assetID.String()).Error; err != nil {
			return err
		}
		if err := tx.Exec("DELETE FROM generation_outputs WHERE task_id IN (SELECT id FROM generation_tasks WHERE "+assetTaskScope+")", projectID, assetID.String()).Error; err != nil {
			return err
		}
		if err := tx.Exec("DELETE FROM generation_tasks WHERE "+assetTaskScope, projectID, assetID.String()).Error; err != nil {
			return err
		}
		if err := tx.Exec("DELETE FROM project_assets WHERE id=?", assetID).Error; err != nil {
			return err
		}
		return media.deleteRows(tx)
	})
	return media.objectKeys(), err
}

func ensureAssetImageHistory(tx *gorm.DB, userID, projectID, assetID, mediaID uuid.UUID) error {
	var count int64
	if err := tx.Table("generation_outputs output").
		Joins("JOIN generation_tasks task ON task.id=output.task_id").
		Where("output.media_asset_id=? AND task.project_id=? AND task.task_type='image_generation' AND task.input_data->>'asset_id'=?", mediaID, projectID, assetID.String()).
		Count(&count).Error; err != nil {
		return err
	}
	if count > 0 {
		return nil
	}
	inputData, _ := json.Marshal(map[string]any{"asset_id": assetID.String(), "source": "manual_upload"})
	taskID := uuid.New()
	if err := tx.Exec(`INSERT INTO generation_tasks
		(id,request_id,user_id,project_id,task_type,status,input_data,finished_at)
		VALUES (?,?,?,?,?,'succeeded',?::jsonb,CURRENT_TIMESTAMP)`,
		taskID, "asset_upload_"+uuid.NewString(), userID, projectID, "image_generation", string(inputData)).Error; err != nil {
		return err
	}
	metadata, _ := json.Marshal(map[string]any{"source": "manual_upload"})
	return tx.Create(&model.GenerationOutput{
		ID: uuid.New(), TaskID: taskID, MediaAssetID: mediaID, OutputType: "image", SequenceNo: 1, Metadata: metadata,
	}).Error
}

func (s *Creative) AttachUploadedAssetImage(userID, projectID, assetID uuid.UUID, media *model.MediaAsset) error {
	return s.DB.Transaction(func(tx *gorm.DB) error {
		if err := tx.Create(media).Error; err != nil {
			return err
		}
		if err := ensureAssetImageHistory(tx, userID, projectID, assetID, media.ID); err != nil {
			return err
		}
		result := tx.Table("project_assets").Where(`id=? AND project_id=? AND deleted_at IS NULL AND EXISTS (
			SELECT 1 FROM creative_projects WHERE id=project_assets.project_id AND user_id=? AND deleted_at IS NULL)`, assetID, projectID, userID).
			Updates(map[string]any{"image_asset_id": media.ID, "user_edited": true})
		if result.Error != nil {
			return result.Error
		}
		if result.RowsAffected == 0 {
			return gorm.ErrRecordNotFound
		}
		return nil
	})
}

func (s *Creative) RemoveAssetImage(userID, projectID, assetID uuid.UUID) error {
	return s.DB.Transaction(func(tx *gorm.DB) error {
		var row struct {
			ImageAssetID *uuid.UUID
		}
		if err := tx.Table("project_assets a").Select("a.image_asset_id").
			Joins("JOIN creative_projects p ON p.id=a.project_id AND p.user_id=? AND p.project_type<>'video_redraw' AND p.deleted_at IS NULL", userID).
			Where("a.id=? AND a.project_id=? AND a.deleted_at IS NULL", assetID, projectID).Take(&row).Error; err != nil {
			return err
		}
		if row.ImageAssetID == nil {
			return nil
		}
		if err := ensureAssetImageHistory(tx, userID, projectID, assetID, *row.ImageAssetID); err != nil {
			return err
		}
		if err := tx.Table("project_assets").Where("id=?", assetID).Updates(map[string]any{"image_asset_id": nil, "user_edited": true}).Error; err != nil {
			return err
		}
		return nil
	})
}

func (s *Creative) UpdateStoryboard(userID, projectID, storyboardID uuid.UUID, values map[string]any) error {
	allowed := map[string]any{}
	for _, key := range []string{"title", "script_content", "source_excerpt", "prompt_content", "image_prompt", "dialogue", "asset_refs", "locked", "active_output_id"} {
		if value, ok := values[key]; ok {
			if key == "asset_refs" || key == "dialogue" {
				encoded, err := json.Marshal(value)
				if err != nil {
					return err
				}
				value = gorm.Expr("?::jsonb", string(encoded))
			}
			allowed[key] = value
		}
	}
	allowed["user_edited"] = true
	if value, ok := values["duration_seconds"]; ok {
		duration, err := strconv.Atoi(fmt.Sprint(value))
		if err != nil {
			return errors.New("分镜时长必须为整数")
		}
		min, max, err := s.videoDurationRange(userID, projectID)
		if err != nil {
			return err
		}
		if duration < min || duration > max {
			return fmt.Errorf("分镜时长必须为 %d 到 %d 秒", min, max)
		}
		allowed["duration_seconds"] = duration
	}
	result := s.DB.Table("episode_storyboards sb").Where(`sb.id=? AND sb.deleted_at IS NULL AND EXISTS (
		SELECT 1 FROM creative_projects p
		WHERE p.id=? AND p.user_id=? AND p.deleted_at IS NULL AND (
			sb.project_id=p.id OR EXISTS (SELECT 1 FROM project_episodes e WHERE e.id=sb.episode_id AND e.project_id=p.id AND e.deleted_at IS NULL)
		))`, storyboardID, projectID, userID).Updates(allowed)
	if result.Error != nil {
		return result.Error
	}
	if result.RowsAffected == 0 {
		return gorm.ErrRecordNotFound
	}
	return nil
}

func (s *Creative) CreateStoryboard(userID, projectID, episodeID uuid.UUID) (*model.EpisodeStoryboard, error) {
	if err := s.requirePremiumEpisode(userID, projectID, episodeID); err != nil {
		return nil, err
	}
	var storyboard model.EpisodeStoryboard
	err := s.DB.Transaction(func(tx *gorm.DB) error {
		var maxSequence int
		if err := tx.Table("episode_storyboards").Where("episode_id=? AND deleted_at IS NULL", episodeID).Select("coalesce(max(sequence_no),0)").Scan(&maxSequence).Error; err != nil {
			return err
		}
		start := int64(maxSequence * 5000)
		storyboard = model.EpisodeStoryboard{ID: uuid.New(), EpisodeID: &episodeID, SequenceNo: maxSequence + 1, StableKey: uuid.NewString(), StartMS: start, EndMS: start + 5000, DurationSeconds: 5, Dialogue: json.RawMessage("[]"), AssetRefs: json.RawMessage("[]"), Status: "idle", UserEdited: true}
		return tx.Create(&storyboard).Error
	})
	return &storyboard, err
}

func (s *Creative) QueueGeneration(userID, projectID uuid.UUID, episodeID *uuid.UUID, storyboardID uuid.UUID, taskType string, input map[string]any) (*model.GenerationTask, error) {
	if taskType != "image_generation" && taskType != "video_generation" {
		return nil, errors.New("生成任务类型无效")
	}
	if s.Queue == nil {
		return nil, errors.New("生成任务队列不可用")
	}
	if input == nil {
		input = map[string]any{}
	}
	var owned int64
	ownedQuery := s.DB.Table("episode_storyboards sb").Joins("JOIN creative_projects p ON p.id=? AND p.user_id=? AND p.deleted_at IS NULL", projectID, userID).
		Where("sb.id=? AND sb.deleted_at IS NULL", storyboardID)
	if episodeID == nil {
		ownedQuery = ownedQuery.Where("sb.project_id=p.id")
	} else {
		ownedQuery = ownedQuery.Where("sb.episode_id=? AND EXISTS (SELECT 1 FROM project_episodes e WHERE e.id=sb.episode_id AND e.project_id=p.id AND e.deleted_at IS NULL)", *episodeID)
	}
	if err := ownedQuery.Count(&owned).Error; err != nil || owned == 0 {
		return nil, gorm.ErrRecordNotFound
	}
	if taskType == "video_generation" {
		var projectType string
		if err := s.DB.Table("creative_projects").Where("id=? AND user_id=? AND deleted_at IS NULL", projectID, userID).Pluck("project_type", &projectType).Error; err != nil {
			return nil, err
		}
		if projectType == "premium_drama" {
			var candidateCount int64
			if err := s.DB.Table("generation_outputs output").Joins("JOIN generation_tasks gt ON gt.id=output.task_id").
				Joins("JOIN episode_storyboards sb ON sb.id=gt.storyboard_id").
				Where("gt.storyboard_id=? AND gt.task_type='video_generation' AND (sb.active_output_id=output.id OR coalesce(output.metadata->>'candidate','false')='true')", storyboardID).
				Count(&candidateCount).Error; err != nil {
				return nil, err
			}
			if candidateCount >= 3 {
				return nil, errors.New("当前分镜最多保留 3 个备选视频，请先删除一个备选视频")
			}
		}
		var historyCount int64
		if err := s.DB.Table("generation_outputs output").Joins("JOIN generation_tasks gt ON gt.id=output.task_id").Joins("JOIN episode_storyboards sb ON sb.id=gt.storyboard_id AND sb.active_output_id IS DISTINCT FROM output.id").Where("gt.storyboard_id=? AND gt.task_type='video_generation' AND coalesce(output.metadata->>'candidate','false')<>'true'", storyboardID).Count(&historyCount).Error; err != nil {
			return nil, err
		}
		if historyCount >= 5 {
			return nil, errors.New("当前分镜最多保留 5 条历史视频，请先删除历史记录")
		}
	}
	if taskType == "image_generation" {
		assetID, _ := input["asset_id"].(string)
		var historyCount int64
		query := s.DB.Table("generation_outputs output").Joins("JOIN generation_tasks gt ON gt.id=output.task_id").Where("gt.task_type='image_generation'")
		if assetID != "" {
			query = query.Joins("JOIN project_assets a ON a.id=? AND a.image_asset_id IS DISTINCT FROM output.media_asset_id", assetID).Where("gt.input_data->>'asset_id'=?", assetID)
		} else {
			query = query.Joins("JOIN episode_storyboards sb ON sb.id=? AND sb.thumbnail_asset_id IS DISTINCT FROM output.media_asset_id", storyboardID).Where("gt.storyboard_id=? AND gt.input_data->>'target_type'='storyboard'", storyboardID)
		}
		if err := query.Count(&historyCount).Error; err != nil {
			return nil, err
		}
		if historyCount >= 5 {
			return nil, errors.New("历史记录已达 5 条，请先删除历史记录")
		}
	}
	purpose := taskType
	var modelRow struct {
		ModelID      uuid.UUID
		ChannelID    uuid.UUID
		ModelName    string
		ProjectType  string
		AspectRatio  string
		Price        string
		PriceExists  bool
		SettingsText string
	}
	priceKey := strings.TrimSpace(fmt.Sprint(input["resolution"]))
	var configuredRow struct{ SettingsText string }
	err := s.DB.Table("project_model_configs").Select("settings::text AS settings_text").Where("project_id=? AND purpose=?", projectID, purpose).Take(&configuredRow).Error
	if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
		return nil, err
	}
	configured, err := decodeJSONObject(configuredRow.SettingsText)
	if err != nil {
		return nil, err
	}
	if priceKey == "" || priceKey == "<nil>" {
		priceKey = strings.TrimSpace(fmt.Sprint(configured["resolution"]))
	}
	if priceKey == "" || priceKey == "<nil>" {
		priceKey = map[string]string{"image_generation": "1k", "video_generation": "480p"}[taskType]
	}
	if priceKey == "" {
		priceKey = "default"
	}
	err = s.DB.Raw(`SELECT m.id AS model_id,m.channel_id,m.name AS model_name,p.project_type,p.aspect_ratio,coalesce(mp.price,0)::text AS price,
		(mp.id IS NOT NULL) AS price_exists,pc.settings::text AS settings_text
		FROM project_model_configs pc JOIN models m ON m.id=pc.model_id
		JOIN channels c ON c.id=m.channel_id
		JOIN creative_projects p ON p.id=pc.project_id
		LEFT JOIN model_prices mp ON mp.model_id=m.id AND lower(mp.price_key)=lower(?)
		WHERE pc.project_id=? AND pc.purpose=? AND m.enabled=true AND m.deleted_at IS NULL AND c.enabled=true AND c.deleted_at IS NULL`, priceKey, projectID, purpose).Scan(&modelRow).Error
	if err != nil || modelRow.ModelID == uuid.Nil {
		return nil, errors.New("请先完成项目模型配置")
	}
	if !modelRow.PriceExists {
		return nil, fmt.Errorf("当前%s模型未配置 %s 价格", map[string]string{"image_generation": "图片", "video_generation": "视频"}[taskType], priceKey)
	}
	duration := 1
	var generationAssetID *uuid.UUID
	input["model"] = modelRow.ModelName
	configSettings, err := decodeJSONObject(modelRow.SettingsText)
	if err != nil {
		return nil, err
	}
	// APIMart's video API uses `size` for the project framing ratio.  Remove
	// any legacy/client-supplied aspect_ratio value before persisting the task
	// input so it cannot leak into the upstream request path.
	delete(input, "aspect_ratio")
	if taskType == "video_generation" {
		capabilities, ok := apimart.VideoCapabilities(modelRow.ModelName)
		if !ok {
			return nil, errors.New("视频模型能力未配置")
		}
		// 短剧创作和视频转绘不开放参考视频，即使上游模型支持也不接受客户端传入。
		delete(input, "video_urls")
		// Video framing is a project-level setting. Model configuration only
		// stores the resolution, so ignore any client-supplied ratio here.
		input["size"] = firstNonEmpty(modelRow.AspectRatio, "16:9")
		input["resolution"] = firstNonEmpty(fmt.Sprint(input["resolution"]), fmt.Sprint(configSettings["resolution"]), "480p")
		var storyboard model.EpisodeStoryboard
		if err := storyboardParentQuery(s.DB, projectID, episodeID, storyboardID).First(&storyboard).Error; err != nil {
			return nil, err
		}
		duration = storyboard.DurationSeconds
		if duration < capabilities.DurationMinSeconds || duration > capabilities.DurationMaxSeconds {
			return nil, fmt.Errorf("视频时长必须为 %d 到 %d 秒", capabilities.DurationMinSeconds, capabilities.DurationMaxSeconds)
		}
		input["duration"] = duration
		input["prompt"] = firstNonEmpty(storyboard.PromptContent, storyboard.ScriptContent)
		var refs []map[string]any
		_ = json.Unmarshal(storyboard.AssetRefs, &refs)
		refIDs := make([]uuid.UUID, 0, len(refs))
		seenRefIDs := make(map[uuid.UUID]struct{}, len(refs))
		for _, ref := range refs {
			if id, err := uuid.Parse(fmt.Sprint(ref["id"])); err == nil {
				if _, exists := seenRefIDs[id]; exists {
					continue
				}
				seenRefIDs[id] = struct{}{}
				refIDs = append(refIDs, id)
			}
		}
		type referenceAsset struct {
			ID                       uuid.UUID
			Name, ImageURL, AudioURL string
			AssetType                string
			AudioDurationMS          *int64
			MentionIndex             int
		}
		mentionMode := modelRow.ProjectType == "premium_drama" && strings.Contains(storyboard.ScriptContent, "@")
		candidateRows := make([]referenceAsset, 0)
		if mentionMode || len(refIDs) > 0 {
			query := s.DB.Table("project_assets a").
				Select("a.id,a.name,a.asset_type,image.public_url AS image_url,audio.public_url AS audio_url,audio.duration_ms AS audio_duration_ms").
				Joins("LEFT JOIN media_assets image ON image.id=a.image_asset_id AND image.deleted_at IS NULL").
				Joins("LEFT JOIN media_assets audio ON audio.id=a.audio_asset_id AND audio.deleted_at IS NULL").
				Where("a.project_id=? AND a.deleted_at IS NULL", projectID)
			if mentionMode {
				query = query.Where("a.asset_type='custom' OR a.id IN ?", refIDs)
			} else {
				query = query.Where("a.id IN ?", refIDs)
			}
			if err := query.Find(&candidateRows).Error; err != nil {
				return nil, err
			}
		}
		selectedRows := make([]referenceAsset, 0, len(candidateRows)+1)
		if mentionMode {
			for _, row := range candidateRows {
				if index := assetMentionIndex(storyboard.ScriptContent, row.Name); index >= 0 {
					row.MentionIndex = index
					selectedRows = append(selectedRows, row)
				}
			}
			storyboardImageName := fmt.Sprintf("分镜 %d 分镜图", storyboard.SequenceNo)
			if index := assetMentionIndex(storyboard.ScriptContent, storyboardImageName); index >= 0 && storyboard.ThumbnailAssetID != nil {
				var media struct{ PublicURL string }
				if err := s.DB.Table("media_assets").Select("public_url").Where("id=? AND deleted_at IS NULL", *storyboard.ThumbnailAssetID).Take(&media).Error; err != nil {
					return nil, err
				}
				selectedRows = append(selectedRows, referenceAsset{Name: storyboardImageName, ImageURL: media.PublicURL, AssetType: "storyboard_image", MentionIndex: index})
			}
			sort.SliceStable(selectedRows, func(left, right int) bool {
				return selectedRows[left].MentionIndex < selectedRows[right].MentionIndex
			})
		}
		if !mentionMode || len(selectedRows) == 0 {
			selectedRows = selectedRows[:0]
			byID := make(map[uuid.UUID]referenceAsset, len(candidateRows))
			for _, row := range candidateRows {
				byID[row.ID] = row
			}
			for _, refID := range refIDs {
				row, exists := byID[refID]
				if !exists {
					return nil, errors.New("分镜引用了已删除或无权访问的项目资产")
				}
				selectedRows = append(selectedRows, row)
			}
		}
		images := make([]string, 0)
		audios := make([]string, 0)
		imageNames := make([]string, 0)
		audioNames := make([]string, 0)
		var audioDurationMS int64
		// 当前分镜已有分镜图时，无条件作为首张参考图参与视频生成；
		// 若已在提及模式下通过 @分镜 N 分镜图 加入，则跳过避免重复。
		storyboardImageMentioned := false
		if mentionMode {
			for _, row := range selectedRows {
				if row.AssetType == "storyboard_image" {
					storyboardImageMentioned = true
					break
				}
			}
		}
		if !storyboardImageMentioned && storyboard.ThumbnailAssetID != nil {
			var media struct{ PublicURL string }
			if err := s.DB.Table("media_assets").Select("public_url").Where("id=? AND deleted_at IS NULL", *storyboard.ThumbnailAssetID).Take(&media).Error; err != nil {
				return nil, err
			}
			if media.PublicURL != "" {
				images = append(images, media.PublicURL)
				imageNames = append(imageNames, fmt.Sprintf("分镜 %d 分镜图", storyboard.SequenceNo))
			}
		}
		for _, row := range selectedRows {
			if row.ImageURL != "" {
				if len(images) >= capabilities.ReferenceImages.MaxCount {
					return nil, errors.New("参考图数量超限")
				}
				images = append(images, row.ImageURL)
				imageNames = append(imageNames, row.Name)
			}
			if row.AudioURL != "" {
				if len(audios) >= capabilities.ReferenceAudios.MaxCount {
					return nil, errors.New("音频数量超限")
				}
				audios = append(audios, row.AudioURL)
				audioNames = append(audioNames, row.Name)
				if row.AudioDurationMS != nil {
					audioDurationMS += *row.AudioDurationMS
				}
			}
		}
		if len(audios) > 0 && len(images) == 0 {
			return nil, errors.New("禁止单独参考音频")
		}
		if capabilities.ReferenceAudios.MaxTotalDurationMS > 0 && audioDurationMS > capabilities.ReferenceAudios.MaxTotalDurationMS {
			return nil, errors.New("音频时长超限")
		}
		input["image_urls"] = images
		input["audio_urls"] = audios
		if len(imageNames) > 0 || len(audioNames) > 0 {
			mapping := []string{"参考资产映射（数组顺序与 URL 一致）："}
			for index, name := range imageNames {
				mapping = append(mapping, fmt.Sprintf("参考图%d ↔ @%s ↔ image_urls[%d]", index+1, name, index))
			}
			for index, name := range audioNames {
				mapping = append(mapping, fmt.Sprintf("参考音频%d ↔ @%s ↔ audio_urls[%d]", index+1, name, index))
			}
			input["prompt"] = strings.Join(mapping, "\n") + "\n" + strings.TrimSpace(fmt.Sprint(input["prompt"]))
		}
	} else {
		// Image generation always uses the standard landscape canvas. The
		// image model config deliberately does not persist an aspect-ratio field.
		input["size"] = "16:9"
		input["resolution"] = firstNonEmpty(fmt.Sprint(input["resolution"]), fmt.Sprint(configSettings["resolution"]), "1k")
		if fmt.Sprint(input["target_type"]) == "storyboard" {
			var storyboard model.EpisodeStoryboard
			if err := storyboardParentQuery(s.DB, projectID, episodeID, storyboardID).Take(&storyboard).Error; err != nil {
				return nil, err
			}
			input["target_type"] = "storyboard"
			input["storyboard_id"] = storyboardID.String()
			// 分镜图提示词优先使用客户端提交的提示词，其次回退到分镜保存的 image_prompt，
			// 再回退到分镜内容（prompt_content/script_content），最后拼接"分镜图生成"提示词预设。
			imagePrompt := firstNonEmpty(fmt.Sprint(input["prompt"]), storyboard.ImagePrompt, storyboard.PromptContent, storyboard.ScriptContent)
			// 自动携带当前分镜关联的角色/场景/道具参考图（排除音色和未关联资产），
			// 与客户端上传的临时参考图合并后随任务提交。
			assetImages, err := storyboardReferenceImageURLs(s.DB, projectID, storyboard)
			if err != nil {
				return nil, err
			}
			if len(assetImages) > 0 {
				input["image_urls"] = append(assetImages, existingImageURLs(input["image_urls"])...)
			}
			promptParts := make([]string, 0, 2)
			var preset struct{ Content string }
			queryErr := s.DB.Table("prompts p").Select("p.content").
				Joins("JOIN user_prompt_preferences pref ON pref.prompt_id=p.id AND pref.user_id=? AND pref.prompt_type=?", userID, "分镜图生成").
				Where("p.type=? AND p.deleted_at IS NULL", "分镜图生成").Limit(1).Find(&preset).Error
			if queryErr != nil && !errors.Is(queryErr, gorm.ErrRecordNotFound) {
				return nil, queryErr
			}
			if strings.TrimSpace(preset.Content) != "" {
				promptParts = append(promptParts, strings.TrimSpace(preset.Content))
			}
			if imagePrompt != "" {
				promptParts = append(promptParts, imagePrompt)
			}
			input["prompt"] = strings.Join(promptParts, "\n")
			if strings.TrimSpace(fmt.Sprint(input["prompt"])) == "" {
				return nil, errors.New("分镜提示词不能为空")
			}
		} else {
			assetID, err := uuid.Parse(fmt.Sprint(input["asset_id"]))
			if err != nil {
				return nil, errors.New("请选择需要生成图片的资产")
			}
			generationAssetID = &assetID
			var assetRow struct {
				ImagePrompt string
				AssetType   string
				ImageURL    string
			}
			if err := s.DB.Table("project_assets a").Select("a.image_prompt,a.asset_type,coalesce(media.public_url,'') AS image_url").Joins("LEFT JOIN media_assets media ON media.id=a.image_asset_id AND media.deleted_at IS NULL").Where("a.id=? AND a.project_id=? AND a.deleted_at IS NULL", assetID, projectID).Take(&assetRow).Error; err != nil {
				return nil, err
			}
			input["asset_id"] = assetID.String()
			promptParts := make([]string, 0, 2)
			if promptType := assetPromptType(assetRow.AssetType); promptType != "" {
				var preset struct{ Content string }
				queryErr := s.DB.Table("prompts p").Select("p.content").
					Joins("JOIN user_prompt_preferences pref ON pref.prompt_id=p.id AND pref.user_id=? AND pref.prompt_type=?", userID, promptType).
					Where("p.type=? AND p.deleted_at IS NULL", promptType).Take(&preset).Error
				if queryErr != nil && !errors.Is(queryErr, gorm.ErrRecordNotFound) {
					return nil, queryErr
				}
				if strings.TrimSpace(preset.Content) != "" {
					promptParts = append(promptParts, strings.TrimSpace(preset.Content))
				}
			}
			if strings.TrimSpace(assetRow.ImagePrompt) != "" {
				promptParts = append(promptParts, strings.TrimSpace(assetRow.ImagePrompt))
			}
			input["prompt"] = strings.Join(promptParts, "\n")
		}
	}
	payload, err := json.Marshal(input)
	if err != nil {
		return nil, err
	}
	task := &model.GenerationTask{
		RequestID: "gen_" + uuid.NewString(), UserID: userID, ChannelID: &modelRow.ChannelID,
		ModelID: &modelRow.ModelID, ProjectID: &projectID, EpisodeID: episodeID, StoryboardID: &storyboardID,
		TaskType: taskType, Status: "pending_submission", InputData: payload,
	}
	err = s.DB.Transaction(func(tx *gorm.DB) error {
		var active int64
		if episodeID != nil {
			if err := tx.Model(&model.DramaParseTask{}).Where("episode_id=? AND status IN ?", *episodeID, []string{"queued", "running", "retry_wait", "cancel_requested"}).Count(&active).Error; err != nil {
				return err
			}
			if active > 0 {
				return errors.New("当前剧集正在解析，暂时不能提交生成任务")
			}
		}
		if taskType == "video_generation" {
			var locked model.EpisodeStoryboard
			if err := storyboardParentQuery(tx.Clauses(clause.Locking{Strength: "UPDATE"}), projectID, episodeID, storyboardID).Select("id").Take(&locked).Error; err != nil {
				return err
			}
			if err := tx.Model(&model.GenerationTask{}).Where("storyboard_id=? AND task_type=? AND status IN ?", storyboardID, taskType, activeTaskStatuses).Count(&active).Error; err != nil {
				return err
			}
		} else {
			if generationAssetID == nil {
				var locked model.EpisodeStoryboard
				if err := storyboardParentQuery(tx.Clauses(clause.Locking{Strength: "UPDATE"}), projectID, episodeID, storyboardID).Select("id").Take(&locked).Error; err != nil {
					return err
				}
				if err := tx.Model(&model.GenerationTask{}).Where("storyboard_id=? AND task_type='image_generation' AND input_data->>'target_type'='storyboard' AND status IN ?", storyboardID, activeTaskStatuses).Count(&active).Error; err != nil {
					return err
				}
			} else {
				var locked model.ProjectAsset
				if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").Where("id=? AND project_id=? AND deleted_at IS NULL", *generationAssetID, projectID).Take(&locked).Error; err != nil {
					return err
				}
				if err := tx.Model(&model.GenerationTask{}).Where("project_id=? AND task_type=? AND input_data->>'asset_id'=? AND status IN ?", projectID, taskType, generationAssetID.String(), activeTaskStatuses).Count(&active).Error; err != nil {
					return err
				}
			}
		}
		if active > 0 {
			return errors.New(map[string]string{"image_generation": "当前目标已有生图任务正在执行", "video_generation": "当前分镜已有视频任务正在执行"}[taskType])
		}
		remark := map[string]string{"image_generation": "资产生成", "video_generation": "视频生成"}[taskType]
		if err := billing.PrechargeGenerationTask(tx, task, modelRow.Price, duration, remark); err != nil {
			return err
		}
		if taskType == "video_generation" {
			return tx.Table("episode_storyboards").Where("id=?", storyboardID).Update("status", "queued").Error
		}
		return nil
	})
	if err != nil {
		return nil, err
	}
	if err := queuepkg.EnqueueID(s.Queue, queuepkg.TypeDispatchChannel, modelRow.ChannelID, 0); err != nil {
		if settleErr := s.failQueuedTask(task.ID, "queue_unavailable", "生成任务入队失败"); settleErr != nil {
			return nil, fmt.Errorf("生成任务入队失败且预扣返还失败: %w", settleErr)
		}
		return nil, errors.New("生成任务队列暂时不可用，请稍后重试")
	}
	return task, nil
}

func storyboardParentQuery(db *gorm.DB, projectID uuid.UUID, episodeID *uuid.UUID, storyboardID uuid.UUID) *gorm.DB {
	query := db.Model(&model.EpisodeStoryboard{}).Where("id=? AND deleted_at IS NULL", storyboardID)
	if episodeID != nil {
		return query.Where("episode_id=?", *episodeID)
	}
	return query.Where("project_id=?", projectID)
}

// videoDurationRange 返回当前项目视频生成模型允许的生成时长范围（秒），
// 用于分镜时长校验与更新，保证时长始终落在模型能力区间内。
func (s *Creative) videoDurationRange(userID, projectID uuid.UUID) (int, int, error) {
	var modelRow struct {
		ModelName string
	}
	err := s.DB.Table("project_model_configs pc").
		Select("m.name AS model_name").
		Joins("JOIN models m ON m.id=pc.model_id AND m.deleted_at IS NULL").
		Joins("JOIN creative_projects p ON p.id=pc.project_id AND p.deleted_at IS NULL").
		Where("pc.project_id=? AND pc.purpose='video_generation' AND p.user_id=?", projectID, userID).
		Take(&modelRow).Error
	if err != nil {
		return 0, 0, err
	}
	capabilities, ok := apimart.VideoCapabilities(modelRow.ModelName)
	if !ok {
		return 0, 0, errors.New("视频模型能力未配置")
	}
	return capabilities.DurationMinSeconds, capabilities.DurationMaxSeconds, nil
}

func firstNonEmpty(values ...string) string {
	for _, value := range values {
		if strings.TrimSpace(value) != "" && value != "<nil>" {
			return strings.TrimSpace(value)
		}
	}
	return ""
}

// storyboardReferenceImageURLs 返回当前分镜关联的角色/场景/道具资产中已上传图片的 URL 列表，
// 用于分镜图生成时自动携带参考图；排除音色资产和未关联资产，并去重。
func storyboardReferenceImageURLs(db *gorm.DB, projectID uuid.UUID, storyboard model.EpisodeStoryboard) ([]string, error) {
	var refs []map[string]any
	if err := json.Unmarshal(storyboard.AssetRefs, &refs); err != nil || len(refs) == 0 {
		return nil, nil
	}
	seen := make(map[uuid.UUID]struct{}, len(refs))
	refIDs := make([]uuid.UUID, 0, len(refs))
	for _, ref := range refs {
		id, err := uuid.Parse(fmt.Sprint(ref["id"]))
		if err != nil {
			continue
		}
		if _, ok := seen[id]; ok {
			continue
		}
		seen[id] = struct{}{}
		refIDs = append(refIDs, id)
	}
	if len(refIDs) == 0 {
		return nil, nil
	}
	var rows []struct {
		ImageURL string
	}
	if err := db.Table("project_assets a").
		Select("coalesce(image.public_url,'') AS image_url").
		Joins("LEFT JOIN media_assets image ON image.id=a.image_asset_id AND image.deleted_at IS NULL").
		Where("a.project_id=? AND a.deleted_at IS NULL AND a.id IN ? AND a.asset_type IN ?", projectID, refIDs, []string{"character", "scene", "prop"}).
		Find(&rows).Error; err != nil {
		return nil, err
	}
	urls := make([]string, 0, len(rows))
	seenURLs := make(map[string]struct{}, len(rows))
	for _, row := range rows {
		url := strings.TrimSpace(row.ImageURL)
		if url == "" {
			continue
		}
		if _, ok := seenURLs[url]; ok {
			continue
		}
		seenURLs[url] = struct{}{}
		urls = append(urls, url)
	}
	return urls, nil
}

// existingImageURLs 将请求 input 中可能为数组或字符串的 image_urls 字段归一化为字符串切片。
func existingImageURLs(value any) []string {
	urls := make([]string, 0, 2)
	switch typed := value.(type) {
	case []any:
		for _, item := range typed {
			if text := strings.TrimSpace(fmt.Sprint(item)); text != "" {
				urls = append(urls, text)
			}
		}
	case []string:
		urls = append(urls, typed...)
	case string:
		if text := strings.TrimSpace(typed); text != "" {
			urls = append(urls, text)
		}
	}
	return urls
}

func assetMentionIndex(content, name string) int {
	name = strings.TrimSpace(name)
	if name == "" {
		return -1
	}
	needle := "@" + name
	searchFrom := 0
	for searchFrom < len(content) {
		relative := strings.Index(content[searchFrom:], needle)
		if relative < 0 {
			return -1
		}
		index := searchFrom + relative
		end := index + len(needle)
		if end == len(content) {
			return index
		}
		next, _ := utf8.DecodeRuneInString(content[end:])
		if unicode.IsSpace(next) || unicode.IsPunct(next) {
			return index
		}
		searchFrom = index + len("@")
	}
	return -1
}

type analysisModelConfig struct {
	ModelID, ChannelID uuid.UUID
	Pricing            billing.TextPricing `gorm:"-"`
}

func (s *Creative) analysisModel(projectID uuid.UUID) (analysisModelConfig, error) {
	var config analysisModelConfig
	var err error
	if err := s.DB.Raw(`SELECT m.id AS model_id,m.channel_id
		FROM project_model_configs pc JOIN models m ON m.id=pc.model_id JOIN channels c ON c.id=m.channel_id
		WHERE pc.project_id=? AND pc.purpose='prompt_reverse' AND m.model_type='text' AND m.multimodal=true
			AND m.enabled=true AND c.enabled=true AND m.deleted_at IS NULL AND c.deleted_at IS NULL`, projectID).Scan(&config).Error; err != nil || config.ModelID == uuid.Nil {
		return config, errors.New("请先配置反推模型")
	}
	config.Pricing, err = billing.LoadTextPricing(s.DB, config.ModelID)
	if err != nil {
		return config, fmt.Errorf("当前反推模型计费配置无效: %w", err)
	}
	return config, nil
}

func (s *Creative) QueueAnalysis(userID, projectID, episodeID uuid.UUID) (*model.GenerationTask, error) {
	if s.Queue == nil {
		return nil, errors.New("反推任务队列不可用")
	}
	config, err := s.analysisModel(projectID)
	if err != nil {
		return nil, err
	}
	task := &model.GenerationTask{RequestID: "analysis_" + uuid.NewString(), UserID: userID, ChannelID: &config.ChannelID, ModelID: &config.ModelID, ProjectID: &projectID, EpisodeID: &episodeID, TaskType: "prompt_reverse", Status: "submitted", InputData: json.RawMessage(`{}`), EstimatedPoints: "0.00", PrepaidPoints: "0.00"}
	if err := s.DB.Transaction(func(tx *gorm.DB) error {
		var episode model.ProjectEpisode
		if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Table("project_episodes e").Select("e.*").
			Joins("JOIN creative_projects p ON p.id=e.project_id AND p.user_id=? AND p.deleted_at IS NULL", userID).
			Where("e.id=? AND e.project_id=? AND e.deleted_at IS NULL", episodeID, projectID).Take(&episode).Error; err != nil {
			return err
		}
		if episode.SourceVideoAssetID == nil {
			return errors.New("请先上传原视频")
		}
		if episode.AudioSource == "subtitle_file" && episode.SubtitleAssetID == nil {
			return errors.New("当前选择字幕文件识别，请先上传字幕")
		}
		var active int64
		if err := tx.Model(&model.GenerationTask{}).Where("episode_id=? AND task_type='prompt_reverse' AND status IN ?", episodeID, activeTaskStatuses).Count(&active).Error; err != nil {
			return err
		}
		if active > 0 {
			return errors.New("当前剧集已有分析任务正在执行")
		}
		if err := billing.CreateTextGenerationTask(tx, task, config.Pricing, "视频反推"); err != nil {
			return err
		}
		return tx.Table("project_episodes").Where("id=?", episodeID).Updates(map[string]any{"status": "analyzing", "analysis_message": "等待视频分析"}).Error
	}); err != nil {
		return nil, err
	}
	if err := queuepkg.EnqueueID(s.Queue, queuepkg.TypeAnalyzeEpisode, task.ID, 0); err != nil {
		if settleErr := s.failQueuedTask(task.ID, "queue_unavailable", "反推任务入队失败"); settleErr != nil {
			return nil, fmt.Errorf("反推任务入队失败且预扣返还失败: %w", settleErr)
		}
		return nil, errors.New("反推任务队列暂时不可用，请稍后重试")
	}
	return task, nil
}

func (s *Creative) QueueStoryboardAnalysis(userID, projectID, episodeID, storyboardID uuid.UUID) (*model.GenerationTask, error) {
	if s.Queue == nil {
		return nil, errors.New("反推任务队列不可用")
	}
	config, err := s.analysisModel(projectID)
	if err != nil {
		return nil, err
	}
	task := &model.GenerationTask{RequestID: "storyboard_analysis_" + uuid.NewString(), UserID: userID, ChannelID: &config.ChannelID, ModelID: &config.ModelID, ProjectID: &projectID, EpisodeID: &episodeID, StoryboardID: &storyboardID, TaskType: "prompt_reverse", Status: "submitted", InputData: json.RawMessage(`{"mode":"storyboard"}`), EstimatedPoints: "0.00", PrepaidPoints: "0.00"}
	if err := s.DB.Transaction(func(tx *gorm.DB) error {
		var episode model.ProjectEpisode
		if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Table("project_episodes e").Select("e.*").
			Joins("JOIN creative_projects p ON p.id=e.project_id AND p.user_id=? AND p.deleted_at IS NULL", userID).
			Where("e.id=? AND e.project_id=? AND e.deleted_at IS NULL", episodeID, projectID).Take(&episode).Error; err != nil {
			return err
		}
		if episode.SourceVideoAssetID == nil {
			return errors.New("请先上传原视频")
		}
		if episode.AudioSource == "subtitle_file" && episode.SubtitleAssetID == nil {
			return errors.New("当前选择字幕文件识别，请先上传字幕")
		}
		var storyboard model.EpisodeStoryboard
		if err := tx.Where("id=? AND episode_id=? AND deleted_at IS NULL", storyboardID, episodeID).Take(&storyboard).Error; err != nil {
			return err
		}
		if storyboard.Locked {
			return errors.New("当前分镜已保护，无法重新反推")
		}
		var active int64
		if err := tx.Model(&model.GenerationTask{}).Where("episode_id=? AND task_type='prompt_reverse' AND status IN ?", episodeID, activeTaskStatuses).Count(&active).Error; err != nil {
			return err
		}
		if active > 0 {
			return errors.New("当前剧集已有分析任务正在执行")
		}
		return billing.CreateTextGenerationTask(tx, task, config.Pricing, "视频反推")
	}); err != nil {
		return nil, err
	}
	if err := queuepkg.EnqueueID(s.Queue, queuepkg.TypeAnalyzeEpisode, task.ID, 0); err != nil {
		if settleErr := s.failQueuedTask(task.ID, "queue_unavailable", "反推任务入队失败"); settleErr != nil {
			return nil, fmt.Errorf("反推任务入队失败且预扣返还失败: %w", settleErr)
		}
		return nil, errors.New("反推任务队列暂时不可用，请稍后重试")
	}
	return task, nil
}

func (s *Creative) ListOptions() (map[string]any, error) {
	styles := make([]map[string]any, 0)
	models := make([]map[string]any, 0)
	if err := s.DB.Table("project_styles").Select("id,name,image_url").Where("deleted_at IS NULL").Order("sort_order,name").Find(&styles).Error; err != nil {
		return nil, err
	}
	if err := s.DB.Table("models m").Select("m.id,m.name,m.model_type,m.multimodal,m.text_billing_mode,c.name AS channel_name").Joins("JOIN channels c ON c.id=m.channel_id").Where("m.enabled=true AND m.deleted_at IS NULL AND c.enabled=true AND c.deleted_at IS NULL").Order("m.model_type,m.name").Find(&models).Error; err != nil {
		return nil, err
	}
	for _, item := range models {
		if fmt.Sprint(item["model_type"]) != "video" {
			continue
		}
		if capabilities, ok := apimart.VideoCapabilities(fmt.Sprint(item["name"])); ok {
			item["capabilities"] = capabilities
		}
	}
	prices := make([]map[string]any, 0)
	if err := s.DB.Table("model_prices").Select("model_id,price_key,unit,price").Find(&prices).Error; err != nil {
		return nil, err
	}
	promptTypes := []string{"剧本解析", "角色、场景、道具解析", "角色生成", "场景生成", "道具生成", "分镜图生成", "首尾帧生成", "视频生成"}
	return map[string]any{"styles": styles, "models": models, "prices": prices, "prompt_types": promptTypes}, nil
}

func ParseUUID(value, label string) (uuid.UUID, error) {
	id, err := uuid.Parse(value)
	if err != nil {
		return uuid.Nil, fmt.Errorf("%s无效", label)
	}
	return id, nil
}

func assetPromptType(assetType string) string {
	switch strings.TrimSpace(assetType) {
	case "character":
		return "角色生成"
	case "scene":
		return "场景生成"
	case "prop":
		return "道具生成"
	default:
		return ""
	}
}

func IntValue(value any, fallback int) int {
	parsed, err := strconv.Atoi(fmt.Sprint(value))
	if err != nil {
		return fallback
	}
	return parsed
}

func NowPlus(seconds int) *time.Time {
	value := time.Now().Add(time.Duration(seconds) * time.Second)
	return &value
}
