from __future__ import annotations

from dataclasses import dataclass
import json
import threading
from typing import Callable, Dict, List, Optional, Tuple
from urllib.parse import urlencode

try:  # pragma: no cover - optional dependency
    import requests
except ImportError:  # pragma: no cover
    requests = None


TMDB_API_BASE = "https://api.themoviedb.org/3"
TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p"
MAX_CAST_MEMBERS = 30
_TMDB_THREAD_LOCAL = threading.local()


def _get_tmdb_session() -> "requests.Session | None":
    """Returns a per-thread shared requests Session.

    We use thread-local storage because ViewIt prefetches TMDB metadata using threads.
    `requests.Session` is not guaranteed to be thread-safe, but reusing a session within
    the same thread keeps connections warm.
    """

    if requests is None:
        return None
    sess = getattr(_TMDB_THREAD_LOCAL, "session", None)
    if sess is None:
        sess = requests.Session()
        setattr(_TMDB_THREAD_LOCAL, "session", sess)
    return sess


@dataclass(frozen=True)
class TmdbCastMember:
    name: str
    role: str
    thumb: str


@dataclass(frozen=True)
class TmdbShowMeta:
    tmdb_id: int
    plot: str
    poster: str
    fanart: str
    rating: float
    votes: int
    cast: List[TmdbCastMember]


def _image_url(path: str, *, size: str) -> str:
    path = (path or "").strip()
    if not path:
        return ""
    return f"{TMDB_IMAGE_BASE}/{size}{path}"


def _fetch_credits(
    *,
    kind: str,
    tmdb_id: int,
    api_key: str,
    language: str,
    timeout: int,
    log: Callable[[str], None] | None,
    log_responses: bool,
) -> List[TmdbCastMember]:
    if requests is None or not tmdb_id:
        return []
    params = {"api_key": api_key, "language": (language or "de-DE").strip()}
    url = f"{TMDB_API_BASE}/{kind}/{tmdb_id}/credits?{urlencode(params)}"
    status, payload, body_text = _tmdb_get_json(url=url, timeout=timeout, log=log, log_responses=log_responses)
    if callable(log):
        log(f"TMDB RESPONSE /{kind}/{{id}}/credits status={status}")
        if log_responses and payload is None and body_text:
            log(f"TMDB RESPONSE_BODY /{kind}/{{id}}/credits body={body_text[:2000]}")
    if status != 200 or not isinstance(payload, dict):
        return []
    cast_payload = payload.get("cast") or []
    if callable(log):
        log(f"TMDB CREDITS /{kind}/{{id}}/credits cast={len(cast_payload)}")
    return _parse_cast_payload(cast_payload)


def _parse_cast_payload(cast_payload: object) -> List[TmdbCastMember]:
    if not isinstance(cast_payload, list):
        return []
    with_images: List[TmdbCastMember] = []
    without_images: List[TmdbCastMember] = []
    for entry in cast_payload:
        if not isinstance(entry, dict):
            continue
        name = (entry.get("name") or "").strip()
        role = (entry.get("character") or "").strip()
        thumb = _image_url(entry.get("profile_path") or "", size="w185")
        if not name:
            continue
        member = TmdbCastMember(name=name, role=role, thumb=thumb)
        if thumb:
            with_images.append(member)
        else:
            without_images.append(member)
    if with_images:
        return with_images[:MAX_CAST_MEMBERS]
    return without_images[:MAX_CAST_MEMBERS]


