"""
小星糖字幕一條龍 — 給已生成的 starvideo 影片燒上 ChenYuluoyan 字體字幕

流程：
  1. ffmpeg 抽 wav (16kHz mono)
  2. 呼叫 QwenASR app-gpu.py（GPUASREngine + Qwen3-ForcedAligner）
     → 取得字級對齊 SRT
  3. 把 ASR 文字替換成主人提供的「分行精準台詞」（保留 aligner 時間戳）
     → 避免 ASR 錯字（林→琳、小新糖→小星糖等）
  4. 寫 ASS 字幕（用 skill 內字體 fonts/ChenYuluoyan-2.0-Thin.ttf）
  5. ffmpeg subtitles filter burn-in
  6. 若超過 Discord 8 MB 上限，自動 ffmpeg 壓縮
  7. stdout 最後一行印 `READY: <絕對路徑>`

用法：
  python add_subtitles.py \
      --video F:/.../input.mp4 \
      --lines-file F:/.../lines.txt \      # 每行一句字幕（主人精準版）
      [--font-size 60] [--margin-v 192] \
      [--qwenasr-dir F:/AIStudio/QwenASR] \
      [--qwenasr-python F:/AIStudio/QwenASR/venv/Scripts/python.exe]

回傳碼：
  0 = 成功，最後一行 `READY: <path>`
  1 = ASR / ffmpeg 失敗
  2 = 參數錯誤
"""
import argparse
import json
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path

SKILL_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_FONT = SKILL_ROOT / "fonts" / "ChenYuluoyan-2.0-Thin.ttf"
FONT_FAMILY = "ChenYuluoyan 2.0 Thin"
DISCORD_LIMIT_BYTES = 8 * 1024 * 1024


def srt_ts_to_seconds(ts: str) -> float:
    """'00:00:02,400' → 2.4"""
    h, m, rest = ts.split(":")
    s, ms = rest.split(",")
    return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000


