#!/usr/bin/env python3
"""Find the moment that earns the first three seconds, and build the open around it.

A talking-head clip almost never starts on its best moment. The speaker warms
up: the first sentence is setup, the delivery is flat, and the frame is static
because nothing has happened yet. That opening loses the viewer before the
content arrives — and no amount of pace later recovers them.

This picks the open from the material instead of from taste. Three things are
measured across the whole file and combined per candidate phrase:

  * **vocal intensity** — level above the speaker's own median, so a naturally
    quiet speaker is not scored against a loud one;
  * **pitch excursion** — how far f0 moves inside the phrase. Flat pitch is
    the acoustic signature of reading; a wide excursion is the signature of
    meaning something;
  * **visual motion** — mean inter-frame difference over the phrase. A gesture,
    a lean-in, a head turn. This is the part a transcript cannot see and the
    reason a phrase that reads well on paper can still open on a frozen face.

Speech rate is measured too, but as a *modifier*: a burst of fast words is only
interesting when the level and pitch agree, otherwise it is just mumbling.

The output is a plan, not a render: which phrase opens, where it is cut from,
how the punch-in moves, which words to emphasise in the captions, and which
transition rejoins the original start. Nothing is invented — every timestamp is
a word boundary from the transcript.
"""
from __future__ import annotations

import argparse
import json
import re
import subprocess
import sys
from pathlib import Path

import numpy as np

sys.path.insert(0, str(Path(__file__).resolve().parent))

from media_probe import probe  # noqa: E402
from skill_config import emit, require_binary, utf8_stdout  # noqa: E402

RATE = 16000
FRAME = 1024
HOP = 256
SENTENCE_END = re.compile(r"[.!?…]+[»\")\]]*$")


def decode(path: Path) -> np.ndarray:
    done = subprocess.run(
        ["ffmpeg", "-v", "error", "-nostdin", "-i", str(path),
         "-map", "0:a:0", "-f", "f32le", "-ac", "1", "-ar", str(RATE), "-"],
        capture_output=True)
    if done.returncode:
        raise SystemExit((done.stderr or b"").decode("utf-8", "replace")[-800:])
    return np.frombuffer(done.stdout, dtype="<f4").astype(np.float64)


def level_and_pitch(signal: np.ndarray) -> tuple[np.ndarray, np.ndarray, float]:
    """Per-frame level in dB and f0 in Hz, plus the frame rate."""
    frames = 1 + max(0, (signal.size - FRAME)) // HOP
    view = np.lib.stride_tricks.as_strided(
        signal, shape=(frames, FRAME),
        strides=(signal.strides[0] * HOP, signal.strides[0]))
    level = 20.0 * np.log10(np.sqrt((view ** 2).mean(axis=1)) + 1e-12)

    # Autocorrelation f0 over the human speaking range. Unvoiced frames come out
    # as 0 and are dropped rather than being counted as a pitch of "whatever the
    # noise correlated with", which would flatten every excursion.
    low, high = int(RATE / 350), int(RATE / 70)
    windowed = view * np.hanning(FRAME)
    pitch = np.zeros(frames)
    for index in range(frames):
        block = windowed[index]
        energy = float((block ** 2).sum())
        if energy < 1e-6:
            continue
        correlation = np.correlate(block, block, mode="full")[FRAME - 1:]
        segment = correlation[low:high]
        if segment.size == 0:
            continue
        lag = int(np.argmax(segment)) + low
        if correlation[lag] > 0.32 * correlation[0]:
            pitch[index] = RATE / lag
    return level, pitch, RATE / HOP


def motion_curve(path: Path, duration: float) -> tuple[np.ndarray, float]:
    """Mean inter-frame difference per second of the clip.

    Read from ffmpeg's own metadata rather than by decoding frames in Python:
    `tblend=difference` plus `signalstats` gives the average magnitude of change
    between consecutive frames, which is exactly "how much is moving" without
    any assumption about what is moving.
    """
    done = subprocess.run(
        ["ffmpeg", "-v", "info", "-nostdin", "-i", str(path),
         "-vf", "fps=10,scale=160:-2,format=gray,tblend=all_mode=difference,"
                "signalstats,metadata=print:key=lavfi.signalstats.YAVG:file=-",
         "-f", "null", "-"],
        capture_output=True, text=True, errors="replace")
    values: list[float] = []
    for line in (done.stdout or "").splitlines():
        if "YAVG=" in line:
            try:
                values.append(float(line.split("YAVG=")[1].strip()))
            except ValueError:
                continue
    if not values:
        return np.zeros(1), 10.0
    return np.asarray(values), 10.0


