"""
时间线编译器 - 将 DSL + TemplateBinding 编译为 Remotion 可消费的 RenderPlan。

负责: 帧号计算、字幕切片、转场注入、层级分配。

⚠️ 跨 skill 契约：
    `skills/render-video/scripts/render_video.py` 通过 sys.path 注入后直接
    `from video_dsl.runtime.timeline_compiler import (split_subtitle,
     split_subtitle_from_lines, segment_narration)`。
    改动以下公共函数的签名 / 返回结构前，请同步核对 render_video.py 的调用点：
      - split_subtitle
      - split_subtitle_from_lines
      - segment_narration
"""

import re
from datetime import datetime, timezone
from typing import Optional

CHARS_PER_SECOND = 5.0

_PRIMARY_BREAK = re.compile(r"(?<=[。！？；\n])")
# 次级断点**不含顿号 `、`**。顿号是并列项之间的分隔，在它上面下刀就是把一串并列
# 拦腰切开（`调研、策划、` ／ `开发、分析，…`）。这一层只数字符、分不出标点的语义
# 强度，所以干脆不碰它 —— 真需要再切时交给渲染端，那边是分级的（句末 > 冒号 >
# 逗号 > 顿号 > 空格），只有前面几级都切不动才会降到顿号。
_SECONDARY_BREAK = re.compile(r"(?<=[，,：:])")
_PRIMARY_TAIL = re.compile(r"[。！？；\n]\s*$")

# ─── 这两个上限管的是「喂给 TTS 的行」，不再是「一屏字幕」 ────────────────
#
# 曾经是 16 / 6，那是渲染端还不会分屏时的产物：谁都不会再切，所以必须在这里
# 就把旁白预切成一屏放得下的大小。现在渲染端（core 的 `chunkSubtitleSegments`）
# 会按**实际字号与实际可用宽度**算容量、按**标点语义强度分级**切分，这件事它
# 做得比这里好得多：
#   · 这里只能数字符数，数不出 `HNSW` 比 4 个中文字窄一半；
#   · 这里的 `_SECONDARY_BREAK` 是平的，冒号和顿号同权，会把并列项拦腰切开；
#   · 这里一个全局 16 要同时服务 16:9（一行放 44 字）和 9:16（一行放 22 字）。
#
# 于是 16 的唯一效果变成了**抢在渲染端之前把句子剁碎**。2026-09-21 线上成片实测：
# 121.5s 里 57 屏、中位 2.10s 一屏、33% 短于 1.5s，「AI Agent」独占一屏 —— 全都是
# 这个 16 的产物，渲染端的分屏阈值（20）一次都没被触发。
#
# 现在这里只负责一件事：**在真实语音停顿处断行**，让 Minimax 按停顿返回时间戳。
# 句末（`_PRIMARY_BREAK`）永远断；逗号那一级只在句子长到一行 TTS 太长时才用。
# 30 是「一个中文长句」的量级 —— 越过它说明这句本来就该有个呼吸点。
#
# ⚠️ 别再把它当字幕宽度调：想改一屏显示多少字，改 core 的
# `DEFAULT_SPLIT_MAX_UNITS` / `LINE_FILL_LIMIT`，那边才看得见字号和画幅。
MAX_SEGMENT_CHARS = 30
# 合并下限保持 6：TTS 行太短会让合成的语气断掉（和字幕闪烁是两回事）。
MIN_SEGMENT_CHARS = 6

# 字幕显示时需要去掉的末尾标点（中英文句末 + 逗号等）
_TRAILING_PUNCT = re.compile(r"[。！？；，,、：:．.…]+$")
# **...** 强调标记（用于计算纯文本长度）
_EMPHASIS_MARKS = re.compile(r"\*\*")

RESOLUTION_MAP = {
    "16:9": {"480p": (854, 480), "720p": (1280, 720), "1080p": (1920, 1080), "4k": (3840, 2160)},
    "9:16": {"480p": (480, 854), "720p": (720, 1280), "1080p": (1080, 1920), "4k": (2160, 3840)},
    "1:1": {"480p": (480, 480), "720p": (720, 720), "1080p": (1080, 1080), "4k": (2160, 2160)},
    "4:3": {"480p": (640, 480), "720p": (960, 720), "1080p": (1440, 1080), "4k": (2880, 2160)},
    "3:4": {"480p": (480, 640), "720p": (720, 960), "1080p": (1080, 1440), "4k": (2160, 2880)},
    "21:9": {"720p": (1720, 720), "1080p": (2560, 1080), "4k": (5120, 2160)},
}


