#!/usr/bin/env python3
"""cameo_hunt.py — find actors recognized in titles that aren't on their TMDB resumé.

For each scene_analyze output JSON, every recognized actor is checked against
their own TMDB filmography (combined_credits). If the title an actor was
recognized in is NOT among the series/movies TMDB lists for them, it's flagged
as a cameo candidate — an uncredited/surprise appearance (or, with no
confidence gating, possibly a face misidentification: the two look identical in
this data, so treat the list as leads, not proof).

Resolution is all via TMDB (key from .env), no per-file Jellyfin calls:
  * each title's series/movie is resolved by name (+year) parsed from the
    JSON's "movie" path, cached per unique title;
  * each actor's resumé is fetched once via /person/{id}/combined_credits,
    cached per unique tmdb_id.

Usage:
    python scripts/cameo_hunt.py *.json
    python scripts/cameo_hunt.py --min-seconds 5 --json out.json *.json
"""

import argparse
import json
import os
import re
import sys
from collections import defaultdict
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
import sae_env  # noqa: F401 — loads .env (TMDB_API_KEY, JELLYFIN_*)
from sae_tmdb import tmdb_get
from sae_jellyfin import jf_get, normalize_jellyfin_url

import requests

# Strip episode markers, a "(year)", and release/quality/codec tags to recover a
# searchable series/movie name from a media path.
# Episode markers: "S01E13", "1x03".
_SXXEXX = re.compile(r"\b([sS]\d{1,2}[eE]\d{1,3}|\d{1,2}x\d{1,3})\b")
_YEAR = re.compile(r"[.\s(](\d{4})[.\s)]")
# Common scene-release tags; everything from the first one onward is noise. Match
# as whole tokens so we can also truncate the name at the first tag hit.
_TAG_WORDS = (
    r"\d{3,4}p|2160p|4k|uhd|hdr(10)?(plus)?|web[\s.\-]?dl|web[\s.\-]?rip|"
    r"bluray|blu[\s.\-]?ray|brrip|bdrip|hdtv|dvdrip|remux|"
    r"x26[45]|h\.?26[45]|hevc|xvid|divx|aac|ac3|dts|ddp?5\.1|"
    r"internal|theatrical|extended|uncut|repack|proper"
)
_TAG = re.compile(rf"(?i)\b({_TAG_WORDS})\b")


def parse_title(movie_path: str) -> tuple[str, int | None, bool]:
    """Return (search_name, year, is_tv) parsed from a media file path.

    TV episodes look like ".../<Series> (year)/Season N/<Series> SxxExx ...".
    For those we want the *series* directory name; for movies, the filename.
    """
    p = Path(movie_path)
    parts = p.parts
    has_season_dir = any(re.match(r"(?i)season\s*\d+", x) for x in parts)
    has_ep_marker = bool(_SXXEXX.search(p.stem))
    is_tv = has_season_dir or has_ep_marker

    if has_season_dir:
        # The series folder is the part above the "Season N" dir (…/Series/Season N/file).
        season_idx = next(i for i, x in enumerate(parts) if re.match(r"(?i)season\s*\d+", x))
        raw = parts[season_idx - 1] if season_idx > 0 else p.stem
    else:
        raw = p.stem

    # Pull a 4-digit year (1900–2099) before stripping, if present.
    year = None
    for m in _YEAR.finditer(" " + raw + " "):
        y = int(m.group(1))
        if 1900 <= y <= 2099:
            year = y
            break

    # Truncate at the first release tag (everything after is noise), then strip
    # episode markers / year / separators from what remains.
    tag = _TAG.search(raw)
    if tag:
        raw = raw[: tag.start()]
    name = _SXXEXX.sub(" ", raw)
    name = re.sub(r"[.\s(]\d{4}[.\s)]?", " ", " " + name + " ")  # drop the year token
    name = name.replace(".", " ").replace("_", " ")
    name = re.sub(r"\s+", " ", name).strip(" -")
    return name, year, is_tv