def _tmdb_get_json(
    *,
    url: str,
    timeout: int,
    log: Callable[[str], None] | None,
    log_responses: bool,
    session: "requests.Session | None" = None,
) -> Tuple[int | None, object | None, str]:
    """Fetches TMDB JSON with optional shared session.

    Returns: (status_code, payload_or_none, body_text_or_empty)
    """

    if requests is None:
        return None, None, ""
    if callable(log):
        log(f"TMDB GET {url}")
    sess = session or _get_tmdb_session() or requests.Session()
    response = None
    try:
        response = sess.get(url, timeout=timeout)
        status = getattr(response, "status_code", None)
        payload: object | None = None
        body_text = ""
        try:
            payload = response.json()
        except Exception:
            try:
                body_text = (response.text or "").strip()
            except Exception:
                body_text = ""
    except Exception as exc:  # pragma: no cover
        if callable(log):
            log(f"TMDB ERROR request_failed url={url} error={exc!r}")
        return None, None, ""
    finally:
        if response is not None:
            try:
                response.close()
            except Exception:
                pass

    if callable(log):
        log(f"TMDB RESPONSE status={status} url={url}")
        if log_responses:
            if payload is not None:
                try:
                    dumped = json.dumps(payload, ensure_ascii=False)
                except Exception:
                    dumped = str(payload)
                log(f"TMDB RESPONSE_BODY url={url} body={dumped[:2000]}")
            elif body_text:
                log(f"TMDB RESPONSE_BODY url={url} body={body_text[:2000]}")
    return status, payload, body_text


def fetch_tv_episode_credits(
    *,
    tmdb_id: int,
    season_number: int,
    episode_number: int,
    api_key: str,
    language: str = "de-DE",
    timeout: int = 15,
    log: Callable[[str], None] | None = None,
    log_responses: bool = False,
) -> List[TmdbCastMember]:
    """Lädt Cast für eine konkrete Episode (/tv/{id}/season/{n}/episode/{e}/credits)."""
    if requests is None:
        return []
    api_key = (api_key or "").strip()
    if not api_key or not tmdb_id:
        return []
    params = {"api_key": api_key, "language": (language or "de-DE").strip()}
    url = f"{TMDB_API_BASE}/tv/{tmdb_id}/season/{season_number}/episode/{episode_number}/credits?{urlencode(params)}"
    status, payload, body_text = _tmdb_get_json(url=url, timeout=timeout, log=log, log_responses=log_responses)
    if callable(log):
        log(f"TMDB RESPONSE /tv/{{id}}/season/{{n}}/episode/{{e}}/credits status={status}")
        if log_responses and payload is None and body_text:
            log(f"TMDB RESPONSE_BODY /tv/{{id}}/season/{{n}}/episode/{{e}}/credits body={body_text[:2000]}")
    if status != 200 or not isinstance(payload, dict):
        return []
    cast_payload = payload.get("cast") or []
    if callable(log):
        log(f"TMDB CREDITS /tv/{{id}}/season/{{n}}/episode/{{e}}/credits cast={len(cast_payload)}")
    return _parse_cast_payload(cast_payload)


