# -*- coding: utf-8 -*-
"""ASS subtitle build + final render.

The audio graph reproduces the pattern proven in the manual pipeline:
narration files are decoded MULTIPLE independent times because on the ffmpeg
builds tested, feeding one decoded/split stream into BOTH a sidechaincompress
trigger input and a later amix input corrupts the mix (verified empirically:
"silent" regions came out louder than the ducked bed alone). Independent
decodes are cheap for small TTS clips.
"""
from pathlib import Path

from .ffbin import (APP_ROOT, ffmpeg, pick_encoder, probe_duration, run,
                    run_progress)

FONTS_DIR = APP_ROOT / "fonts"   # bundled msjh.ttc copies, avoids the
                                 # noisy full C:\Windows\Fonts scan libass does

RESOLUTIONS = {"source": None, "1080p": 1080, "720p": 720}


def _ts(sec):
    sec = max(0.0, sec)
    h, sec = divmod(sec, 3600)
    m, s = divmod(sec, 60)
    return f"{int(h)}:{int(m):02d}:{s:05.2f}"


def _wrap(text, width=20):
    if len(text) <= width:
        return text
    cut = -1
    for i, ch in enumerate(text):
        if ch in "，。！？、" and i <= width:
            cut = i + 1
    if cut < 0:
        cut = width
    a, b = text[:cut], text[cut:]
    return f"{a.rstrip('，。')}\\N{b}" if b else a


def _rect(x, y, w, h):
    return f"{{\\p1}}m {x} {y} l {x+w} {y} l {x+w} {y+h} l {x} {y+h}{{\\p0}}"


def build_ass(lines, win, video_w, video_h, duration, outro, dst,
              hr_bar=True, power_flash=True, pw_flash_th=450,
              extra_events=None, extra_styles=None):
    W, H = video_w, video_h
    fs = round(H / 20)          # narration font scales with resolution
    ev = []

    def add(start, end, style, text, layer=0):
        ev.append(f"Dialogue: {layer},{_ts(start)},{_ts(end)},{style},,0,0,0,,{text}")

    for o in lines:
        a, b = o["start"], o["start"] + o.get("dur", 3.5) + 0.35
        sub = o.get("sub") or o["spoken"]
        add(a, b, "Narr",
            f"{{\\pos({W//2},{int(H*0.73)})\\an2\\fad(180,220)}}{_wrap(sub)}")

    by_t = {int(round(r["vt"])): r for r in win if r.get("vt") is not None}

    if hr_bar and any(r.get("hr") for r in win):
        BX, BW = int(W * 0.956), int(W * 0.018)
        BY, BH = int(H * 0.24), int(H * 0.34)
        HMIN, HMAX = 140, 186
        add(0, duration, "Bar",
            f"{{\\pos(0,0)\\an7\\c&H303030&\\alpha&H90&}}{_rect(BX, BY, BW, BH)}")
        for t in range(0, int(duration)):
            r = by_t.get(t)
            if not r or r.get("hr") is None:
                continue
            hr = r["hr"]
            frac = min(1.0, max(0.0, (hr - HMIN) / (HMAX - HMIN)))
            fh = int(BH * frac)
            col = ("&H0000FF&" if hr >= 178 else "&H0060FF&" if hr >= 170
                   else "&H00C0FF&" if hr >= 160 else "&H40D040&")
            add(t, t + 1, "Bar",
                f"{{\\pos(0,0)\\an7\\c{col}\\alpha&H30&}}"
                f"{_rect(BX, BY + BH - fh, BW, fh)}")
            add(t, t + 1, "BarLbl",
                f"{{\\pos({BX+BW//2},{BY-int(H*0.008)})\\an2\\c{col}}}{hr:.0f}")

    if power_flash:
        run_, bursts = [], []
        for t in range(int(duration)):
            p = (by_t.get(t) or {}).get("pwr")
            if p is not None and p >= pw_flash_th:
                run_.append(t)
            else:
                if len(run_) >= 2:
                    bursts.append((run_[0], run_[-1] + 1))
                run_ = []
        if len(run_) >= 2:
            bursts.append((run_[0], run_[-1] + 1))
        TH = max(8, int(H * 0.008))
        edges = (_rect(0, 0, W, TH), _rect(0, H - TH, W, TH),
                 _rect(0, 0, TH, H), _rect(W - TH, 0, TH, H))
        for a, b in bursts:
            t0 = a
            while t0 < b:                      # repeated short pulses, not a
                t1 = min(t0 + 1.4, b)          # static border
                ms = int((t1 - t0) * 1000)
                for edge in edges:
                    add(t0, t1, "Flash",
                        f"{{\\pos(0,0)\\an7\\c&H0060FF&\\alpha&HFF&"
                        f"\\t(0,{ms//2},\\alpha&H50&)\\t({ms//2},{ms},\\alpha&HFF&)}}"
                        f"{edge}", layer=1)
                t0 = t1

    header = f"""[Script Info]
ScriptType: v4.00+
PlayResX: {W}
PlayResY: {H}
WrapStyle: 2
ScaledBorderAndShadow: yes
YCbCr Matrix: TV.709

[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: Narr,Microsoft JhengHei,{fs},&H00FFFFFF,&H000000FF,&H00101010,&H80000000,-1,0,0,0,100,100,1,0,1,{max(3,fs//18)},{max(2,fs//30)},2,80,80,60,1
Style: Bar,Microsoft JhengHei,40,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1
Style: BarLbl,Microsoft JhengHei,{max(24,fs//2)},&H00FFFFFF,&H000000FF,&H00101010,&H80000000,-1,0,0,0,100,100,1,0,1,3,2,8,0,0,0,1
Style: Flash,Microsoft JhengHei,40,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,0,0,7,0,0,0,1
{chr(10).join(extra_styles or [])}

[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
"""
    # dashboard events ride in the SAME ass file so the whole overlay burns
    # in one ffmpeg pass — no second transcode
    all_ev = ev + list(extra_events or [])
    Path(dst).write_text(header + "\n".join(all_ev) + "\n", encoding="utf-8-sig")
    return len(all_ev)


