#!/usr/bin/env python3
"""cameo_jellyfin.py — find recognised actors not credited in a title, via Jellyfin.

Pure-Jellyfin cameo detection: no TMDB, no id-space cross-walk. For each
scene_analyze output JSON we need two things, both in Jellyfin's own id space:

  * the title's Jellyfin item-id — taken from the JSON's ``jellyfin_item_id``
    field if present (stamped by run_from_jellyfin.py), otherwise resolved by
    shared-cast voting: the item most of the recognised actors are credited in
    is the title we analysed. (The raw ``Path`` lookup and filename name-search
    are both unreliable in this library — see resolve_by_cast.);
  * each recognised actor's ``jellyfin_id`` (already in the JSON).

An actor is a cameo candidate iff the title's item-id is NOT among the library
items Jellyfin credits that person in (``GET /Items?PersonIds=<id>``). Because
both sides come from Jellyfin, a lead role is never flagged — the whole
class of id-mismatch false positives disappears.

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

import argparse
import json
import os
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 (JELLYFIN_URL, JELLYFIN_API_KEY, TMDB_API_KEY)
from sae_jellyfin import normalize_jellyfin_url
from sae_tmdb import tmdb_get

import re
import requests


def norm_title(s: str) -> str:
    """Normalise a title for cross-source name matching (drop punctuation/case)."""
    s = s.lower()
    s = re.sub(r"[^a-z0-9]+", " ", s)
    return re.sub(r"\s+", " ", s).strip()


def tmdb_resume_titles(tmdb_id, key: str, cache: dict) -> set[str] | None:
    """Normalised set of title names on an actor's TMDB resumé (cached), or None.

    Used as an independent second opinion: Jellyfin's People metadata sometimes
    omits even a lead (e.g. Damian Lewis absent from Billions), so we only flag a
    cameo when TMDB *also* lacks the title. Matching by normalised name (not id)
    sidesteps the TMDB title-id mismatch that plagues the pure-TMDB approach.
    """
    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] TMDB resumé fetch failed for {tmdb_id}: {e}", file=sys.stderr)
        cache[tmdb_id] = None
        return None
    titles = set()
    for c in data.get("cast", []) + data.get("crew", []):
        t = c.get("title") or c.get("name") or c.get("original_title") or c.get("original_name")
        if t:
            titles.add(norm_title(t))
    cache[tmdb_id] = titles
    return titles

class Jellyfin:
    def __init__(self, base: str, key: str, timeout: float = 10.0):
        self.base = base.rstrip("/")
        self.h = {"X-Emby-Token": key, "Accept": "application/json"}
        self.timeout = timeout
        self._person_items: dict[str, set[str]] = {}   # jellyfin_id -> {item ids}
        self._item_name: dict[str, str] = {}           # item id -> Name

    def _get(self, path: str, **params) -> dict:
        r = requests.get(self.base + path, headers=self.h, params=params,
                         timeout=self.timeout)
        r.raise_for_status()
        return r.json()

    def item_name(self, item_id: str) -> str:
        """Display name of a library item (cached), or "" on failure."""
        if item_id in self._item_name:
            return self._item_name[item_id]
        try:
            data = self._get("/Items", Ids=item_id, Recursive="true", Limit=1)
            items = data.get("Items", [])
            name = items[0].get("Name", "") if items else ""
        except requests.RequestException:
            name = ""
        self._item_name[item_id] = name
        return name

    def person_item_ids(self, jellyfin_id: str) -> set[str]:
        """Set of library item-ids Jellyfin credits this person in (cached)."""
        if jellyfin_id in self._person_items:
            return self._person_items[jellyfin_id]
        try:
            data = self._get("/Items", PersonIds=jellyfin_id, Recursive="true",
                             IncludeItemTypes="Movie,Series", Limit=500)
            ids = {it["Id"] for it in data.get("Items", []) if "Id" in it}
        except requests.RequestException as e:
            print(f"  [warn] PersonIds lookup failed for {jellyfin_id}: {e}",
                  file=sys.stderr)
            ids = set()
        self._person_items[jellyfin_id] = ids
        return ids

    def resolve_by_cast(self, actor_ids: set[str]) -> str | None:
        """Resolve the analysed title's item-id by shared-cast voting.

        No filename parsing, no name search (both unreliable — see the module
        docstring / [[jellyfin-path-lookup-unreliable]]). Each recognised actor's
        PersonIds filmography is a set of Movie/Series item-ids; the title we
        analysed is the item the most of them share. Reuses the person_item_ids
        cache, so this is ~free beyond the per-actor lookups we already do.

        Works identically for film and TV (a TV title resolves to its Series
        item). Returns None if fewer than two actors agree — one actor alone
        isn't enough to trust (they could be a genuine cross-title match).
        """
        votes: dict[str, int] = {}
        for jid in actor_ids:
            for iid in self.person_item_ids(jid):
                votes[iid] = votes.get(iid, 0) + 1
        if not votes:
            return None
        best_id, best_n = max(votes.items(), key=lambda kv: kv[1])
        return best_id if best_n >= 2 else None


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 if the actor's on-screen time is >= this (default 0)")
    ap.add_argument("--json", default=None, help="also write findings to this JSON file")
    ap.add_argument("--no-tmdb-confirm", dest="tmdb_confirm", action="store_false",
                    help="skip the TMDB second-opinion cross-check (flag on Jellyfin "
                         "credits alone; more false positives from Jellyfin metadata gaps)")
    args = ap.parse_args()

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

    tmdb_key = os.environ.get("TMDB_API_KEY", "") if args.tmdb_confirm else ""
    if args.tmdb_confirm and not tmdb_key:
        sys.exit("TMDB_API_KEY not set (needed for cross-check; pass --no-tmdb-confirm to skip)")
    resume_cache: dict = {}

    cameos: list[dict] = []
    n_unresolved = 0
    n_via_stamp = 0
    n_tmdb_saved = 0  # candidates rejected because TMDB *does* credit the actor

    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", "")
        actor_ids = {a["jellyfin_id"] for a in data["actors"] if a.get("jellyfin_id")}

        title_id = data.get("jellyfin_item_id")
        if title_id:
            n_via_stamp += 1
        else:
            title_id = jf.resolve_by_cast(actor_ids)
        if not title_id:
            n_unresolved += 1
            continue
        title_name = norm_title(jf.item_name(title_id)) if args.tmdb_confirm else ""

        for actor in data["actors"]:
            jid = actor.get("jellyfin_id")
            if not jid:
                continue
            secs = total_seconds(actor.get("scenes", []))
            if secs < args.min_seconds:
                continue
            if title_id in jf.person_item_ids(jid):
                continue  # Jellyfin credits them → not a cameo
            # Jellyfin says not-credited. Confirm with TMDB (independent gaps):
            # only flag if the actor's TMDB resumé also lacks this title name.
            if args.tmdb_confirm:
                tmdb_id = actor.get("tmdb_id")
                titles = tmdb_resume_titles(tmdb_id, tmdb_key, resume_cache) if tmdb_id else None
                if titles and title_name and title_name in titles:
                    n_tmdb_saved += 1
                    continue
            cameos.append({
                "actor": actor.get("name", ""),
                "jellyfin_id": jid,
                "title": Path(movie).stem,
                "title_id": title_id,
                "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)

    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({n_via_stamp} title(s) via stamped id, {n_unresolved} unresolvable, "
          f"{n_tmdb_saved} rejected by TMDB cross-check)", file=sys.stderr)
    print(f"\n=== {len(cameos)} cameo candidate(s) across {len(by_actor)} actor(s) ===\n")
    for actor in sorted(by_actor):
        print(f"{actor}:")
        for c in by_actor[actor]:
            print(f"    {c['title']!r}  {c['seconds']}s / {c['scenes']} scene(s)  [{c['file']}]")

    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()
