package com.yaoai.pipeline.service.impl;

import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yaoai.ai.core.model.ChatMessage;
import com.yaoai.ai.core.model.ChatRequest;
import com.yaoai.ai.core.service.LlmService;
import com.yaoai.billing.dto.BillingChargeRequest;
import com.yaoai.billing.service.BillingService;
import com.yaoai.common.context.UserContext;
import com.yaoai.common.exception.BizException;
import com.yaoai.common.exception.ErrorCode;
import com.yaoai.domain.entity.Character;
import com.yaoai.domain.entity.Episode;
import com.yaoai.domain.entity.Outline;
import com.yaoai.domain.entity.ProjectConsistencyBible;
import com.yaoai.domain.entity.Scene;
import com.yaoai.domain.entity.ScriptUnderstanding;
import com.yaoai.domain.entity.Storyboard;
import com.yaoai.domain.mapper.CharacterMapper;
import com.yaoai.domain.mapper.EpisodeMapper;
import com.yaoai.domain.mapper.OutlineMapper;
import com.yaoai.domain.mapper.ProjectConsistencyBibleMapper;
import com.yaoai.domain.mapper.SceneMapper;
import com.yaoai.domain.mapper.ScriptUnderstandingMapper;
import com.yaoai.domain.mapper.StoryboardMapper;
import com.yaoai.pipeline.service.StoryboardPipelineService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.Collectors;

@Slf4j
@Service
@RequiredArgsConstructor
public class StoryboardPipelineServiceImpl implements StoryboardPipelineService {
    private static final int STORYBOARD_PROMPT_CONCURRENCY = 3;
    private static final int MAX_CONTINUITY_CONTEXT_CHARS = 12000;
    private static final String JSON_RETRY_SUFFIX = """

            上一轮分镜 JSON 不完整或不可解析。请严格重做：
            - 只返回完整 JSON 数组，不要 Markdown，不要解释。
            - 每个字段只写分镜级内容，不要复制类型、集数、制作方式、变现等元信息。
            - 仍必须遵循剧情理解事实、一致性圣经和已有角色/场景引用，不得降级为自由创作。
            - 控制每个字符串长度，优先保证 JSON 闭合完整。
            """;
    private static final List<String> PRODUCTION_METADATA_LABELS = List.of(
            "类型", "题材", "集数", "每集", "字数", "制作方式", "变现", "商业模式", "付费解锁", "AI 3D漫剧", "ai 3d漫剧"
    );


    private static final String SYSTEM_PROMPT_BASE = """
            You are a professional storyboard artist.
            Split the episode script into a sequence of continuous shots, where each shot lasts 4-8 seconds.
            %s

            Hard continuity rules:
            - If script facts or consistency bible are provided, they are the highest-priority source of truth.
            - Treat outline context as plot constraints only; do not copy metadata such as 类型、集数、每集字数、制作方式、变现、付费解锁 into storyboard fields.
            - Do not copy outline text or episode text line-by-line. Convert narrative beats into shot-level visual actions.
            - Do not invent new plot beats, character relationships, locations, or motivations that conflict with them.
            - Use existing @Name character and scene refs exactly; do not rename them.
            - Put only character refs in "characters"; put exactly one existing scene ref in "scene_ref".
            - Every shot must stay faithful to the episode script and project asset setup.
            - If project characters/scenes are provided, each shot should use the most relevant existing refs and not leave refs blank unless the shot truly has no character or location.
            - Storyboard fields may contain only shot-level narrative, visual action, camera/composition, dialogue, duration, and prompt content.

            Return only a JSON array. Each item must follow this schema:
            [
              {
                "sequence_num": 1,
                "scene_number": "001",
                "short_description": "One-sentence shot description in Chinese",
                "detailed_description": "Detailed scene description in Chinese",
                "characters": "@character refs separated by commas, e.g. @Alice,@Bob; never put scene refs here",
                "scene_ref": "@existing scene name, e.g. @Cafe",
                "dialogues": "Dialogue content, empty string when absent",
                "camera_direction": "Camera movement, e.g. static/push/pull/pan/tracking",
                "composition_guide": "Composition guidance, e.g. close-up/medium/wide",
                "duration_seconds": 5,
                "start_frame_prompt": "English still-frame prompt, under 30 words",
                "motion_script": "Short Chinese motion description for video generation"
              }
            ]
            Do not include any explanation outside the JSON array.
            """;

    private static final String PROMPT_GEN_SYSTEM = """
            You are a professional storyboard prompt writer.
            Based on episode context, character appearance, scene setup, and the current storyboard shot,
            turn the shot into a concise multi-segment video-generation prompt.

            Requirements:
            - Stay faithful to the provided plot and the visual setup.
            - Treat script facts and the consistency bible as locked continuity.
            - Never include production/business metadata such as 类型、集数、每集字数、制作方式、变现、付费解锁.
            - Respect character appearance, costume, and scene details.
            - Use 2-4 time segments whose total duration matches the storyboard duration.
            - Each segment should include timing, visuals, dialogue when present, and audio mood.
            - Return only the prompt text with no extra explanation.
            """;

