#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
AI 漫剧 · 剧本导入器
=====================
把小说/剧本文本：
  1) 按「第X章/回/节」拆分章节
  2) 每章按目标字数拆成若干「集」
  3) 用 LLM 提取人物/场景/道具等资产（无 key 时退化为规则提取）
  4) 生成可导入画布的 JSON（{cards, view}）

用法:
  python3 aicomic-import.py 小说.txt                 # 默认输出 aicomic-import.json
  python3 aicomic-import.py 小说.txt -o out.json     # 指定输出
  python3 aicomic-import.py 小说.txt --target 1500   # 每集目标字数
  python3 aicomic-import.py 小说.txt --no-llm        # 只用规则，不调 AI
"""
import json
import os
import re
import sys

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
CONFIG_PATH = os.path.join(BASE_DIR, 'manju', 'api_config.json')

# ---------------- 1) 拆章 ----------------
HEAD_RE = re.compile(
    r'^\s*(第\s*[0-9一二三四五六七八九十百千零两]+\s*[章回节卷]'
    r'|楔子|序章|序言|引子|尾声|番外|终章|后记|大结局'
    r'|(?:CHAPTER|Chapter)\s+[0-9IVXLC]+)'
    r'[\s:：、.．]*([^\n]{0,30})?'
)


def split_chapters(text):
    """按章节标题拆章。无标题时整篇算一章。返回 [{title, content}]"""
    lines = text.split('\n')
    chapters = []
    cur_title = None
    cur = []
    for ln in lines:
        ln = re.sub(r'^#+\s*', '', ln)   # 兼容 markdown 标题前缀
        m = HEAD_RE.match(ln.strip())
        if m:
            if cur_title is not None:
                chapters.append({'title': cur_title, 'content': '\n'.join(cur).strip()})
            cur_title = (m.group(1).strip() + (' ' + m.group(2).strip() if m.group(2) else '')).strip()
            cur = []
        else:
            cur.append(ln)
    if cur_title is not None:
        chapters.append({'title': cur_title, 'content': '\n'.join(cur).strip()})
    if not chapters:
        chapters = [{'title': '正文', 'content': text.strip()}]
    chapters = [c for c in chapters if c['content'].strip()]
    return chapters


# ---------------- 2) 拆集 ----------------
def split_episodes(text, target_chars=1500):
    """按段落边界把一章拆成若干集，每集目标 target_chars 字"""
    paras = [p.strip() for p in re.split(r'\n\s*\n', text) if p.strip()]
    if not paras:
        paras = [p.strip() for p in text.splitlines() if p.strip()]
    episodes, cur, curlen = [], [], 0
    for p in paras:
        if cur and curlen >= target_chars:
            episodes.append('\n'.join(cur))
            cur, curlen = [], 0
        cur.append(p)
        curlen += len(p)
    if cur:
        episodes.append('\n'.join(cur))
    return episodes or ([text.strip()] if text.strip() else [])


# ---------------- 3) LLM 提取资产 ----------------
def _load_llm_config():
    cfg = {}
    if os.path.exists(CONFIG_PATH):
        try:
            cfg = json.load(open(CONFIG_PATH, encoding='utf-8'))
        except Exception:
            cfg = {}
    providers = []
    if cfg.get('api_key'):
        providers.append({
            'name': 'agnes', 'base': cfg.get('base_url', 'https://apihub.agnes-ai.com/v1'),
            'key': cfg['api_key'], 'model': cfg.get('llm_model', 'agnes-2.5-flash')})
    if os.environ.get('DEEPSEEK_API_KEY'):
        providers.append({
            'name': 'deepseek', 'base': 'https://api.deepseek.com/v1',
            'key': os.environ['DEEPSEEK_API_KEY'], 'model': 'deepseek-chat'})
    cred = os.path.expanduser('~/.dsh/.credentials.yaml')
    if os.path.exists(cred):
        try:
            for ln in open(cred, encoding='utf-8'):
                if ln.startswith('DEEPSEEK_API_KEY'):
                    k = ln.split(':', 1)[1].strip().strip(chr(34)).strip(chr(39))
                    if k:
                        providers.append({
                            'name': 'deepseek', 'base': 'https://api.deepseek.com/v1',
                            'key': k, 'model': 'deepseek-chat'})
        except Exception:
            pass
    return providers


def _llm_chat(providers, system, user, max_tokens=2500):
    import urllib.request
    errs = []
    for p in providers:
        body = json.dumps({
            'model': p['model'],
            'messages': [{'role': 'system', 'content': system},
                         {'role': 'user', 'content': user}],
            'max_tokens': max_tokens, 'temperature': 0.3}).encode('utf-8')
        req = urllib.request.Request(p['base'].rstrip('/') + '/chat/completions',
                                     data=body, headers={
                                         'Content-Type': 'application/json',
                                         'Authorization': 'Bearer ' + p['key']})
        try:
            with urllib.request.urlopen(req, timeout=120) as r:
                d = json.loads(r.read().decode('utf-8'))
                return d['choices'][0]['message']['content']
        except Exception as e:
            errs.append('%s: %s' % (p['name'], str(e)[:150]))
    raise RuntimeError('LLM 全部不可用: ' + ' | '.join(errs))


def _extract_json(text):
    """从 LLM 回复里宽容地抠出 JSON 对象"""
    text = text.strip()
    if text.startswith(chr(96) * 3):
        text = re.sub('^' + chr(96) * 3 + '[a-zA-Z]*\n', '', text)
        text = re.sub('\n' + chr(96) * 3 + '$', '', text)
    try:
        return json.loads(text)
    except Exception:
        pass
    s, e = text.find('{'), text.rfind('}')
    if s >= 0 and e > s:
        try:
            return json.loads(text[s:e + 1])
        except Exception:
            pass
    return None


def extract_assets_llm(chapters, providers):
    """LLM 提取资产，返回 {'title','characters':[...],'scenes':[...],'props':[...]}
    通读每章开头较多内容（合计最多约10万字），保证不遗漏。"""
    parts = []
    for c in chapters:
        take = min(len(c['content']), 40000)
        parts.append(c['title'] + '\n' + c['content'][:take])
    context = '\n\n'.join(parts)[:100000]
    system = ('你是资深网文编辑与漫剧制作人。请通读下面小说节选，'
              '完整提取制作漫剧所需的结构化信息。只输出一个 JSON 对象，不要任何其他文字。')
    user = ('小说节选（较长，请通读全部内容，不要只看开头）：\n' + context + '\n\n'
            '请输出 JSON，字段如下：\n'
            '{\n'
            '  "title": "小说/剧本名",\n'
            '  "characters": [{"name": "角色名", "role": "主角/重要配角/反派/路人等", "description": "外貌与性格，20字内", "outfit": "标志性服装，如：灰色卫衣+牛仔裤", "feature": "标志特征，如：左眼角泪痣"}],\n'
            '  "scenes": ["场景名，如：林府大门·清晨"],\n'
            '  "props": ["关键道具名，如：木剑"]\n'
            '}\n'
            '要求：\n'
            '1. 完整提取：所有出场过且有名字/有戏份的角色都要列入，配角、反派、关键路人也不要遗漏；\n'
            '2. 所有出现过的主要地点都要列入场景；\n'
            '3. 反复出现或推动剧情的关键道具都要列入；\n'
            '4. 上限：characters 最多25个，scenes 最多25个，props 最多20个，宁多勿漏；\n'
            '5. 角色名必须从文中出现的人名提取，不要编造。')
    raw = _llm_chat(providers, system, user)
    d = _extract_json(raw) or {}
    d.setdefault('characters', [])
    d.setdefault('scenes', [])
    d.setdefault('props', [])
    d.setdefault('title', '未命名剧本')
    if isinstance(d.get('characters'), list):
        fixed = []
        for c in d['characters']:
            if isinstance(c, dict) and c.get('name'):
                fixed.append({'name': str(c['name']).strip(),
                              'role': str(c.get('role', '')).strip(),
                              'description': str(c.get('description', '')).strip(),
                              'outfit': str(c.get('outfit', '')).strip(),
                              'feature': str(c.get('feature', '')).strip()})
            elif isinstance(c, str) and c.strip():
                fixed.append({'name': c.strip(), 'role': '', 'description': '', 'outfit': '', 'feature': ''})
        d['characters'] = fixed
    return d


STYLE_PACKS = {
    'cel': {'name': '日系赛璐璐（默认）',
            'en': 'japanese anime cel-shaded key visual, clean bold lineart, vibrant flat colors, '
                  'cinematic rim lighting, soft shadows, expressive anime eyes, professional anime illustration'},
    'guoman': {'name': '国漫厚涂',
               'en': 'chinese manhua thick-paint style, rich painterly brushwork, dramatic chiaroscuro, '
                     'vivid saturated colors, expressive character art, professional manhua illustration'},
    'ink': {'name': '水墨仙侠',
            'en': 'chinese ink-wash painting style, ethereal mist, flowing sumi-e brushstrokes, '
                  'xianxia fantasy atmosphere, poetic negative space, high detail'},
    'realistic': {'name': '写实电影感',
                  'en': 'cinematic photorealistic style, dramatic volumetric lighting, film grain, '
                        'shallow depth of field, 8k, hyper-detailed, professional film still'},
    '3d': {'name': '3D渲染',
           'en': 'high quality 3d render, soft global illumination, subsurface scattering, '
                 'detailed character model, octane render, cinematic'},
}
QUALITY_TOKENS = {
    'std': 'high quality, detailed, sharp focus',
    'high': 'masterpiece, best quality, ultra-detailed, intricate details, 8k, '
            'sharp focus, professional illustration, award winning',
}


def optimize_image_prompt(scene='', detail='', chars=None, move='', style='',
                          style_pack='cel', quality='high'):
    """把中文分镜改写成英文画面描述（LLM），并由系统统一追加精修画风包+质量词，
    保证多镜头画风稳定、人物细节出色。"""
    chars = chars or []
    scene = (scene or '').strip()[:300]
    pack = STYLE_PACKS.get(style_pack) or STYLE_PACKS['cel']
    style_en = pack['en']
    quality_en = QUALITY_TOKENS.get(quality, QUALITY_TOKENS['high'])

    # 中文角色信息（喂给 LLM 翻译进画面描述）
    char_info = []
    for c in chars:
        nm = c.get('name', '')
        parts = []
        if c.get('appearance'): parts.append('外貌：' + c['appearance'])
        if c.get('outfit'): parts.append('服装：' + c['outfit'])
        if c.get('feature'): parts.append('标志特征：' + c['feature'])
        if nm and parts:
            char_info.append('角色「%s」：%s' % (nm, '，'.join(parts)))
        elif nm:
            char_info.append('角色「%s」' % nm)

    visual = ''
    providers = _load_llm_config()
    if providers:
        user = ('请把下面的漫剧分镜信息改写成一段用于 AI 生图的英文画面描述（2~4句，只写画面本身：'
                '人物外貌与动作、景别（近景/中景/全景）、光线、氛围、构图）。'
                '出场角色的外貌/服装/标志特征必须准确写进描述中，同一角色每次描述保持一致。'
                '不要加画风词、不要加质量词、不要加"vertical composition"等词，这些由系统统一追加。'
                '直接输出英文，不要任何解释。\n\n'
                '场景：%s\n%s%s'
                % (scene or '（未描述）',
                   ('\n' + '\n'.join(char_info)) if char_info else '',
                   ('\n动作/光线细节：' + detail) if detail else ''))
        try:
            raw = _llm_chat(providers, '你是资深动漫分镜画师。', user, max_tokens=500)
            raw = (raw or '').strip().strip('"').strip("'")
            if raw and len(raw) > 8:
                visual = raw
        except Exception:
            visual = ''

    if not visual:
        # LLM 不可用兜底：中文场景 + 英文画风/质量词
        visual = scene or ('角色「' + (chars[0].get('name', '') if chars else '') + '」' if chars else '')

    # 系统统一追加：画风包 + 质量词 + 构图（保证画风稳定）
    parts = [visual]
    parts.append('style: ' + style_en)
    parts.append(quality_en)
    parts.append('vertical 9:16 composition, no text, no watermark')
    return ', '.join([x for x in parts if x and x.strip()])


def storyboard_episode(ep, ch_title, idx, assets, providers, target_shots=4):
    """把一集剧情用 LLM 拆成 3~5 个分镜镜头，返回 [{scene,prompt,lines,move,chars}]"""
    chars = assets.get('characters', [])
    char_names = [c.get('name', '') for c in chars if c.get('name')]
    system = '你是漫剧资深分镜师。把给定的小说/剧本片段拆成一组连贯的分镜镜头。'
    user = ('章节：%s，第%d集\n剧情片段：\n%s\n\n'
            '出场角色池：%s\n\n'
            '请只输出 JSON：{"shots":[{"scene":"画面描述（视觉化，含人物动作/景别/光线/情绪，可直接用于AI生图）",'
            '"prompt":"画面补充（构图/氛围/细节，可为空字符串）",'
            '"lines":"本镜头台词或旁白，格式：旁白：xxx 或 角色名：xxx；无台词写：无",'
            '"move":"推镜/拉镜/摇镜/静止",'
            '"chars":["出场角色名"]}]}\n'
            '要求：拆成 3~5 个镜头；镜头顺序与剧情连贯；chars 只从角色池里选；'
            'lines 口语化带情绪；move 四选一；只输出 JSON。'
            % (ch_title, idx, ep[:6000], ', '.join(char_names) or '（无）'))
    raw = _llm_chat(providers, system, user, max_tokens=3500)
    d = _extract_json(raw) or {}
    shots = []
    for sh in (d.get('shots') or []):
        if not isinstance(sh, dict):
            continue
        scene = str(sh.get('scene', '')).strip()
        if not scene:
            continue
        shots.append({
            'scene': scene,
            'prompt': str(sh.get('prompt', '')).strip(),
            'lines': str(sh.get('lines', '')).strip(),
            'move': str(sh.get('move', '')).strip() or '静止',
            'chars': [str(x) for x in (sh.get('chars') or []) if str(x).strip()],
        })
    return shots


def storyboard_all(chapters, episodes_map, assets, providers=None):
    """给所有集生成分镜，返回 {(章序号, 集序号): [shots]}"""
    if providers is None:
        providers = _load_llm_config()
    board = {}
    if not providers:
        return board
    for i, ch in enumerate(chapters):
        for j in range(len(episodes_map.get(i, []))):
            ep = episodes_map[i][j]
            try:
                board[(i, j)] = storyboard_episode(ep, ch['title'], j + 1, assets, providers)
            except Exception:
                board[(i, j)] = []
    return board


def extract_assets_heuristic(chapters):
    """规则兜底：对话归属+词频提人物；地点后缀提场景；量词提道具"""
    full = '\n'.join(c['content'] for c in chapters)
    chars = {}
    for m in re.finditer(r'([\u4e00-\u9fa5]{1,4})(?:说|道|问|喊|答|心想|低语|叹|喝|惊|怒道|笑道|沉声道|淡淡道)', full):
        n = m.group(1)
        if len(n) >= 2 and n not in ('他说', '她说', '众人', '大家', '其中'):
            chars[n] = chars.get(n, 0) + 1
    names = [n for n, c in chars.items() if c >= 3][:12]
    scenes = []
    for m in re.finditer(r'(?:来到|走进|回到|离开|站在|位于|在|赶往|逃到)([^，。！？；]{0,10}?)(?:的)?(?:山|城|府|殿|宫|院|镇|村|洞|林|谷|塔|楼|阁|河|湖|门|庙|厅|房|屋|堡|营|寨|堂|场|地)', full):
        s = m.group(1).strip()
        if s and s not in scenes:
            scenes.append(s)
    props = []
    for m in re.finditer(r'(?:一把|一柄|一枚|一颗|一包|一件|一瓶|一卷|一块|一顶|一身|一根|一册|一页|一个|一只|一支|一张|一条|一盏|一份)([^，。！？\s]{1,6})', full):
        p = m.group(1).strip()
        if p and p not in props:
            props.append(p)
    return {
        'title': os.path.basename(sys.argv[1]).rsplit('.', 1)[0] if len(sys.argv) > 1 else '未命名剧本',
        'characters': [{'name': n, 'role': '', 'description': ''} for n in names],
        'scenes': scenes[:15],
        'props': props[:10],
    }


# ---------------- 4) 生成画布 JSON ----------------
def build_canvas_json(title, chapters, episodes_map, assets, board=None):
    board = board or {}
    cards = []
    uid = [1000]

    def cid():
        uid[0] += 1
        return 'c' + str(uid[0])

    def note(x, y, text):
        return {'id': cid(), 'type': 'note', 'x': x, 'y': y, 'status': 0, 'data': {'text': text}}

    def scene(x, y, ep, shot):
        return {'id': cid(), 'type': 'scene', 'x': x, 'y': y, 'status': 0,
                'data': {'ep': ep,
                         'scene': shot.get('scene', ''),
                         'charIds': [name_id[n] for n in shot.get('chars', []) if n in name_id],
                         'prompt': shot.get('prompt', ''),
                         'lines': shot.get('lines', ''),
                         'move': shot.get('move', '静止')}}

    def char(x, y, c):
        return {'id': cid(), 'type': 'char', 'x': x, 'y': y, 'status': 0,
                'data': {'name': c.get('name', '角色'),
                         'appearance': c.get('description', ''),
                         'outfit': c.get('outfit', ''),
                         'feature': c.get('feature', ''),
                         'refImg': '',
                         'refNote': '由剧本导入提取，可再补一张参考图更稳'}}

    def tool(x, y, t, b):
        return {'id': cid(), 'type': 'tool', 'x': x, 'y': y, 'status': 0,
                'data': {'title': t, 'body': b}}

    total_eps = sum(len(v) for v in episodes_map.values())
    total_shots = 0
    for ci in range(len(chapters)):
        for ej in range(len(episodes_map.get(ci, []))):
            total_shots += max(len(board.get((ci, ej), [])), 1)
    cards.append(note(40, 40, '📖 剧本导入总览\n\n《%s》\n章节：%d 章\n集数：%d 集\n分镜镜头：约 %d 个\n人物：%d 位\n场景：%d 个\n道具：%d 件\n\n'
                     '■ 左侧：角色资产库（服装/特征已自动提取）\n■ 中间：每章一列，每集一排分镜镜头卡（已填画面/台词/运镜）\n■ 右上：场景/道具清单\n'
                     '■ 直接点镜头卡「🎨 生图」出图，复制「台词」去配音' % (title, len(chapters), total_eps, total_shots,
                     len(assets.get('characters', [])), len(assets.get('scenes', [])), len(assets.get('props', [])))))
    for i, c in enumerate(assets.get('characters', [])):
        cards.append(char(40, 320 + i * 360, c))
    # 角色名 -> id 映射
    name_id = {}
    for c in cards:
        if c['type'] == 'char':
            name_id[c['data']['name']] = c['id']
    # 章节 + 分镜
    x = 560
    for ci, ch in enumerate(chapters):
        eps = episodes_map.get(ci, [])
        cards.append(note(x, 40, '📚 %s\n\n共拆为 %d 集：\n%s' % (
            ch['title'], len(eps),
            '\n'.join('· 第%d集（%d个镜头）' % (j + 1, len(board.get((ci, j), [])) or 1) for j in range(len(eps))))))
        for j in range(len(eps)):
            shots = board.get((ci, j)) or [{'scene': eps[j].strip()[:200], 'prompt': '',
                                            'lines': '旁白：' + eps[j].strip()[:80], 'move': '静止', 'chars': []}]
            for k, shot in enumerate(shots):
                ep_title = '%s · 第%d集 · 镜头%d' % (ch['title'], j + 1, k + 1)
                cards.append(scene(x + 300 + j * 360, 40 + k * 360, ep_title, shot))
        x += 300 + max(len(eps), 1) * 360 + 60
    body = '【场景】\n' + '\n'.join('· ' + s for s in assets.get('scenes', [])) + '\n\n【道具】\n' + '\n'.join('· ' + p for p in assets.get('props', []))
    cards.append(tool(x, 40, '🧰 场景 / 道具资产', body))
    return {'cards': cards, 'view': {'tx': 0, 'ty': 0, 'z': 1}}


# ---------------- 主入口 ----------------
def run_import(text, target_chars=1500, use_llm=True):
    chapters = split_chapters(text)
    episodes_map = {}
    for i, ch in enumerate(chapters):
        episodes_map[i] = split_episodes(ch['content'], target_chars)
    assets = None
    if use_llm:
        providers = _load_llm_config()
        if providers:
            try:
                assets = extract_assets_llm(chapters, providers)
            except Exception:
                assets = None
    if assets is None:
        assets = extract_assets_heuristic(chapters)
    board = {}
    providers = _load_llm_config()
    if use_llm and providers:
        board = storyboard_all(chapters, episodes_map, assets, providers)
    canvas = build_canvas_json(assets.get('title', '未命名剧本'), chapters, episodes_map, assets, board)
    shots_summary = {}
    for i, ch in enumerate(chapters):
        for j in range(len(episodes_map.get(i, []))):
            shots_summary['%s·第%d集' % (ch['title'], j + 1)] = len(board.get((i, j), []))
    return {
        'ok': True,
        'title': assets.get('title', '未命名剧本'),
        'chapters': [{'title': c['title'], 'episodes': len(episodes_map[i])} for i, c in enumerate(chapters)],
        'assets': assets,
        'shots': shots_summary,
        'cards': canvas['cards'],
        'view': canvas['view'],
    }


def main():
    args = sys.argv[1:]
    if not args:
        print(__doc__)
        sys.exit(1)
    path = args[0]
    out = 'aicomic-import.json'
    target = 1500
    use_llm = True
    if '-o' in args:
        out = args[args.index('-o') + 1]
    if '--target' in args:
        target = int(args[args.index('--target') + 1])
    if '--no-llm' in args:
        use_llm = False
    if not os.path.exists(path):
        print('文件不存在: %s' % path)
        sys.exit(1)
    text = open(path, encoding='utf-8').read()
    res = run_import(text, target_chars=target, use_llm=use_llm)
    with open(out, 'w', encoding='utf-8') as f:
        json.dump({'cards': res['cards'], 'view': res['view']}, f, ensure_ascii=False, indent=2)
    print('✅ 导入完成')
    print('  标题:', res['title'])
    print('  章节:', len(res['chapters']), '章 / 集数:', sum(c['episodes'] for c in res['chapters']))
    print('  人物:', len(res['assets'].get('characters', [])), '位 ->', ', '.join(c['name'] for c in res['assets'].get('characters', []))[:80])
    print('  场景:', len(res['assets'].get('scenes', [])), '个 / 道具:', len(res['assets'].get('props', [])))
    print('  输出:', os.path.abspath(out))
    print('  提示: 打开画布 → 点「⬆ 导入」选这个 json')


if __name__ == '__main__':
    main()