def lookup_tv_show(
    *,
    title: str,
    api_key: str,
    language: str = "de-DE",
    timeout: int = 15,
    log: Callable[[str], None] | None = None,
    log_responses: bool = False,
    include_cast: bool = False,
) -> Optional[TmdbShowMeta]:
    """Sucht eine TV-Show bei TMDB und liefert Plot + Poster-URL (wenn vorhanden)."""
    if requests is None:
        return None
    api_key = (api_key or "").strip()
    if not api_key:
        return None
    query = (title or "").strip()
    if not query:
        return None

    params = {
        "api_key": api_key,
        "language": (language or "de-DE").strip(),
        "query": query,
        "include_adult": "false",
        "page": "1",
    }
    url = f"{TMDB_API_BASE}/search/tv?{urlencode(params)}"
    status, payload, body_text = _tmdb_get_json(
        url=url,
        timeout=timeout,
        log=log,
        log_responses=log_responses,
    )
    results = (payload or {}).get("results") if isinstance(payload, dict) else []
    results = results or []
    if callable(log):
        log(f"TMDB RESPONSE /search/tv status={status} results={len(results)}")
        if log_responses and payload is None and body_text:
            log(f"TMDB RESPONSE_BODY /search/tv body={body_text[:2000]}")

    if status != 200:
        return None
    if not results:
        return None

    normalized_query = query.casefold()
    best = None
    for candidate in results:
        name = (candidate.get("name") or "").casefold()
        original_name = (candidate.get("original_name") or "").casefold()
        if name == normalized_query or original_name == normalized_query:
            best = candidate
            break
    if best is None:
        best = results[0]

    tmdb_id = int(best.get("id") or 0)
    plot = (best.get("overview") or "").strip()
    poster = _image_url(best.get("poster_path") or "", size="w342")
    fanart = _image_url(best.get("backdrop_path") or "", size="w780")
    try:
        rating = float(best.get("vote_average") or 0.0)
    except Exception:
        rating = 0.0
    try:
        votes = int(best.get("vote_count") or 0)
    except Exception:
        votes = 0
    if not tmdb_id:
        return None
    cast: List[TmdbCastMember] = []
    if include_cast and tmdb_id:
        detail_params = {
            "api_key": api_key,
            "language": (language or "de-DE").strip(),
            "append_to_response": "credits",
        }
        detail_url = f"{TMDB_API_BASE}/tv/{tmdb_id}?{urlencode(detail_params)}"
        d_status, d_payload, d_body = _tmdb_get_json(
            url=detail_url,
            timeout=timeout,
            log=log,
            log_responses=log_responses,
        )
        if callable(log):
            log(f"TMDB RESPONSE /tv/{{id}} status={d_status}")
            if log_responses and d_payload is None and d_body:
                log(f"TMDB RESPONSE_BODY /tv/{{id}} body={d_body[:2000]}")
        if d_status == 200 and isinstance(d_payload, dict):
            credits = d_payload.get("credits") or {}
            cast = _parse_cast_payload((credits or {}).get("cast"))
    if not plot and not poster and not fanart and not rating and not votes and not cast:
        return None
    return TmdbShowMeta(
        tmdb_id=tmdb_id,
        plot=plot,
        poster=poster,
        fanart=fanart,
        rating=rating,
        votes=votes,
        cast=cast,
    )


@dataclass(frozen=True)
class TmdbMovieMeta:
    tmdb_id: int
    plot: str
    poster: str
    fanart: str
    runtime_minutes: int
    rating: float
    votes: int
    cast: List[TmdbCastMember]


def _fetch_movie_details(
    *,
    tmdb_id: int,
    api_key: str,
    language: str,
    timeout: int,
    log: Callable[[str], None] | None,
    log_responses: bool,
    include_cast: bool,
) -> Tuple[int, List[TmdbCastMember]]:
    """Fetches /movie/{id} and (optionally) bundles credits via append_to_response=credits."""
    if requests is None or not tmdb_id:
        return 0, []
    api_key = (api_key or "").strip()
    if not api_key:
        return 0, []
    params: Dict[str, str] = {
        "api_key": api_key,
        "language": (language or "de-DE").strip(),
    }
    if include_cast:
        params["append_to_response"] = "credits"
    url = f"{TMDB_API_BASE}/movie/{tmdb_id}?{urlencode(params)}"
    status, payload, body_text = _tmdb_get_json(url=url, timeout=timeout, log=log, log_responses=log_responses)
    if callable(log):
        log(f"TMDB RESPONSE /movie/{{id}} status={status}")
        if log_responses and payload is None and body_text:
            log(f"TMDB RESPONSE_BODY /movie/{{id}} body={body_text[:2000]}")
    if status != 200 or not isinstance(payload, dict):
        return 0, []
    try:
        runtime = int(payload.get("runtime") or 0)
    except Exception:
        runtime = 0
    cast: List[TmdbCastMember] = []
    if include_cast:
        credits = payload.get("credits") or {}
        cast = _parse_cast_payload((credits or {}).get("cast"))
    return runtime, cast


