# -*- coding: utf-8 -*-
"""Draft narration generation: AI-assisted when the user's CLI is configured,
rule-based fallback otherwise. Output format is the editable script:
[{"start": sec, "spoken": tts_text, "sub": subtitle_or_null}]
"""
import json
import re
from pathlib import Path

from . import ai_cli

AI_PROMPT = """你是熱血的自行車賽事主播。根據以下資料，為一支第一人稱騎乘影片寫旁白腳本。

【最重要的規則：不可以編造，也不可以竄改細節】
- 每一句提到的畫面事件，都必須在「該時間點正負2秒內」的場景描述裡真的寫到。
  例如場景描述寫「t=200s 右側正在被機車超車」，你就只能把機車寫在 198~202 秒之間，
  不可以寫到 208 秒去。時間點對不上就是錯的。
- 場景描述的細節要照抄，不可以改：
  · 寫「超越左側車手」就不能說成右側；寫「被超車」就不能說成「超車」（方向相反）
  · 寫「前方有3人」就不能說成 5 人
  · 沒寫到的東西（機車、汽車、紅綠燈、彎道…）一律不准出現
- 如果某個時間點的場景描述只是「單獨騎乘、無超車」這種平淡內容，
  就改用「數據」來寫（時速/心率/功率/踏頻），不要硬掰畫面事件。
- 寧可平實，也不要為了熱血而虛構。

規格要求：
- 回傳「純 JSON 陣列」，不要 markdown、不要說明文字
- 每個元素: {{"start": 秒數(數字), "spoken": "旁白唸的話", "sub": "畫面字幕(可省略設null)"}}
- 至少要有 {min_lines} 句：一句講完之後，最多隔 {density} 秒就要有下一句，
  不要讓影片出現太長的無聲空白（會很乾很無聊）。可視畫面精彩程度多寫幾句。
- 中文旁白約每秒 5 個字，估算長度時請把唸完的時間算進去
- 句與句之間至少間隔 6 秒，最後一句不要超過影片長度
- spoken 用繁體中文口語，數字直接寫阿拉伯數字；「W」在 spoken 寫成「瓦」（發音用），sub 寫 W
- 語氣可以熱血，但內容必須忠於上面的場景描述與數據
- 主角稱呼「他」即可

影片長度: {duration:.0f} 秒

每10秒數據 (t=影片秒數, spd=時速, hr=心率, pwr=功率):
{table}

自動偵測的亮點:
{highlights}

每{interval}秒畫面場景描述:
{scenes}

使用者補充的背景資訊（寫旁白時務必參考，例如天氣、心情、人物、賽事背景）:
{context}

使用者提供的附件參考資料（排行榜、相關文章、人物介紹等，可引用其中的具體數字與名字）:
{attachments}
"""


def _fmt_table(rows):
    return "\n".join(
        f"t={r['t']:>4}  spd={r['spd'] or '-':>5}  hr={r['hr'] or '-':>3}  "
        f"pwr={r['pwr'] or '-':>4}" for r in rows)


def _fmt_highlights(hl):
    names = {"power": "功率爆發", "speed": "高速", "hr_red": "心率紅區"}
    return "\n".join(
        f"{h['start']}~{h['end']}s {names[h['kind']]} 峰值{h['peak']}"
        for h in hl) or "(無)"


def _fmt_scenes(frames, max_chars=12000):
    """Run-length compress consecutive near-identical descriptions.

    At a 1-second sampling interval most neighbouring frames describe the
    same situation ("在橋上平路集團內騎乘，前方有數名車手" fifty times over).
    Collapsing those runs into "t=81-130s ..." keeps the prompt inside the
    OS command-line limit AND reads better to the model — what matters is
    when the situation CHANGES, not that it stayed the same 50 times."""
    # Overtaking claims from SINGLE stills are unreliable and get stripped;
    # burst descriptions (frame sequences) were validated to judge direction
    # and passing correctly, so theirs are kept — that dynamism is the whole
    # point of burst mode.
    described = [(f["t"],
                  f["scene"] if f.get("burst") else strip_overtaking(f["scene"]))
                 for f in frames if f.get("scene")]
    described = [(t, s) for t, s in described if s]
    if not described:
        return "(未啟用場景辨識)"

    runs = []          # [start_t, end_t, scene]
    for t, scene in described:
        if runs and _similar(runs[-1][2], scene):
            runs[-1][1] = t
        else:
            runs.append([t, t, scene])

    lines = []
    for start, end, scene in runs:
        label = f"t={start}s" if start == end else f"t={start}-{end}s"
        lines.append(f"{label}  {scene}")
    text = "\n".join(lines)
    if len(text) > max_chars:      # hard safety net for very long rides
        text = text[:max_chars] + "\n…（場景描述過長，已截斷）"
    return text