    private final LlmService llmService;
    private final StoryboardMapper storyboardMapper;
    private final EpisodeMapper episodeMapper;
    private final OutlineMapper outlineMapper;
    private final CharacterMapper characterMapper;
    private final SceneMapper sceneMapper;
    private final ScriptUnderstandingMapper scriptUnderstandingMapper;
    private final ProjectConsistencyBibleMapper projectConsistencyBibleMapper;
    private final ObjectMapper objectMapper;
    private final BillingService billingService;

    @Override
    public List<Storyboard> generateStoryboards(Long episodeId, Long projectId, Long tenantId) {
        Episode episode = episodeMapper.selectById(episodeId);
        if (episode == null) {
            throw new BizException(ErrorCode.NOT_FOUND, "Episode not found");
        }

        List<Character> projectCharacters = characterMapper.findByProject(projectId, tenantId);
        List<Scene> projectScenes = sceneMapper.findByProject(projectId, tenantId);
        removeImportedMetadataStoryboards(episodeId, tenantId);

        String assetContext = buildAssetContext(projectId, tenantId, projectCharacters, projectScenes);
        String systemPrompt = String.format(SYSTEM_PROMPT_BASE, assetContext);
        String episodeContent = episode.getScript() != null && !episode.getScript().isBlank()
                ? episode.getScript()
                : nullSafe(episode.getSummary());
        String userMsg = String.format(
                "%s%n%n%s%n%nEpisode title: %s%n%nEpisode script, narrative only:%n%s",
                buildContinuityContext(projectId, tenantId),
                buildOutlineContext(projectId, tenantId),
                nullSafe(episode.getTitle()),
                cleanStoryboardText(episodeContent)
        );

        log.info("Generating storyboards: episodeId={}, projectId={}", episodeId, projectId);

        billingService.checkBalance(BillingChargeRequest.builder()
                .tenantId(tenantId)
                .userId(UserContext.require())
                .projectId(projectId)
                .operation("storyboard_generate")
                .modality("TEXT")
                .modelProvider(llmService.getProvider())
                .modelId(llmService.getModelId())
                .modelName(llmService.getModelName())
                .unitCount(1)
                .refId(String.valueOf(episodeId))
                .build());

        try {
            List<Map<String, Object>> items = requestStoryboardItems(systemPrompt, userMsg);
            Set<String> sceneRefs = projectSceneRefs(projectScenes);
            Map<String, Character> characterByRef = charactersByRef(projectCharacters);
            Map<String, Scene> sceneByRef = scenesByRef(projectScenes);
            int seqBase = maxExistingSequence(episodeId, tenantId);

            List<Storyboard> result = new ArrayList<>();
            for (Map<String, Object> item : items) {
                StoryboardRefs refs = resolveStoryboardRefs(item, sceneRefs, projectScenes);
                int nextSeq = seqBase + result.size() + 1;

                Storyboard storyboard = new Storyboard();
                storyboard.setProjectId(projectId);
                storyboard.setEpisodeId(episodeId);
                storyboard.setTenantId(tenantId);
                storyboard.setSequenceNum(nextSeq);
                storyboard.setSceneNumber(cleanStoryboardText(stringValue(item.get("scene_number"), String.format("%03d", nextSeq))));
                storyboard.setShortDescription(cleanStoryboardText(stringValue(item.get("short_description"), "")));
                storyboard.setDetailedDescription(cleanStoryboardText(stringValue(item.get("detailed_description"), "")));
                storyboard.setCharacters(refs.characters());
                storyboard.setSceneRef(refs.sceneRef());
                storyboard.setDialogues(cleanStoryboardText(stringValue(item.get("dialogues"), "")));
                storyboard.setCameraDirection(cleanStoryboardText(stringValue(item.get("camera_direction"), "")));
                storyboard.setCompositionGuide(cleanStoryboardText(stringValue(item.get("composition_guide"), "")));
                storyboard.setDurationSeconds(((Number) item.getOrDefault("duration_seconds", 5)).intValue());
                storyboard.setStartFramePrompt(cleanStoryboardText(stringValue(item.get("start_frame_prompt"), "")));
                storyboard.setMotionScript(cleanStoryboardText(stringValue(item.get("motion_script"), "")));
                applyReferencedAssetKeys(storyboard, refs, characterByRef, sceneByRef);
                storyboard.setStatus("draft");
                storyboardMapper.insert(storyboard);
                result.add(storyboard);
            }

            billingService.charge(BillingChargeRequest.builder()
                    .tenantId(tenantId)
                    .userId(UserContext.require())
                    .projectId(projectId)
                    .operation("storyboard_generate")
                    .modality("TEXT")
                    .modelProvider(llmService.getProvider())
                    .modelId(llmService.getModelId())
                    .modelName(llmService.getModelName())
                    .unitCount(result.size())
                    .refId(String.valueOf(episodeId))
                    .build());
            log.info("Storyboards generated: count={}", result.size());
            return result;
        } catch (Exception e) {
            log.error("Storyboard parsing failed", e);
            throw new BizException(ErrorCode.INTERNAL_ERROR, "Storyboard parsing failed: " + e.getMessage());
        }
    }