def seconds_to_ass_ts(sec: float) -> str:
    """2.4 → '0:00:02.40' (ASS uses centiseconds)"""
    h = int(sec // 3600)
    m = int((sec % 3600) // 60)
    s = sec - h * 3600 - m * 60
    return f"{h}:{m:02d}:{s:05.2f}"


def parse_srt(srt_path: Path) -> list[tuple[float, float, str]]:
    """Parse SRT into [(start_sec, end_sec, text), ...]."""
    content = srt_path.read_text(encoding="utf-8").strip()
    blocks = re.split(r"\n\s*\n", content)
    out = []
    for b in blocks:
        lines = [ln for ln in b.strip().split("\n") if ln.strip()]
        if len(lines) < 3:
            continue
        # lines[0] = index, lines[1] = "start --> end", lines[2:] = text
        m = re.match(r"([\d:,]+)\s*-->\s*([\d:,]+)", lines[1])
        if not m:
            continue
        start = srt_ts_to_seconds(m.group(1))
        end = srt_ts_to_seconds(m.group(2))
        text = "\n".join(lines[2:]).strip()
        out.append((start, end, text))
    return out


def extract_audio(video: Path, out_wav: Path) -> None:
    """ffmpeg 抽 16kHz mono wav。"""
    cmd = [
        "ffmpeg", "-y", "-i", str(video),
        "-vn", "-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1",
        str(out_wav),
    ]
    r = subprocess.run(cmd, capture_output=True, text=True)
    if r.returncode != 0:
        raise RuntimeError(f"audio extract failed: {r.stderr[-500:]}")


def run_aligned_asr(
    wav: Path, qwenasr_dir: Path, qwenasr_python: Path
) -> Path:
    """Run QwenASR ForcedAligner via app-gpu.py's GPUASREngine.
    Writes SRT next to the wav. Returns SRT path.
    """
    # Inline Python via -c that imports app-gpu and runs process_file.
    # We write the bridge script to a tempfile because importlib on
    # "app-gpu.py" (dash) is awkward inline.
    bridge_code = f"""
import sys
from pathlib import Path
import importlib.util

QWEN_DIR = Path(r'{qwenasr_dir}')
WAV = Path(r'{wav}')
sys.path.insert(0, str(QWEN_DIR))

spec = importlib.util.spec_from_file_location(
    'app_gpu', str(QWEN_DIR / 'app-gpu.py')
)
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)

engine = m.GPUASREngine()
engine.load(
    device='cpu',
    model_dir=QWEN_DIR / 'GPUModel',
    cb=lambda msg: print(f'  [asr] {{msg}}', flush=True),
    use_aligner=True,
)
srt = engine.process_file(WAV, language='Chinese')
if not srt:
    print('ASR_FAILED')
    sys.exit(1)
print(f'SRT={{srt}}')
"""
    bridge_file = wav.with_suffix(".bridge.py")
    bridge_file.write_text(bridge_code, encoding="utf-8")
    try:
        r = subprocess.run(
            [str(qwenasr_python), str(bridge_file)],
            capture_output=True, text=True, encoding="utf-8", errors="replace",
            timeout=600,
        )
    finally:
        bridge_file.unlink(missing_ok=True)

    if r.returncode != 0:
        raise RuntimeError(
            f"ASR failed (exit {r.returncode}):\n"
            f"stdout: {r.stdout[-300:]}\nstderr: {r.stderr[-300:]}"
        )

    # Find "SRT=..." line
    m = re.search(r"^SRT=(.+)$", r.stdout, re.MULTILINE)
    if not m:
        raise RuntimeError(f"could not parse SRT path from ASR output:\n{r.stdout[-500:]}")
    return Path(m.group(1).strip())


def align_lines_to_asr(
    asr_segs: list[tuple[float, float, str]],
    user_lines: list[str],
) -> list[tuple[float, float, str]]:
    """Replace ASR text with user lines while keeping aligner timestamps.

    Strategy:
      - If counts match → 1:1 replace
      - If user has fewer lines → merge consecutive ASR segs into user line count
        (proportional to ASR text length)
      - If user has more lines → split ASR segs proportionally by user line length

    For now (vlog use case), we expect user to provide one line per ASR segment.
    If counts differ, we log a warning and use the simplest fallback.
    """
    if not asr_segs:
        return []
    if not user_lines:
        return asr_segs  # no replacement; keep ASR text

    if len(asr_segs) == len(user_lines):
        return [
            (s, e, user_lines[i].strip())
            for i, (s, e, _) in enumerate(asr_segs)
        ]

    # Counts differ — proportional remap based on ASR seg widths
    print(
        f"[warn] ASR gave {len(asr_segs)} segs but user provided "
        f"{len(user_lines)} lines; remapping proportionally",
        file=sys.stderr,
    )
    total_dur = asr_segs[-1][1] - asr_segs[0][0]
    start_t = asr_segs[0][0]
    out = []
    total_chars = sum(len(ln) for ln in user_lines) or 1
    cursor = start_t
    for ln in user_lines:
        portion = len(ln) / total_chars
        end = cursor + portion * total_dur
        out.append((cursor, end, ln.strip()))
        cursor = end
    return out


def write_ass(
    segs: list[tuple[float, float, str]],
    out_path: Path,
    play_x: int, play_y: int,
    font_size: int, margin_v: int,
) -> None:
    """Generate ASS subtitle file."""
    header = f"""[Script Info]
ScriptType: v4.00+
PlayResX: {play_x}
PlayResY: {play_y}
ScaledBorderAndShadow: yes
WrapStyle: 2

[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,{FONT_FAMILY},{font_size},&H00FFFFFF,&H000000FF,&H00000000,&HA0000000,0,0,0,0,100,100,0.5,0,1,3,1.5,2,30,30,{margin_v},1

[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
"""
    lines = [header]
    for s, e, txt in segs:
        lines.append(
            f"Dialogue: 0,{seconds_to_ass_ts(s)},{seconds_to_ass_ts(e)},"
            f"Default,,0,0,0,,{txt}\n"
        )
    out_path.write_text("".join(lines), encoding="utf-8")


def burn_in_subtitles(
    video: Path, ass_path: Path, fonts_dir: Path, out_path: Path,
) -> None:
    """ffmpeg subtitles filter — fontsdir keyed to skill's fonts/ folder.

    Trick: run ffmpeg with cwd=ass_path.parent and pass only the basename
    so the subtitles filter never sees a Windows-style "C:/..." path
    (libass's option parser treats `:` as a delimiter even after escape).
    """
    # Copy the font into the ASS folder for this run, so we can use a
    # relative fontsdir="." and avoid path-colon issues there too.
    ass_dir = ass_path.parent
    font_src = fonts_dir / "ChenYuluoyan-2.0-Thin.ttf"
    font_dst = ass_dir / "ChenYuluoyan-2.0-Thin.ttf"
    if not font_dst.exists():
        font_dst.write_bytes(font_src.read_bytes())

    cmd = [
        "ffmpeg", "-y", "-i", str(video.resolve()),
        "-vf", f"subtitles={ass_path.name}:fontsdir=.",
        "-c:v", "libx264", "-preset", "medium", "-crf", "22",
        "-pix_fmt", "yuv420p",
        "-c:a", "copy",
        "-movflags", "+faststart",
        str(out_path.resolve()),
    ]
    r = subprocess.run(
        cmd, capture_output=True, text=True,
        cwd=str(ass_dir),
    )
    if r.returncode != 0:
        raise RuntimeError(f"burn-in failed: {r.stderr[-500:]}")


def compress_for_discord(raw: Path) -> Path:
    """If raw > 8MB, re-encode with CRF 28."""
    if raw.stat().st_size <= DISCORD_LIMIT_BYTES:
        return raw
    out = raw.with_name(raw.stem + "-compressed.mp4")
    cmd = [
        "ffmpeg", "-y", "-i", str(raw),
        "-c:v", "libx264", "-preset", "medium", "-crf", "28",
        "-c:a", "aac", "-b:a", "96k",
        "-movflags", "+faststart",
        str(out),
    ]
    r = subprocess.run(cmd, capture_output=True, text=True)
    if r.returncode != 0:
        raise RuntimeError(f"compress failed: {r.stderr[-500:]}")
    return out


def main() -> int:
    parser = argparse.ArgumentParser(
        description="小星糖字幕一條龍（ForcedAligner + ChenYuluoyan）"
    )
    parser.add_argument("--video", required=True, type=Path,
                        help="原始影片路徑（make_video.py 的輸出）")
    parser.add_argument("--lines-file", required=True, type=Path,
                        help="主人精準台詞檔，每行一句字幕")
    parser.add_argument("--output-dir", type=Path,
                        help="輸出資料夾（預設與原影片同目錄）")
    parser.add_argument("--font-size", type=int, default=60,
                        help="字幕字體大小（預設 60 px）")
    parser.add_argument("--margin-v-percent", type=int, default=15,
                        help="字幕距底部百分比（預設 15）")
    parser.add_argument("--qwenasr-dir", type=Path,
                        default=Path("F:/AIStudio/QwenASR"),
                        help="QwenASR 專案目錄")
    parser.add_argument("--qwenasr-python", type=Path,
                        default=Path("F:/AIStudio/QwenASR/venv/Scripts/python.exe"),
                        help="QwenASR venv 的 python.exe")
    args = parser.parse_args()

    # Pre-flight
    for p in (args.video, args.lines_file, args.qwenasr_dir, args.qwenasr_python):
        if not p.exists():
            print(f"ERROR: not found: {p}", file=sys.stderr)
            return 2
    if not shutil.which("ffmpeg"):
        print("ERROR: ffmpeg not on PATH", file=sys.stderr)
        return 2
    if not DEFAULT_FONT.exists():
        print(f"ERROR: font missing: {DEFAULT_FONT}", file=sys.stderr)
        return 2

    output_dir = args.output_dir or args.video.parent
    output_dir.mkdir(parents=True, exist_ok=True)

    user_lines = [
        ln.rstrip() for ln in args.lines_file.read_text(encoding="utf-8").splitlines()
        if ln.strip()
    ]

    # Get video resolution for ASS PlayResX/Y
    probe = subprocess.run(
        ["ffprobe", "-v", "error", "-select_streams", "v:0",
         "-show_entries", "stream=width,height",
         "-of", "json", str(args.video)],
        capture_output=True, text=True,
    )
    meta = json.loads(probe.stdout)
    v = meta["streams"][0]
    play_x, play_y = int(v["width"]), int(v["height"])
    margin_v = int(play_y * args.margin_v_percent / 100)
    print(f"[step 1/6] video {play_x}x{play_y}, margin_v={margin_v}px",
          flush=True)

    with tempfile.TemporaryDirectory() as td:
        wav = Path(td) / "audio.wav"
        print(f"[step 2/6] extracting audio", flush=True)
        extract_audio(args.video, wav)

        print(f"[step 3/6] running QwenASR ForcedAligner (CPU, ~30s)",
              flush=True)
        srt_tmp = run_aligned_asr(wav, args.qwenasr_dir, args.qwenasr_python)

        # Persist SRT and an "ASR suggestion lines" template to output_dir
        # so the user can inspect alignment and easily re-author lines.txt.
        srt_final = output_dir / (args.video.stem + ".srt")
        srt_final.write_bytes(srt_tmp.read_bytes())
        asr_segs = parse_srt(srt_final)
        print(f"           got {len(asr_segs)} aligned segments → {srt_final.name}",
              flush=True)

        asr_lines = output_dir / (args.video.stem + "-asr-lines.txt")
        asr_lines.write_text(
            "\n".join(text for _, _, text in asr_segs) + "\n",
            encoding="utf-8",
        )
        print(f"           ASR suggestion lines → {asr_lines.name} "
              f"({len(asr_segs)} lines, fix typos & re-run with this as --lines-file)",
              flush=True)

        # Loud warning when user lines don't match ASR segments — points at
        # the artifacts they need to fix instead of silently mis-aligning.
        if user_lines and len(user_lines) != len(asr_segs):
            print(
                f"\n[!! ALIGNMENT WARNING] user provided {len(user_lines)} lines "
                f"but ASR gave {len(asr_segs)} segments\n"
                f"    → Subtitles will use proportional remap (less accurate).\n"
                f"    → Recommended: edit `{asr_lines.name}` (ASR text with "
                f"correct line breaks) to match your wording, then re-run with\n"
                f"      --lines-file \"{asr_lines}\"\n",
                flush=True,
            )

        aligned = align_lines_to_asr(asr_segs, user_lines)

        ass_path = output_dir / (args.video.stem + ".ass")
        write_ass(aligned, ass_path, play_x, play_y,
                  args.font_size, margin_v)
        print(f"[step 4/6] wrote ASS: {ass_path.name}", flush=True)

    # Use the skill's fonts/ folder directly (only .ttf inside, no mess)
    fonts_dir = SKILL_ROOT / "fonts"

    burn_path = output_dir / (args.video.stem + "-with-subs.mp4")
    print(f"[step 5/6] burning subtitles (ffmpeg libass)", flush=True)
    burn_in_subtitles(args.video, ass_path, fonts_dir, burn_path)
    size_mb = burn_path.stat().st_size / 1024 / 1024
    print(f"           burned: {burn_path.name} ({size_mb:.1f} MB)",
          flush=True)

    print(f"[step 6/6] check Discord size limit", flush=True)
    final = compress_for_discord(burn_path)
    size_mb = final.stat().st_size / 1024 / 1024
    print(f"           final: {final.name} ({size_mb:.1f} MB)",
          flush=True)

    print(f"READY: {final}", flush=True)
    return 0


if __name__ == "__main__":
    sys.exit(main())