def lookup_movie(
    *,
    title: str,
    api_key: str,
    language: str = "de-DE",
    timeout: int = 15,
    log: Callable[[str], None] | None = None,
    log_responses: bool = False,
    include_cast: bool = False,
) -> Optional[TmdbMovieMeta]:
    """Sucht einen Film bei TMDB und liefert Plot + Poster-URL (wenn vorhanden)."""
    if requests is None:
        return None
    api_key = (api_key or "").strip()
    if not api_key:
        return None
    query = (title or "").strip()
    if not query:
        return None

    params = {
        "api_key": api_key,
        "language": (language or "de-DE").strip(),
        "query": query,
        "include_adult": "false",
        "page": "1",
    }
    url = f"{TMDB_API_BASE}/search/movie?{urlencode(params)}"
    status, payload, body_text = _tmdb_get_json(
        url=url,
        timeout=timeout,
        log=log,
        log_responses=log_responses,
    )
    results = (payload or {}).get("results") if isinstance(payload, dict) else []
    results = results or []
    if callable(log):
        log(f"TMDB RESPONSE /search/movie status={status} results={len(results)}")
        if log_responses and payload is None and body_text:
            log(f"TMDB RESPONSE_BODY /search/movie body={body_text[:2000]}")

    if status != 200:
        return None
    if not results:
        return None

    normalized_query = query.casefold()
    best = None
    for candidate in results:
        name = (candidate.get("title") or "").casefold()
        original_name = (candidate.get("original_title") or "").casefold()
        if name == normalized_query or original_name == normalized_query:
            best = candidate
            break
    if best is None:
        best = results[0]

    tmdb_id = int(best.get("id") or 0)
    plot = (best.get("overview") or "").strip()
    poster = _image_url(best.get("poster_path") or "", size="w342")
    fanart = _image_url(best.get("backdrop_path") or "", size="w780")
    runtime_minutes = 0
    try:
        rating = float(best.get("vote_average") or 0.0)
    except Exception:
        rating = 0.0
    try:
        votes = int(best.get("vote_count") or 0)
    except Exception:
        votes = 0
    if not tmdb_id:
        return None
    cast: List[TmdbCastMember] = []
    runtime_minutes, cast = _fetch_movie_details(
        tmdb_id=tmdb_id,
        api_key=api_key,
        language=language,
        timeout=timeout,
        log=log,
        log_responses=log_responses,
        include_cast=include_cast,
    )
    if not plot and not poster and not fanart and not rating and not votes and not cast:
        return None
    return TmdbMovieMeta(
        tmdb_id=tmdb_id,
        plot=plot,
        poster=poster,
        fanart=fanart,
        runtime_minutes=runtime_minutes,
        rating=rating,
        votes=votes,
        cast=cast,
    )


@dataclass(frozen=True)
class TmdbEpisodeMeta:
    plot: str
    thumb: str
    runtime_minutes: int


@dataclass(frozen=True)
class TmdbSeasonMeta:
    plot: str
    poster: str


def lookup_tv_season_summary(
    *,
    tmdb_id: int,
    season_number: int,
    api_key: str,
    language: str = "de-DE",
    timeout: int = 15,
    log: Callable[[str], None] | None = None,
    log_responses: bool = False,
) -> Optional[TmdbSeasonMeta]:
    """Lädt Staffel-Meta (Plot + Poster)."""
    if requests is None:
        return None

    api_key = (api_key or "").strip()
    if not api_key or not tmdb_id:
        return None

    params = {"api_key": api_key, "language": (language or "de-DE").strip()}
    url = f"{TMDB_API_BASE}/tv/{tmdb_id}/season/{season_number}?{urlencode(params)}"
    status, payload, body_text = _tmdb_get_json(url=url, timeout=timeout, log=log, log_responses=log_responses)
    if callable(log):
        log(f"TMDB RESPONSE /tv/{{id}}/season/{{n}} status={status}")
        if log_responses and payload is None and body_text:
            log(f"TMDB RESPONSE_BODY /tv/{{id}}/season/{{n}} body={body_text[:2000]}")
    if status != 200 or not isinstance(payload, dict):
        return None

    plot = (payload.get("overview") or "").strip()
    poster_path = (payload.get("poster_path") or "").strip()
    poster = f"{TMDB_IMAGE_BASE}/w342{poster_path}" if poster_path else ""
    if not plot and not poster:
        return None
    return TmdbSeasonMeta(plot=plot, poster=poster)