    @Override
    public List<Storyboard> listByEpisode(Long episodeId, Long tenantId) {
        return storyboardMapper.findByEpisode(episodeId, tenantId);
    }

    @Override
    public List<Storyboard> listByProject(Long projectId, Long tenantId) {
        return storyboardMapper.findByProject(projectId, tenantId);
    }

    @Override
    public Storyboard create(Storyboard storyboard) {
        int maxSeq = storyboardMapper.maxSequenceByEpisode(storyboard.getEpisodeId());
        storyboard.setSequenceNum(maxSeq + 1);
        storyboard.setStatus("draft");
        storyboardMapper.insert(storyboard);
        return storyboard;
    }

    @Override
    @Transactional
    public Storyboard update(Long id, Long tenantId, Storyboard patch) {
        Storyboard existing = storyboardMapper.selectById(id);
        if (existing == null || !tenantId.equals(existing.getTenantId())) {
            throw new BizException(ErrorCode.NOT_FOUND, "Storyboard not found");
        }

        boolean bodyFieldsChanged = hasStoryboardBodyChanges(existing, patch);
        patch.setId(id);
        patch.setTenantId(tenantId);
        int updatedRows = storyboardMapper.updateById(patch);
        if (updatedRows != 1) {
            throw new BizException(ErrorCode.INTERNAL_ERROR, "Failed to update storyboard");
        }
        if (bodyFieldsChanged && patch.getVideoPrompt() == null) {
            clearVideoPrompt(id, tenantId);
        }
        return storyboardMapper.selectById(id);
    }

    @Override
    public void delete(Long id, Long tenantId) {
        Storyboard existing = storyboardMapper.selectById(id);
        if (existing == null || !tenantId.equals(existing.getTenantId())) {
            throw new BizException(ErrorCode.NOT_FOUND, "Storyboard not found");
        }
        storyboardMapper.deleteById(id);
    }

    @Override
    public void reorder(List<Long> orderedIds, Long tenantId) {
        for (int i = 0; i < orderedIds.size(); i++) {
            storyboardMapper.updateSequence(orderedIds.get(i), i + 1, tenantId);
        }
    }

    @Override
    @Transactional
    public String generatePrompt(Long storyboardId, Long tenantId) {
        Storyboard storyboard = storyboardMapper.selectById(storyboardId);
        if (storyboard == null || !tenantId.equals(storyboard.getTenantId())) {
            throw new BizException(ErrorCode.NOT_FOUND, "Storyboard not found");
        }

        StringBuilder userMsg = new StringBuilder();
        userMsg.append(buildContinuityContext(storyboard.getProjectId(), tenantId)).append("\n\n");
        userMsg.append(buildOutlineContext(storyboard.getProjectId(), tenantId)).append("\n\n");
        if (storyboard.getEpisodeId() != null) {
            Episode episode = episodeMapper.selectById(storyboard.getEpisodeId());
            if (episode != null) {
                String episodeContent = episode.getScript() != null && !episode.getScript().isBlank()
                        ? episode.getScript()
                        : nullSafe(episode.getSummary());
                userMsg.append(String.format(
                        "Episode context:%nEpisode %d - %s%n%s%n%n",
                        episode.getEpisodeNumber() != null ? episode.getEpisodeNumber() : 0,
                        nullSafe(episode.getTitle()),
                        cleanStoryboardText(episodeContent)
                ));
            }
        }

        List<Character> characters = characterMapper.findByProject(storyboard.getProjectId(), tenantId);
        List<Scene> scenes = sceneMapper.findByProject(storyboard.getProjectId(), tenantId);
        StoryboardRefs promptRefs = promptRefsForCurrentShot(storyboard, scenes);
        List<Character> contextCharacters = referencedCharacters(characters, promptRefs.characters());
        List<Scene> contextScenes = referencedScenes(scenes, promptRefs.sceneRef());

        if (!contextCharacters.isEmpty()) {
            userMsg.append("Character visual setup:\n");
            for (Character character : contextCharacters) {
                userMsg.append(String.format(
                        "- @%s (%s): personality=%s, costume=%s, visual=%s%n",
                        nullSafe(character.getName()),
                        nullSafe(character.getRoleType()),
                        nullSafe(character.getPersonality()),
                        nullSafe(character.getCostume()),
                        nullSafe(character.getVisualHint())
                ));
            }
            userMsg.append("\n");
        }

        if (!contextScenes.isEmpty()) {
            userMsg.append("Scene visual setup:\n");
            for (Scene scene : contextScenes) {
                userMsg.append(String.format(
                        "- @%s (%s): %s%n",
                        nullSafe(scene.getName()),
                        sceneTypeLabel(scene.getSceneType()),
                        nullSafe(scene.getDescription())
                ));
            }
            userMsg.append("\n");
        }

        userMsg.append(String.format(
                "Current storyboard shot:%n" +
                        "- Sequence: %d%n" +
                "- Short description: %s%n" +
                        "- Detailed description: %s%n" +
                        "- Character refs: %s%n" +
                        "- Scene refs: %s%n" +
                        "- Dialogues: %s%n" +
                        "- Camera direction: %s%n" +
                        "- Composition guide: %s%n" +
                        "- Duration seconds: %d%n",
                storyboard.getSequenceNum() != null ? storyboard.getSequenceNum() : 0,
                cleanStoryboardText(storyboard.getShortDescription()),
                cleanStoryboardText(storyboard.getDetailedDescription()),
                nullSafe(promptRefs.characters()),
                nullSafe(promptRefs.sceneRef()),
                cleanStoryboardText(storyboard.getDialogues()),
                cleanStoryboardText(storyboard.getCameraDirection()),
                cleanStoryboardText(storyboard.getCompositionGuide()),
                storyboard.getDurationSeconds() != null ? storyboard.getDurationSeconds() : 5
        ));

        ChatRequest request = ChatRequest.builder()
                .messages(List.of(
                        ChatMessage.system(PROMPT_GEN_SYSTEM),
                        ChatMessage.user(userMsg.toString())
                ))
                .build();

        log.info(
                "Generating storyboard prompt: storyboardId={}, episodeId={}, characters={}, scenes={}",
                storyboardId,
                storyboard.getEpisodeId(),
                contextCharacters.size(),
                contextScenes.size()
        );

        billingService.checkBalance(BillingChargeRequest.builder()
                .tenantId(tenantId)
                .userId(UserContext.require())
                .projectId(storyboard.getProjectId())
                .operation("storyboard_prompt_generate")
                .modality("TEXT")
                .modelProvider(llmService.getProvider())
                .modelId(llmService.getModelId())
                .modelName(llmService.getModelName())
                .unitCount(1)
                .refId(String.valueOf(storyboardId))
                .build());

        String prompt = cleanStoryboardText(llmService.chat(request));

        billingService.charge(BillingChargeRequest.builder()
                .tenantId(tenantId)
                .userId(UserContext.require())
                .projectId(storyboard.getProjectId())
                .operation("storyboard_prompt_generate")
                .modality("TEXT")
                .modelProvider(llmService.getProvider())
                .modelId(llmService.getModelId())
                .modelName(llmService.getModelName())
                .unitCount(1)
                .refId(String.valueOf(storyboardId))
                .build());

        Storyboard promptUpdate = new Storyboard();
        promptUpdate.setId(storyboard.getId());
        promptUpdate.setVideoPrompt(prompt);
        int updatedRows = storyboardMapper.updateById(promptUpdate);
        if (updatedRows != 1) {
            throw new BizException(ErrorCode.INTERNAL_ERROR, "Failed to persist storyboard prompt");
        }
        return prompt;
    }

