"""Minimal qBittorrent Web API client (read-only: sync/maindata only).

Modeled on :class:`~media_library_viewer_api.clients.jellyfin.JellyfinClient`'s
session pattern. Authentication uses username/password login which stores an
SID cookie in the requests session. The client re-logins transparently on 403.
"""

from __future__ import annotations

import logging
import threading
import time
from typing import Any

import requests

from media_library_viewer_api.clients.http_timeout import DEFAULT_READ_TIMEOUT, http_timeout

logger = logging.getLogger(__name__)

# qBittorrent's built-in web server is effectively single-threaded; collapse
# concurrent widget polls onto one fetch and back off when it struggles.
MAINDATA_CACHE_TTL = 3.0  # seconds a snapshot is served without re-hitting qBittorrent
MAINDATA_BACKOFF_MAX = 30.0  # cap exponential backoff after repeated failures


class QbittorrentClient:
    """Small wrapper around the qBittorrent Web API.

    Only the endpoints needed by the dashboard widgets are implemented
    (currently just ``/sync/maindata``). All calls share a single
    :class:`requests.Session` that carries the login cookie.
    """

    def __init__(self, base_url: str, username: str, password: str, timeout: float = DEFAULT_READ_TIMEOUT) -> None:
        if not base_url:
            raise ValueError("qBittorrent base_url is required")
        if not username:
            raise ValueError("qBittorrent username is required")

        self.base_url = base_url.rstrip("/")
        if not self.base_url.endswith("/api/v2"):
            self.base_url += "/api/v2"
        self._username = username
        self._password = password
        # timeout is the per-response READ timeout (seconds); connect timeout is fixed at 5s.
        self.timeout = http_timeout(timeout)
        self._session = requests.Session()
        self._logged_in = False
        # /sync/maindata is the only hot endpoint. Maintain a rid-merged
        # snapshot (incremental updates -> small payloads), a short-TTL cache
        # + lock so concurrent widgets share one fetch, and back off when
        # qBittorrent is struggling rather than piling on (its web server is
        # single-threaded and otherwise hangs the Web UI for everyone).
        self._rid: int | None = None
        self._snapshot: dict[str, Any] = {
            "server_state": {},
            "torrents": {},
            "categories": {},
            "tags": [],
            "trackers": [],
        }
        self._maindata_lock = threading.Lock()
        self._maindata_fetched_at: float = 0.0
        self._maindata_ttl: float = MAINDATA_CACHE_TTL
        self._backoff_until: float = 0.0
        self._consecutive_failures = 0

    def _login(self) -> None:
        """POST username/password to ``/auth/login``; store the SID cookie.

        qBittorrent replies with the plain text ``"Ok."`` and a ``SID`` cookie
        on success, ``"Fails."`` on bad credentials, and ``403 Forbidden`` when
        the source IP is banned (too many failed attempts). The ``Referer``
        header is required by qBittorrent's CSRF protection.

        Any other body — in particular an *empty* 200 — means the request did not
        reach qBittorrent's login handler, almost always because ``base_url`` is
        wrong (wrong host/port/path) or a reverse proxy is misrouting
        ``/api/v2/auth/login``. We surface a diagnostic error in that case
        instead of the useless ``"login failed: "`` message.
        """
        resp = self._session.post(
            f"{self.base_url}/auth/login",
            data={"username": self._username, "password": self._password},
            timeout=self.timeout,
            headers={"Referer": self.base_url},
        )
        # 502/503/504 come from the reverse proxy when qBittorrent is down,
        # starting up, or can't answer within the proxy's forwarding timeout
        # (qBittorrent's PBKDF2 password check is intentionally slow, so a
        # flood of concurrent logins can trip this). Surface it clearly rather
        # than as a bare HTTPError.
        if resp.status_code in (502, 503, 504):
            raise RuntimeError(
                f"qBittorrent is unreachable: reverse proxy returned HTTP {resp.status_code} "
                f"for {resp.url}. qBittorrent may be down, starting up, or unable to "
                "answer within the proxy's forwarding timeout."
            )
        resp.raise_for_status()
        body = resp.text.strip()

        # qBittorrent signals a successful login with the body "Ok." and/or by
        # setting a session cookie. The cookie is named "SID" in older versions
        # and "QBT_SID" / "QBT_SID_<port>" in newer ones. Some setups return 204
        # No Content with the cookie and no body, and ``requests`` doesn't always
        # populate the cookie jar, so check both the jar and the raw Set-Cookie
        # header. qBittorrent only sets this cookie on a valid login.
        def _is_session_cookie(name: str) -> bool:
            upper = name.strip().upper()
            return upper == "SID" or upper.startswith("QBT_SID")

        set_cookie_hdr = resp.headers.get("Set-Cookie", "") or ""
        first_cookie_name = set_cookie_hdr.split("=", 1)[0].strip()
        sid_ok = any(_is_session_cookie(k) for k in resp.cookies.keys()) or (
            bool(first_cookie_name) and _is_session_cookie(first_cookie_name)
        )
        if body == "Ok." or sid_ok:
            self._logged_in = True
            logger.info("qBittorrent login successful for %s", self.base_url)
            return
        if body == "Fails.":
            raise RuntimeError(f"qBittorrent login failed (HTTP {resp.status_code}): invalid username or password")
        cookie_names = sorted(resp.cookies.keys()) or (["<unparsed>"] if set_cookie_hdr else [])
        raise RuntimeError(
            f"Unexpected response from qBittorrent login endpoint (HTTP {resp.status_code}, "
            f"body={body!r}, cookies={cookie_names}). Expected the text 'Ok.' or a session "
            "cookie (SID / QBT_SID) from /api/v2/auth/login — this usually means base_url does "
            "not reach the qBittorrent Web API (check the URL, path, and any reverse proxy in "
            "front of qBittorrent)."
        )

    def _get(self, path: str, **params: Any) -> dict[str, Any]:
        """GET an endpoint with auto-login on first call and re-login on 403."""
        if not self._logged_in:
            self._login()
        url = f"{self.base_url}{path}"
        resp = self._session.get(url, params=params, timeout=self.timeout)
        if resp.status_code == 403:
            logger.debug("qBittorrent 403 on %s, re-logging in", path)
            self._logged_in = False
            self._login()
            resp = self._session.get(url, params=params, timeout=self.timeout)
        resp.raise_for_status()
        return resp.json()

    def maindata(self) -> dict[str, Any]:
        """Return the current ``/sync/maindata`` snapshot.

        Uses qBittorrent's incremental ``rid`` protocol (first call is a full
        update, subsequent calls send the last rid and get a small diff that is
        merged into the cached snapshot), so payloads stay small. A short-TTL
        cache + lock collapses concurrent widget polls onto a single fetch, and
        on repeated failures the client backs off instead of hammering
        qBittorrent's single-threaded web server (serving the last good
        snapshot when available).

        Returns a dict with ``server_state`` and ``torrents``.
        """
        now = time.time()
        with self._maindata_lock:
            # Serve a fresh-enough cached snapshot without re-hitting qBittorrent.
            if self._snapshot.get("torrents") and (now - self._maindata_fetched_at) < self._maindata_ttl:
                return self._copy_snapshot()
            # While backing off, don't pile on; serve stale or raise.
            if now < self._backoff_until:
                if self._snapshot.get("torrents"):
                    return self._copy_snapshot()
                raise RuntimeError(
                    "qBittorrent maindata unavailable (backing off after repeated failures)"
                )
            try:
                update = self._fetch_maindata_incremental()
                self._apply_update(update)
            except Exception as exc:
                self._consecutive_failures += 1
                delay = min(2 ** self._consecutive_failures, MAINDATA_BACKOFF_MAX)
                self._backoff_until = time.time() + delay
                logger.warning(
                    "qBittorrent maindata fetch failed (#%s); backing off %.0fs: %s",
                    self._consecutive_failures,
                    delay,
                    exc,
                )
                if self._snapshot.get("torrents"):
                    return self._copy_snapshot()
                raise RuntimeError(f"qBittorrent maindata failed: {exc}") from exc
            self._maindata_fetched_at = time.time()
            self._consecutive_failures = 0
            self._backoff_until = 0.0
            return self._copy_snapshot()

    def _fetch_maindata_incremental(self) -> dict[str, Any]:
        """GET /sync/maindata, sending the last rid for an incremental update."""
        params: dict[str, Any] = {}
        if self._rid is not None:
            params["rid"] = self._rid
        return self._get("/sync/maindata", **params)

    def _apply_update(self, update: dict[str, Any]) -> None:
        """Merge a full or partial maindata update into the cached snapshot."""
        is_full = bool(update.get("full_update")) or self._rid is None
        self._rid = update.get("rid", self._rid)
        snap = self._snapshot
        if is_full:
            snap.clear()
            snap["server_state"] = dict(update.get("server_state") or {})
            snap["torrents"] = dict(update.get("torrents") or {})
            snap["categories"] = dict(update.get("categories") or {})
            snap["tags"] = list(update.get("tags") or [])
            snap["trackers"] = list(update.get("trackers") or [])
            return
        # Partial update — merge the diff.
        server_state = update.get("server_state")
        if isinstance(server_state, dict):
            snap["server_state"].update(server_state)
        changed = update.get("torrents")
        if isinstance(changed, dict):
            for hash_, fields in changed.items():
                if fields is None:
                    snap["torrents"].pop(hash_, None)
                else:
                    snap["torrents"][hash_] = fields
        for hash_ in update.get("torrents_removed") or []:
            snap["torrents"].pop(hash_, None)
        categories = update.get("categories")
        if isinstance(categories, dict):
            snap["categories"].update(categories)
        for name in update.get("categories_removed") or []:
            snap["categories"].pop(name, None)
        if "tags" in update:
            snap["tags"] = list(update.get("tags") or [])
        if "trackers" in update:
            snap["trackers"] = list(update.get("trackers") or [])

    def _copy_snapshot(self) -> dict[str, Any]:
        """Return a shallow, race-safe copy of the current snapshot."""
        snap = self._snapshot
        return {
            "rid": self._rid,
            "server_state": dict(snap.get("server_state") or {}),
            "torrents": dict(snap.get("torrents") or {}),
            "categories": dict(snap.get("categories") or {}),
            "tags": list(snap.get("tags") or []),
            "trackers": list(snap.get("trackers") or []),
        }
