# -*- coding: utf-8 -*-
"""Frame sampling and (optional) AI scene descriptions."""
from pathlib import Path

from . import ai_cli
from .ffbin import ffmpeg, run

SCENE_PROMPT = (
    "這是一張自行車比賽的第一人稱行車紀錄畫面。用一句繁體中文（30字內）描述"
    "騎乘情境：前方有幾個人、是在集團內還是單獨騎、有沒有正在超車或被超車、"
    "路況（橋上/平路/彎道/紅綠燈）。只回這一句話，不要其他文字。"
)

# Multi-frame prompts. Two variants because what a frame sequence can
# reliably support depends entirely on the spacing:
#   - ~1s apart (dense): genuine motion — who is passing whom, from which
#     side — is readable and was validated on real footage
#     (spikes/burst_analysis.py).
#   - sparse (e.g. 10s apart): the frames are SAMPLES of a period, not
#     continuous motion. Claiming "X overtook from the left" across a 10s
#     gap would be the exact single-still guessing problem again, so the
#     sparse prompt restricts the AI to changes that sampling CAN prove
#     (rider counts, formation, terrain, obvious distance change).
BURST_PROMPT_DENSE = (
    "這是自行車比賽第一人稱行車紀錄的連續畫面，每約 {step} 秒一張、時間連續"
    "（第 {t0}~{t1} 秒）。請依序看完全部後，用1~2句繁體中文（60字內）描述"
    "這段期間「發生了什麼動態」：我方與前方騎士的距離變化、"
    "有沒有人的相對位置明顯改變（誰、穿什麼顏色車衣、從哪一側往前或往後）、"
    "集團隊形變化、路況。重點是跨張比較的『變化』，不是單張的靜態描述。"
    "只回描述文字。\n{paths}"
)
BURST_PROMPT_SPARSE = (
    "這是自行車比賽第一人稱行車紀錄的「取樣」畫面，每隔約 {step} 秒取一張"
    "（涵蓋第 {t0}~{t1} 秒，張與張之間不連續）。請看完全部後，"
    "用1~2句繁體中文（60字內）描述這段期間可以確定的變化："
    "前方人數增減、單獨騎變集團或相反、隊形明顯改變、路況地形變化、"
    "與前方距離的明顯拉近或拉開。"
    "注意：因為取樣間隔大，「誰從哪一側超車」這類瞬間過程無法從這些畫面確定，"
    "不要臆測，只講取樣能證明的變化。只回描述文字。\n{paths}"
)