    @Override
    public List<Storyboard> populateMissingVideoPromptsByEpisode(Long episodeId, Long tenantId) {
        List<Storyboard> pending = storyboardMapper.findMissingVideoPromptByEpisode(episodeId, tenantId);
        if (pending.isEmpty()) {
            return storyboardMapper.findByEpisode(episodeId, tenantId);
        }

        Long userId = UserContext.get();
        int poolSize = Math.min(STORYBOARD_PROMPT_CONCURRENCY, pending.size());
        ExecutorService executor = Executors.newFixedThreadPool(poolSize);
        try {
            List<CompletableFuture<Void>> futures = pending.stream()
                    .map(storyboard -> CompletableFuture.runAsync(() -> {
                        if (userId != null) {
                            UserContext.set(userId);
                        }
                        try {
                            generatePrompt(storyboard.getId(), tenantId);
                        } finally {
                            UserContext.clear();
                        }
                    }, executor))
                    .toList();

            for (CompletableFuture<Void> future : futures) {
                future.join();
            }
        } catch (CompletionException e) {
            if (e.getCause() instanceof RuntimeException runtimeException) {
                throw runtimeException;
            }
            throw e;
        } finally {
            executor.shutdown();
        }
        return storyboardMapper.findByEpisode(episodeId, tenantId);
    }

    private String buildAssetContext(Long projectId, Long tenantId) {
        return buildAssetContext(projectId, tenantId,
                characterMapper.findByProject(projectId, tenantId),
                sceneMapper.findByProject(projectId, tenantId));
    }

