"""
Cached vision descriptions for ambiguous trailer/source matching.

This module is deliberately conservative: it never writes a final match and it
does not replace CV. It describes a small number of 3-frame beat/scene samples,
caches those descriptions, and returns extra source in-point seeds for the CV
scanner to verify.
"""

from __future__ import annotations

import base64
import json
import logging
import re
import socket
import time
import urllib.error
import urllib.request
from dataclasses import asdict
from pathlib import Path
from typing import Sequence

import cv2

from src.core.config import AppConfig
from src.core.models import Scene, TrailerBeat

logger = logging.getLogger(__name__)

_CACHE_VERSION = 1
_STOPWORDS = {
    "the", "and", "with", "from", "that", "this", "there", "their", "into",
    "scene", "frame", "image", "shot", "video", "visible", "looks", "appears",
    "eine", "einer", "einem", "einen", "und", "oder", "mit", "der", "die", "das",
}

_SYSTEM_PROMPT = """You describe film shots for automatic matching.
Return only compact JSON with these keys:
subject, setting, composition, action_phase, distinctive_objects, lighting_color, negatives.
Focus on stable visual facts and spatial layout. Ignore timecode overlays, subtitles, logos, compression, aspect ratio, and color grading differences."""

_RETRYABLE_HTTP_CODES = {408, 409, 425, 429, 500, 502, 503, 504}
_CREDIT_ERROR_PATTERNS = (
    "insufficient credit",
    "insufficient credits",
    "no credits",
    "out of credits",
    "billing",
    "quota exceeded",
    "payment required",
)

_ACTION_GROUPS = {
    "kiss": {"kiss", "kisses", "kissing", "kissed"},
    # "touch" is intentionally NOT in forehead_touch — a generic "touch" can
    # mean "touches the door / handle / brush" which is unrelated to person
    # contact. forehead_touch is added in _semantic_action_groups() only
    # when an explicit body-part target is present.
    "forehead_touch": {"forehead", "foreheads"},
    "approach": {"approach", "approaches", "approaching", "closer", "lean", "leans", "leaning"},
    "talk": {"talk", "talking", "speak", "speaking", "conversation", "conversing"},
    "hand": {"hand", "hands", "holding", "holds", "raise", "raises", "raising", "lift", "lifting"},
    "cutting": {"cut", "cuts", "cutting", "knife", "blade", "scissors"},
    "look_down": {"down", "lowering", "lowers"},
    "turn": {"turn", "turns", "turning"},
}

# Words that, when paired with "touch"-family verbs, signal an object touch
# (door, handle, brush, tool, ...) rather than a person-on-person touch.
_OBJECT_TOUCH_TARGETS = {
    "door", "doors", "handle", "knob", "lock", "mechanism", "brush", "tool",
    "pole", "stand", "rail", "button", "switch", "wall", "surface", "object",
    "knife", "blade", "weapon", "phone", "glass", "bottle", "cup",
}
# Words that, when paired with "touch", signal a person-on-person touch
# (forehead/face/skin/...). These keep forehead_touch as a strong action.
_PERSON_TOUCH_TARGETS = {
    "forehead", "foreheads", "face", "faces", "cheek", "cheeks",
    "head", "skin", "lips", "lip", "neck", "shoulder", "shoulders",
    "arm", "arms", "chest", "hand", "hands", "hair", "body",
}
_STRONG_ACTION_GROUPS = {"kiss", "forehead_touch", "approach", "hand", "cutting"}


def _cache_path(cfg: AppConfig) -> Path:
    return cfg.paths.cache_dir / "vision_descriptions.json"


def _load_cache(cfg: AppConfig) -> dict:
    path = _cache_path(cfg)
    if not path.exists():
        return {"version": _CACHE_VERSION, "items": {}}
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except json.JSONDecodeError:
        logger.warning("Vision cache is unreadable; rebuilding: %s", path)
        return {"version": _CACHE_VERSION, "items": {}}
    if data.get("version") != _CACHE_VERSION or not isinstance(data.get("items"), dict):
        return {"version": _CACHE_VERSION, "items": {}}
    return data


def _save_cache(cfg: AppConfig, cache: dict) -> None:
    path = _cache_path(cfg)
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(cache, indent=2, ensure_ascii=False), encoding="utf-8")