def jf_title_tmdb_id(movie_path: str, jf_url: str, jf_key: str, is_tv: bool,
                     timeout: float = 8.0) -> int | None:
    """Resolve a media path to its (series, for TV) TMDB id via Jellyfin, or None.

    Looks the item up by Path, then reads ProviderIds.Tmdb off the parent Series
    (TV) or the item itself (movie). Uses a short timeout so a slow Path query
    degrades to the TMDB fallback instead of stalling the whole run.
    """
    if not (jf_url and jf_key):
        return None
    base = jf_url.rstrip("/")
    headers = {"X-Emby-Token": jf_key, "Accept": "application/json"}
    try:
        r = requests.get(base + "/Items", headers=headers, timeout=timeout,
                         params={"Path": movie_path, "Recursive": "true",
                                 "IncludeItemTypes": "Episode,Movie",
                                 "Fields": "ProviderIds", "Limit": 1})
        r.raise_for_status()
        items = r.json().get("Items", [])
        if not items:
            return None
        item = items[0]
        if is_tv:
            # The path says TV episode (SxxExx + Season dir). If Jellyfin matched
            # it to a Movie item or an episode with no SeriesId, its Tmdb id is
            # for the wrong title (Jellyfin mis-identified the file) — don't trust
            # it; return None so the caller falls back to TMDB name search.
            if item.get("Type") != "Episode" or not item.get("SeriesId"):
                return None
        target_id = item.get("SeriesId") if is_tv else item.get("Id")
        providers = item.get("ProviderIds", {})
        if is_tv and target_id:
            # ProviderIds on the episode are the episode's; fetch the series'.
            r2 = requests.get(base + "/Items", headers=headers, timeout=timeout,
                             params={"Ids": target_id, "Fields": "ProviderIds",
                                     "Recursive": "true", "Limit": 1})
            r2.raise_for_status()
            sitems = r2.json().get("Items", [])
            providers = sitems[0].get("ProviderIds", {}) if sitems else {}
        tmdb = providers.get("Tmdb")
        return int(tmdb) if tmdb else None
    except (requests.RequestException, ValueError):
        return None


def resolve_title_id(name: str, year: int | None, is_tv: bool, key: str,
                     cache: dict) -> int | None:
    """Resolve a series/movie name to its TMDB id (cached per (name, is_tv))."""
    ck = (name, is_tv)
    if ck in cache:
        return cache[ck]
    endpoint = "/search/tv" if is_tv else "/search/movie"
    yparam = {"first_air_date_year": year} if (is_tv and year) else (
             {"year": year} if year else {})
    try:
        data = tmdb_get(endpoint, key, query=name, **yparam)
    except requests.RequestException as e:
        print(f"  [warn] TMDB search failed for {name!r}: {e}", file=sys.stderr)
        cache[ck] = None
        return None
    results = data.get("results", [])
    tid = results[0]["id"] if results else None
    if tid is None:
        print(f"  [warn] no TMDB match for {name!r} ({'tv' if is_tv else 'movie'})", file=sys.stderr)
    cache[ck] = tid
    return tid


TMDB_ANIMATION_GENRE_ID = 16  # "Animation" — same id for /movie and /tv on TMDB


def is_animation(title_id: int, is_tv: bool, key: str, cache: dict) -> bool:
    """Return True if the title is tagged with TMDB's Animation genre (cached).

    Fetches /movie/{id} or /tv/{id} details (which include `genres`). On a fetch
    failure we return False (don't drop the title on a transient API error).
    """
    ck = (title_id, is_tv)
    if ck in cache:
        return cache[ck]
    endpoint = f"/tv/{title_id}" if is_tv else f"/movie/{title_id}"
    try:
        data = tmdb_get(endpoint, key)
    except requests.RequestException as e:
        print(f"  [warn] genre fetch failed for title {title_id}: {e}", file=sys.stderr)
        cache[ck] = False
        return False
    animated = any(g.get("id") == TMDB_ANIMATION_GENRE_ID for g in data.get("genres", []))
    cache[ck] = animated
    return animated


def actor_resume(tmdb_id: str, key: str, cache: dict) -> set[int] | None:
    """Return the set of TMDB title ids on an actor's resumé (cached), or None on failure."""
    if tmdb_id in cache:
        return cache[tmdb_id]
    try:
        data = tmdb_get(f"/person/{tmdb_id}/combined_credits", key)
    except requests.RequestException as e:
        print(f"  [warn] resumé fetch failed for tmdb person {tmdb_id}: {e}", file=sys.stderr)
        cache[tmdb_id] = None
        return None
    ids = {c["id"] for c in data.get("cast", []) if "id" in c}
    ids |= {c["id"] for c in data.get("crew", []) if "id" in c}
    cache[tmdb_id] = ids
    return ids


def total_seconds(scenes: list) -> float:
    return sum(e - s for s, e in (x for x in scenes if len(x) == 2))