def load_words(path: Path) -> list[dict]:
    data = json.loads(path.read_text(encoding="utf-8"))
    words = data.get("words") if isinstance(data, dict) else data
    if not words:
        raise SystemExit(f"No words in {path}")
    return [{"i": index, "word": entry["word"],
             "start": float(entry["start"]), "end": float(entry["end"])}
            for index, entry in enumerate(words)]


def phrase_candidates(words: list[dict], min_words: int, max_words: int,
                      max_seconds: float) -> list[tuple[int, int]]:
    """Runs that begin after a sentence end and finish on one.

    Cutting mid-sentence to make an opening is how a cold open ends up sounding
    like a mistake rather than a hook, so candidates are bounded by the
    punctuation the transcriber actually produced.
    """
    starts = [0] + [index + 1 for index, entry in enumerate(words)
                    if SENTENCE_END.search(entry["word"]) and index + 1 < len(words)]
    ends = {index for index, entry in enumerate(words)
            if SENTENCE_END.search(entry["word"])}
    ends.add(len(words) - 1)
    out: list[tuple[int, int]] = []
    for start in starts:
        for end in sorted(ends):
            if end < start + min_words - 1:
                continue
            if end > start + max_words - 1:
                break
            if words[end]["end"] - words[start]["start"] > max_seconds:
                break
            out.append((start, end))
    return out


