package com.yaoai.pipeline.service.impl;

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.ai.providers.service.SeedreamService;
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.CharacterState;
import com.yaoai.domain.entity.Episode;
import com.yaoai.domain.entity.Outline;
import com.yaoai.domain.entity.Project;
import com.yaoai.domain.entity.ProjectConsistencyBible;
import com.yaoai.domain.entity.Scene;
import com.yaoai.domain.entity.ScriptUnderstanding;
import com.yaoai.domain.mapper.CharacterMapper;
import com.yaoai.domain.mapper.CharacterStateMapper;
import com.yaoai.domain.mapper.EpisodeMapper;
import com.yaoai.domain.mapper.OutlineMapper;
import com.yaoai.domain.mapper.ProjectConsistencyBibleMapper;
import com.yaoai.domain.mapper.ProjectMapper;
import com.yaoai.domain.mapper.SceneMapper;
import com.yaoai.domain.mapper.ScriptUnderstandingMapper;
import com.yaoai.pipeline.service.AssetGenPipelineService;
import com.yaoai.storage.service.TosService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Locale;
import java.util.Map;

@Slf4j
@Service
@RequiredArgsConstructor
public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {

    private static final int MAX_CONTINUITY_CONTEXT_CHARS = 12000;
    private static final int MAX_IMAGE_PROMPT_CHARS = 3600;
    private static final String JSON_RETRY_SUFFIX = """

            上一轮输出不是完整可解析 JSON 数组。请严格重做：
            - 只返回完整 JSON 数组，不要 Markdown，不要说明文字。
            - 不要省略闭合括号、引号或逗号。
            - 不得降低剧情准确性要求，不得虚构原资料没有的核心信息。
            - 控制每个字符串长度，优先保证 JSON 完整。
            """;

    private static final String CHARACTER_SYSTEM = """
            你是影视短剧角色设定专家。请根据给定的大纲和分集信息，提取主要角色并输出 JSON 数组。
            如果输入包含剧情理解事实或一致性圣经，必须优先依据其中的角色事实、关系事实和证据；不得凭空新增核心角色或改写身份关系。
            必须识别角色之间的视觉相似性：双胞胎、姐妹/兄弟、替身、同一人不同身份、相似脸、血缘相似、剧情明确要求长相接近的角色，必须共享同一个 similarity_group 和 shared_appearance_anchor。
            同一 similarity_group 内的角色必须有高度一致的脸型、五官比例、肤色、体态和基础气质，只允许在发型、服装、妆容、年龄感、表情或气场上做少量差异。
            如果剧本没有明确相似关系，不要强行合并角色外貌。
            image_prompt 必须只使用项目选择的视觉风格，不得同时出现真人/写实照片和动漫/漫画/赛璐璐/二次元风格。
            不要向用户索要补充资料，不要输出说明文字；资料不足时由后端拦截，不由你降级处理。
            返回格式如下：
            [
              {
                "name": "角色姓名",
                "role_type": "female_lead / male_lead / antagonist / supporting",
                "gender": "male or female",
                "age": "年龄描述",
                "personality": "性格特征，30字以内",
                "costume": "主要服装描述，30字以内",
                "visual_hint": "外貌特征，30字以内",
                "similarity_group": "相似外貌分组，可空；相似/双胞胎/同一人变体必须相同，如 twin_a",
                "shared_appearance_anchor": "同组共享外貌锚点，可空，如 oval face, same almond eyes, similar nose bridge",
                "difference_notes": "同组内差异点，可空，如 shorter hair, darker outfit",
                "image_prompt": "英文角色设定图 prompt，突出共享外貌锚点、少量差异、服装、发型、体型、气质，用于统一角色三视图生成"
              }
            ]
            只返回 3 到 8 个主要角色，只返回 JSON。
            """;

    private static final String SCENE_SYSTEM = """
            你是影视短剧场景视觉设定专家。你只负责构建场景环境，绝不生成或描述任何人物。场景即叙事主体——氛围、光影、质感、空间张力必须自身传递情绪与悬念。场景不是门牌、床头、杯子、墙面等局部物件，而是一个可用于拍摄和走位的完整空间。镜头必须以全空间全景(establishing shot)为主，清晰呈现场景全貌：空间结构、元素布局、方位关系、环境细节。除非原文或用户明确指定其它镜头，image_prompt 必须默认使用 45-degree elevated three-quarter overhead panoramic establishing shot（45度斜俯视全景），展示完整空间、出入口、关键区域、关键道具、活动区和走位动线。禁止无依据的局部表达(close-up/detail shot/medium shot/eye-level corridor shot/极端裁切)，确保同一场景内画面一致性。请根据给定的大纲和分集信息，提取最具视觉冲击力的主要场景，输出 JSON 数组。
            如果输入包含剧情理解事实或一致性圣经，必须优先依据其中的地点事实、场景功能和证据；不得凭空新增与剧情无关的场景。
            不要向用户索要补充资料，不要输出说明文字；资料不足时由后端拦截，不由你降级处理。
            返回格式如下：
            [
              {
                "name": "场景名称",
                "scene_type": "indoor or outdoor",
                "description": "场景描述，40字以内，必须描述完整空间和区域关系，局部线索只能作为空间内元素",
                "image_prompt": "英文场景图 prompt，电影级环境描述：先描述完整场地类型和空间布局，再描述局部线索；指定色调(cool grey/blood red/neon green等)、光影类型(harsh shadow/volumetric fog/backlit等)、材质质感(cracked mirror/frosted glass/wet concrete等)、默认镜头语言必须是45-degree elevated three-quarter overhead panoramic establishing shot / distant wide full-space overview，除非原文明确指定其它镜头；氛围关键词(oppressive/eerie/suffocating等)，清晰描述空间布局、元素方位、出入口、关键区域、活动区和走位动线，严禁出现person/human/figure/face/silhouette/character等人物词汇，严禁无依据的close-up/detail shot/medium shot/eye-level/局部特写"
              }
            ]
            只返回 3 到 6 个主要场景，只返回 JSON。
            """;

    private final LlmService llmService;
    private final SeedreamService seedreamService;
    private final TosService tosService;
    private final CharacterMapper characterMapper;
    private final CharacterStateMapper characterStateMapper;
    private final SceneMapper sceneMapper;
    private final OutlineMapper outlineMapper;
    private final EpisodeMapper episodeMapper;
    private final ProjectMapper projectMapper;
    private final ScriptUnderstandingMapper scriptUnderstandingMapper;
    private final ProjectConsistencyBibleMapper projectConsistencyBibleMapper;
    private final ObjectMapper objectMapper;
    private final BillingService billingService;
    private final HttpClient httpClient = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(10))
            .build();

    @Override
    public List<Character> extractCharacters(Long projectId, Long tenantId) {
        Project project = loadProject(projectId, tenantId);
        ensureNarrativeContext(projectId, tenantId, "角色");
        ProjectVisualStyle visualStyle = resolveProjectVisualStyle(project);
        ProjectRenderSpec renderSpec = resolveProjectRenderSpec(project);
        String context = buildCharacterExtractionContext(projectId, tenantId, visualStyle, renderSpec);
        log.info("Extracting characters: projectId={}", projectId);

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

        try {
            List<Map<String, Object>> items = requestJsonArray(CHARACTER_SYSTEM, context, "角色提取");
            if (items.isEmpty()) {
                throw new BizException(ErrorCode.INTERNAL_ERROR, "角色提取失败：模型返回空数组，请检查剧情资料中是否包含角色信息");
            }
            List<Character> result = new ArrayList<>();
            for (Map<String, Object> item : items) {
                Character character = new Character();
                character.setProjectId(projectId);
                character.setTenantId(tenantId);
                character.setName((String) item.getOrDefault("name", ""));
                character.setRoleType((String) item.getOrDefault("role_type", ""));
                character.setGender((String) item.getOrDefault("gender", ""));
                character.setAge((String) item.getOrDefault("age", ""));
                character.setPersonality((String) item.getOrDefault("personality", ""));
                character.setCostume((String) item.getOrDefault("costume", ""));
                character.setVisualHint(enrichVisualHintWithSimilarity(item));
                character.setImagePrompt(buildExtractedCharacterImagePrompt(item, visualStyle));
                character.setStatus("draft");
                characterMapper.insert(character);
                result.add(character);
            }
            billingService.charge(BillingChargeRequest.builder()
                    .tenantId(tenantId)
                    .userId(UserContext.require())
                    .projectId(projectId)
                    .operation("character_extract")
                    .modality("TEXT")
                    .modelProvider(llmService.getProvider())
                    .modelId(llmService.getModelId())
                    .modelName(llmService.getModelName())
                    .unitCount(result.size())
                    .refId(String.valueOf(projectId))
                    .build());
            return result;
        } catch (BizException e) {
            throw e;
        } catch (Exception e) {
            log.error("Character extraction failed", e);
            throw new BizException(ErrorCode.INTERNAL_ERROR, "角色提取失败: " + e.getMessage());
        }
    }

    @Override
    public List<Scene> extractScenes(Long projectId, Long tenantId) {
        Project project = loadProject(projectId, tenantId);
        ensureNarrativeContext(projectId, tenantId, "场景");
        ProjectVisualStyle visualStyle = resolveProjectVisualStyle(project);
        ProjectRenderSpec renderSpec = resolveProjectRenderSpec(project);
        String context = buildSceneExtractionContext(projectId, tenantId, visualStyle, renderSpec);
        log.info("Extracting scenes: projectId={}", projectId);

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

        try {
            List<Map<String, Object>> items = requestJsonArray(SCENE_SYSTEM, context, "场景提取");
            if (items.isEmpty()) {
                throw new BizException(ErrorCode.INTERNAL_ERROR, "场景提取失败：模型返回空数组，请检查剧情资料中是否包含场景信息");
            }
            List<Scene> result = new ArrayList<>();
            for (Map<String, Object> item : items) {
                Scene scene = new Scene();
                scene.setProjectId(projectId);
                scene.setTenantId(tenantId);
                scene.setName((String) item.getOrDefault("name", ""));
                scene.setSceneType((String) item.getOrDefault("scene_type", "indoor"));
                scene.setDescription((String) item.getOrDefault("description", ""));
                scene.setImagePrompt(sanitizePromptForStyle((String) item.getOrDefault("image_prompt", ""), visualStyle));
                scene.setStatus("draft");
                sceneMapper.insert(scene);
                result.add(scene);
            }
            billingService.charge(BillingChargeRequest.builder()
                    .tenantId(tenantId)
                    .userId(UserContext.require())
                    .projectId(projectId)
                    .operation("scene_extract")
                    .modality("TEXT")
                    .modelProvider(llmService.getProvider())
                    .modelId(llmService.getModelId())
                    .modelName(llmService.getModelName())
                    .unitCount(result.size())
                    .refId(String.valueOf(projectId))
                    .build());
            return result;
        } catch (BizException e) {
            throw e;
        } catch (Exception e) {
            log.error("Scene extraction failed", e);
            throw new BizException(ErrorCode.INTERNAL_ERROR, "场景提取失败: " + e.getMessage());
        }
    }

    @Override
    public Character generateCharacterImage(Long characterId, Long tenantId) {
        Character character = requireCharacter(characterId, tenantId);
        Project project = loadProject(character.getProjectId(), tenantId);
        ProjectVisualStyle visualStyle = resolveProjectVisualStyle(project);
        ProjectRenderSpec renderSpec = resolveCharacterSheetRenderSpec();
        if (character.getImagePrompt() == null || character.getImagePrompt().isBlank()) {
            character.setImagePrompt(buildCharacterImagePromptFallback(character, visualStyle));
        }
        if (character.getImagePrompt() == null || character.getImagePrompt().isBlank()) {
            throw new BizException(ErrorCode.INVALID_PARAM, "角色缺少图片 Prompt，且无法从角色资料推导");
        }

        billingService.checkBalance(BillingChargeRequest.builder()
                .tenantId(tenantId)
                .userId(UserContext.require())
                .projectId(character.getProjectId())
                .operation("character_image_generate")
                .modality("IMAGE")
                .modelProvider(seedreamService.getProvider())
                .modelId(seedreamService.getModelId())
                .modelName(seedreamService.getModelName())
                .unitCount(1)
                .meta(Map.of("layout", "sheet"))
                .refId(String.valueOf(characterId))
                .build());

        character.setStatus("generating");
        characterMapper.updateById(character);

        try {
            String prompt = limitImagePrompt(buildCharacterSheetPrompt(character.getImagePrompt(), visualStyle, renderSpec));
            String generatedUrl = seedreamService.generateImage(prompt, renderSpec.size());
            byte[] bytes = downloadBytes(generatedUrl);
            String key = TosService.buildKey(
                    character.getTenantId(),
                    character.getProjectId(),
                    "characters/" + character.getId(),
                    "sheet.jpg"
            );
            tosService.upload(key, new ByteArrayInputStream(bytes), bytes.length, "image/jpeg");

            String publicUrl = tosService.publicUrl(key);
            // 拼图同时写入 imageUrl 与 frontImage* 字段：
            // - imageUrl 是卡片主图
            // - frontImage* 是 storyboard 引用兜底（StoryboardWorkspace.tsx 用到 frontImageUrl）
            character.setImageUrl(publicUrl);
            character.setImageTosKey(key);
            character.setFrontImageUrl(publicUrl);
            character.setFrontImageTosKey(key);
            character.setSideImageUrl(null);
            character.setSideImageTosKey(null);
            character.setBackImageUrl(null);
            character.setBackImageTosKey(null);

            character.setStatus("ready");
            characterMapper.updateById(character);
            billingService.charge(BillingChargeRequest.builder()
                    .tenantId(tenantId)
                    .userId(UserContext.require())
                    .projectId(character.getProjectId())
                    .operation("character_image_generate")
                    .modality("IMAGE")
                    .modelProvider(seedreamService.getProvider())
                    .modelId(seedreamService.getModelId())
                    .modelName(seedreamService.getModelName())
                    .unitCount(1)
                    .meta(Map.of("layout", "sheet"))
                    .refId(String.valueOf(characterId))
                    .build());
            return character;
        } catch (BizException e) {
            character.setStatus("failed");
            characterMapper.updateById(character);
            throw e;
        } catch (Exception e) {
            character.setStatus("failed");
            characterMapper.updateById(character);
            throw new BizException(ErrorCode.INTERNAL_ERROR, "角色三视图生成失败: " + e.getMessage());
        }
    }

    @Override
    public Scene generateSceneImage(Long sceneId, Long tenantId) {
        Scene scene = sceneMapper.selectById(sceneId);
        if (scene == null || !tenantId.equals(scene.getTenantId())) {
            throw new BizException(ErrorCode.NOT_FOUND, "场景不存在");
        }
        Project project = loadProject(scene.getProjectId(), tenantId);
        ProjectVisualStyle visualStyle = resolveProjectVisualStyle(project);
        ProjectRenderSpec renderSpec = resolveProjectRenderSpec(project);
        if (scene.getImagePrompt() == null || scene.getImagePrompt().isBlank()) {
            scene.setImagePrompt(buildSceneImagePromptFallback(scene, visualStyle));
        }
        if (scene.getImagePrompt() == null || scene.getImagePrompt().isBlank()) {
            throw new BizException(ErrorCode.INVALID_PARAM, "场景缺少图片 Prompt");
        }

        billingService.checkBalance(BillingChargeRequest.builder()
                .tenantId(tenantId)
                .userId(UserContext.require())
                .projectId(scene.getProjectId())
                .operation("scene_image_generate")
                .modality("IMAGE")
                .modelProvider(seedreamService.getProvider())
                .modelId(seedreamService.getModelId())
                .modelName(seedreamService.getModelName())
                .unitCount(1)
                .refId(String.valueOf(sceneId))
                .build());

        scene.setStatus("generating");
        sceneMapper.updateById(scene);

        try {
            String prompt = limitImagePrompt(buildSceneRenderPrompt(scene.getImagePrompt(), visualStyle, renderSpec));
            String imageUrl = seedreamService.generateImage(prompt, renderSpec.size());
            byte[] bytes = downloadBytes(imageUrl);
            String key = TosService.buildKey(scene.getTenantId(), scene.getProjectId(), "scenes", scene.getName() + ".jpg");
            tosService.upload(key, new ByteArrayInputStream(bytes), bytes.length, "image/jpeg");

            scene.setImageUrl(tosService.publicUrl(key));
            scene.setImageTosKey(key);
            scene.setStatus("ready");
            sceneMapper.updateById(scene);
            billingService.charge(BillingChargeRequest.builder()
                    .tenantId(tenantId)
                    .userId(UserContext.require())
                    .projectId(scene.getProjectId())
                    .operation("scene_image_generate")
                    .modality("IMAGE")
                    .modelProvider(seedreamService.getProvider())
                    .modelId(seedreamService.getModelId())
                    .modelName(seedreamService.getModelName())
                    .unitCount(1)
                    .refId(String.valueOf(sceneId))
                    .build());
            return scene;
        } catch (BizException e) {
            scene.setStatus("failed");
            sceneMapper.updateById(scene);
            throw e;
        } catch (Exception e) {
            scene.setStatus("failed");
            sceneMapper.updateById(scene);
            throw new BizException(ErrorCode.INTERNAL_ERROR, "场景图片生成失败: " + e.getMessage());
        }
    }

    @Override
    public List<Character> listCharacters(Long projectId, Long tenantId) {
        return characterMapper.findByProject(projectId, tenantId)
                .stream()
                .peek(this::normalizeCharacterImageUrls)
                .toList();
    }

    @Override
    public List<CharacterState> listCharacterStates(Long projectId, Long tenantId) {
        return characterStateMapper.findByProject(projectId, tenantId)
                .stream()
                .peek(this::normalizeCharacterStateImageUrl)
                .toList();
    }

    @Override
    public List<CharacterState> listCharacterStates(Long projectId, Long characterId, Long tenantId) {
        Character character = requireCharacter(characterId, tenantId);
        if (!projectId.equals(character.getProjectId())) {
            throw new BizException(ErrorCode.NOT_FOUND, "角色不存在");
        }
        return characterStateMapper.findByCharacter(projectId, characterId, tenantId)
                .stream()
                .peek(this::normalizeCharacterStateImageUrl)
                .toList();
    }

    @Override
    public List<Scene> listScenes(Long projectId, Long tenantId) {
        return sceneMapper.findByProject(projectId, tenantId)
                .stream()
                .peek(this::normalizeSceneImageUrl)
                .toList();
    }

    private void normalizeCharacterImageUrls(Character character) {
        if (character == null) {
            return;
        }
        if (hasText(character.getImageTosKey())) {
            character.setImageUrl(tosService.publicUrl(character.getImageTosKey()));
        }
        if (hasText(character.getFrontImageTosKey())) {
            character.setFrontImageUrl(tosService.publicUrl(character.getFrontImageTosKey()));
        }
        if (hasText(character.getSideImageTosKey())) {
            character.setSideImageUrl(tosService.publicUrl(character.getSideImageTosKey()));
        }
        if (hasText(character.getBackImageTosKey())) {
            character.setBackImageUrl(tosService.publicUrl(character.getBackImageTosKey()));
        }
    }

    private void normalizeCharacterStateImageUrl(CharacterState state) {
        if (state != null && hasText(state.getImageTosKey())) {
            state.setImageUrl(tosService.publicUrl(state.getImageTosKey()));
        }
    }

    private void normalizeSceneImageUrl(Scene scene) {
        if (scene != null && hasText(scene.getImageTosKey())) {
            scene.setImageUrl(tosService.publicUrl(scene.getImageTosKey()));
        }
    }

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

    @Override
    public Character saveCharacter(Character character) {
        Project project = loadProject(character.getProjectId(), character.getTenantId());
        ProjectVisualStyle visualStyle = resolveProjectVisualStyle(project);
        if (character.getId() != null) {
            Character existing = characterMapper.selectById(character.getId());
            if (existing != null && shouldRefreshCharacterImagePrompt(character, existing)) {
                character.setImagePrompt(buildCharacterImagePromptFallback(character, visualStyle));
            }
        }
        if (character.getImagePrompt() == null || character.getImagePrompt().isBlank()) {
            character.setImagePrompt(buildCharacterImagePromptFallback(character, visualStyle));
        }
        if (character.getId() == null) {
            characterMapper.insert(character);
        } else {
            characterMapper.updateById(character);
        }
        return character;
    }

    @Override
    public CharacterState saveCharacterState(Long projectId, Long characterId, Long tenantId, CharacterState state) {
        Character character = requireCharacter(characterId, tenantId);
        if (!projectId.equals(character.getProjectId())) {
            throw new BizException(ErrorCode.NOT_FOUND, "角色不存在");
        }
        state.setProjectId(projectId);
        state.setTenantId(tenantId);
        state.setCharacterId(characterId);
        if (!hasText(state.getName())) {
            throw new BizException(ErrorCode.INVALID_PARAM, "请填写角色状态名称");
        }
        if (!hasText(state.getStateType())) {
            state.setStateType("costume");
        }
        if (!hasText(state.getStatus())) {
            state.setStatus("draft");
        }
        if (state.getId() == null) {
            characterStateMapper.insert(state);
        } else {
            CharacterState existing = characterStateMapper.selectById(state.getId());
            if (existing == null || !tenantId.equals(existing.getTenantId()) || !characterId.equals(existing.getCharacterId())) {
                throw new BizException(ErrorCode.NOT_FOUND, "角色状态不存在");
            }
            state.setProjectId(existing.getProjectId());
            state.setTenantId(existing.getTenantId());
            state.setCharacterId(existing.getCharacterId());
            if (!hasText(state.getReferenceImageKey())) {
                state.setReferenceImageKey(existing.getReferenceImageKey());
            }
            if (!hasText(state.getImageTosKey())) {
                state.setImageTosKey(existing.getImageTosKey());
                state.setImageUrl(existing.getImageUrl());
            }
            characterStateMapper.updateById(state);
        }
        normalizeCharacterStateImageUrl(state);
        return state;
    }

    @Override
    public CharacterState generateCharacterStateImage(Long projectId, Long characterId, Long stateId, Long tenantId) {
        CharacterState state = characterStateMapper.selectById(stateId);
        if (state == null
                || !tenantId.equals(state.getTenantId())
                || !projectId.equals(state.getProjectId())
                || !characterId.equals(state.getCharacterId())) {
            throw new BizException(ErrorCode.NOT_FOUND, "角色状态不存在");
        }
        Character character = requireCharacter(state.getCharacterId(), tenantId);
        Project project = loadProject(state.getProjectId(), tenantId);
        ProjectVisualStyle visualStyle = resolveProjectVisualStyle(project);
        ProjectRenderSpec renderSpec = resolveCharacterSheetRenderSpec();
        String referenceKey = firstText(
                state.getReferenceImageKey(),
                character.getFrontImageTosKey(),
                character.getImageTosKey(),
                character.getSideImageTosKey(),
                character.getBackImageTosKey()
        );
        if (!hasText(referenceKey)) {
            throw new BizException(ErrorCode.INVALID_PARAM, "请先生成或上传角色基准图，再生成角色状态图");
        }
        String referenceUrl = tosService.publicUrl(referenceKey);

        billingService.checkBalance(BillingChargeRequest.builder()
                .tenantId(tenantId)
                .userId(UserContext.require())
                .projectId(state.getProjectId())
                .operation("character_image_generate")
                .modality("IMAGE")
                .modelProvider(seedreamService.getProvider())
                .modelId(seedreamService.getModelId())
                .modelName(seedreamService.getModelName())
                .unitCount(1)
                .meta(Map.of("layout", "state", "reference", "image-to-image"))
                .refId(String.valueOf(stateId))
                .build());

        state.setStatus("generating");
        state.setReferenceImageKey(referenceKey);
        characterStateMapper.updateById(state);

        try {
            String prompt = limitImagePrompt(buildCharacterStatePrompt(character, state, visualStyle, renderSpec));
            String generatedUrl = seedreamService.generateImage(prompt, renderSpec.size(), List.of(referenceUrl));
            byte[] bytes = downloadBytes(generatedUrl);
            String key = TosService.buildKey(
                    state.getTenantId(),
                    state.getProjectId(),
                    "characters/" + state.getCharacterId() + "/states",
                    state.getId() + ".jpg"
            );
            tosService.upload(key, new ByteArrayInputStream(bytes), bytes.length, "image/jpeg");

            state.setImageTosKey(key);
            state.setImageUrl(tosService.publicUrl(key));
            state.setStatus("ready");
            characterStateMapper.updateById(state);
            billingService.charge(BillingChargeRequest.builder()
                    .tenantId(tenantId)
                    .userId(UserContext.require())
                    .projectId(state.getProjectId())
                    .operation("character_image_generate")
                    .modality("IMAGE")
                    .modelProvider(seedreamService.getProvider())
                    .modelId(seedreamService.getModelId())
                    .modelName(seedreamService.getModelName())
                    .unitCount(1)
                    .meta(Map.of("layout", "state", "reference", "image-to-image"))
                    .refId(String.valueOf(stateId))
                    .build());
            return state;
        } catch (BizException e) {
            state.setStatus("failed");
            characterStateMapper.updateById(state);
            throw e;
        } catch (Exception e) {
            state.setStatus("failed");
            characterStateMapper.updateById(state);
            throw new BizException(ErrorCode.INTERNAL_ERROR, "角色状态图生成失败: " + e.getMessage());
        }
    }

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

    private String buildCharacterImagePromptFallback(Character character, ProjectVisualStyle visualStyle) {
        if (character == null) {
            return "";
        }
        List<String> parts = new ArrayList<>();
        appendIfPresent(parts, character.getName());
        if (character.getGender() != null) {
            String gender = character.getGender().trim().toLowerCase(Locale.ROOT);
            if ("male".equals(gender)) {
                parts.add("male character");
            } else if ("female".equals(gender)) {
                parts.add("female character");
            }
        }
        appendIfPresent(parts, character.getAge());
        appendIfPresent(parts, character.getPersonality());
        appendIfPresent(parts, character.getCostume());
        appendIfPresent(parts, character.getVisualHint());
        if (visualStyle != null) {
            parts.add("style: " + visualStyle.label());
            parts.add(buildCharacterRenderStyleSummary(visualStyle));
            parts.add(buildVisualModeLock(visualStyle));
        }
        if (parts.isEmpty()) {
            return "";
        }
        return sanitizePromptForStyle(String.join(", ", parts), visualStyle);
    }

    private String buildCharacterStatePrompt(Character character,
                                             CharacterState state,
                                             ProjectVisualStyle visualStyle,
                                             ProjectRenderSpec renderSpec) {
        List<String> parts = new ArrayList<>();
        parts.add("image-to-image edit task: edit the provided reference image in place; treat it as one flat character design sheet, not as loose collage material");
        parts.add("create a state variant of the same character turnaround sheet for " + character.getName());
        parts.add("preserve the reference sheet format exactly: same canvas orientation, same four-panel arrangement, same panel order, same character bounding boxes, same positions, same scale, same margins, same white studio background");
        parts.add("required output layout: " + buildCharacterSheetLayoutPrompt(renderSpec));
        parts.add("do not create a new collage, do not add before/after comparison, do not add a large cropped half-body view, do not overlap panels, do not zoom in, do not crop heads or feet, do not change the panel count");
        parts.add("keep panel 1 as the bust portrait in the same location, panel 2 as full-body front view, panel 3 as full-body side view, panel 4 as full-body back view");
        parts.add("selected state name: " + state.getName());
        if (hasText(state.getStateType())) {
            parts.add("state type: " + state.getStateType());
        }
        parts.add("requested changes are listed next; apply only those changes and keep every unspecified element unchanged");
        appendIfPresent(parts, state.getDescription());
        appendIfPresent(parts, state.getPrompt());
        appendIfPresent(parts, "base identity notes, for preserving identity only: " + character.getVisualHint());
        parts.add("selected style: " + visualStyle.label());
        parts.add("selected aspect ratio: " + renderSpec.aspectRatio() + ", compose specifically for a " + renderSpec.aspectRatio() + " canvas");
        parts.add(buildVisualModeLock(visualStyle));
        parts.add(buildCharacterRenderStyleSummary(visualStyle));
        parts.addAll(buildStyleSpecificPromptParts(visualStyle));
        parts.add("preserve the exact same character identity from the reference image: same face shape, eye shape, nose bridge, mouth, facial geometry, body proportion, height impression, and core hairstyle unless the state prompt explicitly asks to change one of them");
        parts.add("if outfit or accessories are requested, update the bust portrait visible area and all three body views consistently while preserving the original pose, camera angle, ground line, and body proportions");
        parts.add("if expression, makeup, injury, skin tone, or hair-detail changes are requested, apply them consistently to every panel where that part is visible; do not alter clothing or accessories unless requested");
        parts.add("if the prompt does not mention clothing, keep the original clothing exactly; if it does not mention skin tone, keep the original skin tone exactly; if it does not mention hairstyle, keep the original hairstyle exactly");
        parts.add("do not redesign the person, do not change race, age group, body type, facial structure, panel layout, canvas ratio, or visual style");
        parts.add("single character only across the four required reference views, no extra people, no props, no text labels, no watermark");
        parts.addAll(buildStyleNegativePromptParts(visualStyle));
        return sanitizePromptForStyle(String.join(", ", parts), visualStyle);
    }

    private boolean shouldRefreshCharacterImagePrompt(Character incoming, Character existing) {
        if (incoming == null || existing == null) {
            return false;
        }
        String incomingPrompt = normalizeCharacterField(incoming.getImagePrompt());
        String existingPrompt = normalizeCharacterField(existing.getImagePrompt());
        if (incomingPrompt.isBlank()) {
            return true;
        }
        if (!incomingPrompt.equals(existingPrompt)) {
            return false;
        }
        return !normalizeCharacterField(incoming.getName()).equals(normalizeCharacterField(existing.getName()))
                || !normalizeCharacterField(incoming.getGender()).equals(normalizeCharacterField(existing.getGender()))
                || !normalizeCharacterField(incoming.getAge()).equals(normalizeCharacterField(existing.getAge()))
                || !normalizeCharacterField(incoming.getPersonality()).equals(normalizeCharacterField(existing.getPersonality()))
                || !normalizeCharacterField(incoming.getCostume()).equals(normalizeCharacterField(existing.getCostume()))
                || !normalizeCharacterField(incoming.getVisualHint()).equals(normalizeCharacterField(existing.getVisualHint()));
    }

    private String normalizeCharacterField(String value) {
        return value == null ? "" : value.trim();
    }

    private void appendIfPresent(List<String> parts, String value) {
        if (value != null && !value.isBlank()) {
            parts.add(value.trim());
        }
    }

    @Override
    public Scene saveScene(Scene scene) {
        Project project = loadProject(scene.getProjectId(), scene.getTenantId());
        ProjectVisualStyle visualStyle = resolveProjectVisualStyle(project);
        if (scene.getId() != null) {
            Scene existing = sceneMapper.selectById(scene.getId());
            if (existing != null && shouldRefreshSceneImagePrompt(scene, existing)) {
                scene.setImagePrompt(buildSceneImagePromptFallback(scene, visualStyle));
            }
        }
        if (scene.getImagePrompt() == null || scene.getImagePrompt().isBlank()) {
            scene.setImagePrompt(buildSceneImagePromptFallback(scene, visualStyle));
        }
        if (scene.getId() == null) {
            sceneMapper.insert(scene);
        } else {
            sceneMapper.updateById(scene);
        }
        return scene;
    }

    private String buildSceneExtractionContext(Long projectId, Long tenantId,
                                               ProjectVisualStyle visualStyle,
                                               ProjectRenderSpec renderSpec) {
        StringBuilder builder = new StringBuilder(buildProjectContext(projectId, tenantId));
        if (builder.length() > 0) {
            builder.append("\n\n");
        }
        builder.append("Scene visual style requirements:\n");
        builder.append("- Selected project style: ").append(visualStyle.label()).append('\n');
        builder.append("- Selected project aspect ratio: ").append(renderSpec.aspectRatio()).append(" (all generated scene images must use this ratio).\n");
        builder.append("- Style direction: ").append(buildSceneExtractionStyleDirection(visualStyle)).append('\n');
        builder.append("- The image_prompt must keep all scenes in one consistent project-wide visual language.\n");
        builder.append("- Extract complete spaces, not isolated props or medium shots. A suspicious cup, door number, bed, wall stain or corridor door is only a set-dressing element inside a larger room/corridor/building space.\n");
        builder.append("- Default camera unless explicitly overridden by the story/user: 45-degree elevated three-quarter overhead panoramic establishing shot, high-corner/near-ceiling camera, ultra wide-angle architectural set overview, distant wide full-space view showing floor plane, wall/door/window positions, entrances/exits, key props, functional zones, empty walking lanes and blocking routes.\n");
        builder.append("- Forbidden default framing: close-up, medium shot, eye-level corridor view, doorway-framed room view, cropped wall/door/object composition, shallow depth-of-field object focus.\n");
        builder.append("- Visual mode lock: ").append(buildVisualModeLock(visualStyle)).append('\n');
        builder.append("- Output target: ").append(buildSceneExtractionTargetRule(visualStyle)).append('\n');
        builder.append("- Avoid: ").append(buildSceneExtractionAvoidRule(visualStyle)).append('\n');
        builder.append("- Additional style notes: ").append(buildSceneRenderStyleSummary(visualStyle)).append('\n');
        return builder.toString();
    }

    private String buildSceneImagePromptFallback(Scene scene, ProjectVisualStyle visualStyle) {
        if (scene == null) {
            return "";
        }
        List<String> parts = new ArrayList<>();
        if (scene.getName() != null && !scene.getName().isBlank()) {
            parts.add(scene.getName().trim());
        }
        if (scene.getDescription() != null && !scene.getDescription().isBlank()) {
            parts.add(scene.getDescription().trim());
        }
        if (scene.getSceneType() != null && !scene.getSceneType().isBlank()) {
            parts.add("indoor".equalsIgnoreCase(scene.getSceneType()) ? "indoor scene" : "outdoor scene");
        }
        if (visualStyle != null) {
            parts.add("style: " + visualStyle.label());
            parts.add(buildSceneRenderStyleSummary(visualStyle));
            parts.add(buildVisualModeLock(visualStyle));
        }
        return sanitizePromptForStyle(String.join(", ", parts), visualStyle);
    }

    private String buildSceneRenderPrompt(String basePrompt, ProjectVisualStyle visualStyle, ProjectRenderSpec renderSpec) {
        List<String> promptParts = new ArrayList<>();
        String normalizedPrompt = sanitizePromptForStyle(basePrompt, visualStyle);
        promptParts.add(buildSceneCameraRule(normalizedPrompt));
        promptParts.add(buildSceneSpatialOverviewRule(normalizedPrompt));
        promptParts.add(buildSceneFramingNegativeRule());
        promptParts.add("treat the scene text as a content inventory inside the wide overview, never as a close/medium framing instruction");
        if (!normalizedPrompt.isBlank()) {
            promptParts.add("scene content inventory: " + normalizeScenePromptAsSpatialInventory(normalizedPrompt));
        }
        promptParts.add("match the selected project visual style consistently across all scene images");
        promptParts.add("selected style: " + visualStyle.label());
        promptParts.add("selected aspect ratio: " + renderSpec.aspectRatio() + ", compose specifically for a " + renderSpec.aspectRatio() + " canvas");
        promptParts.add(buildVisualModeLock(visualStyle));
        promptParts.add(buildSceneRenderStyleSummary(visualStyle));
        promptParts.addAll(buildSceneStylePromptParts(visualStyle));
        promptParts.add("environment concept art focused on scene, architecture, props, atmosphere, lighting, and spatial storytelling");
        promptParts.add("show the required elements and areas as much as possible: complete spatial layout, floor plane, wall geometry, entrances and exits, key props, functional zones, clear empty walking lanes, action areas, and blocking routes");
        promptParts.add("no humans, no characters, no faces, no silhouettes, no body parts");
        promptParts.add("wide establishing environment shot only, no map diagram, no blueprint, no labels, no arrows");
        promptParts.add("keep the visual language consistent with the rest of the project scenes");
        promptParts.addAll(buildSceneStyleNegativePromptParts(visualStyle));
        return String.join(", ", promptParts);
    }

    private String buildSceneCameraRule(String prompt) {
        if (hasUserSpecifiedSceneCameraOverride(prompt)) {
            return "respect the explicit camera or shot instruction in the prompt, while still keeping enough environmental context and required scene elements visible";
        }
        return "HIGHEST PRIORITY CAMERA: 45-degree elevated three-quarter overhead panoramic establishing shot, high oblique top-down perspective, camera placed high near the ceiling corner or above the set, 18mm ultra wide-angle lens, architectural cinematic set overview, dollhouse-like cutaway spatial view when needed to reveal layout, visible floor plane and spatial geometry, the whole room/corridor/site fits in one frame";
    }

    private String buildSceneSpatialOverviewRule(String prompt) {
        if (hasUserSpecifiedSceneCameraOverride(prompt)) {
            return "even with the special camera, include enough of the surrounding environment to understand the layout and action blocking";
        }
        return "composition priority: describe the entire environment first, then place local story clues as small readable details within that environment; for interiors show wall layout, door/window positions, furniture placement, connected areas and walking routes from a high corner perspective; for corridors show the full corridor length, branches, doors, floor path and exits from a high oblique view";
    }

    private String buildSceneFramingNegativeRule() {
        return "strict framing negatives: no eye-level perspective, no straight-on frontal wall view, no medium shot, no close-up, no object close-up, no doorway-framed room view, no row-of-doors eye-level corridor shot, no single-door composition, no cropped bed corner, no wall-only composition, no shallow-depth-of-field focus, no telephoto compression";
    }

    private String normalizeScenePromptAsSpatialInventory(String prompt) {
        if (!hasText(prompt) || hasUserSpecifiedSceneCameraOverride(prompt)) {
            return prompt == null ? "" : prompt.trim();
        }
        return prompt.trim()
                .replaceAll("(?i)\\bclose[- ]?up\\b", "small visible clue")
                .replaceAll("(?i)\\bdetail shot\\b", "small visible detail")
                .replaceAll("(?i)\\bmedium shot\\b", "wide spatial view")
                .replaceAll("(?i)\\beye[- ]level\\b", "high oblique overhead")
                .replaceAll("(?i)\\blow angle\\b", "high oblique overhead")
                .replace("局部特写", "空间内可见细节")
                .replace("特写", "空间内可见细节")
                .replace("近景", "全空间视图")
                .replace("中景", "全空间视图")
                .replace("平视", "斜俯视")
                .replace("低机位", "高位斜俯视")
                .replace("门口视角", "高位斜俯视全景")
                .replace("门框视角", "高位斜俯视全景");
    }

    private boolean hasUserSpecifiedSceneCameraOverride(String prompt) {
        if (!hasText(prompt)) {
            return false;
        }
        String normalized = prompt.toLowerCase(Locale.ROOT);
        return normalized.contains("user-specified camera")
                || normalized.contains("user specified camera")
                || normalized.contains("camera override")
                || normalized.contains("special camera")
                || normalized.contains("explicit camera")
                || normalized.contains("特殊镜头")
                || normalized.contains("用户指定镜头")
                || normalized.contains("自定义镜头")
                || normalized.contains("镜头覆盖");
    }

    private boolean shouldRefreshSceneImagePrompt(Scene incoming, Scene existing) {
        if (incoming == null || existing == null) {
            return false;
        }
        String incomingPrompt = normalizeSceneField(incoming.getImagePrompt());
        String existingPrompt = normalizeSceneField(existing.getImagePrompt());
        if (incomingPrompt.isBlank()) {
            return true;
        }
        if (!incomingPrompt.equals(existingPrompt)) {
            return false;
        }
        return !normalizeSceneField(incoming.getName()).equals(normalizeSceneField(existing.getName()))
                || !normalizeSceneField(incoming.getDescription()).equals(normalizeSceneField(existing.getDescription()))
                || !normalizeSceneField(incoming.getSceneType()).equals(normalizeSceneField(existing.getSceneType()));
    }

    private String normalizeSceneField(String value) {
        return value == null ? "" : value.trim();
    }

    @Override
    public void deleteCharacter(Long id, Long tenantId) {
        Character character = characterMapper.selectById(id);
        if (character != null && tenantId.equals(character.getTenantId())) {
            characterStateMapper.deleteByCharacter(id, tenantId);
            characterMapper.deleteById(id);
        }
    }

    @Override
    public void deleteCharacterState(Long projectId, Long characterId, Long id, Long tenantId) {
        CharacterState state = characterStateMapper.selectById(id);
        if (state != null
                && tenantId.equals(state.getTenantId())
                && projectId.equals(state.getProjectId())
                && characterId.equals(state.getCharacterId())) {
            characterStateMapper.deleteById(id);
        }
    }

    @Override
    public void deleteScene(Long id, Long tenantId) {
        Scene scene = sceneMapper.selectById(id);
        if (scene != null && tenantId.equals(scene.getTenantId())) {
            sceneMapper.deleteById(scene);
        }
    }

    @Override
    public Character uploadCharacterImage(Long characterId, Long tenantId, String viewType,
                                          InputStream stream, long size, String contentType, String ext) {
        Character character = requireCharacter(characterId, tenantId);
        String normalizedViewType = normalizeCharacterViewType(viewType);
        String safeExt = (ext == null || ext.isBlank()) ? "jpg" : ext;
        String key = "projects/" + character.getProjectId() + "/characters/" + characterId + "/" + normalizedViewType + "." + safeExt;
        tosService.upload(key, stream, size, contentType);
        applyCharacterView(character, normalizedViewType, key);
        character.setStatus("ready");
        characterMapper.updateById(character);
        return character;
    }

    @Override
    public Scene uploadSceneImage(Long sceneId, Long tenantId,
                                  InputStream stream, long size, String contentType, String ext) {
        Scene scene = sceneMapper.selectById(sceneId);
        if (scene == null || !tenantId.equals(scene.getTenantId())) {
            throw new RuntimeException("场景不存在或无权限");
        }
        String safeExt = (ext == null || ext.isBlank()) ? "jpg" : ext;
        String key = "projects/" + scene.getProjectId() + "/scenes/" + sceneId + "/image." + safeExt;
        tosService.upload(key, stream, size, contentType);
        scene.setImageTosKey(key);
        scene.setImageUrl(tosService.publicUrl(key));
        scene.setStatus("ready");
        sceneMapper.updateById(scene);
        return scene;
    }

    private Character requireCharacter(Long characterId, Long tenantId) {
        Character character = characterMapper.selectById(characterId);
        if (character == null || !tenantId.equals(character.getTenantId())) {
            throw new BizException(ErrorCode.NOT_FOUND, "角色不存在");
        }
        return character;
    }

    private void applyCharacterView(Character character, String viewType, String key) {
        String publicUrl = tosService.publicUrl(key);
        switch (normalizeCharacterViewType(viewType)) {
            case "front" -> {
                character.setImageUrl(publicUrl);
                character.setImageTosKey(key);
                character.setFrontImageUrl(publicUrl);
                character.setFrontImageTosKey(key);
            }
            case "side" -> {
                character.setSideImageUrl(publicUrl);
                character.setSideImageTosKey(key);
            }
            case "back" -> {
                character.setBackImageUrl(publicUrl);
                character.setBackImageTosKey(key);
            }
            default -> throw new BizException(ErrorCode.INVALID_PARAM, "不支持的角色视图: " + viewType);
        }
    }

    private String buildCharacterSheetPrompt(String basePrompt, ProjectVisualStyle visualStyle, ProjectRenderSpec renderSpec) {
        List<String> promptParts = new ArrayList<>();
        String normalizedPrompt = sanitizePromptForStyle(basePrompt, visualStyle);
        if (!normalizedPrompt.isBlank()) {
            promptParts.add(normalizedPrompt);
        }
        promptParts.add("selected style: " + visualStyle.label());
        promptParts.add("selected aspect ratio: " + renderSpec.aspectRatio() + ", compose specifically for a " + renderSpec.aspectRatio() + " canvas");
        promptParts.add(buildVisualModeLock(visualStyle));
        promptParts.add(visualStyle.renderGuidance());
        promptParts.addAll(buildStyleSpecificPromptParts(visualStyle));

        promptParts.add(buildCharacterSheetLayoutPrompt(renderSpec));
        promptParts.add(
                "the three full-body views (panels 2, 3, 4) show the character standing naturally and relaxed, " +
                "arms hanging straight down along the sides of the body, feet together flat on the ground with both shoes fully visible, neutral expression, " +
                "no extra gestures or poses, no T-pose, no arms raised or outstretched, " +
                "the entire body must fit inside each panel with comfortable headroom above the hair and clear margin below the feet, " +
                "do not crop or cut off the feet, shoes, ankles or top of the head"
        );
        promptParts.add(
                "background: pure solid white (#FFFFFF) studio backdrop across the entire canvas and across all four panels, " +
                "flat seamless white, no gradient, no off-white tint, no cream, no beige, no gray, no colored cast, " +
                "no environment, no scenery, no props, no floor pattern, no textures, no patterns, no shadows on the background, " +
                "soft even studio lighting with no cast shadows behind the character"
        );
        promptParts.add("the four views render the exact same character: identical face, hairstyle, skin tone, body proportions, costume, accessories and footwear");
        promptParts.add("if the prompt contains a shared resemblance anchor or similarity group, preserve that shared face/body anchor exactly; only apply the listed small differences");
        promptParts.add("do not make similar characters look unrelated; preserve same facial geometry, eye shape, nose bridge, skin tone, and body proportion when a shared anchor is present");
        promptParts.add("balanced composition, each figure vertically centered within its panel, full-body figures share a consistent ground line and identical scale");
        promptParts.add("no panel borders, no dividing lines, no text labels, no captions, no watermarks, no logos");
        promptParts.add("single character only, no extra people, no duplicated faces beyond these four views, no props blocking the body");
        promptParts.addAll(buildStyleNegativePromptParts(visualStyle));
        return String.join(", ", promptParts);
    }

    private String buildCharacterSheetLayoutPrompt(ProjectRenderSpec renderSpec) {
        return switch (renderSpec.aspectRatio()) {
            case "9:16" -> "character design sheet, single composite image in portrait orientation, aspect ratio 9:16, the canvas is divided into exactly four equal-height horizontal panels stacked vertically from top to bottom: panel 1 bust portrait looking straight at camera, panel 2 full-body front view, panel 3 full-body left side profile view, panel 4 full-body back view; every panel has identical width and height, all body views complete from head to toe";
            case "1:1" -> "character design sheet, single composite image in square orientation, aspect ratio 1:1, the canvas is divided into a clean 2x2 grid of exactly four equal panels: top-left bust portrait looking straight at camera, top-right full-body front view, bottom-left full-body left side profile view, bottom-right full-body back view; every panel has identical width and height, all body views complete from head to toe";
            default -> "character design sheet, single composite image arranged as one horizontal row of four panels, landscape orientation canvas, aspect ratio 16:9, the canvas is divided into exactly four equal-width vertical panels of identical height and identical width, placed side by side in a single row, all four panels share the exact same dimensions and aspect ratio, from left to right: panel 1 bust portrait looking straight at camera; panel 2 full-body front view; panel 3 full-body left side profile view; panel 4 full-body back view; all body views complete from head to toe";
        };
    }

    private String normalizeCharacterViewType(String viewType) {
        if (viewType == null || viewType.isBlank()) {
            return "front";
        }
        String normalized = viewType.toLowerCase(Locale.ROOT);
        return switch (normalized) {
            case "front", "side", "back" -> normalized;
            default -> throw new BizException(ErrorCode.INVALID_PARAM, "不支持的角色视图: " + viewType);
        };
    }

    private String buildProjectContext(Long projectId, Long tenantId) {
        Outline outline = outlineMapper.findLatestByProject(projectId, tenantId);
        List<Episode> episodes = episodeMapper.findByProject(projectId, tenantId);

        StringBuilder builder = new StringBuilder();
        appendContinuityContext(builder, projectId, tenantId);
        if (outline != null) {
            builder.append("剧名：").append(outline.getTitle()).append('\n');
            builder.append("类型：").append(outline.getGenre()).append('\n');
            builder.append("故事梗概：").append(outline.getSynopsis()).append("\n\n");
        }
        builder.append("分集摘要：\n");
        for (Episode episode : episodes) {
            builder.append("第")
                    .append(episode.getEpisodeNumber())
                    .append("集《")
                    .append(episode.getTitle())
                    .append("》：")
                    .append(episode.getSummary())
                    .append('\n');
        }
        return builder.toString();
    }

    private void ensureNarrativeContext(Long projectId, Long tenantId, String targetName) {
        ScriptUnderstanding understanding = scriptUnderstandingMapper.findLatestByProject(projectId, tenantId);
        if (understanding != null && hasText(understanding.getUnderstandingJson())) {
            if (!"PASSED".equals(understanding.getQualityStatus())) {
                throw new BizException(
                        ErrorCode.INVALID_PARAM,
                        "无法提取" + targetName + "：剧情理解未通过，请重新导入剧本或修正剧本内容后再试"
                );
            }
            return;
        }

        Outline outline = outlineMapper.findLatestByProject(projectId, tenantId);
        boolean hasOutline = outline != null && (hasText(outline.getSynopsis()) || hasText(outline.getTitle()));
        boolean hasEpisodes = episodeMapper.findByProject(projectId, tenantId).stream()
                .anyMatch(episode -> hasText(episode.getSummary()) || hasText(episode.getScript()));
        if (!hasOutline && !hasEpisodes) {
            throw new BizException(
                    ErrorCode.INVALID_PARAM,
                    "无法提取" + targetName + "：项目缺少可用剧情资料，请先一键导入剧本，或先生成大纲和分集后再提取"
            );
        }
    }

    private void appendContinuityContext(StringBuilder builder, Long projectId, Long tenantId) {
        ScriptUnderstanding understanding = scriptUnderstandingMapper.findLatestByProject(projectId, tenantId);
        if (understanding != null && hasText(understanding.getUnderstandingJson())) {
            builder.append("剧情理解事实（最高优先级，必须遵循，不得自由改写）：\n")
                    .append(limitContext(understanding.getUnderstandingJson()))
                    .append("\n\n");
        }
        ProjectConsistencyBible bible = projectConsistencyBibleMapper.findByProject(projectId, tenantId);
        if (bible != null && hasText(bible.getBibleJson())) {
            builder.append("一致性圣经（角色/场景/剧情锁定项）：\n")
                    .append(limitContext(bible.getBibleJson()))
                    .append("\n\n");
        }
    }

    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[上下文过长，已截断]";
    }

    private String buildCharacterExtractionContext(Long projectId, Long tenantId,
                                                   ProjectVisualStyle visualStyle,
                                                   ProjectRenderSpec renderSpec) {
        StringBuilder builder = new StringBuilder(buildProjectContext(projectId, tenantId));
        if (builder.length() > 0) {
            builder.append("\n\n");
        }
        builder.append("Character visual style requirements:\n");
        builder.append("- Selected project style: ").append(visualStyle.label()).append('\n');
        builder.append("- Selected project aspect ratio: ").append(renderSpec.aspectRatio()).append(" (all generated character images must use this ratio).\n");
        builder.append("- Style direction: ").append(visualStyle.extractionGuidance()).append('\n');
        builder.append("- The image_prompt must be suitable for generating a consistent front/side/back turnaround sheet for the same person in the selected visual style.\n");
        builder.append("- Visual mode lock: ").append(buildVisualModeLock(visualStyle)).append('\n');
        builder.append("- Similarity rule: if two characters are twins/siblings/same-person variants/doppelgangers or explicitly similar, give them the same similarity_group and the same shared_appearance_anchor. The shared anchor must describe face shape, eyes, nose, skin tone, body proportion and overall resemblance; difference_notes must list only small intentional differences.\n");
        builder.append("- Output target: ").append(buildExtractionTargetRule(visualStyle)).append('\n');
        builder.append("- Avoid: ").append(buildExtractionAvoidRule(visualStyle)).append('\n');
        builder.append("- Additional style notes: ").append(visualStyle.extractionGuidance()).append('\n');
        return builder.toString();
    }

    private String buildSceneExtractionStyleDirection(ProjectVisualStyle visualStyle) {
        return switch (visualStyle.slug()) {
            case "japanese_anime" -> "2D Japanese anime environments with clear shape design, cel-shaded color logic, and stylized readability";
            case "korean_webtoon" -> "2D Korean webtoon urban environments with modern fashion-editorial mood and soft lighting";
            case "chinese_anime" -> "2D Chinese animation environments with strong silhouette design and polished guoman atmosphere";
            case "chinese_fantasy_3d" -> "stylized 3D Chinese fantasy environments with layered materials, cinematic lighting, and xianxia worldbuilding";
            case "cg_cinematic" -> "high-end CG cinematic environments with premium materials, depth, and film-grade atmosphere";
            default -> "live-action short-drama environments with grounded production design and believable real-world texture";
        };
    }

    private Project loadProject(Long projectId, Long tenantId) {
        return projectMapper.findActiveById(projectId, tenantId).orElse(null);
    }

    private ProjectVisualStyle resolveProjectVisualStyle(Project project) {
        if (project == null || project.getStyle() == null || project.getStyle().isBlank()) {
            return new ProjectVisualStyle(
                    "default",
                    "真人短剧视觉",
                    "grounded live-action short drama characters with realistic styling",
                    "grounded live-action short drama aesthetic, cinematic realism, realistic wardrobe"
            );
        }

        String slug = project.getStyle().trim().toLowerCase(Locale.ROOT);
        return switch (slug) {
            case "live_action_drama" -> new ProjectVisualStyle(
                    slug,
                    "真人短剧视觉",
                    "live-action short drama styling with realistic actors, believable wardrobe, and production-ready character design",
                    "live-action short drama aesthetic, realistic actors, cinematic realism, believable wardrobe details"
            );
            case "japanese_anime" -> new ProjectVisualStyle(
                    slug,
                    "2D日漫",
                    "2D Japanese anime styling with clean line art, cel shading, expressive silhouettes, and strong character readability",
                    "2D Japanese anime aesthetic, clean line art, cel shading, expressive character design, polished turnaround sheet"
            );
            case "korean_webtoon" -> new ProjectVisualStyle(
                    slug,
                    "2D韩漫都市",
                    "Korean webtoon urban styling with fashionable silhouettes, soft shading, and modern romance or city-drama appeal",
                    "2D Korean webtoon aesthetic, modern urban fashion, clean line art, soft shading, stylish character sheet"
            );
            case "chinese_anime" -> new ProjectVisualStyle(
                    slug,
                    "2D国漫",
                    "2D Chinese animation styling with strong silhouette design, refined costume details, and contemporary guoman appeal",
                    "2D Chinese animation aesthetic, clean line art, refined costume design, guoman character sheet"
            );
            case "chinese_fantasy_3d" -> new ProjectVisualStyle(
                    slug,
                    "3D国风仙侠",
                    "stylized 3D Chinese fantasy styling with layered costume details, elegant accessories, and xianxia or guofeng presentation",
                    "3D Chinese fantasy aesthetic, stylized character render, ornate costume layers, xianxia guofeng hero design"
            );
            case "cg_cinematic" -> new ProjectVisualStyle(
                    slug,
                    "CG电影感",
                    "high-end CGI cinematic styling with hero-asset presentation, premium materials, and polished production design",
                    "cinematic CGI character design, premium materials, high-end render quality, hero asset presentation"
            );
            case "urban" -> new ProjectVisualStyle(
                    slug,
                    "Urban emotional drama",
                    "modern city setting, fashionable contemporary wardrobe, restrained makeup, grounded live-action drama references",
                    "modern urban live-action drama aesthetic, contemporary Chinese wardrobe, cinematic realism, grounded emotional tone"
            );
            case "fantasy" -> new ProjectVisualStyle(
                    slug,
                    "Fantasy xianxia drama",
                    "fantasy or xianxia worldbuilding, layered costume details, elegant accessories, but still designed as live-action costume drama characters",
                    "Chinese fantasy xianxia live-action costume drama aesthetic, refined accessories, layered fabrics, cinematic realism"
            );
            case "romance" -> new ProjectVisualStyle(
                    slug,
                    "Romance drama",
                    "attractive but believable real-world styling, polished wardrobe, intimate emotional tone, live-action drama reference",
                    "contemporary live-action romance drama aesthetic, polished styling, soft cinematic lighting, realistic people"
            );
            case "historical" -> new ProjectVisualStyle(
                    slug,
                    "Historical costume drama",
                    "historical Chinese costume drama styling, era-appropriate silhouette and accessories, realistic live-action wardrobe construction",
                    "Chinese historical live-action costume drama aesthetic, period wardrobe, elegant materials, cinematic realism"
            );
            case "suspense" -> new ProjectVisualStyle(
                    slug,
                    "Suspense thriller",
                    "grounded thriller styling, practical wardrobe, realistic tension, understated colors, live-action reference",
                    "grounded suspense thriller live-action aesthetic, restrained palette, realistic wardrobe, cinematic realism"
            );
            case "comedy" -> new ProjectVisualStyle(
                    slug,
                    "Comedy drama",
                    "bright and expressive but still realistic live-action styling, contemporary wardrobe, approachable character design",
                    "bright live-action comedy-drama aesthetic, expressive styling, realistic people, clean contemporary wardrobe"
            );
            default -> new ProjectVisualStyle(
                    slug,
                    "Custom style: " + slug,
                    "match the selected project style while keeping the result visually coherent and suitable for character turnaround generation",
                    "match the selected project style `" + slug + "` with a clear, consistent character-design presentation"
            );
        };
    }

    private List<String> buildStyleSpecificPromptParts(ProjectVisualStyle visualStyle) {
        return switch (visualStyle.slug()) {
            case "japanese_anime" -> List.of(
                    "2D Japanese anime character turnaround sheet",
                    "clean line art",
                    "cel shading",
                    "anime-style color blocking"
            );
            case "korean_webtoon" -> List.of(
                    "2D Korean webtoon character turnaround sheet",
                    "clean line art",
                    "soft webtoon shading",
                    "modern urban fashion styling"
            );
            case "chinese_anime" -> List.of(
                    "2D Chinese animation character turnaround sheet",
                    "clean line art",
                    "stylized guoman character design",
                    "refined costume detailing"
            );
            case "chinese_fantasy_3d" -> List.of(
                    "3D Chinese fantasy character turnaround sheet",
                    "stylized 3D render",
                    "ornate costume layers",
                    "xianxia-inspired accessories"
            );
            case "cg_cinematic" -> List.of(
                    "cinematic CGI character turnaround sheet",
                    "high-end CGI rendering",
                    "hero asset presentation",
                    "premium material definition"
            );
            default -> List.of(
                    "live-action TV drama character turnaround sheet",
                    "production-ready costume design reference",
                    "realistic human anatomy and natural skin texture"
            );
        };
    }

    private List<String> buildStyleNegativePromptParts(ProjectVisualStyle visualStyle) {
        return switch (visualStyle.slug()) {
            case "japanese_anime", "korean_webtoon", "chinese_anime" -> List.of(
                    "not live action",
                    "not photorealistic skin texture",
                    "not 3D render",
                    "not photo"
            );
            case "chinese_fantasy_3d", "cg_cinematic" -> List.of(
                    "not live action photo",
                    "not flat 2D illustration",
                    "not manga panel",
                    "not collage"
            );
            default -> List.of(
                    "not anime",
                    "not manga",
                    "not cartoon",
                    "not cel shaded",
                    "not illustration"
            );
        };
    }

    private String buildCharacterRenderStyleSummary(ProjectVisualStyle visualStyle) {
        return switch (visualStyle.slug()) {
            case "japanese_anime" -> "2D Japanese anime character design, clean line art, cel shading, expressive silhouette";
            case "korean_webtoon" -> "2D Korean webtoon character design, soft shading, modern urban styling, stylish silhouette";
            case "chinese_anime" -> "2D Chinese animation character design, refined line work, polished costume detail, guoman mood";
            case "chinese_fantasy_3d" -> "3D Chinese fantasy character render, layered costume materials, xianxia guofeng styling";
            case "cg_cinematic" -> "cinematic CGI character design, premium materials, hero-asset rendering, film-grade polish";
            default -> "live-action short drama character styling, grounded realism, believable wardrobe, cinematic presentation";
        };
    }

    private String buildSceneExtractionTargetRule(ProjectVisualStyle visualStyle) {
        return switch (visualStyle.slug()) {
            case "japanese_anime" -> "a 2D Japanese anime scene pack with consistent line, color, and atmosphere design";
            case "korean_webtoon" -> "a 2D Korean webtoon urban scene pack with stylish city mood and soft webtoon lighting";
            case "chinese_anime" -> "a 2D guoman scene pack with refined atmosphere and strong environmental silhouette design";
            case "chinese_fantasy_3d" -> "a 3D Chinese fantasy scene pack with ornate worldbuilding, layered materials, and xianxia atmosphere";
            case "cg_cinematic" -> "a CG cinematic scene pack with premium material rendering and film-grade environment storytelling";
            default -> "a grounded live-action short-drama scene pack with believable architecture, props, and cinematic atmosphere";
        };
    }

    private String buildSceneExtractionAvoidRule(ProjectVisualStyle visualStyle) {
        return switch (visualStyle.slug()) {
            case "japanese_anime", "korean_webtoon", "chinese_anime" ->
                    "live-action photography wording, realistic actor-photo language, or photorealistic skin and portrait cues";
            case "chinese_fantasy_3d", "cg_cinematic" ->
                    "flat 2D illustration wording, manga panel framing, or low-detail casual sketch language";
            default ->
                    "anime, manga, cartoon, cel-shaded, or stylized game-art wording unless the selected style explicitly requires it";
        };
    }

    private String buildSceneRenderStyleSummary(ProjectVisualStyle visualStyle) {
        return switch (visualStyle.slug()) {
            case "japanese_anime" -> "2D Japanese anime environment art, clean line art, cel shading, stylized atmospheric perspective";
            case "korean_webtoon" -> "2D Korean webtoon environment art, soft shading, modern urban mood, stylish city-drama palette";
            case "chinese_anime" -> "2D Chinese animation environment art, refined line work, guoman mood, elegant environmental composition";
            case "chinese_fantasy_3d" -> "3D Chinese fantasy environment art, xianxia guofeng mood, layered architecture and materials";
            case "cg_cinematic" -> "cinematic CG environment art, premium materials, dramatic lighting, film-grade atmosphere";
            default -> "live-action short drama environment design, grounded realism, believable set dressing, cinematic lighting";
        };
    }

    private List<String> buildSceneStylePromptParts(ProjectVisualStyle visualStyle) {
        return switch (visualStyle.slug()) {
            case "japanese_anime" -> List.of(
                    "2D Japanese anime background art",
                    "clean line art",
                    "cel shading",
                    "stylized color blocking"
            );
            case "korean_webtoon" -> List.of(
                    "2D Korean webtoon environment art",
                    "soft webtoon shading",
                    "modern urban fashion-drama mood",
                    "clean stylish composition"
            );
            case "chinese_anime" -> List.of(
                    "2D Chinese animation environment art",
                    "refined line work",
                    "stylized guoman atmosphere",
                    "polished environmental composition"
            );
            case "chinese_fantasy_3d" -> List.of(
                    "3D Chinese fantasy environment render",
                    "xianxia guofeng worldbuilding",
                    "ornate architecture and layered materials",
                    "stylized cinematic lighting"
            );
            case "cg_cinematic" -> List.of(
                    "cinematic CGI environment render",
                    "premium material definition",
                    "film-grade lighting",
                    "high-end production design"
            );
            default -> List.of(
                    "live-action short drama production design",
                    "grounded realistic environment",
                    "believable set dressing",
                    "cinematic real-world lighting"
            );
        };
    }

    private List<String> buildSceneStyleNegativePromptParts(ProjectVisualStyle visualStyle) {
        return switch (visualStyle.slug()) {
            case "japanese_anime", "korean_webtoon", "chinese_anime" -> List.of(
                    "not live action photo",
                    "not photorealistic actor scene",
                    "not 3D render"
            );
            case "chinese_fantasy_3d", "cg_cinematic" -> List.of(
                    "not flat 2D illustration",
                    "not manga panel",
                    "not casual sketch"
            );
            default -> List.of(
                    "not anime",
                    "not manga",
                    "not cartoon",
                    "not cel shaded"
            );
        };
    }

    private String buildExtractionTargetRule(ProjectVisualStyle visualStyle) {
        return switch (visualStyle.slug()) {
            case "japanese_anime" -> "a 2D Japanese anime turnaround sheet with front, side, and back consistency";
            case "korean_webtoon" -> "a 2D Korean webtoon turnaround sheet with modern urban styling and clear silhouette consistency";
            case "chinese_anime" -> "a 2D guoman turnaround sheet with refined costume details and consistent proportions";
            case "chinese_fantasy_3d" -> "a 3D Chinese fantasy turnaround sheet with layered costume construction and clean silhouette readability";
            case "cg_cinematic" -> "a cinematic CGI turnaround sheet with premium material definition and hero-asset clarity";
            default -> "a live-action short drama turnaround sheet with realistic wardrobe, hair, and body proportions";
        };
    }

    private String buildExtractionAvoidRule(ProjectVisualStyle visualStyle) {
        return switch (visualStyle.slug()) {
            case "japanese_anime", "korean_webtoon", "chinese_anime" ->
                    "live-action photography, photorealistic actor language, or realistic skin-texture wording";
            case "chinese_fantasy_3d", "cg_cinematic" ->
                    "flat 2D illustration wording, manga panel composition, or live-action actor-photo language";
            default ->
                    "anime, manga, cartoon, cel-shaded, or illustrated wording unless the selected style explicitly requires it";
        };
    }

    private String enrichVisualHintWithSimilarity(Map<String, Object> item) {
        List<String> parts = new ArrayList<>();
        appendIfPresent(parts, asString(item.get("visual_hint")));
        String group = asString(item.get("similarity_group"));
        String anchor = asString(item.get("shared_appearance_anchor"));
        String differences = asString(item.get("difference_notes"));
        if (hasText(group)) {
            parts.add("similarity_group=" + group);
        }
        if (hasText(anchor)) {
            parts.add("shared_anchor=" + anchor);
        }
        if (hasText(differences)) {
            parts.add("differences=" + differences);
        }
        return String.join("；", parts);
    }

    private String buildExtractedCharacterImagePrompt(Map<String, Object> item, ProjectVisualStyle visualStyle) {
        List<String> parts = new ArrayList<>();
        appendIfPresent(parts, asString(item.get("image_prompt")));
        appendIfPresent(parts, asString(item.get("name")));
        appendIfPresent(parts, asString(item.get("gender")));
        appendIfPresent(parts, asString(item.get("age")));
        appendIfPresent(parts, asString(item.get("costume")));
        appendIfPresent(parts, asString(item.get("visual_hint")));
        String group = asString(item.get("similarity_group"));
        String anchor = asString(item.get("shared_appearance_anchor"));
        String differences = asString(item.get("difference_notes"));
        if (hasText(group)) {
            parts.add("similarity group: " + group);
        }
        if (hasText(anchor)) {
            parts.add("shared appearance anchor: " + anchor);
        }
        if (hasText(differences)) {
            parts.add("intentional small differences only: " + differences);
        }
        parts.add("selected style: " + visualStyle.label());
        parts.add(buildVisualModeLock(visualStyle));
        parts.add(buildCharacterRenderStyleSummary(visualStyle));
        parts.add("consistent turnaround-ready character identity, no mixed visual style");
        return sanitizePromptForStyle(String.join(", ", parts), visualStyle);
    }

    private String asString(Object value) {
        return value == null ? "" : String.valueOf(value).trim();
    }

    private ProjectRenderSpec resolveProjectRenderSpec(Project project) {
        String ratio = project == null ? null : project.getAspectRatio();
        String normalizedRatio = normalizeAspectRatio(ratio);
        String size = switch (normalizedRatio) {
            case "9:16" -> "1440x2560";
            case "1:1" -> "2048x2048";
            default -> "2560x1440";
        };
        return new ProjectRenderSpec(normalizedRatio, size);
    }

    private ProjectRenderSpec resolveCharacterSheetRenderSpec() {
        return new ProjectRenderSpec("16:9", "2560x1440");
    }

    private String normalizeAspectRatio(String aspectRatio) {
        if (!hasText(aspectRatio)) {
            return "16:9";
        }
        String normalized = aspectRatio.trim()
                .replace('：', ':')
                .replace("横屏", "")
                .replace("竖屏", "")
                .replace("方形", "")
                .trim();
        return switch (normalized) {
            case "9:16", "916", "portrait" -> "9:16";
            case "1:1", "11", "square" -> "1:1";
            default -> "16:9";
        };
    }

    private String buildVisualModeLock(ProjectVisualStyle visualStyle) {
        return switch (visualStyle.slug()) {
            case "japanese_anime", "korean_webtoon", "chinese_anime" ->
                    "strictly 2D illustrated/anime/webtoon mode only; never use live-action photography, realistic actor photo, photoreal skin, or mixed real-person + anime style";
            case "chinese_fantasy_3d", "cg_cinematic" ->
                    "strictly stylized 3D/CG mode only; never mix flat 2D anime, manga panels, live-action actor photos, or real-person photography";
            default ->
                    "strictly live-action/realistic short-drama mode only; never use anime, manga, cartoon, cel-shaded, webtoon, or mixed real-person + anime style";
        };
    }

    private String sanitizePromptForStyle(String prompt, ProjectVisualStyle visualStyle) {
        if (prompt == null) {
            prompt = "";
        }
        String sanitized = prompt.trim();
        if (sanitized.isBlank()) {
            return "";
        }
        if (visualStyle == null) {
            return sanitized;
        }
        boolean twoDimensional = switch (visualStyle.slug()) {
            case "japanese_anime", "korean_webtoon", "chinese_anime" -> true;
            default -> false;
        };
        boolean cg = switch (visualStyle.slug()) {
            case "chinese_fantasy_3d", "cg_cinematic" -> true;
            default -> false;
        };
        if (twoDimensional) {
            sanitized = sanitized
                    .replaceAll("(?i)\\bphotorealistic\\b", "stylized")
                    .replaceAll("(?i)\\blive[- ]action\\b", "2D illustrated")
                    .replaceAll("(?i)\\brealistic actor\\b", "2D character")
                    .replaceAll("(?i)\\breal person\\b", "2D character")
                    .replaceAll("(?i)\\bphoto\\b", "illustration");
        } else if (cg) {
            sanitized = sanitized
                    .replaceAll("(?i)\\blive[- ]action\\b", "stylized CG")
                    .replaceAll("(?i)\\brealistic actor photo\\b", "stylized CG character")
                    .replaceAll("(?i)\\bmanga\\b", "CG")
                    .replaceAll("(?i)\\bwebtoon\\b", "CG");
        } else {
            sanitized = sanitized
                    .replaceAll("(?i)\\banime\\b", "live-action")
                    .replaceAll("(?i)\\bmanga\\b", "live-action")
                    .replaceAll("(?i)\\bwebtoon\\b", "live-action")
                    .replaceAll("(?i)\\bcel[- ]shaded\\b", "cinematic")
                    .replaceAll("(?i)\\bcartoon\\b", "live-action");
        }
        return sanitized;
    }

    private byte[] downloadBytes(String url) throws Exception {
        if (url != null && url.startsWith("data:")) {
            int comma = url.indexOf(',');
            if (comma <= 0) {
                throw new BizException(ErrorCode.INTERNAL_ERROR, "图片 data URL 格式无效");
            }
            return Base64.getDecoder().decode(url.substring(comma + 1));
        }

        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(url))
                .timeout(Duration.ofMinutes(2))
                .GET()
                .build();
        HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
        if (response.statusCode() != 200) {
            throw new BizException(ErrorCode.INTERNAL_ERROR, "下载图片失败 HTTP " + response.statusCode());
        }
        return response.body();
    }

    private List<Map<String, Object>> requestJsonArray(String systemPrompt, String userPrompt, String taskName) throws Exception {
        Exception lastError = null;
        for (int attempt = 1; attempt <= 2; attempt++) {
            String prompt = attempt == 1 ? userPrompt : userPrompt + JSON_RETRY_SUFFIX;
            String raw = llmService.chat(ChatRequest.builder()
                    .messages(List.of(ChatMessage.system(systemPrompt), ChatMessage.user(prompt)))
                    .build());
            try {
                return objectMapper.readValue(extractJsonArray(raw, taskName), new TypeReference<>() {});
            } catch (Exception e) {
                lastError = e;
                log.warn("{} JSON parse failed, attempt={}: {}", taskName, attempt, e.getMessage());
            }
        }
        if (lastError instanceof BizException bizException) {
            throw bizException;
        }
        throw lastError != null ? lastError : new BizException(ErrorCode.INTERNAL_ERROR, taskName + "失败：模型未返回可解析 JSON");
    }

    private String limitImagePrompt(String prompt) {
        if (prompt == null || prompt.length() <= MAX_IMAGE_PROMPT_CHARS) {
            return prompt;
        }
        int headLength = Math.max(0, MAX_IMAGE_PROMPT_CHARS - 520);
        String head = prompt.substring(0, Math.min(headLength, prompt.length())).strip();
        String tail = prompt.substring(Math.max(head.length(), prompt.length() - 460)).strip();
        return head
                + ", keep all earlier hard constraints, prompt compacted for provider limits, "
                + tail;
    }

    private String extractJson(String raw) {
        String text = raw == null ? "" : 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 String extractJsonArray(String raw, String taskName) {
        String text = extractJson(raw);
        if (!text.startsWith("[") || !text.endsWith("]")) {
            throw new BizException(
                    ErrorCode.INTERNAL_ERROR,
                    taskName + "失败：模型返回格式不合规，必须只返回 JSON 数组，不能返回说明文字"
            );
        }
        return text;
    }

    private record ProjectVisualStyle(
            String slug,
            String label,
            String extractionGuidance,
            String renderGuidance
    ) {
    }

    private record ProjectRenderSpec(
            String aspectRatio,
            String size
    ) {
    }
}