def _now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()


def resolve_dimensions(ratio: str, resolution: str) -> tuple[int, int]:
    dims = RESOLUTION_MAP.get(ratio, RESOLUTION_MAP["16:9"])
    return dims.get(resolution, dims.get("1080p", (1920, 1080)))


def _is_latin_word_char(ch: str) -> bool:
    return ch.isascii() and ch.isalnum()


def _breakable(prev_token: str, next_token: str) -> bool:
    """两个 token 之间断开是否安全 —— 拉丁↔拉丁即同一词组内部，不安全。

    拆与并共用这一个判据：断的时候躲开的边界，并的时候就得把空格补回去。
    """
    if not prev_token or not next_token:
        return True
    return not (_is_latin_word_char(prev_token[-1]) and _is_latin_word_char(next_token[0]))


def _concat_segments(left: str, right: str) -> str:
    """拼接两个字幕段，必要时补回被切分吃掉的那个空格。

    ``_split_at_spaces`` 在空格处断开，空格本身不属于任何一段；直接 ``+`` 拼回去
    会把 ``capabilities and`` + ``more`` 粘成 ``capabilities andmore`` —— 这串同样会
    原样进 TTS，读出来就是一个不存在的词。中文两侧不补空格（中文本来就不分词）。
    """
    if _breakable(left, right):
        return left + right
    return left + " " + right


def _split_at_spaces(text: str, max_len: int) -> list[str]:
    """Split long text at space boundaries, keeping multi-word Latin terms whole.

    These boundaries are not only subtitle cuts: ``segment_narration`` joins the
    result with ``\\n`` and that is what goes to TTS, where each line is a hard
    segment boundary. Cutting between two Latin words therefore breaks the term
    apart in the *audio* — Minimax loses both the liaison and the context that
    told it this was English at all.

    实测(2026-09-20): ``最近刷爆科技圈的 AI Agent，`` 是 18 字、超过 16 的上限，而这
    段里仅有的两个空格都在 "AI Agent" 中间,于是旧实现只能从词组中间下刀,切成
    ``最近刷爆科技圈的 AI`` + ``Agent，`` —— 合成出来 "AI" 收在上一段末尾、"Agent"
    孤零零起一段,那个词就读不准了。同句末尾的 "Agent Loop。" 因为没超上限反而完好,
    用户听到的正是"中间那个 Agent 不对"。

    所以断点优先选**中文↔拉丁**边界(回退到词组之前),找不到再退回"就近断开"。
    纯英文旁白里根本不存在中文↔拉丁边界,每个断点都会走回退分支,行为与旧实现逐字
    相同 —— picture-book-en 这类全英文模板不受影响。
    """
    if len(text) <= max_len:
        return [text]

    parts: list[str] = []
    buf: list[str] = []  # 当前段已收的 token
    for word in text.split(" "):
        if buf and len(" ".join(buf + [word])) > max_len:
            # 从最靠后的边界往回找第一个安全断点;整段都没有就按旧行为就近断开。
            cut = len(buf)
            for i in range(len(buf), 0, -1):
                following = word if i == len(buf) else buf[i]
                if _breakable(buf[i - 1], following):
                    cut = i
                    break
            parts.append(" ".join(buf[:cut]))
            buf = buf[cut:]
        buf.append(word)
    if buf:
        parts.append(" ".join(buf))
    return parts


def _merge_short(segments: list[str], min_len: int) -> list[str]:
    """Merge fragments shorter than *min_len* into a neighbour.

    Never merge a short segment backward into a sentence-ending segment
    (ending with 。！？；\\n) to avoid cross-sentence subtitle lines like
    "可复用技能。第三，".  Instead, keep it as a new entry so it merges
    forward with the next segment.
    """
    if len(segments) <= 1:
        return segments
    merged: list[str] = [segments[0]]
    for seg in segments[1:]:
        prev_is_sentence_end = bool(_PRIMARY_TAIL.search(merged[-1]))
        if len(merged[-1]) < min_len:
            # Previous fragment is short — always merge forward into it
            merged[-1] = _concat_segments(merged[-1], seg)
        elif len(seg) < min_len and not prev_is_sentence_end:
            # Current fragment is short and prev is NOT a sentence end — merge backward
            merged[-1] = _concat_segments(merged[-1], seg)
        else:
            # Either both are long enough, or seg is short but prev ends a sentence
            merged.append(seg)
    # Handle trailing short segment (avoid merging across sentence boundary)
    if len(merged) > 1 and len(merged[-1]) < min_len:
        if not _PRIMARY_TAIL.search(merged[-2]):
            merged[-2] = _concat_segments(merged[-2], merged.pop())
    return merged