    private String buildAssetContext(Long projectId, Long tenantId,
                                     List<Character> characters, List<Scene> scenes) {
        StringBuilder context = new StringBuilder();
        String continuityContext = buildContinuityContext(projectId, tenantId);
        if (!continuityContext.isBlank()) {
            context.append(continuityContext).append("\n\n");
        }

        if (characters.isEmpty() && scenes.isEmpty()) {
            context.append("No project character or scene setup exists yet. Use only the episode script and locked script facts; do not copy outline/source lines as storyboard text.");
            return context.toString();
        }

        if (!characters.isEmpty()) {
            context.append("Project characters (refer to them as @Name in the characters field; visual image availability is noted):\n");
            for (Character character : characters) {
                context.append(String.format(
                        "- @%s: role=%s, personality=%s, visual=%s, costume=%s, image=%s%n",
                        nullSafe(character.getName()),
                        nullSafe(character.getRoleType()),
                        nullSafe(character.getPersonality()),
                        nullSafe(character.getVisualHint()),
                        nullSafe(character.getCostume()),
                        hasText(primaryCharacterImageKey(character)) ? "ready" : "missing"
                ));
            }
        }

        if (!scenes.isEmpty()) {
            context.append("\nProject scenes (refer to them as @Name; one scene ref should be selected for each location-bearing shot):\n");
            for (Scene scene : scenes) {
                context.append(String.format(
                        "- @%s (%s): %s, image=%s%n",
                        nullSafe(scene.getName()),
                        sceneTypeLabel(scene.getSceneType()),
                        nullSafe(scene.getDescription()),
                        hasText(scene.getImageTosKey()) ? "ready" : "missing"
                ));
            }
        }

        context.append("\nThe characters field must use @Name refs, separated by commas.");
        return context.toString();
    }

    private String buildOutlineContext(Long projectId, Long tenantId) {
        Outline outline = outlineMapper.findLatestByProject(projectId, tenantId);
        if (outline == null) {
            return "";
        }
        return String.format(
                "Outline context / 大纲约束 (follow for plot only; never copy production metadata into storyboard fields):%n"
                        + "- Title: %s%n"
                        + "- Genre: %s%n"
                        + "- Episode count: %s%n"
                        + "- Synopsis: %s",
                cleanStoryboardText(outline.getTitle()),
                cleanStoryboardText(outline.getGenre()),
                outline.getEpisodeCount() == null ? "" : outline.getEpisodeCount(),
                cleanStoryboardText(outline.getSynopsis())
        ).trim();
    }

    private String buildContinuityContext(Long projectId, Long tenantId) {
        StringBuilder builder = new StringBuilder();
        ScriptUnderstanding understanding = scriptUnderstandingMapper.findLatestByProject(projectId, tenantId);
        if (understanding != null && hasText(understanding.getUnderstandingJson())) {
            builder.append("Script facts / 剧情理解事实 (highest priority, must be followed):\n")
                    .append(limitContext(understanding.getUnderstandingJson()))
                    .append("\n\n");
        }
        ProjectConsistencyBible bible = projectConsistencyBibleMapper.findByProject(projectId, tenantId);
        if (bible != null && hasText(bible.getBibleJson())) {
            builder.append("Consistency bible / 一致性圣经 (locked character, scene and plot facts):\n")
                    .append(limitContext(bible.getBibleJson()))
                    .append("\n\n");
        }
        return builder.toString().trim();
    }

    private String cleanStoryboardText(String value) {
        if (value == null || value.isBlank()) {
            return "";
        }
        List<String> keptLines = new ArrayList<>();
        for (String rawLine : value.replace("\r\n", "\n").replace('\r', '\n').split("\n")) {
            String line = rawLine.strip();
            while (line.startsWith(">")) {
                line = line.substring(1).strip();
            }
            if (line.isBlank() || isProductionMetadataLine(line)) {
                continue;
            }
            String cleanedLine = stripProductionMetadataFragments(line);
            if (!cleanedLine.isBlank()) {
                keptLines.add(cleanedLine);
            }
        }
        return String.join("\n", keptLines).strip();
    }

    private boolean isProductionMetadataLine(String line) {
        String normalized = line.replace("：", ":").strip();
        if (normalized.contains("|")) {
            long metadataFragments = Arrays.stream(normalized.split("\\|"))
                    .map(String::strip)
                    .filter(this::isProductionMetadataFragment)
                    .count();
            if (metadataFragments > 0) {
                return true;
            }
        }
        return isProductionMetadataFragment(normalized);
    }

    private String stripProductionMetadataFragments(String line) {
        if (!line.contains("|")) {
            return line;
        }
        List<String> fragments = Arrays.stream(line.split("\\|"))
                .map(String::strip)
                .filter(fragment -> !fragment.isBlank())
                .filter(fragment -> !isProductionMetadataFragment(fragment.replace("：", ":")))
                .toList();
        return String.join("，", fragments);
    }

    private boolean isProductionMetadataFragment(String value) {
        if (value == null || value.isBlank()) {
            return false;
        }
        String normalized = value.replace("：", ":").strip();
        String lower = normalized.toLowerCase();
        for (String label : PRODUCTION_METADATA_LABELS) {
            String labelLower = label.toLowerCase();
            if (lower.startsWith(labelLower + ":") || lower.startsWith(labelLower + "：")) {
                return true;
            }
            if (lower.equals(labelLower) || lower.contains(labelLower + ":")) {
                return true;
            }
        }
        return lower.matches("^每集.{0,8}\\d+.*(字|分钟|秒).*")
                || lower.matches("^\\d+\\s*集.*")
                || lower.contains("付费解锁");
    }

    private String stringValue(Object value, String defaultValue) {
        return value == null ? defaultValue : String.valueOf(value);
    }

    private boolean hasText(String value) {
        return value != null && !value.isBlank();
    }