def _similar(a: str, b: str) -> bool:
    """Cheap similarity: identical, or sharing most of their characters."""
    if a == b:
        return True
    sa, sb = set(a), set(b)
    if not sa or not sb:
        return False
    return len(sa & sb) / len(sa | sb) >= 0.8


def _extract_json(text: str):
    """Models love wrapping JSON in fences or prose; dig the array out."""
    m = re.search(r"\[.*\]", text, re.DOTALL)
    if not m:
        raise ValueError("no JSON array in AI reply")
    return json.loads(m.group(0))


MAX_GAP = 20.0   # 旁白最大空白秒數：超過這個長度沒人講話，影片看起來會很乾


PLACEHOLDERS = ("duration", "table", "highlights", "scenes", "interval",
                "context", "attachments", "min_lines", "density")


def validate_template(tmpl: str) -> str | None:
    """Returns an error message if the template can't be filled, else None.
    A user-edited template with a typo'd placeholder would otherwise blow up
    mid-job with a bare KeyError."""
    dummy = {k: 0 if k in ("duration", "interval", "min_lines", "density") else "x"
             for k in PLACEHOLDERS}
    try:
        tmpl.format(**dummy)
    except KeyError as e:
        return (f"範本裡有不認得的變數 {e}。"
                f"可用的只有：{'、'.join('{%s}' % p for p in PLACEHOLDERS)}")
    except (IndexError, ValueError) as e:
        return (f"範本格式有誤（{e}）。提醒：文字裡若要出現大括號本身，"
                f"要打兩個，例如 {{{{ 和 }}}}")
    return None


