#!/usr/bin/env python3
"""Fetch missing subtitles for a Jellyfin series/season via Jellyfin's own
RemoteSearch/Subtitles API (backed by OpenSubtitles).

Built 2026-08-31 after doing this by hand for Mission: Impossible (1988) S1 —
generalized so any show/season can get the same treatment.

Usage:
    python3 bin/jellyfin-fetch-subs.py "Mission: Impossible" --season 1
    python3 bin/jellyfin-fetch-subs.py "Mission: Impossible" --season 1 --year 1988
    python3 bin/jellyfin-fetch-subs.py "Mission: Impossible" --season 1 --lang eng --force
    python3 bin/jellyfin-fetch-subs.py --list-seasons "Mission: Impossible"

Series lookup goes through /Search/Hints (Type=Series) rather than /Items —
plain /Items search came back empty against this server for reasons not fully
chased down; Search/Hints works reliably. When a title matches more than one
series (e.g. two "Mission: Impossible" — 1966 and 1988), pass --year to
disambiguate, or the script lists the candidates and exits.

Credentials, in priority order: --jellyfin-url/--jellyfin-key flags, then
JELLYFIN_URL/JELLYFIN_API_KEY env vars, then secrets/credentials.yaml (Evie's
own default — needs the project .venv for PyYAML). The flag/env path needs
nothing beyond stdlib, so anyone with their own Jellyfin API key can run this
standalone with plain system python3:
    JELLYFIN_URL=http://jellyfin.lan.1qaz.ca:8096 JELLYFIN_API_KEY=<key> \\
        python3 bin/jellyfin-fetch-subs.py "Mission: Impossible" --season 1
"""
import argparse
import json
import os
import sys
import time
import urllib.parse
import urllib.request
from pathlib import Path

BASE = None
HEADERS = None


def _configure(url: str | None, key: str | None) -> None:
    """Resolve BASE/HEADERS from (in order) explicit args, env vars, or
    secrets/credentials.yaml. The credentials.yaml path is imported lazily,
    right here, so anyone supplying their own url/key never needs PyYAML or
    the project .venv at all — a plain system python3 works."""
    global BASE, HEADERS
    url = url or os.environ.get("JELLYFIN_URL")
    key = key or os.environ.get("JELLYFIN_API_KEY")
    if not url or not key:
        sys.path.insert(0, str(Path(__file__).parent))
        from credentials import service  # noqa: E402
        jf = service("jellyfin")
        url = url or jf["url"]
        key = key or jf["api_key"]
    BASE = url.rstrip("/")
    HEADERS = {"X-Emby-Token": key}


def _get(path, params=None):
    url = f"{BASE}{path}"
    if params:
        url += "?" + urllib.parse.urlencode(params)
    req = urllib.request.Request(url, headers=HEADERS)
    with urllib.request.urlopen(req) as resp:
        body = resp.read()
    return json.loads(body) if body else None


def _post(path):
    req = urllib.request.Request(url=f"{BASE}{path}", headers=HEADERS, method="POST")
    with urllib.request.urlopen(req) as resp:
        return resp.status


def _first_user_id():
    users = _get("/Users")
    if not users:
        raise RuntimeError("no Jellyfin users found — can't check subtitle state")
    return users[0]["Id"]


def find_series(title, year=None):
    hints = _get("/Search/Hints", {"SearchTerm": title, "IncludeItemTypes": "Series"})
    candidates = [h for h in hints.get("SearchHints", []) if h.get("Type") == "Series"]
    if year:
        candidates = [c for c in candidates if c.get("ProductionYear") == year]
    if len(candidates) == 1:
        return candidates[0]
    if not candidates:
        raise SystemExit(f"no series found matching '{title}'" + (f" ({year})" if year else ""))
    print(f"multiple series match '{title}' — pass --year to disambiguate:", file=sys.stderr)
    for c in candidates:
        print(f"  {c.get('ProductionYear', '?')} — {c['Name']} (id={c['Id']})", file=sys.stderr)
    raise SystemExit(2)


def list_seasons(series_id):
    return _get(f"/Shows/{series_id}/Seasons")["Items"]


def find_season(series_id, season_num):
    seasons = list_seasons(series_id)
    for s in seasons:
        if s.get("IndexNumber") == season_num:
            return s
    available = ", ".join(str(s.get("IndexNumber")) for s in seasons)
    raise SystemExit(f"season {season_num} not found — available: {available}")


def list_episodes(series_id, season_id):
    eps = _get(f"/Shows/{series_id}/Episodes", {"SeasonId": season_id})["Items"]
    return sorted(eps, key=lambda e: e.get("IndexNumber") or 0)