def _plain_len(text: str) -> int:
    """计算去掉 **...** 标记后的纯文本字符数，用于更准确的时间分配。"""
    return len(_EMPHASIS_MARKS.sub("", text))


def _strip_trailing_punct(text: str) -> str:
    """去掉字幕段末尾的标点符号（中英文句末 + 逗号等），让画面更干净。"""
    return _TRAILING_PUNCT.sub("", text)


def segment_narration(text: str) -> list[str]:
    """Split narration into **TTS lines**, cutting at speech pauses.

    Use before TTS: ``"\\n".join(segment_narration(text))`` sends to Minimax
    so it returns per-line timestamps.

    ⚠️ 这里切的**不是字幕屏**。字幕分屏归渲染端（core 的
    ``chunkSubtitleSegments``），它知道字号、画幅和标点的语义强度；这里只知道
    字符数。这一层切得越细，留给渲染端的余地越小 —— 见 ``MAX_SEGMENT_CHARS``
    的注释。
    """
    if not text:
        return []
    raw = _PRIMARY_BREAK.split(text.strip())
    raw = [s for s in raw if s.strip()]
    segments: list[str] = []
    for chunk in raw:
        if len(chunk) <= MAX_SEGMENT_CHARS:
            segments.append(chunk)
            continue
        sub = _SECONDARY_BREAK.split(chunk)
        sub = [s for s in sub if s.strip()]
        for part in sub:
            if len(part) <= MAX_SEGMENT_CHARS:
                segments.append(part)
            else:
                segments.extend(_split_at_spaces(part, MAX_SEGMENT_CHARS))
    return _merge_short(segments, MIN_SEGMENT_CHARS)