def generate_ai(duration, table, highlights, frames, interval,
                context: str = "", log=print, prompts_dir=None,
                attachments_text: str = "", density: float = MAX_GAP,
                template: str | None = None):
    min_lines = max(4, int(duration // density))
    tmpl = (template or AI_PROMPT).strip() or AI_PROMPT
    prompt = tmpl.format(duration=duration, table=_fmt_table(table),
                         highlights=_fmt_highlights(highlights),
                         scenes=_fmt_scenes(frames), interval=interval,
                         context=context.strip() or "(無)",
                         attachments=attachments_text.strip() or "(無)",
                         min_lines=min_lines, density=int(density))
    raw = ai_cli.ask(prompt, timeout=180, log=log)
    if prompts_dir:
        p = Path(prompts_dir)
        p.mkdir(parents=True, exist_ok=True)
        (p / "script_prompt.txt").write_text(prompt, encoding="utf-8")
        (p / "script_reply.txt").write_text(raw, encoding="utf-8")
    lines = _extract_json(raw)
    out = []
    for ln in lines:
        start = float(ln.get("start", 0))
        spoken = str(ln.get("spoken", "")).strip()
        if not spoken or start >= duration:
            continue
        out.append({"start": round(start, 1), "spoken": spoken,
                    "sub": (str(ln["sub"]).strip() if ln.get("sub") else None)})
    out.sort(key=lambda x: x["start"])
    log(f"  AI 產生 {len(out)} 句")
    if len(out) < 3:
        raise ValueError("AI returned too few usable lines")
    filled = _fill_gaps(out, duration, table, density, log)
    return filled


CHARS_PER_SEC = 5.0   # zh-TW edge-tts at +18% rate, measured across the
                      # narration.json lines of a real render (4.2–6.2 c/s)


def estimate_speech_seconds(text: str) -> float:
    """Rough spoken length. TTS hasn't run at script-generation time, so the
    silence measurement has to work off an estimate — without it a 10-second
    line and a 2-second line would be treated as equally long."""
    return max(1.0, len(str(text or "")) / CHARS_PER_SEC)


def _fill_gaps(lines, duration, table, density, log=print):
    """Insert data-readout lines wherever the narration would go quiet for
    longer than `density` seconds, measured from when the previous line
    FINISHES speaking (not when it starts). The model doesn't always honour
    the density instruction, and a long silent stretch makes the cut feel
    dead — so this is enforced here rather than merely requested."""
    by_t = {r["t"]: r for r in table}

    def readout(t):
        r = by_t.get(int(round(t / 10) * 10))
        if not r:
            return None
        bits = []
        if r.get("spd"):
            bits.append(f"時速{r['spd']:.0f}")
        if r.get("hr"):
            bits.append(f"心率{r['hr']:.0f}")
        if r.get("pwr"):
            bits.append(f"功率{r['pwr']:.0f}瓦")
        return "，".join(bits) + "，持續推進。" if bits else None

    out, added = [], 0
    prev_end = 0.0
    for ln in lines + [{"start": duration, "spoken": None}]:
        gap_start, gap_end = prev_end, ln["start"]
        while gap_end - gap_start > density:
            t = round(gap_start + density, 1)
            text = readout(t)
            if not text:
                break
            out.append({"start": t, "spoken": text, "sub": None})
            added += 1
            gap_start = t + estimate_speech_seconds(text)
        if ln["spoken"]:
            out.append(ln)
            prev_end = ln["start"] + estimate_speech_seconds(ln["spoken"])
    out.sort(key=lambda x: x["start"])
    if added:
        log(f"  補了 {added} 句避免超過 {density:.0f} 秒沒旁白（共 {len(out)} 句）")
    return out


# Concrete, checkable claims. Each entry is (label, terms-in-narration,
# terms-that-would-appear-in-a-scene-description). Only visual facts are
# listed — numbers come from the ride data, not the frames, so they are not
# checked here.
GROUNDING_TERMS = [
    ("機車", ["機車", "摩托"], ["機車", "摩托"]),
    ("汽車", ["汽車", "轎車", "小客車"], ["汽車", "轎車", "小客車", "車輛"]),
    ("紅綠燈", ["紅綠燈", "號誌", "紅燈"], ["紅綠燈", "號誌", "紅燈"]),
    ("彎道", ["彎道", "過彎", "轉彎"], ["彎道", "過彎", "轉彎"]),
    ("左側", ["左側", "左邊"], ["左側", "左邊"]),
    ("右側", ["右側", "右邊"], ["右側", "右邊"]),
    ("超車", ["超車", "超越", "超過"], ["超車", "超越"]),
]

# A single still can't distinguish overtaking from merely riding alongside
# someone, so these claims were unreliable and became the main source of
# invented "overtook on the right" narration. Scrubbed from descriptions
# before they ever reach the narration prompt.
_OVERTAKE_RE = re.compile(r"超車|超越|超過|被超")


def strip_overtaking(scene: str) -> str:
    if not scene:
        return scene
    parts = re.split(r"[，,]|且|並(?!排)", scene.rstrip("。"))
    kept = []
    for p in parts:
        p = p.strip().strip("、")
        if not p or _OVERTAKE_RE.search(p):
            continue
        if len(p) <= 2 and p in ("目前", "無", "沒有", "正在", "未"):
            continue
        kept.append(p)
    return ("，".join(kept) + "。") if kept else ""

GROUND_WINDOW = 2.5   # the prompt promises ±2 s; allow a hair more


def check_grounding(script: list, frames: list, window: float = GROUND_WINDOW):
    """Flag narration lines making visual claims that no nearby frame
    description supports.

    This is a heuristic safety net, not a proof of correctness: it catches
    the failure mode actually observed (an event described at t=200 retold
    at t=208, and "left" retold as "right"), and deliberately says
    "unsupported" rather than "wrong" — a claim can be true and simply
    phrased differently from the frame text.
    """
    described = [(f["t"], f["scene"]) for f in frames if f.get("scene")]
    out = []
    for ln in script:
        t = float(ln.get("start", 0))
        near = " ".join(s for ft, s in described if abs(float(ft) - t) <= window)
        spoken = f"{ln.get('spoken', '')} {ln.get('sub') or ''}"
        missing = []
        for label, said, seen in GROUNDING_TERMS:
            if any(w in spoken for w in said) and not any(w in near for w in seen):
                missing.append(label)
        out.append(missing)
    return out


def generate_rules(duration, stats, highlights, log=print, table=None,
                   density: float = MAX_GAP):
    """No-AI fallback: data-driven template lines at detected moments."""
    lines = [{"start": 0.6, "spoken": "出發，計時開始。", "sub": None}]
    spd = stats.get("spd_kmh") or {}
    hr = stats.get("hr") or {}
    if spd and hr:
        lines.append({
            "start": 8.0,
            "spoken": f"目前均速{spd.get('avg')}，心率{hr.get('avg')}，開始進入狀況。",
            "sub": None})
    used = {0, 8}
    for h in highlights:
        t = h["start"] + 0.5
        if any(abs(t - u) < 6 for u in used) or t >= duration - 4:
            continue
        used.add(t)
        if h["kind"] == "power":
            lines.append({"start": t,
                          "spoken": f"瓦數來到{h['peak']:.0f}，踩下去！",
                          "sub": f"{h['peak']:.0f}W"})
        elif h["kind"] == "speed":
            lines.append({"start": t,
                          "spoken": f"時速突破{h['peak']:.0f}公里！",
                          "sub": f"{h['peak']:.0f} km/h"})
        elif h["kind"] == "hr_red":
            dur_s = h["end"] - h["start"]
            lines.append({"start": t,
                          "spoken": f"心率{h['peak']:.0f}，紅區硬撐{dur_s}秒。",
                          "sub": None})
    if spd.get("max"):
        lines.append({"start": max(0.6, duration - 6),
                      "spoken": f"最高時速{spd['max']}，辛苦了，這是今天的紀錄。",
                      "sub": None})
    lines.sort(key=lambda x: x["start"])
    log(f"  規則產生 {len(lines)} 句（未使用 AI）")
    if table:
        lines = _fill_gaps(lines, duration, table, density, log)
    return lines