def extract_frames(video: str, out_dir: str, interval: int = 10,
                   scale_w: int = 960, duration: float | None = None,
                   log=print) -> list:
    """Sample one frame every `interval` seconds.

    Each interval gets its OWN subdirectory. Sharing one directory was
    actively wrong: the frame list is built by enumerating what's on disk,
    so leftovers from a denser previous run (220 files at 1 s) got counted
    as part of a sparser new run (22 files at 10 s) and every timestamp
    after the first few came out wrong — t=2190 s on a 220 s video.
    Separate directories also mean switching back and forth between
    intervals never re-extracts anything already on disk.
    """
    root = Path(out_dir)
    root.mkdir(parents=True, exist_ok=True)
    # ffmpeg's fps=1/N sampling yields floor(duration/N) frames for a
    # non-exact division (220.2 s at 10 s -> 22, not 23), so this is the
    # count to compare against — an off-by-one here silently defeats the
    # skip and re-extracts every single time.
    expected = max(1, int(duration // interval)) if duration else None

    own = root / f"i{interval}"
    have = sorted(own.glob("f_*.jpg")) if own.is_dir() else []
    if expected and len(have) >= expected:
        log(f"  已有每 {interval} 秒的 {len(have)} 張，沿用不重抽")
        return [{"t": i * interval, "file": f"i{interval}/{p.name}"}
                for i, p in enumerate(have)]

    # A coarser request is a strict subset of a finer extraction already on
    # disk: every 10 s frame is also in the 1 s set. Sub-sampling costs
    # nothing, where re-extracting would burn minutes of CPU to reproduce
    # images that already exist byte-for-byte.
    for other in sorted(root.glob("i*"), key=lambda p: int(p.name[1:])):
        m = int(other.name[1:])
        if m >= interval or interval % m:
            continue
        files = sorted(other.glob("f_*.jpg"))
        picked = files[::interval // m]
        if expected and len(picked) >= expected:
            log(f"  直接從既有的每 {m} 秒截圖每 {interval // m} 張取 1 張，不用重抽")
            return [{"t": i * interval, "file": f"i{m}/{p.name}"}
                    for i, p in enumerate(picked)]

    own.mkdir(parents=True, exist_ok=True)
    run([ffmpeg(), "-y", "-v", "error", "-i", str(video),
         "-vf", f"fps=1/{interval},scale={scale_w}:-1",
         str(own / "f_%04d.jpg")])
    have = sorted(own.glob("f_*.jpg"))
    return [{"t": i * interval, "file": f"i{interval}/{p.name}"}
            for i, p in enumerate(have)]


def extract_frames_multi(clips: list, out_dir: str, interval: int = 10,
                         scale_w: int = 960, log=print) -> list:
    """Multi-clip extraction: each clip goes to its own subdirectory
    (frames/c0/i{N}/…), and frame timestamps are offset onto the OUTPUT
    timeline (concatenated clips), which is what every downstream consumer
    (scene cache, narration, dashboard) speaks.

    clips: [{"path": str, "duration": float, "offset": float}] in order.
    """
    merged = []
    for k, c in enumerate(clips):
        sub = Path(out_dir) / f"c{k}"
        fr = extract_frames(c["path"], str(sub), interval,
                            scale_w=scale_w, duration=c["duration"], log=log)
        off = int(round(c["offset"]))
        for f in fr:
            merged.append({"t": f["t"] + off, "file": f"c{k}/{f['file']}"})
    return merged


def extract_frames_at(items: list, out_dir: str, scale_w: int = 960,
                      log=print) -> list:
    """Targeted extraction for AUTO mode: one seek per wanted second instead
    of decoding the whole video. items = [{"t": output_sec, "path": video,
    "local": clip_local_sec}]. Already-extracted frames are reused."""
    out = Path(out_dir) / "auto"
    out.mkdir(parents=True, exist_ok=True)
    frames = []
    fresh = 0
    for it in items:
        name = f"t{int(it['t']):06d}.jpg"
        dst = out / name
        if not dst.exists():
            run([ffmpeg(), "-y", "-v", "error", "-ss", str(it["local"]),
                 "-i", str(it["path"]), "-frames:v", "1",
                 "-vf", f"scale={scale_w}:-1", str(dst)])
            fresh += 1
        frames.append({"t": int(it["t"]), "file": f"auto/{name}"})
    if fresh < len(items):
        log(f"  {len(items) - fresh} 張已存在沿用，抽了 {fresh} 張新圖")
    return frames


def describe_frames(frames: list, frames_dir: str, log=print, prompts_dir=None,
                    on_each=None, prompt: str | None = None,
                    should_cancel=None) -> list:
    """Adds 'scene' to each frame dict via the user's AI CLI.

    - Frames that already carry a 'scene' are skipped, so a re-run after an
      interruption resumes instead of re-spending AI quota on work done.
    - `on_each(frames)` is invoked after every completed call so the caller
      can persist progress to disk immediately — each description costs real
      time and quota, and losing a half-finished batch to a crash or server
      restart is exactly the failure mode this guards against.
    - Failures are per-frame (logged, skipped) so one bad call doesn't kill
      the batch; a failed frame stays scene-less and will be retried on the
      next run.
    """
    ask_prompt = (prompt or SCENE_PROMPT).strip() or SCENE_PROMPT
    if prompts_dir:
        p = Path(prompts_dir)
        p.mkdir(parents=True, exist_ok=True)
        (p / "scene_prompt.txt").write_text(ask_prompt, encoding="utf-8")
    todo = [f for f in frames if not f.get("scene")]
    if len(todo) < len(frames):
        log(f"  {len(frames) - len(todo)} 張已有辨識結果，續跑剩下 {len(todo)} 張")
    for f in todo:
        if should_cancel and should_cancel():
            from .ffbin import JobCancelled
            raise JobCancelled("使用者中止場景辨識")
        img = str(Path(frames_dir) / f["file"])
        try:
            f["scene"] = ai_cli.ask(ask_prompt, image=img, log=log)
            log(f"  t={f['t']:>4}s  {f['scene']}")
        except Exception as e:  # noqa: BLE001 - user CLI can fail arbitrarily
            f["scene"] = None
            log(f"  t={f['t']:>4}s  (AI失敗: {e})")
        if on_each:
            on_each(frames)
    return frames


def describe_batches(frames: list, frames_dir: str, batch: int, log=print,
                     prompts_dir=None, on_each=None, should_cancel=None,
                     on_progress=None) -> list:
    """Burst mode: consecutive frames are analysed `batch` at a time, one AI
    call per group, producing ONE dynamic description for the whole span.

    The description is written onto every frame of the group with a
    `burst: True` marker. Duplicated text is deliberate: the run-length
    merge in scriptgen collapses identical consecutive descriptions into a
    single `t=a-b` range for the narration prompt, the per-second scene
    cache keeps working unchanged, and resume logic stays trivial (a group
    is done iff all its frames carry a scene).
    """
    groups = [frames[i:i + batch] for i in range(0, len(frames), batch)]
    todo = [g for g in groups if any(not f.get("scene") for f in g)]
    if len(todo) < len(groups):
        log(f"  {len(groups) - len(todo)} 批已有辨識結果，續跑剩下 {len(todo)} 批")

    # spacing decides which prompt is honest to use (see constants above)
    def spacing(g):
        if len(g) < 2:
            return 1
        return max(1, round((g[-1]["t"] - g[0]["t"]) / (len(g) - 1)))

    if prompts_dir:
        p = Path(prompts_dir)
        p.mkdir(parents=True, exist_ok=True)
        sample = spacing(groups[0]) if groups else 1
        tmpl = BURST_PROMPT_DENSE if sample <= 2 else BURST_PROMPT_SPARSE
        (p / "scene_prompt.txt").write_text(tmpl, encoding="utf-8")

    for n, g in enumerate(todo, start=1):
        if should_cancel and should_cancel():
            from .ffbin import JobCancelled
            raise JobCancelled("使用者中止場景辨識")
        paths = [str(Path(frames_dir) / f["file"]) for f in g]
        listing = "\n".join(f"第{i+1}張(第{f['t']}秒): {p}"
                            for i, (f, p) in enumerate(zip(g, paths)))
        step = spacing(g)
        tmpl = BURST_PROMPT_DENSE if step <= 2 else BURST_PROMPT_SPARSE
        prompt = tmpl.format(t0=g[0]["t"], t1=g[-1]["t"], step=step,
                             paths=listing)
        try:
            text = ai_cli.ask(prompt, image=paths, timeout=240, log=log)
            for f in g:
                f["scene"] = text
                # the burst flag exempts a description from the overtaking
                # strip — an exemption only DENSE sequences have earned;
                # sparse samples can't prove passing events, so their text
                # stays subject to the safety strip should the AI slip one in
                if step <= 2:
                    f["burst"] = True
            # full text: the 60-char preview this used to print looked like
            # the description itself was cut off mid-sentence
            log(f"  t={g[0]['t']}~{g[-1]['t']}s  {text}")
        except Exception as e:  # noqa: BLE001
            for f in g:
                f["scene"] = None
            log(f"  t={g[0]['t']}~{g[-1]['t']}s  (AI失敗: {e})")
        if on_each:
            on_each(frames)
        if on_progress:
            on_progress(n, len(todo))
    return frames