def _sample_times(start_s: float, end_s: float) -> list[float]:
    duration_s = max(0.04, end_s - start_s)
    return [
        start_s + min(duration_s * 0.12, max(0.0, duration_s - 0.04)),
        start_s + duration_s * 0.50,
        start_s + max(0.0, duration_s - min(duration_s * 0.12, 0.20)),
    ]


def _frame_data_url(video_path: Path, t_s: float) -> str | None:
    cap = cv2.VideoCapture(str(video_path))
    try:
        if not cap.isOpened():
            return None
        cap.set(cv2.CAP_PROP_POS_MSEC, max(0.0, t_s) * 1000.0)
        ok, frame = cap.read()
        if not ok or frame is None:
            return None
        h, w = frame.shape[:2]
        if w > 640:
            frame = cv2.resize(frame, (640, int(h * (640 / w))), interpolation=cv2.INTER_AREA)
        ok, encoded = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), 72])
        if not ok:
            return None
        payload = base64.b64encode(encoded.tobytes()).decode("ascii")
        return f"data:image/jpeg;base64,{payload}"
    finally:
        cap.release()


def _call_vision_model(label: str, image_urls: list[str], cfg: AppConfig) -> str:
    vision = cfg.vision
    if vision.provider in ("openai", "openrouter") and not vision.api_key:
        raise RuntimeError(
            "Vision is enabled but no API key is available. Set VISION_API_KEY, "
            "OPENROUTER_API_KEY, OPENAI_API_KEY, or LLM_API_KEY."
        )

    content: list[dict] = [{
        "type": "text",
        "text": (
            f"Describe this 3-frame sample for matching. Label: {label}. "
            "The frames are start, middle, and end of the same beat/scene."
        ),
    }]
    content.extend({
        "type": "image_url",
        "image_url": {"url": url, "detail": "low"},
    } for url in image_urls)

    headers = {
        "Content-Type": "application/json",
        "Authorization": f"Bearer {vision.api_key}",
    }
    if vision.provider == "openrouter":
        headers["HTTP-Referer"] = "https://github.com/ai-trailer-2026"
        headers["X-Title"] = "AI Trailer Generator v2"

    body = json.dumps({
        "model": vision.model,
        "messages": [
            {"role": "system", "content": _SYSTEM_PROMPT},
            {"role": "user", "content": content},
        ],
        "temperature": vision.temperature,
        "max_tokens": vision.max_tokens,
    }).encode("utf-8")

    url = f"{vision.base_url.rstrip('/')}/chat/completions"
    req = urllib.request.Request(url, data=body, headers=headers, method="POST")
    delays_s = (8.0, 20.0, 45.0, 90.0)
    for attempt in range(len(delays_s) + 1):
        try:
            with urllib.request.urlopen(req, timeout=vision.timeout_seconds) as resp:
                data = json.loads(resp.read().decode("utf-8"))
                return str(data["choices"][0]["message"]["content"]).strip()
        except urllib.error.HTTPError as exc:
            body_text = exc.read().decode(errors="replace")
            lowered = body_text.lower()
            if exc.code == 402 or any(pattern in lowered for pattern in _CREDIT_ERROR_PATTERNS):
                raise RuntimeError(f"Vision HTTP {exc.code} from {url}:\n{body_text}") from exc
            if exc.code not in _RETRYABLE_HTTP_CODES or attempt >= len(delays_s):
                raise RuntimeError(f"Vision HTTP {exc.code} from {url}:\n{body_text}") from exc
            delay_s = delays_s[attempt]
            logger.warning(
                "Vision HTTP %d for %s; waiting %.0fs before retry %d/%d.",
                exc.code,
                label,
                delay_s,
                attempt + 1,
                len(delays_s),
            )
            time.sleep(delay_s)
        except urllib.error.URLError as exc:
            if attempt >= len(delays_s):
                raise RuntimeError(f"Vision request failed for {url}: {exc}") from exc
            delay_s = delays_s[attempt]
            logger.warning(
                "Vision request failed for %s (%s); waiting %.0fs before retry %d/%d.",
                label,
                exc.reason,
                delay_s,
                attempt + 1,
                len(delays_s),
            )
            time.sleep(delay_s)
        except (TimeoutError, socket.timeout, ConnectionError, OSError) as exc:
            if attempt >= len(delays_s):
                raise RuntimeError(f"Vision request failed for {url}: {exc}") from exc
            delay_s = delays_s[attempt]
            logger.warning(
                "Vision request network error for %s (%s); waiting %.0fs before retry %d/%d.",
                label,
                exc,
                delay_s,
                attempt + 1,
                len(delays_s),
            )
            time.sleep(delay_s)

    raise RuntimeError(f"Vision request failed unexpectedly for {url}")