def main() -> None:
    utf8_stdout()
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument("source")
    parser.add_argument("words", help="*.words.json from transcribe_words.py")
    parser.add_argument("--out", default="cold_open.json")
    parser.add_argument("--min-words", type=int, default=4)
    parser.add_argument("--max-words", type=int, default=20)
    parser.add_argument("--max-seconds", type=float, default=6.0)
    parser.add_argument("--skip-first", type=float, default=2.0,
                        help="A phrase that already starts here is not worth moving")
    parser.add_argument("--protect-ending", type=float, default=0.12,
                        help="Fraction of the file at the end treated as the payoff and "
                             "discounted: an open promises, it does not spoil")
    parser.add_argument("--punch-in", type=float, default=1.12,
                        help="Scale the open starts at before settling to 1.0")
    parser.add_argument("--rejoin", default="flash_punch",
                        help="Transition id that returns to the original start")
    parser.add_argument("--top", type=int, default=5)
    args = parser.parse_args()

    require_binary("ffmpeg")
    source = Path(args.source).resolve()
    words = load_words(Path(args.words).resolve())
    info = probe(source)
    duration = float((info.get("video") or {}).get("duration") or info.get("duration") or 0.0)

    signal = decode(source)
    level, pitch, audio_fps = level_and_pitch(signal)
    motion, motion_fps = motion_curve(source, duration)

    median_level = float(np.median(level[level > np.percentile(level, 25)]))
    voiced = pitch[pitch > 0]
    median_pitch = float(np.median(voiced)) if voiced.size else 0.0
    median_motion = float(np.median(motion))

    def window(curve: np.ndarray, fps: float, start: float, end: float) -> np.ndarray:
        lo, hi = int(start * fps), max(int(start * fps) + 1, int(end * fps))
        return curve[lo:min(hi, curve.size)]

    scored = []
    for start_index, end_index in phrase_candidates(words, args.min_words,
                                                    args.max_words, args.max_seconds):
        start = words[start_index]["start"]
        end = words[end_index]["end"]
        if end - start < 0.6:
            continue
        span_level = window(level, audio_fps, start, end)
        span_pitch = window(pitch, audio_fps, start, end)
        span_motion = window(motion, motion_fps, start, end)
        if span_level.size == 0:
            continue
        loud = span_level[span_level > np.percentile(span_level, 55)]
        intensity = float(np.mean(loud) - median_level) if loud.size else 0.0
        voiced_span = span_pitch[span_pitch > 0]
        if voiced_span.size >= 4 and median_pitch:
            excursion = float(np.percentile(voiced_span, 90) - np.percentile(voiced_span, 10))
            excursion_semitones = 12 * np.log2((median_pitch + excursion) / median_pitch)
        else:
            excursion_semitones = 0.0
        # Motion against this file's own median, not in absolute units. A raw
        # mean rewards any phrase in a busy clip equally, so a promo line
        # delivered loudly at a frozen face outscores a real gesture: it wins on
        # level and pitch and pays nothing for standing still.
        move = (float(np.mean(span_motion)) - median_motion) if span_motion.size else 0.0
        rate = (end_index - start_index + 1) / max(0.4, end - start)

        # Level and pitch carry the delivery, motion carries the frame. Rate only
        # amplifies what the other two already found — on its own it rewards
        # someone rushing through a filler.
        agreement = 1.0 + 0.10 * max(0.0, rate - 3.0) if (intensity > 0.6 and
                                                          excursion_semitones > 1.5) else 1.0
        score = (intensity * 3.2 + excursion_semitones * 2.4 + move * 1.8) * agreement
        if start < args.skip_first:
            score *= 0.35
        # The payoff and the call to action live at the end, and they are almost
        # always the loudest and most animated thing in the file — exactly what
        # this scorer rewards. Moving them to the front spends the ending to buy
        # the opening, so the tail is discounted rather than ranked on merit.
        tail_from = duration * (1.0 - args.protect_ending)
        in_ending = start >= tail_from
        if in_ending:
            score *= 0.30
        scored.append({
            "word_from": start_index, "word_to": end_index,
            "start": round(start, 3), "end": round(end, 3),
            "duration": round(end - start, 3),
            "text": " ".join(entry["word"] for entry in words[start_index:end_index + 1]).strip(),
            "intensity_db_over_median": round(intensity, 2),
            "pitch_excursion_semitones": round(excursion_semitones, 2),
            "visual_motion_over_median": round(move, 2),
            "words_per_second": round(rate, 2),
            "score": round(score, 2),
            "already_at_start": start < args.skip_first,
            "in_protected_ending": in_ending,
        })

    if not scored:
        raise SystemExit("No usable phrase found — check the transcript punctuation")
    scored.sort(key=lambda item: item["score"], reverse=True)
    best = scored[0]

    original_first_word = words[0]["start"]
    plan = {
        "version": "1.0",
        "source": str(source),
        "source_duration": round(duration, 3),
        "measured": {
            "median_speech_level_db": round(median_level, 2),
            "median_pitch_hz": round(median_pitch, 1),
            "mean_visual_motion": round(float(np.mean(motion)), 2),
        },
        "cold_open": {
            "source_in": best["start"],
            "source_out": best["end"],
            "text": best["text"],
            "why": (f"уровень +{best['intensity_db_over_median']} дБ над медианой этого спикера, "
                    f"размах тона {best['pitch_excursion_semitones']} полутона, "
                    f"движение в кадре {best['visual_motion_over_median']:+} относительно "
                    f"медианы файла ({round(median_motion, 2)})"),
            "decision": ("Это ранжирование, а не выбор. Акустика измеряет подачу, но не смысл: "
                         "рекламная строка звучит громко и на широком тоне, а обещанием ролика "
                         "не является. Прочти shortlist и выбери фразу, которая ставит вопрос."),
            "emphasis_words": [words[index]["word"] for index in
                               range(best["word_from"], best["word_to"] + 1)][:3],
        },
        "motion": {
            "punch_in_from": args.punch_in,
            "punch_in_to": 1.0,
            "punch_in_seconds": 1.2,
            "ease": "expo.out",
            "why": ("Первая секунда обязана двигаться. Наезд с "
                    f"{args.punch_in:.2f} до 1.0 даёт движение, не трогая композицию "
                    "и не требуя второго плана."),
        },
        "rejoin": {
            "transition": args.rejoin,
            "into_source_at": round(original_first_word, 3),
            "why": "Возврат к настоящему началу помечается переходом, иначе холодный "
                   "старт читается как склеенная ошибка.",
        },
        "caption": {
            "preset_hint": "neo_brutal",
            "why": "Только на холодном старте: карточка бьёт сильнее строки, но держать "
                   "её весь ролик нельзя.",
        },
        "sfx": [
            {"id": "riser_tension", "at": round(max(0.0, best["duration"] - 1.2), 3),
             "gain_db": -18, "why": "нагнетание к стыку с настоящим началом"},
            {"id": "flash_pop", "at": round(best["duration"], 3), "gain_db": -13,
             "why": "пик звука совпадает с точкой склейки"},
        ],
        "alternatives": scored[1:args.top],
        "checks": [
            "Прослушай выбранную фразу целиком: она должна быть понятна без контекста.",
            "Убедись, что фраза не спойлерит финал — холодный старт обещает, а не пересказывает.",
            "Проверь липсинк на стыке: фраза переносится вместе со своим видео, не поверх другого.",
        ],
    }
    Path(args.out).write_text(json.dumps(plan, ensure_ascii=False, indent=2), encoding="utf-8")
    emit({**plan, "out": args.out, "candidates_considered": len(scored)})


if __name__ == "__main__":
    main()