    private String limitContext(String value) {
        if (value == null || value.length() <= MAX_CONTINUITY_CONTEXT_CHARS) {
            return value == null ? "" : value;
        }
        return value.substring(0, MAX_CONTINUITY_CONTEXT_CHARS) + "\n[context truncated]";
    }

    private List<Map<String, Object>> requestStoryboardItems(String systemPrompt, String userMsg) throws Exception {
        Exception lastError = null;
        for (int attempt = 1; attempt <= 2; attempt++) {
            String prompt = attempt == 1 ? userMsg : userMsg + JSON_RETRY_SUFFIX;
            String raw = llmService.chat(ChatRequest.builder()
                    .messages(List.of(
                            ChatMessage.system(systemPrompt),
                            ChatMessage.user(prompt)
                    ))
                    .build());
            log.debug("Storyboard LLM response length={}, attempt={}", raw.length(), attempt);
            try {
                return objectMapper.readValue(extractJson(raw), new TypeReference<>() {});
            } catch (Exception e) {
                lastError = e;
                log.warn("Storyboard JSON parse failed, attempt={}: {}", attempt, e.getMessage());
            }
        }
        throw lastError != null ? lastError : new BizException(ErrorCode.INTERNAL_ERROR, "Storyboard parsing failed");
    }

    private Set<String> projectSceneRefs(Long projectId, Long tenantId) {
        return projectSceneRefs(sceneMapper.findByProject(projectId, tenantId));
    }

    private Set<String> projectSceneRefs(List<Scene> scenes) {
        return scenes.stream()
                .map(Scene::getName)
                .filter(Objects::nonNull)
                .map(String::trim)
                .filter(name -> !name.isEmpty())
                .map(name -> "@" + name)
                .collect(Collectors.toCollection(LinkedHashSet::new));
    }

    private Map<String, Character> charactersByRef(List<Character> characters) {
        return characters.stream()
                .filter(character -> hasText(character.getName()))
                .collect(Collectors.toMap(
                        character -> "@" + character.getName().trim(),
                        character -> character,
                        (first, ignored) -> first
                ));
    }

    private Map<String, Scene> scenesByRef(List<Scene> scenes) {
        return scenes.stream()
                .filter(scene -> hasText(scene.getName()))
                .collect(Collectors.toMap(
                        scene -> "@" + scene.getName().trim(),
                        scene -> scene,
                        (first, ignored) -> first
                ));
    }

    private List<Character> referencedCharacters(List<Character> characters, String refs) {
        if (!hasText(refs)) {
            return characters;
        }
        Set<String> wanted = new LinkedHashSet<>(parseRefs(refs));
        List<Character> selected = characters.stream()
                .filter(character -> hasText(character.getName()))
                .filter(character -> wanted.contains("@" + character.getName().trim()))
                .toList();
        return selected.isEmpty() ? characters : selected;
    }

    private List<Scene> referencedScenes(List<Scene> scenes, String sceneRef) {
        if (!hasText(sceneRef)) {
            return scenes;
        }
        List<Scene> selected = scenes.stream()
                .filter(scene -> hasText(scene.getName()))
                .filter(scene -> sceneRef.equals("@" + scene.getName().trim()))
                .toList();
        return selected.isEmpty() ? scenes : selected;
    }

    private void applyReferencedAssetKeys(Storyboard storyboard, StoryboardRefs refs,
                                          Map<String, Character> characterByRef,
                                          Map<String, Scene> sceneByRef) {
        List<String> characterImageKeys = parseRefs(refs.characters()).stream()
                .map(characterByRef::get)
                .filter(Objects::nonNull)
                .map(this::primaryCharacterImageKey)
                .filter(this::hasText)
                .distinct()
                .toList();
        if (!characterImageKeys.isEmpty()) {
            storyboard.setCharacterImageKeys(toJson(characterImageKeys));
        }
        Scene scene = refs.sceneRef() == null ? null : sceneByRef.get(refs.sceneRef());
        if (scene != null && hasText(scene.getImageTosKey())) {
            storyboard.setSceneImageKey(scene.getImageTosKey());
        }
    }

    private String primaryCharacterImageKey(Character character) {
        if (character == null) {
            return null;
        }
        if (hasText(character.getFrontImageTosKey())) {
            return character.getFrontImageTosKey();
        }
        return character.getImageTosKey();
    }

    private String toJson(Object value) {
        try {
            return objectMapper.writeValueAsString(value);
        } catch (Exception e) {
            log.warn("Failed to serialize storyboard asset refs: {}", e.getMessage());
            return null;
        }
    }

    private int maxExistingSequence(Long episodeId, Long tenantId) {
        List<Storyboard> existing = storyboardMapper.findByEpisode(episodeId, tenantId);
        if (existing == null || existing.isEmpty()) {
            return 0;
        }
        return existing.stream()
                .map(Storyboard::getSequenceNum)
                .filter(Objects::nonNull)
                .max(Integer::compareTo)
                .orElse(0);
    }