def _description_key(kind: str, item_id: int, start_s: float, end_s: float, cfg: AppConfig) -> str:
    path = cfg.paths.reference_trailer if kind == "beat" else cfg.paths.source_movie
    try:
        stamp = int(path.stat().st_mtime)
    except OSError:
        stamp = 0
    return (
        f"{kind}:{item_id}:"
        f"{start_s:.3f}:{end_s:.3f}:"
        f"{cfg.vision.provider}:{cfg.vision.model}:{stamp}"
    )


def _describe_sample(
    *,
    kind: str,
    item_id: int,
    label: str,
    video_path: Path,
    start_s: float,
    end_s: float,
    cfg: AppConfig,
    cache: dict,
    budget: list[int],
) -> str | None:
    key = _description_key(kind, item_id, start_s, end_s, cfg)
    cached = cache["items"].get(key)
    if cached:
        return str(cached.get("description", ""))
    if budget[0] <= 0:
        return None

    image_urls = [
        url for url in (_frame_data_url(video_path, t) for t in _sample_times(start_s, end_s))
        if url is not None
    ]
    if len(image_urls) < 2:
        return None

    description = _call_vision_model(label, image_urls, cfg)
    cache["items"][key] = {
        "kind": kind,
        "item_id": item_id,
        "start_s": start_s,
        "end_s": end_s,
        "label": label,
        "description": description,
    }
    budget[0] -= 1
    return description


def _terms(text: str) -> set[str]:
    words = re.findall(r"[a-zA-Z][a-zA-Z0-9_'-]{2,}", text.lower())
    return {w for w in words if w not in _STOPWORDS}


def _text_similarity(a: str, b: str) -> float:
    ta = _terms(a)
    tb = _terms(b)
    if not ta or not tb:
        return 0.0
    overlap = len(ta & tb)
    return float(overlap / max(8, min(len(ta), len(tb))))


def _semantic_action_groups(text: str) -> set[str]:
    terms = _terms(text)
    lowered = text.lower()
    groups = {
        name
        for name, needles in _ACTION_GROUPS.items()
        if terms & needles
    }
    # Distinguish person-on-person touches from object touches. "touches the
    # red door" must NOT count as forehead_touch; "touches her forehead"
    # must. We look at the action_phase first (most specific), fall back to
    # the full description.
    phase = _action_phase_text(text)
    touch_present = any(w in phase for w in ("touch", "touches", "touching", "touched"))
    if touch_present:
        person_target = any(w in phase for w in _PERSON_TOUCH_TARGETS)
        object_target = any(w in phase for w in _OBJECT_TOUCH_TARGETS)
        if person_target and not object_target:
            groups.add("forehead_touch")
    if "moving closer" in lowered or "move closer" in lowered:
        groups.add("approach")
    if "face-to-face" in lowered or "faces facing" in lowered:
        groups.add("approach")
    return groups


def _semantic_match_score(beat_desc: str, candidate_desc: str) -> tuple[float, str]:
    text_score = _text_similarity(beat_desc, candidate_desc)
    beat_actions = _semantic_action_groups(beat_desc)
    candidate_actions = _semantic_action_groups(candidate_desc)
    required = beat_actions & _STRONG_ACTION_GROUPS
    missing = _missing_action_groups(required, candidate_actions)
    if missing:
        penalty = min(0.45, 0.18 * len(missing))
        text_score = max(0.0, text_score - penalty)
    if required and not missing:
        text_score = min(1.0, text_score + 0.12)
    reason = (
        f"semantic={text_score:.3f} "
        f"beat_actions={sorted(beat_actions)} "
        f"candidate_actions={sorted(candidate_actions)}"
    )
    return text_score, reason


def _missing_action_groups(required: set[str], candidate_actions: set[str]) -> set[str]:
    missing = set(required) - set(candidate_actions)
    if "approach" in missing and ({"kiss", "forehead_touch"} & set(candidate_actions)):
        missing.remove("approach")
    return missing


