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 int PROMPT_REPAIR_MAX_ATTEMPTS = 2;
    private static final TypeReference<List<String>> LIST_STRING_TYPE = new TypeReference<>() {};
    private static final List<String> SHOT_SHEET_LABELS = List.of(
            "### 【镜", "```", "[景别]", "[运镜]", "[构图]", "[画面]", "[灯光]", "[声音]", "[时长]", "[台词]"
    );
    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.
            - When adjacent shots stay in the same scene/location, treat them as one continuous physical moment unless the script explicitly introduces a jump in time or blocking.
            - Preserve same-scene continuity of blocking, standing or seated positions, facing directions, wardrobe state, held props, emotional intensity, lighting source direction, color temperature, and shadow pressure.
            - Do not reset characters to neutral poses when cutting within the same scene. The new shot should inherit the most recent visible state and continue from it.
            - Enforce focus relay across neighboring shots: physical relay, gaze relay, or environmental relay should carry the viewer into the next shot when possible.
            - Build continuity by micro-causality, not by repeating the same action text. The next shot must feel caused by the prior shot's final focus.
            - Every shot should feel like a director's shot breakdown, not a plot recap.
            - Prefer an action chain with 2-4 linked beats inside the shot: trigger -> reaction -> counteraction -> residue.
            - Use visible evidence for emotion and conflict: micro-expression, hesitation, trembling fingers, avoided eye contact, prop detail, environment reaction, sound, and lighting.
            - In same-scene neighboring shots, do not reinvent room geography. Camera angle may change, but staging continuity must remain trackable.
            - Short-drama rhythm is preferred: use 4-8 seconds for most shots, 9-12 seconds only for one continuous action that truly needs three readable beats.
            - Keep screen geography stable. If a shot says an object starts on screen-right and a character ends on screen-left, explain the camera move or cut that changes the relationship; do not hide it behind rack focus.
            - Rack focus / 拉焦 only changes focus depth inside the same composition. It cannot move a knife, hand, face, or character from left to right. For rack focus, write a same-axis depth relation: foreground object -> background character(s), with stable screen positions.
            - If a shot opens on a held object, anchor it immediately with the hand, sleeve, body, or shadow of the holder. Never describe a knife/tool as if it floats without a visible holder.
            - Avoid exact micro-distances such as 五公分、三厘米、1.5米 unless the script explicitly requires them and the distance is visually measurable. Prefer visual relations such as "刀尖正对着白布下方胸口位置".
            - When a shot contains interception, struggle, hesitation, or impact, motion_script must split the action into compact ordered beats using "先...；随即...；最后..." so blocking, reaction, and prop movement do not collapse into one static tableau.
            - If the final visible state prepares the next shot, keep it as a visible state inside the current shot. Do not write next-shot speculation or a separate handoff instruction into storyboard fields.
            - Let composition_guide explicitly describe shot size and framing in Chinese, for example: 中景（人物全身至膝盖） / 近景（人物胸部以上） / 特写（手部或眼部）.
            - Let camera_direction explicitly describe movement progression, for example: 手持跟拍(handheld tracking shot) -> 急停急推(quick push-in), 缓慢推进(slow push in) -> 焦点转移(rack focus).
            - detailed_description must contain director-grade visual content in Chinese: blocking, performance, prop detail, environment reaction, lighting atmosphere, and emotional subtext.
            - motion_script must describe how the shot evolves over time, including action rhythm, camera response, and the current shot's final visible state.
            - Never output timestamped timeline segments like "0-2s ..." or "0-2秒 ...", neither in detailed_description nor in motion_script.

            Return only a JSON array. Each item must follow this schema:
            [
              {
                "sequence_num": 1,
                "scene_number": "001",
                "short_description": "One-sentence Chinese shot intention with a clear dramatic beat",
                "detailed_description": "Director-grade Chinese shot description with action chain, blocking, expression, props, lighting, atmosphere, and subtext",
                "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 with speaker when present, empty string when absent",
                "camera_direction": "Explicit Chinese camera movement progression with optional English cue",
                "composition_guide": "Explicit Chinese shot size and framing",
                "duration_seconds": 7,
                "start_frame_prompt": "English still-frame prompt, under 30 words",
                "motion_script": "Chinese beat-by-beat shot progression including camera reaction and final visible state"
              }
            ]
            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 reasoning-first cinematic 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.
            - Preserve same-scene blocking continuity, wardrobe state, prop state, emotional carry-over, and lighting continuity unless the script clearly motivates a change.
            - Do not silently reset standing positions, seated positions, hand occupancy, prop placement, costume state, or light direction between adjacent shots in the same scene.
            - Enforce focus relay with adjacent shots when they are provided, but use it only as hidden planning context. Do not mention next-shot handoff content in the final video prompt.
            - Infer and materialize the hidden dramatic reasoning of the shot: shot objective, power relation, emotional subtext, visible evidence, and why the camera moves this way.
            - Expand sparse storyboard text into concrete directorial detail rather than repeating the original sentence.
            - The prompt must be shootable. Before writing the final answer, silently audit lens scale, camera movement, spatial geography, visible detail, sound source, light source, period plausibility, and duration budget.
            - Match shot size to detail scale. If 景别 is 全景/远景, only describe large readable silhouettes, signage, blocking, and major light areas. Do not describe ice beads, fabric fibers, wrench rotation direction, paper paste texture, finger tremors, or other close-up details unless the shot size changes to 中近景/近景/特写.
            - Composition must follow the requested video aspect ratio. Never write a composition that only works in another ratio.
            - For 16:9 横屏: favor horizontal spatial relations, left/middle/right staging, readable lateral movement, foreground-middle-background depth, and keep key faces/signage away from the extreme left/right edges.
            - For 9:16 竖屏: favor vertical layering, top/middle/bottom anchors, foreground obstruction or doorway/window frames, and keep the key subject in the central safe area. Avoid wide horizontal pans that depend on off-screen left-right geography.
            - For 1:1 方形: favor centered or diagonal composition, compact spatial relationships, balanced negative space, and one dominant subject plus one secondary anchor. Avoid long lateral geography.
            - In [构图] and [画面], explicitly mention the composition anchor implied by the ratio, for example "横屏左侧标语、中部人物、右侧门缝暖光", "竖屏上方招牌、中部人物、下方道具", or "方形画面中心人物、右下角道具".
            - If one shot begins wide and ends closer, write 景别 as a progression, for example "全景起幅 -> 中景落幅". Then only put close detail after the camera has reached that closer framing.
            - Use camera terms precisely: pan/摇摄 means the camera rotates from a fixed point; truck/dolly/横移/平移 means the camera physically moves sideways. Never call one movement by the other name. If the shot says "pan right", use "向右摇摄", not "横移".
            - Keep geography physically trackable. Do not jump between top-of-pole, street level, face detail, and doorway detail unless the camera tilt/pan/reframe explains the route. A single moving shot should have a clear start anchor, middle anchor, and landing anchor.
            - Rack focus / 拉焦 cannot move objects left or right and cannot reveal a subject from a different screen position. It only changes focus depth inside the same composition. If using 拉焦, keep foreground and background on a stable same-axis spatial line.
            - For foreground-to-background rack focus, state the depth relationship explicitly: foreground object, who holds it, background subject positions, and what stays in the same screen area after focus changes.
            - Never let a prop appear to float. If the shot begins on a held knife/tool/object, include the hand, sleeve, or body anchor in the opening image.
            - Avoid exact micro-distances such as 五公分 unless they are script-critical and visually measurable. Prefer visual relation such as "刀尖正对着白布下方胸口位置".
            - Make action timing explicit inside [画面] with compact beat markers: "先...；随即...；最后...". For 6-8 seconds use 2 beats; for 9-12 seconds use 3 beats. Do not make blocking, reaction, and prop movement happen all at once.
            - Limit focus anchors by duration. For 5-8 seconds use at most 2 focus anchors; for 9-12 seconds use at most 3 focus anchors; for 13-15 seconds use at most 4 focus anchors. Remove extra micro-details instead of cramming them into one shot.
            - Short-drama pacing is preferred. Use 4-8 seconds and tight 2-beat shots by default. Use 9-12 seconds only when one continuous action truly needs three readable beats.
            - Do not invent exact technical specs unless provided by the script or asset context. Avoid unsupported wattage, brand, model, numeric distance, license plate, badge number, or tool measurements. Use plausible descriptive ranges only when necessary.
            - Keep 1980s Chinese county-town details physically plausible. Prefer aged paper, dry paste, wind-torn edges, dim incandescent or mercury-vapor public lighting, bicycle repair silhouettes, enamel signs, and worn concrete. Avoid implausibly precise or underpowered public street-lamp specs.
            - Every sound in 音效/配乐 must have a visible on-screen source, a clearly off-screen source, or a motivated interior/exterior source. If a sound matters, coordinate it with the action in 画面描述. Do not put sound effects only in 画面描述 and omit them from 音效/配乐.
            - Light source relationships must be spatially clear. If there are two sources, describe which side/top/back they come from and how they overlap on the subject. Do not list separate light sources without explaining their interaction.
            - Hidden continuity handoff must be concrete visual/audio reasoning, not narrative guessing. Use it to choose the current shot's final visible state, but do not output a handoff/接力 field.
            - If the selected reference image or scene setup conflicts with the current shot's location/action, do not force incompatible spatial details into the prompt. Stay faithful to the current storyboard and script; use incompatible references only as loose style/material reference.
            - Write the result as one Chinese director shot sheet, not as segmented timeline narration.
            - Do not output forms like "0-2s ... / 2-5s ..." or "0-2秒 ... / 2-5秒 ...", and do not use "视觉：" / "音频：" as the top-level structure.
            - Output in this single editable text format, similar to a director's shot note:
              ### 【镜1】定场 — 简短镜头目的

              ```text
              [景别] 俯拍 / 全景起幅 -> 中景落幅 / 近景 / 特写，必须与可见细节匹配
              [运镜] 固定机位 / 向右摇摄(pan right) / 轨道横移(dolly right) / 缓慢推进
              [构图] 按当前画幅写清楚构图锚点，例如横屏左/中/右、竖屏上/中/下、方形中心/对角线
              [画面] 120-220字，2-3个可拍摄视觉节拍，像示例一样写成完整电影画面，不要堆字段
              [灯光] 主光方向、冷暖关系、阴影压力、多个光源如何叠加
              [声音] 有画面来源或明确场外来源的环境声/动作声/音乐情绪
              [时长] 7秒
              [台词] 无 / 角色名：台词
              ```
            - 景别 must be a specific framing description such as 中景（人物全身至膝盖）, 近景（人物胸部以上）, 特写（手部或眼部）, or 全景起幅 -> 中景落幅 when the shot scale changes.
            - 运镜 must use exact camera language and one movement family unless a motivated transition is necessary, for example 固定机位向右摇摄(pan right) or 轨道向右平移(dolly/truck right).
            - 画面描述 must be the longest part but concise: 120-220 Chinese characters, 2-3 linked visual beats, no more than the allowed focus anchors for the duration, and no details invisible at the stated shot size.
            - 灯光氛围 must explain light source direction, color contrast, shadow pressure, and overlap between sources when more than one source exists.
            - 音效/配乐 must prioritize concrete diegetic sound first, synchronize sounds with visible/off-screen action, and avoid unmotivated extra noises.
            - 时长 must be written as Arabic number plus "秒", matching the storyboard duration.
            - 台词 must include speaker names when dialogue exists; write "无" when no dialogue is needed.
            - Keep all bracket labels inside one ```text fenced block so the user can freely edit or paste the whole prompt as text.
            - Output only the heading and fenced text block. No extra explanation outside them.
            """;

    private static final String PROMPT_GEN_REPAIR_SYSTEM = """
            You are repairing an invalid storyboard prompt output.
            Rewrite it into exactly one Chinese director shot sheet for the current shot.

            Hard output rules:
            - Use exactly this editable director-note format:
              ### 【镜1】定场 — 简短镜头目的

              ```text
              [景别] ...
              [运镜] ...
              [构图] ...
              [画面] ...
              [灯光] ...
              [声音] ...
              [时长] ...
              [台词] ...
              ```
            - Do not output any timestamped segments such as 0-2s, 2-4s, 4-6s, 0-2秒, 2-4秒, 4-6秒.
            - Do not use labels like 视觉： or 音频： as the top-level structure.
            - Keep 画面描述 as the longest section but concise: 120-220 Chinese characters, 2-3 visible beats, and no detail invisible at the stated 景别.
            - Repair lens-scale contradictions: 全景/远景 cannot show tiny beads, fabric fibers, exact wrench rotation, finger tremors, paste texture, or printed-paper microdetail. Either change 景别 to a progression ending closer, or remove the microdetails.
            - Repair camera terminology: pan/摇摄 is fixed-point rotation; truck/dolly/横移/平移 is physical side movement. Use one correct term consistently.
            - Repair rack-focus spatial errors: 拉焦 only changes focus depth inside one stable composition; it cannot move a knife/object from screen right to screen left or reveal a person in a different position without a reframing/cut.
            - For rack focus, rewrite into same-axis depth when possible: foreground held object with visible hand/sleeve/body anchor -> background character positions, while screen-left/screen-right positions stay stable.
            - If a knife/tool/object is described in close-up, include who holds it through visible fingers, sleeve, hand shadow, or body relation. Never leave it visually floating.
            - Remove exact micro-distances such as 五公分、三厘米、1.5米 unless they are explicitly supplied by source context and visible in frame.
            - Repair unclear action timing by writing compact sequence words inside [画面], such as "先...；随即...；最后...".
            - Repair focus overload: 5-8s max 2 focus anchors, 9-12s max 3, 13-15s max 4. Delete extras.
            - Repair physical/period implausibility: do not invent exact wattage, brand, model, numeric distances, or unlikely 1980s public-street details unless source text explicitly provides them.
            - Repair audio-visual mismatch: every listed sound must correspond to an action or motivated off-screen source.
            - Remove any 镜头接力 / [接力] / next-shot handoff field from the final output. Handoff is hidden planning only and must not affect the current video prompt.
            - Preserve same-scene continuity from adjacent-shot context: blocking, wardrobe state, held props, emotional carry-over, and lighting logic must not reset.
            - Keep the shot faithful to the supplied storyboard context; do not invent conflicting plot.
            - Keep all bracket labels inside one ```text fenced block so the user can freely edit or paste the whole prompt as text.
            - Output only the heading and fenced text block. No extra explanation outside them.
            """;

    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
    @Transactional
    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)
        );
        userMsg += """

                Shot count requirement:
                - Generate a complete storyboard sequence for the whole episode, normally 8-12 shots for a 60-second short-drama episode.
                - Never collapse the whole episode into one summary shot.
                - Each shot must represent one concrete visual beat with its own camera/composition/duration.
                """;

        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) {
        return generatePrompt(storyboardId, tenantId, null);
    }

    @Override
    @Transactional
    public String generatePrompt(Long storyboardId, Long tenantId, String videoRatio) {
        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, characters, 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");
        }

        String shotContinuityContext = buildShotContinuityContext(storyboard, tenantId);
        if (hasText(shotContinuityContext)) {
            userMsg.append(shotContinuityContext).append("\n\n");
        }
        userMsg.append(buildAspectRatioCompositionContext(videoRatio)).append("\n\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
        ));

        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 = generatePromptWithFormatGuard(storyboard, userMsg.toString());

        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;
    }

    private String generatePromptWithFormatGuard(Storyboard storyboard, String userMessage) {
        String prompt = cleanStoryboardText(llmService.chat(ChatRequest.builder()
                .messages(List.of(
                        ChatMessage.system(PROMPT_GEN_SYSTEM),
                        ChatMessage.user(userMessage)
                ))
                .build()));
        if (isValidShotSheetPrompt(prompt)) {
            return prompt;
        }

        String invalidPrompt = prompt;
        for (int attempt = 1; attempt <= PROMPT_REPAIR_MAX_ATTEMPTS; attempt++) {
            log.warn("Storyboard prompt format invalid, retrying repair: storyboardId={}, attempt={}",
                    storyboard.getId(), attempt);
            prompt = cleanStoryboardText(llmService.chat(ChatRequest.builder()
                    .messages(List.of(
                            ChatMessage.system(PROMPT_GEN_REPAIR_SYSTEM),
                            ChatMessage.user(userMessage + """

                                    Invalid previous output:
                                    """ + invalidPrompt)
                    ))
                    .build()));
            if (isValidShotSheetPrompt(prompt)) {
                return prompt;
            }
            invalidPrompt = prompt;
        }

        log.warn("Storyboard prompt still invalid after repair, using local shot-sheet fallback: storyboardId={}",
                storyboard.getId());
        return buildFallbackShotSheet(storyboard, invalidPrompt);
    }

    @Override
    public List<Storyboard> populateMissingVideoPromptsByEpisode(Long episodeId, Long tenantId) {
        List<Storyboard> allStoryboards = storyboardMapper.findByEpisode(episodeId, tenantId);
        List<Storyboard> pending = allStoryboards.stream()
                .filter(this::shouldRefreshVideoPrompt)
                .toList();
        if (pending.isEmpty()) {
            return allStoryboards;
        }

        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 boolean shouldRefreshVideoPrompt(Storyboard storyboard) {
        String prompt = nullSafe(storyboard.getVideoPrompt()).trim();
        if (prompt.isBlank()) {
            return true;
        }
        return isLegacySegmentedPrompt(prompt);
    }

    private boolean isValidShotSheetPrompt(String prompt) {
        if (!hasText(prompt) || isLegacySegmentedPrompt(prompt)) {
            return false;
        }
        return SHOT_SHEET_LABELS.stream().allMatch(prompt::contains)
                && !hasShotGrammarSmell(prompt);
    }

    private boolean hasShotGrammarSmell(String prompt) {
        String normalized = nullSafe(prompt).toLowerCase(Locale.ROOT);
        if (normalized.length() > 1800) {
            return true;
        }
        if (prompt.contains("[接力]") || prompt.contains("镜头接力：") || prompt.contains("镜头接力")) {
            return true;
        }
        if (prompt.matches("(?s).*\\d+(?:\\.\\d+)?\\s*(?:公分|厘米|cm|CM).*")) {
            return true;
        }
        if (prompt.contains("拉焦")
                && (prompt.matches("(?s).*起幅[^\\n。；;]*(?:右侧|右边)[^\\n。；;]*落幅[^\\n。；;]*(?:左侧|左边).*")
                || prompt.matches("(?s).*起幅[^\\n。；;]*(?:左侧|左边)[^\\n。；;]*落幅[^\\n。；;]*(?:右侧|右边).*"))) {
            return true;
        }
        if ((prompt.contains("手术刀") || prompt.contains("刀柄") || prompt.contains("刀尖"))
                && prompt.contains("拉焦")
                && !(prompt.contains("手") || prompt.contains("指节") || prompt.contains("袖口") || prompt.contains("攥") || prompt.contains("握"))) {
            return true;
        }
        if ((prompt.contains("横移") || prompt.contains("平移"))
                && (normalized.contains("pan right") || normalized.contains("pan left")
                || prompt.contains("摇摄") || prompt.contains("摇镜"))) {
            return true;
        }
        if (prompt.matches("(?s).*\\d+\\s*[wW瓦].*")) {
            return true;
        }
        boolean wideShot = prompt.contains("景别：全景") || prompt.contains("景别：远景");
        boolean microscopicDetail = prompt.contains("冰珠")
                || prompt.contains("起毛")
                || prompt.contains("顺时针")
                || prompt.contains("逆时针")
                || prompt.contains("纤维")
                || prompt.contains("指尖")
                || prompt.contains("裂纹");
        return wideShot && microscopicDetail && !prompt.contains("中景落幅") && !prompt.contains("近景落幅");
    }

    private boolean isLegacySegmentedPrompt(String prompt) {
        if (!hasText(prompt)) {
            return false;
        }
        String normalized = prompt.toLowerCase(Locale.ROOT);
        if (normalized.contains("light-color mood")
                || normalized.contains("sound-music design")
                || normalized.contains("emotional subtext")
                || normalized.contains("next-shot relay anchor")) {
            return true;
        }
        if (prompt.matches("(?s).*\\d+\\s*-\\s*\\d+\\s*(?:s|秒)[:：]?.*")) {
            return true;
        }
        if (prompt.matches("(?s).*【\\s*镜头\\s*\\d+\\s*】.*")
                || prompt.matches("(?s).*\\[\\s*镜头\\s*\\d+\\s*].*")) {
            return true;
        }
        return prompt.contains("视觉：") && prompt.contains("音频：");
    }

    private String buildFallbackShotSheet(Storyboard storyboard, String invalidPrompt) {
        String raw = nullSafe(invalidPrompt).trim();
        String visual = extractSegmentContent(raw, "视觉：");
        String audio = extractSegmentContent(raw, "音频：");
        String pictureBody = hasText(visual)
                ? visual
                : firstNonBlank(
                        cleanStoryboardText(storyboard.getDetailedDescription()),
                        cleanStoryboardText(storyboard.getShortDescription()),
                        raw
                );
        String audioBody = hasText(audio)
                ? audio
                : "以现场环境音为主，避免空泛配乐描述。";
        return """
                ### 【镜%s】%s — 视频生成提示

                ```text
                [景别] %s
                [运镜] %s
                [构图] 按当前画幅保留主体安全区，明确前景、中景、背景的空间锚点。
                [画面] %s
                [灯光] %s
                [声音] %s
                [时长] %s秒
                [台词] %s
                ```
                """.formatted(
                storyboard.getSequenceNum() != null ? storyboard.getSequenceNum() : 1,
                firstNonBlank(cleanStoryboardText(storyboard.getShortDescription()), "当前分镜"),
                firstNonBlank(cleanStoryboardText(storyboard.getCompositionGuide()), "中景（人物全身至膝盖）"),
                firstNonBlank(cleanStoryboardText(storyboard.getCameraDirection()), "缓慢推进(slow push in)"),
                pictureBody,
                inferLightingFallback(storyboard),
                audioBody,
                storyboard.getDurationSeconds() != null ? storyboard.getDurationSeconds() : 5,
                firstNonBlank(cleanStoryboardText(storyboard.getDialogues()), "无")
        ).trim();
    }

    private String extractSegmentContent(String prompt, String label) {
        if (!hasText(prompt) || !prompt.contains(label)) {
            return "";
        }
        return Arrays.stream(prompt.split("\\R"))
                .map(String::trim)
                .filter(line -> line.contains(label))
                .map(line -> {
                    int start = line.indexOf(label) + label.length();
                    int next = line.indexOf("；", start);
                    if (next < 0) {
                        next = line.indexOf(";", start);
                    }
                    return next > start ? line.substring(start, next).trim() : line.substring(start).trim();
                })
                .filter(this::hasText)
                .collect(Collectors.joining(" "));
    }

    private String inferLightingFallback(Storyboard storyboard) {
        String description = cleanStoryboardText(storyboard.getDetailedDescription());
        if (hasText(description)) {
            return "沿用当前场景中已出现的主光源与冷暖关系，让光线服务人物压迫感与情绪变化。";
        }
        return "保持场景既有光线逻辑，用明暗对比承托当前情绪。";
    }

    private String lastSentence(String value) {
        if (!hasText(value)) {
            return "";
        }
        String[] parts = value.split("[。！？!?]");
        for (int i = parts.length - 1; i >= 0; i--) {
            if (hasText(parts[i])) {
                return parts[i].trim();
            }
        }
        return value.trim();
    }

    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 buildShotContinuityContext(Storyboard storyboard, Long tenantId) {
        if (storyboard.getEpisodeId() == null) {
            return "";
        }
        List<Storyboard> episodeShots = storyboardMapper.findByEpisode(storyboard.getEpisodeId(), tenantId);
        if (episodeShots.isEmpty()) {
            return "";
        }

        Storyboard previous = null;
        Storyboard next = null;
        for (int i = 0; i < episodeShots.size(); i++) {
            Storyboard current = episodeShots.get(i);
            if (!Objects.equals(current.getId(), storyboard.getId())) {
                continue;
            }
            previous = i > 0 ? episodeShots.get(i - 1) : null;
            next = i + 1 < episodeShots.size() ? episodeShots.get(i + 1) : null;
            break;
        }

        if (previous == null && next == null) {
            return "";
        }

        StringBuilder builder = new StringBuilder("Shot continuity relay / 分镜焦点接力:\n");
        if (previous != null) {
            builder.append("- Previous shot ending focus:\n")
                    .append(describeShotForRelay(previous));
            if (sameScene(previous, storyboard)) {
                builder.append("- Previous shot shares the same scene/location with current shot. Preserve visible continuity of blocking, wardrobe state, held props, emotional level, and light logic unless the script explicitly changes them.\n");
            }
        }
        builder.append("- Current shot must preserve one relay anchor from the previous shot when possible. Choose the most natural relay type: physical / gaze / environmental.\n");
        builder.append("- Same-scene continuity checklist for current shot: standing/seated positions, facing directions, body distance, costume state, hand occupancy, prop placement, emotional carry-over, light source direction and color temperature.\n");
        if (next != null) {
            builder.append("- Next shot handoff target:\n")
                    .append(describeShotForRelay(next))
                    .append("- Current shot should end on a relay anchor that can naturally hand off to the next shot.\n");
            if (sameScene(next, storyboard)) {
                builder.append("- Next shot is in the same scene/location, so hand off a trackable continuity state rather than a reset tableau.\n");
            }
        }
        return builder.toString().trim();
    }

    private String buildAspectRatioCompositionContext(String videoRatio) {
        String ratio = normalizeVideoRatio(videoRatio);
        StringBuilder builder = new StringBuilder("Aspect ratio composition contract / 画幅构图约束:\n");
        builder.append("- Requested video ratio: ").append(ratio).append("\n");
        builder.append("- The prompt must make composition choices that work for this exact ratio. Do not write a shot that only reads in another frame.\n");
        switch (ratio) {
            case "9:16" -> builder.append("""
                    - 竖屏构图：优先上/中/下层次、门框/窗框/前景遮挡形成纵深，主体放在中央安全区。
                    - 避免依赖大范围左右横摇来交代空间；如果必须移动，使用轻微推进、俯仰或纵深调度。
                    - 画面描述要写清楚竖屏锚点，例如：上方招牌/灯源 -> 中部人物表演 -> 下方道具或脚步。
                    - 重要人物脸、字幕式标语、门缝光不要贴近上下边缘，给头顶和脚底留安全空间。
                    """);
            case "1:1" -> builder.append("""
                    - 方形构图：优先中心权重、对角线关系、紧凑空间和均衡留白。
                    - 一镜最多一个主视觉中心加一个次级锚点；避免横向长空间或竖向过高空间。
                    - 画面描述要写清楚方形锚点，例如：中心人物/物件 -> 右下角道具 -> 左上角光源。
                    - 运动宜小而稳定：轻推、轻拉、焦点转移、微调构图，不要长距离横移/摇摄。
                    """);
            default -> builder.append("""
                    - 横屏构图：优先左/中/右空间关系、横向动线、前景-中景-背景层次。
                    - 横向摇摄或平移可以使用，但必须明确是 pan/摇摄 还是 dolly/truck/横移。
                    - 画面描述要写清楚横屏锚点，例如：左侧时代标语 -> 中部人物轮廓 -> 右侧门缝暖光。
                    - 关键人物脸、标语、门缝光不要贴极端左右边缘，保留横屏安全区和运动余量。
                    """);
        }
        return builder.toString().trim();
    }

    private String normalizeVideoRatio(String videoRatio) {
        if ("9:16".equals(videoRatio) || "1:1".equals(videoRatio)) {
            return videoRatio;
        }
        return "16:9";
    }

    private String describeShotForRelay(Storyboard shot) {
        return String.format(
                "  sequence=%d, short=%s, detail=%s, characters=%s, scene=%s, camera=%s, composition=%s, motion=%s, dialogues=%s%n",
                shot.getSequenceNum() != null ? shot.getSequenceNum() : 0,
                cleanStoryboardText(shot.getShortDescription()),
                cleanStoryboardText(shot.getDetailedDescription()),
                nullSafe(shot.getCharacters()),
                nullSafe(shot.getSceneRef()),
                cleanStoryboardText(shot.getCameraDirection()),
                cleanStoryboardText(shot.getCompositionGuide()),
                cleanStoryboardText(shot.getMotionScript()),
                cleanStoryboardText(shot.getDialogues())
        );
    }

    private boolean sameScene(Storyboard a, Storyboard b) {
        if (a == null || b == null) {
            return false;
        }
        String sceneA = normalizeMatchText(a.getSceneRef());
        String sceneB = normalizeMatchText(b.getSceneRef());
        return hasText(sceneA) && sceneA.equals(sceneB);
    }

    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<Character> projectCharacters,
                                                    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 selectedCharacterRefs = selectedCharacterRefsByImageKeys(projectCharacters, storyboard.getCharacterImageKeys());
        String selectedSceneRef = selectedSceneRefByImageKey(projectScenes, storyboard.getSceneImageKey());
        String sceneRef = hasText(selectedSceneRef)
                ? selectedSceneRef
                : storyboard.getSceneRef() != null && !storyboard.getSceneRef().isBlank()
                ? storyboard.getSceneRef()
                : fallbackRefs.sceneRef();
        return new StoryboardRefs(hasText(selectedCharacterRefs) ? selectedCharacterRefs : fallbackRefs.characters(), sceneRef);
    }

    private String selectedCharacterRefsByImageKeys(List<Character> projectCharacters, String characterImageKeysJson) {
        List<String> selectedKeys = parseStringList(characterImageKeysJson);
        if (selectedKeys.isEmpty() || projectCharacters == null || projectCharacters.isEmpty()) {
            return "";
        }
        return projectCharacters.stream()
                .filter(character -> hasText(character.getName()))
                .filter(character -> selectedKeys.contains(primaryCharacterImageKey(character)))
                .map(character -> "@" + character.getName().trim())
                .distinct()
                .collect(Collectors.joining(","));
    }

    private String selectedSceneRefByImageKey(List<Scene> projectScenes, String sceneImageKey) {
        if (!hasText(sceneImageKey) || projectScenes == null || projectScenes.isEmpty()) {
            return "";
        }
        return projectScenes.stream()
                .filter(scene -> hasText(scene.getName()))
                .filter(scene -> sceneImageKey.equals(scene.getImageTosKey()))
                .map(scene -> "@" + scene.getName().trim())
                .findFirst()
                .orElse("");
    }

    private List<String> parseStringList(String json) {
        if (!hasText(json)) {
            return List.of();
        }
        try {
            return objectMapper.readValue(json, LIST_STRING_TYPE).stream()
                    .filter(this::hasText)
                    .distinct()
                    .toList();
        } catch (Exception e) {
            log.warn("Failed to parse storyboard string list: {}", e.getMessage());
            return List.of();
        }
    }

    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.getCharacterImageKeys(), existing.getCharacterImageKeys())
                || hasChanged(patch.getSceneImageKey(), existing.getSceneImageKey())
                || hasChanged(patch.getPropImageKeys(), existing.getPropImageKeys())
                || hasChanged(patch.getStyleImageKey(), existing.getStyleImageKey())
                || 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) {
    }
}