SEAM_FADE = 0.03    # seconds of audio fade at each clip join (see render)


def render(video, lines, ass_path, out_path, duration, outro=0.0,
           resolution="1080p", encoder_pref="auto", pulse_path=None,
           pulse_gain=0.9, ambient_gain=0.45, narr_gain=2.3, log=print,
           on_progress=None, should_cancel=None):
    """`video` is a single path or an ordered list of clip paths from the
    SAME ride. Multiple clips are concatenated on the fly inside the one
    filter graph — timeline positions in `lines`/ASS are output-timeline
    seconds, which by construction equal concatenated-clip seconds."""
    videos = [str(v) for v in (video if isinstance(video, (list, tuple))
                               else [video])]
    nclips = len(videos)
    total = duration + outro
    enc = pick_encoder(encoder_pref)
    log(f"  encoder: {enc}  resolution: {resolution}  片段數: {nclips}")

    cmd = [ffmpeg(), "-y", "-hide_banner", "-v", "warning"]
    for v in videos:
        cmd += ["-i", v]
    copies = 3 if pulse_path else 2
    for _ in range(copies):
        for o in lines:
            cmd += ["-i", o["file"]]
    n = len(lines)
    pulse_idx = nclips + copies * n
    if pulse_path:
        cmd += ["-i", str(pulse_path)]

    ass_esc = str(ass_path).replace("\\", "/").replace(":", r"\:")
    fonts_esc = str(FONTS_DIR).replace("\\", "/").replace(":", r"\:")
    parts = []
    if nclips > 1:
        # Butt-joining two independently recorded audio streams leaves a step
        # discontinuity in the waveform at the seam, heard as a click/pop.
        # A fade this short is inaudible as a fade but removes the step.
        # Applied at both ends of every clip, which also kills the
        # start/stop pop on the outer two.
        pairs = ""
        for i, v in enumerate(videos):
            d = probe_duration(v)
            parts.append(
                f"[{i}:a]afade=t=in:st=0:d={SEAM_FADE},"
                f"afade=t=out:st={max(0.0, d - SEAM_FADE):.3f}:d={SEAM_FADE}"
                f"[sa{i}]")
            pairs += f"[{i}:v][sa{i}]"
        parts.append(f"{pairs}concat=n={nclips}:v=1:a=1[cv][ca]")
        vsrc, asrc = "[cv]", "[ca]"
    else:
        vsrc, asrc = "[0:v]", "[0:a]"
    vf = f"{vsrc}tpad=stop_mode=clone:stop_duration={outro}," \
         f"subtitles=filename='{ass_esc}':fontsdir='{fonts_esc}'"
    target_h = RESOLUTIONS.get(resolution)
    if target_h:
        vf += f",scale=-2:{target_h}:flags=lanczos"
    vf += "[v]"
    parts.append(vf)

    def chain(offset, suffix):
        mixin = []
        for k, o in enumerate(lines, start=1):
            idx = nclips - 1 + offset + k
            ms = int(o["start"] * 1000)
            parts.append(f"[{idx}:a]adelay={ms}|{ms},volume={narr_gain}[n{suffix}{k}]")
            mixin.append(f"[n{suffix}{k}]")
        parts.append("".join(mixin) +
                     f"amix=inputs={n}:normalize=0:dropout_transition=0[nar{suffix}]")
        parts.append(f"[nar{suffix}]apad=whole_dur={total}[narp{suffix}]")

    chain(0, "A")            # duck trigger for ambient
    chain(n, "B")            # audible narration
    parts.append(f"{asrc}apad=whole_dur={total},volume={ambient_gain}[amb]")
    parts.append("[amb][narpA]sidechaincompress=threshold=0.025:ratio=14:"
                 "attack=20:release=400:makeup=1[duck]")
    final_inputs = "[duck][narpB]"
    n_inputs = 2
    if pulse_path:
        chain(2 * n, "C")    # duck trigger for pulse bed
        parts.append(f"[{pulse_idx}:a]volume={pulse_gain},"
                     f"apad=whole_dur={total}[pulseRaw]")
        parts.append("[pulseRaw][narpC]sidechaincompress=threshold=0.03:ratio=8:"
                     "attack=25:release=450:makeup=1[pulseDuck]")
        final_inputs += "[pulseDuck]"
        n_inputs = 3
    parts.append(f"{final_inputs}amix=inputs={n_inputs}:normalize=0[mixed]")
    parts.append(f"[mixed]alimiter=limit=0.95,atrim=0:{total},asetpts=N/SR/TB[a]")

    cmd += ["-filter_complex", ";".join(parts), "-map", "[v]", "-map", "[a]"]
    if enc == "h264_nvenc":
        cmd += ["-c:v", "h264_nvenc", "-preset", "p5", "-rc", "vbr",
                "-cq", "22", "-b:v", "0"]
    else:
        cmd += ["-c:v", "libx264", "-preset", "faster", "-crf", "22",
                "-threads", "0"]
    cmd += ["-pix_fmt", "yuv420p", "-movflags", "+faststart",
            "-c:a", "aac", "-b:a", "192k", "-t", str(total), str(out_path)]

    run_progress(cmd, total, on_progress=on_progress, timeout=7200,
                 should_cancel=should_cancel)
    verify_output(out_path, total, log=log)
    size_mb = Path(out_path).stat().st_size / 1024 / 1024
    log(f"  -> {out_path} ({size_mb:.0f} MB, {probe_duration(out_path):.1f}s)")
    return str(out_path)


def verify_output(out_path, expected_dur, log=print, tol=1.5):
    """A file that exists and has size is not proof of a good render.

    NVENC (and a disk filling up, or a driver hiccup) can leave an mp4 whose
    header parses fine — so ffprobe is happy — but whose tail is truncated or
    undecodable. That only surfaces when the user scrubs to the end, long
    after the job reported success. Decoding every packet to null is the only
    check that actually catches it; it costs a fraction of the encode because
    nothing is re-encoded.
    """
    log("  驗證輸出檔（完整解碼一次）…")
    r = run([ffmpeg(), "-v", "error", "-xerror", "-i", str(out_path),
             "-f", "null", "-"], timeout=3600)
    err = (r.stderr or "").strip()
    if err:
        raise RuntimeError(f"輸出檔解碼時發生錯誤，可能已損毀：{err[:400]}")
    got = probe_duration(out_path)
    if abs(got - expected_dur) > tol:
        raise RuntimeError(
            f"輸出長度 {got:.1f}s 與預期 {expected_dur:.1f}s 不符"
            f"（差 {abs(got - expected_dur):.1f}s），檔案可能被截斷")
    log(f"  ✔ 驗證通過（{got:.1f}s，串流可完整解碼）")