def _action_phase_text(desc: str) -> str:
    match = re.search(r'"action_phase"\s*:\s*"([^"]+)"', desc, flags=re.IGNORECASE | re.DOTALL)
    if match:
        return match.group(1).lower()
    return desc.lower()


def _action_phase_adjustment(beat_desc: str, candidate_desc: str) -> tuple[float, str]:
    """
    Score whether a candidate is in the same action phase, not just the same scene.

    Vision text can make two moments in one long dialogue sound equally similar:
    "moving closer until foreheads touch" and "static conversation after contact"
    share subjects/composition, but their temporal phase is different. This
    adjustment is intentionally generic and only looks for broad phase words.
    """
    beat_phase = _action_phase_text(beat_desc)
    candidate_phase = _action_phase_text(candidate_desc)
    adjustment = 0.0
    notes: list[str] = []

    beat_dynamic = any(term in beat_phase for term in (
        "moving", "move", "approach", "approaching", "lean", "leaning",
        "raise", "raising", "lift", "lifting", "turn", "turning",
        "pull", "pulling", "transition",
    ))
    candidate_static = any(term in candidate_phase for term in (
        "static", "conversation", "talking", "speaking", "listening",
        "subtle facial", "slight facial",
    ))
    if beat_dynamic and candidate_static:
        adjustment -= 0.18
        notes.append("dynamic_vs_static")

    beat_contact_target = any(term in beat_phase for term in (
        "forehead", "foreheads", "touch", "kiss",
    ))
    candidate_contact = any(term in candidate_phase for term in (
        "forehead", "foreheads", "touch", "touching", "kiss", "kissing",
    ))
    candidate_after_contact = any(term in candidate_phase for term in (
        "gap", "pull back", "pulling back", "look at each other",
        "looks at each other", "conversation",
    ))
    if beat_contact_target and candidate_contact:
        adjustment += 0.08
        notes.append("contact")
    if beat_contact_target and candidate_after_contact and "until" in beat_phase:
        adjustment -= 0.28
        notes.append("after_contact")

    beat_kiss = "kiss" in beat_phase or "kissing" in beat_phase
    candidate_kiss = "kiss" in candidate_phase or "kissing" in candidate_phase
    if beat_kiss and candidate_kiss:
        adjustment += 0.12
        notes.append("kiss")
    elif beat_contact_target and candidate_kiss:
        adjustment += 0.06
        notes.append("kiss_contact")

    if "talk" not in beat_phase and "conversation" in candidate_phase:
        adjustment -= 0.10
        notes.append("unexpected_conversation")

    return adjustment, ",".join(notes) if notes else "phase_neutral"


def _scene_seed_points(scene: Scene, max_points: int) -> list[float]:
    if max_points <= 1 or scene.duration_s <= 0:
        return [scene.start_s]
    usable_end = max(scene.start_s, scene.end_s - 0.2)
    if usable_end <= scene.start_s:
        return [scene.start_s]
    step = (usable_end - scene.start_s) / max(1, max_points - 1)
    return [scene.start_s + step * idx for idx in range(max_points)]


def _scene_window_ranges(scene: Scene, beat: TrailerBeat, max_windows: int) -> list[tuple[float, float]]:
    if max_windows <= 0 or scene.duration_s <= 0:
        return []
    window_s = min(scene.duration_s, max(1.0, beat.duration_s))
    if scene.duration_s <= window_s + 0.2:
        return [(scene.start_s, min(scene.end_s, scene.start_s + window_s))]

    usable_start = scene.start_s
    usable_end = max(scene.start_s, scene.end_s - window_s)
    if max_windows == 1:
        starts = [usable_start + (usable_end - usable_start) * 0.5]
    else:
        step = (usable_end - usable_start) / max(1, max_windows - 1)
        starts = [usable_start + step * idx for idx in range(max_windows)]
    return [(start_s, min(scene.end_s, start_s + window_s)) for start_s in starts]


def _cached_scene_descriptions(
    cache: dict,
    scenes_by_id: dict[int, Scene],
    cfg: AppConfig,
) -> list[tuple[Scene, str]]:
    descriptions: list[tuple[Scene, str]] = []
    items = cache.get("items", {})
    if not isinstance(items, dict):
        return descriptions
    current_model_marker = f":{cfg.vision.provider}:{cfg.vision.model}:"
    for key, item in items.items():
        if current_model_marker not in str(key):
            continue
        if not isinstance(item, dict) or item.get("kind") != "scene":
            continue
        scene_id = item.get("item_id")
        if not isinstance(scene_id, int):
            continue
        scene = scenes_by_id.get(scene_id)
        description = item.get("description", "")
        if scene is not None and isinstance(description, str) and description.strip():
            descriptions.append((scene, description))
    return descriptions