    private void removeImportedMetadataStoryboards(Long episodeId, Long tenantId) {
        List<Storyboard> existing = storyboardMapper.findByEpisode(episodeId, tenantId);
        if (existing == null || existing.isEmpty()) {
            return;
        }
        int removed = 0;
        for (Storyboard storyboard : existing) {
            if (looksLikeImportedMetadataStoryboard(storyboard)) {
                storyboardMapper.deleteById(storyboard.getId());
                removed++;
            }
        }
        if (removed > 0) {
            log.info("Removed imported metadata storyboards before agent generation: episodeId={}, count={}", episodeId, removed);
        }
    }

    private boolean looksLikeImportedMetadataStoryboard(Storyboard storyboard) {
        if (storyboard == null || storyboard.getId() == null) {
            return false;
        }
        if (hasText(storyboard.getVideoPrompt())
                || hasText(storyboard.getStartFramePrompt())
                || hasText(storyboard.getMotionScript())
                || hasText(storyboard.getSceneRef())
                || hasText(storyboard.getCharacterImageKeys())
                || hasText(storyboard.getSceneImageKey())) {
            return false;
        }
        String text = String.join("\n",
                nullSafe(storyboard.getSceneNumber()),
                nullSafe(storyboard.getShortDescription()),
                nullSafe(storyboard.getDetailedDescription()),
                nullSafe(storyboard.getDialogues())
        );
        return text.lines().anyMatch(this::isProductionMetadataLine);
    }

    private StoryboardRefs resolveStoryboardRefs(Map<String, Object> item,
                                                 Set<String> sceneRefs,
                                                 List<Scene> projectScenes) {
        StoryboardRefs split = splitStoryboardRefs(stringValue(item.get("characters"), ""), sceneRefs);
        String explicitScene = firstNonBlank(
                stringValue(item.get("scene_ref"), ""),
                stringValue(item.get("scene"), ""),
                stringValue(item.get("scene_name"), ""),
                stringValue(item.get("location"), "")
        );
        String sceneRef = normalizeSceneRef(explicitScene, sceneRefs);
        if (!hasText(sceneRef)) {
            sceneRef = split.sceneRef();
        }
        if (!hasText(sceneRef)) {
            sceneRef = inferSceneRefFromStoryboardItem(item, projectScenes);
        }
        return new StoryboardRefs(split.characters(), sceneRef);
    }

    private String normalizeSceneRef(String raw, Set<String> sceneRefs) {
        if (!hasText(raw) || sceneRefs.isEmpty()) {
            return null;
        }
        String token = raw.trim();
        if (!token.startsWith("@")) {
            token = "@" + token;
        }
        if (sceneRefs.contains(token)) {
            return token;
        }
        String normalized = normalizeMatchText(token);
        return sceneRefs.stream()
                .filter(ref -> normalizeMatchText(ref).equals(normalized))
                .findFirst()
                .orElse(null);
    }

    private String inferSceneRefFromStoryboardItem(Map<String, Object> item, List<Scene> projectScenes) {
        if (projectScenes == null || projectScenes.isEmpty()) {
            return null;
        }
        String text = String.join("\n",
                stringValue(item.get("scene_number"), ""),
                stringValue(item.get("short_description"), ""),
                stringValue(item.get("detailed_description"), ""),
                stringValue(item.get("start_frame_prompt"), ""),
                stringValue(item.get("motion_script"), ""),
                stringValue(item.get("dialogues"), "")
        );
        String normalizedText = normalizeMatchText(text);
        int bestScore = 0;
        Scene bestScene = null;
        for (Scene scene : projectScenes) {
            int score = sceneMatchScore(scene, normalizedText);
            if (score > bestScore) {
                bestScore = score;
                bestScene = scene;
            }
        }
        if (bestScene != null && bestScore >= 6 && hasText(bestScene.getName())) {
            return "@" + bestScene.getName().trim();
        }
        return null;
    }

    private int sceneMatchScore(Scene scene, String normalizedText) {
        if (scene == null || !hasText(normalizedText)) {
            return 0;
        }
        int score = 0;
        String name = normalizeMatchText(scene.getName());
        if (hasText(name) && normalizedText.contains(name)) {
            score += 100 + Math.min(20, name.length());
        }
        for (String token : sceneMatchTokens(scene)) {
            String normalizedToken = normalizeMatchText(token);
            if (normalizedToken.length() >= 2 && normalizedText.contains(normalizedToken)) {
                score += normalizedToken.length() >= 4 ? 4 : 2;
            }
        }
        return score;
    }

    private List<String> sceneMatchTokens(Scene scene) {
        if (scene == null) {
            return List.of();
        }
        String raw = String.join(" ",
                nullSafe(scene.getName()),
                nullSafe(scene.getDescription())
        );
        return Arrays.stream(raw.split("[\\s,，、。；;：:|/\\\\()（）【】\\[\\]《》\"“”'‘’]+"))
                .map(String::trim)
                .filter(token -> token.length() >= 2)
                .filter(token -> !isSceneMatchStopWord(token))
                .distinct()
                .toList();
    }