# Jellyfin falls back to writing subtitles here when it can't write next to
# the media file (e.g. a permission problem on the share) — silently, no
# error, just a stream Plex (or anything else reading the raw files) will
# never see. Found 2026-08-31/09-01 the hard way: HTTP 204 on every download
# the whole time, subtitle genuinely present in Jellyfin, Plex rescans forever
# finding nothing, because the file was living in here instead of on the NAS.
INTERNAL_SUB_PREFIX = "/var/lib/jellyfin/metadata"


def subtitle_state(user_id, item_id):
    """'external' (real sidecar file, visible to Plex etc), 'internal' (only in
    Jellyfin's own store — needs refetching once write access is fixed), or
    'none'."""
    item = _get(f"/Users/{user_id}/Items/{item_id}")
    subs = [s for s in item.get("MediaStreams", []) if s["Type"] == "Subtitle"]
    if not subs:
        return "none"
    if any(not (s.get("Path") or "").startswith(INTERNAL_SUB_PREFIX) for s in subs):
        return "external"
    return "internal"


def fetch_subtitle(item_id, lang):
    results = _get(f"/Items/{item_id}/RemoteSearch/Subtitles/{lang}")
    if not results:
        return None, "no results"
    hi = [r for r in results if r.get("HearingImpaired")]
    pick = hi[0] if hi else results[0]
    status = _post(f"/Items/{item_id}/RemoteSearch/Subtitles/{urllib.parse.quote(pick['Id'], safe='')}")
    return pick, status


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("title", nargs="?", help="series title (fuzzy match via Jellyfin search)")
    ap.add_argument("--season", type=int, help="season number")
    ap.add_argument("--year", type=int, help="production year, to disambiguate same-titled series")
    ap.add_argument("--lang", default="eng", help="ISO 639-2 language code (default: eng)")
    ap.add_argument("--force", action="store_true", help="refetch even an existing external (real sidecar) subtitle")
    ap.add_argument("--list-seasons", action="store_true", help="just list seasons for the matched series and exit")
    ap.add_argument("--jellyfin-url", help="Jellyfin base URL (overrides JELLYFIN_URL env var and credentials.yaml)")
    ap.add_argument("--jellyfin-key", help="Jellyfin API key (overrides JELLYFIN_API_KEY env var and credentials.yaml)")
    args = ap.parse_args()

    if not args.title:
        ap.error("title is required")

    _configure(args.jellyfin_url, args.jellyfin_key)

    series = find_series(args.title, args.year)
    print(f"series: {series['Name']} ({series.get('ProductionYear', '?')}) id={series['Id']}")

    if args.list_seasons:
        for s in list_seasons(series["Id"]):
            print(f"  season {s.get('IndexNumber')}: {s['Name']} id={s['Id']}")
        return

    if args.season is None:
        ap.error("--season is required (or use --list-seasons)")

    season = find_season(series["Id"], args.season)
    episodes = list_episodes(series["Id"], season["Id"])
    print(f"season {args.season}: {len(episodes)} episodes")

    user_id = _first_user_id()

    fetched, skipped, failed, healed = 0, 0, 0, 0
    for ep in episodes:
        idx = ep.get("IndexNumber")
        name = ep.get("Name")
        item_id = ep["Id"]

        state = subtitle_state(user_id, item_id)
        if state == "external" and not args.force:
            print(f"  ep{idx} '{name}': already has a real external sub, skipping")
            skipped += 1
            continue
        was_internal_only = state == "internal"

        pick, status = fetch_subtitle(item_id, args.lang)
        if pick is None:
            print(f"  ep{idx} '{name}': {status}")
            failed += 1
            continue
        ok = status == 204
        if not ok:
            print(f"  ep{idx} '{name}': {pick['ProviderName']} '{pick['Name']}' -> HTTP {status} (FAILED)")
            failed += 1
            continue

        # HTTP 204 alone isn't proof — Jellyfin can silently no-op a rate-limited
        # download and still return 204 (found 2026-09-01). Re-check the real
        # state, but the download+write is itself async on Jellyfin's side, so
        # a check made immediately after the POST returns can race ahead of the
        # write finishing (also found 2026-09-01 — every "failure" in a batch
        # turned out to be a real success once re-checked a few seconds later).
        # Poll briefly before calling it a failure.
        new_state = "none"
        for _ in range(5):
            new_state = subtitle_state(user_id, item_id)
            if new_state == "external":
                break
            time.sleep(1.5)
        if new_state == "external":
            tag = "healed (was internal-only)" if was_internal_only else "ok"
            print(f"  ep{idx} '{name}': {pick['ProviderName']} '{pick['Name']}' -> external sub confirmed ({tag})")
            fetched += 1
            if was_internal_only:
                healed += 1
        else:
            print(f"  ep{idx} '{name}': HTTP 204 but no external sub landed (state={new_state}) — likely a silent failure, not written to disk")
            failed += 1

    print(f"\ndone — fetched {fetched} (of which {healed} healed from internal-only), skipped {skipped}, failed {failed}")


if __name__ == "__main__":
    main()