def lookup_tv_season(
    *,
    tmdb_id: int,
    season_number: int,
    api_key: str,
    language: str = "de-DE",
    timeout: int = 15,
    log: Callable[[str], None] | None = None,
    log_responses: bool = False,
) -> Optional[Dict[int, TmdbEpisodeMeta]]:
    """Lädt Episoden-Meta für eine Staffel: episode_number -> (plot, thumb)."""
    if requests is None:
        return None
    api_key = (api_key or "").strip()
    if not api_key or not tmdb_id or season_number is None:
        return None
    params = {"api_key": api_key, "language": (language or "de-DE").strip()}
    url = f"{TMDB_API_BASE}/tv/{tmdb_id}/season/{season_number}?{urlencode(params)}"
    status, payload, body_text = _tmdb_get_json(url=url, timeout=timeout, log=log, log_responses=log_responses)
    episodes = (payload or {}).get("episodes") if isinstance(payload, dict) else []
    episodes = episodes or []
    if callable(log):
        log(f"TMDB RESPONSE /tv/{{id}}/season/{{n}} status={status} episodes={len(episodes)}")
        if log_responses:
            if payload is not None:
                try:
                    dumped = json.dumps(payload, ensure_ascii=False)
                except Exception:
                    dumped = str(payload)
                log(f"TMDB RESPONSE_BODY /tv/{{id}}/season/{{n}} body={dumped[:2000]}")
            elif body_text:
                log(f"TMDB RESPONSE_BODY /tv/{{id}}/season/{{n}} body={body_text[:2000]}")

    if status != 200 or not episodes:
        return None

    result: Dict[int, TmdbEpisodeMeta] = {}
    for entry in episodes:
        try:
            ep_number = int(entry.get("episode_number") or 0)
        except Exception:
            continue
        if not ep_number:
            continue
        plot = (entry.get("overview") or "").strip()
        runtime_minutes = 0
        try:
            runtime_minutes = int(entry.get("runtime") or 0)
        except Exception:
            runtime_minutes = 0
        still_path = (entry.get("still_path") or "").strip()
        thumb = f"{TMDB_IMAGE_BASE}/w300{still_path}" if still_path else ""
        if not plot and not thumb and not runtime_minutes:
            continue
        result[ep_number] = TmdbEpisodeMeta(plot=plot, thumb=thumb, runtime_minutes=runtime_minutes)
    return result or None


# ---------------------------------------------------------------------------
# External IDs (IMDb, TVDb) – für Trakt-Integration
# ---------------------------------------------------------------------------

@dataclass(frozen=True)
class TmdbExternalIds:
    imdb_id: str   # z.B. "tt1234567"
    tvdb_id: int   # TheTVDB-ID


def fetch_external_ids(
    *,
    kind: str,
    tmdb_id: int,
    api_key: str,
    timeout: int = 15,
    log: Callable[[str], None] | None = None,
    log_responses: bool = False,
) -> Optional[TmdbExternalIds]:
    """Ruft IMDb-ID und TVDb-ID via /movie/{id}/external_ids oder /tv/{id}/external_ids ab."""
    if requests is None or not tmdb_id:
        return None
    api_key = (api_key or "").strip()
    if not api_key:
        return None
    kind = (kind or "").strip()
    if kind not in ("movie", "tv"):
        return None
    params = {"api_key": api_key}
    url = f"{TMDB_API_BASE}/{kind}/{tmdb_id}/external_ids?{urlencode(params)}"
    status, payload, body_text = _tmdb_get_json(
        url=url, timeout=timeout, log=log, log_responses=log_responses,
    )
    if callable(log):
        log(f"TMDB RESPONSE /{kind}/{{id}}/external_ids status={status}")
    if status != 200 or not isinstance(payload, dict):
        return None
    imdb_id = (payload.get("imdb_id") or "").strip()
    tvdb_id = 0
    try:
        tvdb_id = int(payload.get("tvdb_id") or 0)
    except (ValueError, TypeError):
        tvdb_id = 0
    if not imdb_id and not tvdb_id:
        return None
    return TmdbExternalIds(imdb_id=imdb_id, tvdb_id=tvdb_id)