def main():
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("files", nargs="+", help="scene_analyze output JSON files")
    ap.add_argument("--min-seconds", type=float, default=0.0,
                    help="only flag a cameo if the actor's total on-screen time in the "
                         "title is at least this many seconds (default: 0 — flag all)")
    ap.add_argument("--json", default=None, help="also write findings to this JSON file")
    ap.add_argument("--no-skip-animation", dest="skip_animation", action="store_false",
                    help="do not skip titles tagged with TMDB's Animation genre "
                         "(animated titles are skipped by default)")
    args = ap.parse_args()

    key = os.environ.get("TMDB_API_KEY")
    if not key:
        sys.exit("TMDB_API_KEY not set (expected in .env)")
    jf_url = normalize_jellyfin_url(os.environ.get("JELLYFIN_URL", "")) or ""
    jf_key = os.environ.get("JELLYFIN_API_KEY", "")

    title_cache: dict = {}       # (name, is_tv) -> tmdb id, for TMDB name search
    jf_cache: dict = {}          # series folder / movie path -> tmdb id, for Jellyfin
    resume_cache: dict = {}
    genre_cache: dict = {}       # (title_id, is_tv) -> is-animation bool
    cameos: list[dict] = []
    skipped_no_tmdb = 0
    skipped_animation = 0
    n_via_jf = 0

    paths = [Path(f) for f in args.files]
    for i, path in enumerate(paths, 1):
        try:
            data = json.loads(path.read_text())
        except (OSError, ValueError):
            continue
        if not isinstance(data, dict) or "actors" not in data:
            continue
        movie = data.get("movie", "")
        name, year, is_tv = parse_title(movie)

        # Jellyfin first (authoritative TMDB id), cached per series folder so a
        # show is only looked up once across all its episodes; TMDB name search
        # as fallback on timeout/miss.
        title_id = None
        if jf_url and jf_key:
            mp = Path(movie)
            ck = str(mp.parent.parent) if is_tv else movie  # series folder vs movie file
            if ck in jf_cache:
                title_id = jf_cache[ck]
            else:
                title_id = jf_title_tmdb_id(movie, jf_url, jf_key, is_tv)
                jf_cache[ck] = title_id
            if title_id is not None:
                n_via_jf += 1
        if title_id is None:
            title_id = resolve_title_id(name, year, is_tv, key, title_cache)
        if title_id is None:
            continue  # can't decide cameo without the title's id

        if args.skip_animation and is_animation(title_id, is_tv, key, genre_cache):
            skipped_animation += 1
            continue

        for actor in data["actors"]:
            tmdb_id = actor.get("tmdb_id")
            if not tmdb_id:
                skipped_no_tmdb += 1
                continue
            secs = total_seconds(actor.get("scenes", []))
            if secs < args.min_seconds:
                continue
            resume = actor_resume(tmdb_id, key, resume_cache)
            if resume is None:
                continue
            if title_id not in resume:
                cameos.append({
                    "actor": actor.get("name", ""),
                    "tmdb_id": tmdb_id,
                    "title": name,
                    "title_id": title_id,
                    "is_tv": is_tv,
                    "file": path.name,
                    "seconds": round(secs, 1),
                    "scenes": len(actor.get("scenes", [])),
                })
        if i % 50 == 0:
            print(f"  …{i}/{len(paths)} files, {len(cameos)} candidates so far", file=sys.stderr)

    # Report, grouped by actor, most on-screen time first.
    cameos.sort(key=lambda c: (c["actor"], -c["seconds"]))
    by_actor = defaultdict(list)
    for c in cameos:
        by_actor[c["actor"]].append(c)

    print(f"\n(resolved {n_via_jf} title lookup(s) via Jellyfin, rest via TMDB search)",
          file=sys.stderr)
    print(f"\n=== {len(cameos)} cameo candidate(s) across {len(by_actor)} actor(s) ===\n")
    for actor in sorted(by_actor):
        rows = by_actor[actor]
        print(f"{actor}:")
        for c in rows:
            kind = "TV" if c["is_tv"] else "film"
            print(f"    {c['title']!r} ({kind})  {c['seconds']}s / {c['scenes']} scene(s)  [{c['file']}]")
    if skipped_animation:
        print(f"\n({skipped_animation} animated title file(s) skipped)", file=sys.stderr)
    if skipped_no_tmdb:
        print(f"\n({skipped_no_tmdb} recognized actor(s) had no tmdb_id and were skipped)",
              file=sys.stderr)

    if args.json:
        Path(args.json).write_text(json.dumps(cameos, indent=2) + "\n")
        print(f"\nWrote {len(cameos)} candidate(s) to {args.json}", file=sys.stderr)


if __name__ == "__main__":
    main()