def _add_window_seed_descriptions(
    *,
    beat: TrailerBeat,
    beat_desc: str,
    ranked: list[tuple[float, Scene, str]],
    cfg: AppConfig,
    cache: dict,
    budget: list[int],
    ranked_by_scene: dict[int, tuple[float, Scene, str]],
) -> list[tuple[float, float]]:
    points: list[tuple[float, float]] = []
    if budget[0] <= 0:
        return points

    probe_limit = max(
        cfg.vision.max_seed_scenes * 4,
        cfg.vision.scene_candidate_top_k,
    )
    scenes_to_probe = ranked[: max(1, min(len(ranked), probe_limit))]
    windows_per_scene = max(1, min(6, cfg.vision.seed_points_per_scene // 2))
    for _, scene, _ in scenes_to_probe:
        if budget[0] <= 0:
            break
        if scene.duration_s <= max(beat.duration_s * 1.6, 6.0):
            continue
        for start_s, end_s in _scene_window_ranges(scene, beat, windows_per_scene):
            if budget[0] <= 0:
                break
            desc = _describe_sample(
                kind="scene_window",
                item_id=scene.scene_id,
                label=f"source scene {scene.scene_id} window {start_s:.2f}-{end_s:.2f}",
                video_path=scene.source_path,
                start_s=start_s,
                end_s=end_s,
                cfg=cfg,
                cache=cache,
                budget=budget,
            )
            if not desc:
                continue
            score, _reason = _semantic_match_score(beat_desc, desc)
            if score < cfg.vision.similarity_threshold:
                continue
            semantic_score = min(0.99, score + 0.30)
            existing = ranked_by_scene.get(scene.scene_id)
            if existing is None or semantic_score > existing[0]:
                ranked_by_scene[scene.scene_id] = (semantic_score, scene, "window")
            logger.info(
                "Beat %d: vision window seed scene=%d start=%.3fs score=%.3f",
                beat.beat_id,
                scene.scene_id,
                start_s,
                semantic_score,
            )
            weighted_score = max(
                cfg.cv.deep_scan.coarse_candidate_threshold,
                min(0.99, cfg.vision.seed_score * (0.78 + min(1.0, semantic_score) * 0.22)),
            )
            points.append((start_s, weighted_score))
    return points


def build_vision_seed_in_points(
    beats: Sequence[TrailerBeat],
    scenes: Sequence[Scene],
    cfg: AppConfig,
) -> dict[int, list[tuple[float, float]]]:
    """
    Return extra in-point seeds from cached vision descriptions.

    The function is intentionally small-budget: for each beat it describes the
    beat once and only a few top scene-level candidates. Existing descriptions
    are read from cache and cost nothing.
    """
    if not cfg.vision.enabled:
        return {}
    if not beats or not scenes:
        return {}

    from src.cv.vibe_check import run_vibe_check

    cache = _load_cache(cfg)
    budget = [cfg.vision.max_new_descriptions_per_run]
    scenes_by_id = {scene.scene_id: scene for scene in scenes}
    seeds: dict[int, list[tuple[float, float]]] = {}

    for beat in beats:
        beat_desc = _describe_sample(
            kind="beat",
            item_id=beat.beat_id,
            label=f"trailer beat {beat.beat_id}",
            video_path=beat.trailer_path,
            start_s=beat.start_s,
            end_s=beat.end_s,
            cfg=cfg,
            cache=cache,
            budget=budget,
        )
        if not beat_desc:
            continue

        hits = run_vibe_check(
            beat,
            scenes,
            top_k=cfg.vision.scene_candidate_top_k,
            hist_method=cfg.cv.vibe_check.hist_compare_method,
            phash_max_distance=64,
        )

        ranked_by_scene: dict[int, tuple[float, Scene, str]] = {}
        probe_ranked_by_scene: dict[int, tuple[float, Scene, str]] = {}
        for hit in hits:
            scene = scenes_by_id.get(hit.scene_id)
            if scene is None:
                continue
            scene_desc = _describe_sample(
                kind="scene",
                item_id=scene.scene_id,
                label=f"source scene {scene.scene_id}",
                video_path=scene.source_path,
                start_s=scene.start_s,
                end_s=scene.end_s,
                cfg=cfg,
                cache=cache,
                budget=budget,
            )
            if not scene_desc:
                continue
            probe_score = _text_similarity(beat_desc, scene_desc)
            if probe_score >= cfg.vision.similarity_threshold:
                probe_ranked_by_scene[scene.scene_id] = (
                    min(0.99, probe_score + 0.25),
                    scene,
                    "probe",
                )
            score, _reason = _semantic_match_score(beat_desc, scene_desc)
            if score >= cfg.vision.similarity_threshold:
                ranked_by_scene[scene.scene_id] = (min(0.99, score + 0.25), scene, "vision")

            # Keep the strongest low-level visual candidates as seeds as well.
            # Text descriptions can miss timing-specific repeats inside one
            # scene; the deep scan still has to validate every seed frame.
            vibe_score = max(0.0, min(1.0, float(hit.combined_score)))
            existing = ranked_by_scene.get(scene.scene_id)
            if existing is None or vibe_score > existing[0]:
                ranked_by_scene[scene.scene_id] = (vibe_score, scene, "vibe")
            probe_existing = probe_ranked_by_scene.get(scene.scene_id)
            if probe_existing is None or vibe_score > probe_existing[0]:
                probe_ranked_by_scene[scene.scene_id] = (vibe_score, scene, "vibe")

        for scene, scene_desc in _cached_scene_descriptions(cache, scenes_by_id, cfg):
            probe_score = _text_similarity(beat_desc, scene_desc)
            if probe_score >= cfg.vision.similarity_threshold:
                probe_semantic_score = min(0.99, probe_score + 0.25)
                probe_existing = probe_ranked_by_scene.get(scene.scene_id)
                if probe_existing is None or probe_semantic_score > probe_existing[0]:
                    probe_ranked_by_scene[scene.scene_id] = (probe_semantic_score, scene, "probe-cache")
            score, _reason = _semantic_match_score(beat_desc, scene_desc)
            if score < cfg.vision.similarity_threshold:
                continue
            semantic_score = min(0.99, score + 0.25)
            existing = ranked_by_scene.get(scene.scene_id)
            if existing is None or semantic_score > existing[0]:
                ranked_by_scene[scene.scene_id] = (semantic_score, scene, "cache")

        ranked = sorted(ranked_by_scene.values(), key=lambda item: item[0], reverse=True)
        probe_ranked = sorted(probe_ranked_by_scene.values(), key=lambda item: item[0], reverse=True)
        window_points = _add_window_seed_descriptions(
            beat=beat,
            beat_desc=beat_desc,
            ranked=probe_ranked,
            cfg=cfg,
            cache=cache,
            budget=budget,
            ranked_by_scene=ranked_by_scene,
        )
        ranked = sorted(ranked_by_scene.values(), key=lambda item: item[0], reverse=True)
        seed_limit = min(len(ranked), max(cfg.vision.max_seed_scenes, cfg.vision.max_seed_scenes * 2))
        points: list[tuple[float, float]] = [*window_points]
        for score, scene, source in ranked[:seed_limit]:
            logger.info(
                "Beat %d: vision seed scene=%d score=%.3f source=%s",
                beat.beat_id,
                scene.scene_id,
                score,
                source,
            )
            weighted_score = max(
                cfg.cv.deep_scan.coarse_candidate_threshold,
                min(0.98, cfg.vision.seed_score * (0.75 + min(1.0, score) * 0.25)),
            )
            points.extend(
                (point, weighted_score)
                for point in _scene_seed_points(scene, cfg.vision.seed_points_per_scene)
            )

        if points:
            merged: dict[float, float] = {}
            for point, weighted_score in points:
                key = round(max(0.0, point), 3)
                merged[key] = max(weighted_score, merged.get(key, 0.0))
            seeds[beat.beat_id] = sorted((point, score) for point, score in merged.items())

    _save_cache(cfg, cache)
    return seeds


def validate_match_window_with_vision(
    beat: TrailerBeat,
    *,
    source_path: Path,
    scene_id: int,
    in_point_s: float,
    out_point_s: float,
    cfg: AppConfig,
) -> tuple[bool, str]:
    """
    Verify a final match window with cached vision descriptions.

    This is a conservative safety net for visually ambiguous low-light shots:
    CV may confirm a similar-looking two-shot, but the action phase still has
    to agree with the beat description. Descriptions are cached by exact window.
    """
    if not cfg.vision.enabled:
        return True, "vision disabled"
    if out_point_s <= in_point_s:
        return False, "empty source window"

    cache = _load_cache(cfg)
    budget = [min(2, max(0, cfg.vision.max_new_descriptions_per_run))]
    beat_desc = _describe_sample(
        kind="beat",
        item_id=beat.beat_id,
        label=f"trailer beat {beat.beat_id} verification",
        video_path=beat.trailer_path,
        start_s=beat.start_s,
        end_s=beat.end_s,
        cfg=cfg,
        cache=cache,
        budget=budget,
    )
    if not beat_desc:
        return True, "no beat vision description"

    source_desc = _describe_sample(
        kind="match_window",
        item_id=scene_id,
        label=f"matched source scene {scene_id} window {in_point_s:.2f}-{out_point_s:.2f}",
        video_path=source_path,
        start_s=in_point_s,
        end_s=out_point_s,
        cfg=cfg,
        cache=cache,
        budget=budget,
    )
    _save_cache(cfg, cache)
    if not source_desc:
        return True, "no source vision description"

    score, reason = _semantic_match_score(beat_desc, source_desc)
    beat_actions = _semantic_action_groups(beat_desc) & _STRONG_ACTION_GROUPS
    source_actions = _semantic_action_groups(source_desc)
    missing_actions = _missing_action_groups(beat_actions, source_actions)
    threshold = max(0.32, cfg.vision.similarity_threshold + 0.12)
    if missing_actions and score < threshold:
        return False, f"{reason} missing_actions={sorted(missing_actions)}"
    return True, reason


def find_action_window_in_scene(
    beat: TrailerBeat,
    scene: Scene,
    cfg: AppConfig,
) -> tuple[float, float, float, str] | None:
    """
    Search one already selected source scene for the beat's action phase.

    This is used after CV picked the right broad scene but the wrong time
    inside that scene. It stays automatic and cached: windows are described
    evenly across the scene until the per-run vision budget is consumed.
    """
    if not cfg.vision.enabled or scene.duration_s <= 0:
        return None

    cache = _load_cache(cfg)
    budget = [max(0, cfg.vision.max_new_descriptions_per_run)]
    beat_desc = _describe_sample(
        kind="beat",
        item_id=beat.beat_id,
        label=f"trailer beat {beat.beat_id} action search",
        video_path=beat.trailer_path,
        start_s=beat.start_s,
        end_s=beat.end_s,
        cfg=cfg,
        cache=cache,
        budget=budget,
    )
    if not beat_desc:
        return None

    beat_actions = _semantic_action_groups(beat_desc) & _STRONG_ACTION_GROUPS
    if not beat_actions:
        return None

    max_windows = max(
        cfg.vision.seed_points_per_scene,
        cfg.vision.max_new_descriptions_per_run,
    )
    best: tuple[float, float, float, str] | None = None
    for start_s, end_s in _scene_window_ranges(scene, beat, max_windows):
        desc = _describe_sample(
            kind="action_window",
            item_id=scene.scene_id,
            label=f"source scene {scene.scene_id} action window {start_s:.2f}-{end_s:.2f}",
            video_path=scene.source_path,
            start_s=start_s,
            end_s=end_s,
            cfg=cfg,
            cache=cache,
            budget=budget,
        )
        if not desc:
            continue
        score, reason = _semantic_match_score(beat_desc, desc)
        source_actions = _semantic_action_groups(desc)
        missing_actions = _missing_action_groups(beat_actions, source_actions)
        if missing_actions:
            continue
        threshold = max(0.38, cfg.vision.similarity_threshold + 0.18)
        if score < threshold:
            continue
        phase_adjustment, phase_reason = _action_phase_adjustment(beat_desc, desc)
        adjusted_score = max(0.0, min(1.0, score + phase_adjustment))
        if adjusted_score < threshold:
            continue
        candidate = (
            start_s,
            end_s,
            adjusted_score,
            f"{reason} phase={phase_reason} raw={score:.3f}",
        )
        if best is None or candidate[2] > best[2] + 0.03 or (
            candidate[2] >= best[2] - 0.03 and candidate[0] < best[0]
        ):
            best = candidate

    _save_cache(cfg, cache)
    return best