def split_subtitle_from_lines(
    lines: list[str], duration_frames: int, fps: int, padding_frames: int = 10
) -> list[dict]:
    """Fallback subtitle timing for pre-split narration lines.

    Preserves exactly ``len(lines)`` segments (no secondary comma-split,
    no short-fragment merging). Used when narration was authored as
    ``{intro, items, outro}`` and TTS timestamps are unavailable.

    末尾标点会被去掉（画面更干净），时间分配按纯文本长度（去掉 ** 标记）。
    """
    segments = [s.strip() for s in lines if s and s.strip()]
    if not segments:
        return []

    total_chars = max(sum(_plain_len(s) for s in segments), 1)
    estimated_audio_frames = int(total_chars / CHARS_PER_SECOND * fps)
    usable_frames = min(
        max(duration_frames - 2 * padding_frames, fps),
        max(estimated_audio_frames, fps),
    )
    result: list[dict] = []
    current_frame = padding_frames
    for seg in segments:
        ratio = _plain_len(seg) / total_chars
        frames = max(int(usable_frames * ratio), fps // 2)
        result.append({
            "text": _strip_trailing_punct(seg.strip()),
            "startFrame": current_frame,
            "endFrame": current_frame + frames,
        })
        current_frame += frames
    return result


def split_subtitle(text: str, duration_frames: int, fps: int, padding_frames: int = 10) -> list[dict]:
    """Split narration text into subtitle segments aligned to frames.

    Fallback path used when real TTS timestamps are not available.
    末尾标点会被去掉（画面更干净），时间分配按纯文本长度（去掉 ** 标记）。
    """
    segments = segment_narration(text)
    if not segments:
        return []

    total_plain = max(sum(_plain_len(s) for s in segments), 1)
    estimated_audio_frames = int(total_plain / CHARS_PER_SECOND * fps)
    usable_frames = min(
        max(duration_frames - 2 * padding_frames, fps),
        max(estimated_audio_frames, fps),
    )
    result: list[dict] = []
    current_frame = padding_frames
    for seg in segments:
        ratio = _plain_len(seg) / total_plain
        frames = max(int(usable_frames * ratio), fps // 2)
        result.append({
            "text": _strip_trailing_punct(seg.strip()),
            "startFrame": current_frame,
            "endFrame": current_frame + frames,
        })
        current_frame += frames

    return result


def _resolve_root_composition(binding: dict, ratio: str) -> str:
    """Pick Remotion root Composition ID from template's remotionEntry by aspect ratio."""
    entry_map = binding.get("remotionEntry", {})
    if entry_map:
        return entry_map.get(ratio, next(iter(entry_map.values()), "MainVideo"))
    return binding.get("templateId", "MainVideo")


def compile_timeline(dsl: dict, binding: dict) -> dict:
    """Compile DSL + TemplateBinding into a full RenderPlan."""
    fps = dsl.get("global", {}).get("fps", 30)
    ratio = dsl.get("global", {}).get("aspectRatio", "16:9")
    resolution = dsl.get("global", {}).get("resolution", "1080p")
    width, height = resolve_dimensions(ratio, resolution)

    binding_map = {}
    for b in binding.get("bindings", []):
        binding_map[b["sceneId"]] = b

    assets_pool = {}
    for a in dsl.get("assets", []):
        status = a.get("status", "planned")
        if status in ("planned", "missing"):
            status = "pending"
        assets_pool[a["assetId"]] = {
            "assetId": a["assetId"],
            "type": a.get("type", ""),
            "source": a.get("source", "existing"),
            "status": status,
            "url": a.get("url", ""),
            "localPath": a.get("localPath", ""),
            "duration": None,
            "width": None,
            "height": None,
            "mimeType": "",
            "generatedBy": {},
            "retryCount": 0,
            "maxRetries": 3,
        }

    timeline = []
    current_frame = 0
    trans_cfg = dsl.get("transitions", {})
    default_trans = trans_cfg.get("default", "fade")
    default_trans_dur = int(trans_cfg.get("duration", 0.5) * fps)

    for scene in dsl.get("scenes", []):
        scene_id = scene["id"]
        duration = scene.get("duration", 5)
        dur_frames = int(duration * fps)

        scene_bind = binding_map.get(scene_id, {})

        # P2.3: 不再产出 entry.layers——背景视觉资产、旁白音频、文本图层全部
        # 通过 propExtractors 写入 binding.props（backgroundAssetId / narrationAssetId
        # / titleText 等），渲染端用 props 直接消费。

        narration = scene.get("audio", {}).get("narration", {})

        narration_text = narration.get("text", "")
        sub_segs = split_subtitle(narration_text, dur_frames, fps)

        entry = {
            "sceneId": scene_id,
            "startFrame": current_frame,
            "endFrame": current_frame + dur_frames,
            "durationFrames": dur_frames,
            "startTime": round(current_frame / fps, 2),
            "endTime": round((current_frame + dur_frames) / fps, 2),
            "compositionId": scene_bind.get("compositionId", "GenericScene"),
            "props": scene_bind.get("props", {}),
            "subtitleSegments": sub_segs,
            "transition": {"type": default_trans, "durationFrames": default_trans_dur},
        }
        timeline.append(entry)
        current_frame += dur_frames

    render_plan = {
        "version": "v1alpha1",
        "createdAt": _now_iso(),
        "status": "planning",
        "templateId": binding.get("templateId", ""),
        "resolvedAssets": list(assets_pool.values()),
        "timeline": timeline,
        "renderConfig": {
            "width": width,
            "height": height,
            "fps": fps,
            "totalFrames": current_frame,
            "totalDuration": round(current_frame / fps, 2),
            "codec": "h264",
            "crf": 18,
            "outputFormat": "mp4",
        },
        "remotionProps": {
            "compositionId": _resolve_root_composition(binding, ratio),
            "inputProps": {
                "globalTypography": binding.get("globalOverrides", {}).get("typography", {}),
                "motionPreset": binding.get("globalOverrides", {}).get("motionPreset", "smooth"),
                "colorScheme": binding.get("globalOverrides", {}).get("colorScheme", []),
                **({"themeConfig": binding["themeConfig"]} if binding.get("themeConfig") else {}),
                **({"variantId": binding["variantId"]} if binding.get("variantId") else {}),
            },
        },
        "errors": [],
        "logs": [
            {"phase": "validate", "message": "DSL validation passed", "timestamp": _now_iso()},
            {"phase": "compile", "message": f"Compiled {len(timeline)} scenes, {current_frame} total frames", "timestamp": _now_iso()},
        ],
    }

    return render_plan