    private boolean isSceneMatchStopWord(String token) {
        String normalized = normalizeMatchText(token);
        return Set.of("场景", "画面", "空间", "室内", "室外", "全景", "中景", "近景", "特写", "深夜", "夜晚", "白天", "氛围")
                .contains(normalized);
    }

    private String normalizeMatchText(String value) {
        if (value == null) {
            return "";
        }
        return value.toLowerCase(Locale.ROOT)
                .replace("@", "")
                .replaceAll("[\\s　,，、。；;：:|/\\\\()（）【】\\[\\]《》\"“”'‘’\\-_.#]+", "")
                .trim();
    }

    private String firstNonBlank(String... values) {
        if (values == null) {
            return "";
        }
        for (String value : values) {
            if (hasText(value)) {
                return value.trim();
            }
        }
        return "";
    }

    private StoryboardRefs splitStoryboardRefs(String rawRefs, Set<String> sceneRefs) {
        List<String> refs = parseRefs(rawRefs);
        List<String> characterRefs = new ArrayList<>();
        String sceneRef = null;

        for (String ref : refs) {
            if (sceneRefs.contains(ref)) {
                if (sceneRef == null) {
                    sceneRef = ref;
                }
            } else {
                characterRefs.add(ref);
            }
        }

        return new StoryboardRefs(joinRefs(characterRefs), sceneRef);
    }

    private StoryboardRefs promptRefsForCurrentShot(Storyboard storyboard, List<Scene> projectScenes) {
        Set<String> sceneRefs = projectScenes.stream()
                .map(Scene::getName)
                .filter(Objects::nonNull)
                .map(String::trim)
                .filter(name -> !name.isEmpty())
                .map(name -> "@" + name)
                .collect(Collectors.toCollection(LinkedHashSet::new));
        StoryboardRefs fallbackRefs = splitStoryboardRefs(storyboard.getCharacters(), sceneRefs);
        String sceneRef = storyboard.getSceneRef() != null && !storyboard.getSceneRef().isBlank()
                ? storyboard.getSceneRef()
                : fallbackRefs.sceneRef();
        return new StoryboardRefs(fallbackRefs.characters(), sceneRef);
    }

    private List<String> parseRefs(String rawRefs) {
        if (rawRefs == null || rawRefs.isBlank()) {
            return List.of();
        }
        return Arrays.stream(rawRefs.split(","))
                .map(String::trim)
                .filter(token -> !token.isBlank())
                .distinct()
                .toList();
    }

    private String joinRefs(List<String> refs) {
        if (refs.isEmpty()) {
            return null;
        }
        return String.join(",", refs);
    }

    private void clearVideoPrompt(Long storyboardId, Long tenantId) {
        int updatedRows = storyboardMapper.update(
                null,
                new UpdateWrapper<Storyboard>()
                        .eq("id", storyboardId)
                        .eq("tenant_id", tenantId)
                        .set("video_prompt", null)
        );
        if (updatedRows != 1) {
            throw new BizException(ErrorCode.INTERNAL_ERROR, "Failed to clear storyboard prompt");
        }
    }

    private boolean hasStoryboardBodyChanges(Storyboard existing, Storyboard patch) {
        return hasChanged(patch.getSceneNumber(), existing.getSceneNumber())
                || hasChanged(patch.getShortDescription(), existing.getShortDescription())
                || hasChanged(patch.getDetailedDescription(), existing.getDetailedDescription())
                || hasChanged(patch.getCharacters(), existing.getCharacters())
                || hasChanged(patch.getSceneRef(), existing.getSceneRef())
                || hasChanged(patch.getDialogues(), existing.getDialogues())
                || hasChanged(patch.getCameraDirection(), existing.getCameraDirection())
                || hasChanged(patch.getCompositionGuide(), existing.getCompositionGuide())
                || hasChanged(patch.getDurationSeconds(), existing.getDurationSeconds())
                || hasChanged(patch.getStartFramePrompt(), existing.getStartFramePrompt())
                || hasChanged(patch.getEndFramePrompt(), existing.getEndFramePrompt())
                || hasChanged(patch.getMotionScript(), existing.getMotionScript())
                || hasChanged(patch.getNotes(), existing.getNotes());
    }

    private boolean hasChanged(String patchValue, String existingValue) {
        return patchValue != null && !Objects.equals(patchValue, existingValue);
    }

    private boolean hasChanged(Integer patchValue, Integer existingValue) {
        return patchValue != null && !Objects.equals(patchValue, existingValue);
    }

    private String extractJson(String raw) {
        String text = raw.strip();
        if (text.startsWith("```")) {
            int start = text.indexOf('\n') + 1;
            int end = text.lastIndexOf("```");
            if (end > start) {
                text = text.substring(start, end).strip();
            }
        }
        return text;
    }

    private static String nullSafe(String value) {
        return value != null ? value : "";
    }

    private String sceneTypeLabel(String sceneType) {
        if ("indoor".equals(sceneType)) {
            return "indoor";
        }
        if ("outdoor".equals(sceneType)) {
            return "outdoor";
        }
        return "";
    }

    private record StoryboardRefs(String characters, String sceneRef) {
    }
}
