"""
Stalker / MAC portal proxy.

Browsers can't talk to a Stalker portal directly because:
  1. Custom non-CORS-allowed headers are required (User-Agent, X-User-Agent, Cookie).
  2. Most portals don't return CORS headers at all.
  3. Browsers refuse to set the literal `User-Agent` header from JS.

So we expose two narrow endpoints that the frontend calls:

    POST /api/stalker/handshake         { portal_url, mac } → { token, profile }
    POST /api/stalker/all-channels      { portal_url, mac } → { channels: [...] }
    POST /api/stalker/vod-categories    { portal_url, mac } → { categories: [...] }
    POST /api/stalker/resolve-stream    { portal_url, mac, cmd } → { url }

The implementation does the full Stalker handshake (handshake → get_profile →
get_all_channels) impersonating a MAG250 set-top box, exactly like the
QtStalker / IPTV Stalker player would.

In-memory caches:
  • Portal sessions (token) — reused across calls for the same (portal, mac)
    pair for ~5 minutes so we don't pay the full handshake on every zap.
  • Resolved stream URLs — cached for 30 s per (portal, mac, cmd). Stalker
    URLs are typically valid for a couple of minutes; 30 s is the sweet spot
    that lets a user zap up/down/up across the same channel without
    triggering a fresh `create_link`.
"""
from __future__ import annotations

import logging
import re
import time
from typing import Any, Optional
from urllib.parse import urlparse, urlunparse

import httpx
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field, field_validator

logger = logging.getLogger("stalker")
router = APIRouter(prefix="/api/stalker", tags=["stalker"])

MAC_RE = re.compile(r"^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$")

# The headers a MAG250 STB sends. Stalker portals gate on these.
STB_USER_AGENT = "Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG200 stbapp ver: 4 rev: 250 Safari/533.3"
STB_X_USER_AGENT = "Model: MAG250; Link: WiFi"

# Common portal entry points — we try them in order during handshake. Most
# providers serve `portal.php` directly; some require `c/portal.php` or the
# legacy `stalker_portal/server/load.php`.
PORTAL_PATHS = [
    "portal.php",
    "c/portal.php",
    "stalker_portal/server/load.php",
]

# ---- in-memory caches -----------------------------------------------------
# {(portal_root, mac): (token, expires_at)}
_TOKEN_CACHE: dict[tuple[str, str], tuple[str, float]] = {}
_TOKEN_TTL = 300.0  # 5 minutes — well under any reasonable Stalker session

# {(portal_root, mac, cmd): (url, expires_at)}
_URL_CACHE: dict[tuple[str, str, str], tuple[str, float]] = {}
_URL_TTL = 30.0  # 30 seconds — long enough for fast zapping, short enough to
                 # avoid serving a stale URL after the portal rotates it.


def _cache_get(d: dict, key: tuple) -> Optional[Any]:
    item = d.get(key)
    if not item:
        return None
    value, expires_at = item
    if expires_at < time.time():
        d.pop(key, None)
        return None
    return value


def _cache_put(d: dict, key: tuple, value: Any, ttl: float) -> None:
    d[key] = (value, time.time() + ttl)
    # Cheap eviction — drop expired keys when the dict grows.
    if len(d) > 256:
        now = time.time()
        for k in [k for k, (_, exp) in d.items() if exp < now]:
            d.pop(k, None)


class StalkerRequest(BaseModel):
    portal_url: str = Field(min_length=4, max_length=500)
    mac: str

    @field_validator("mac")
    @classmethod
    def _mac_format(cls, v: str) -> str:
        v = v.strip().upper()
        if not MAC_RE.match(v):
            raise ValueError(
                "MAC must be in 00:1A:79:XX:XX:XX format"
            )
        return v


def _normalise_portal(raw: str) -> str:
    """Strip trailing slashes / known portal path so we can append our own."""
    raw = raw.strip()
    if not raw:
        raise HTTPException(400, "portal_url is required")
    if not raw.startswith(("http://", "https://")):
        raw = "http://" + raw
    p = urlparse(raw)
    if not p.hostname:
        raise HTTPException(400, "Invalid portal_url")
    # Drop any portal.php / load.php tail — we'll add it ourselves.
    path = re.sub(
        r"/(portal\.php|c/portal\.php|stalker_portal/server/load\.php)/?$",
        "",
        p.path or "",
    )
    return urlunparse((p.scheme, p.netloc, path.rstrip("/"), "", "", ""))


def _build_headers(mac: str, token: Optional[str] = None) -> dict[str, str]:
    cookie = (
        f"mac={mac}; stb_lang=en; timezone=Europe/London"
    )
    h = {
        "User-Agent": STB_USER_AGENT,
        "X-User-Agent": STB_X_USER_AGENT,
        "Cookie": cookie,
        "Accept": "*/*",
        "Connection": "Close",
    }
    if token:
        h["Authorization"] = f"Bearer {token}"
    return h


async def _portal_get(
    client: httpx.AsyncClient,
    portal_root: str,
    params: dict[str, Any],
    mac: str,
    token: Optional[str] = None,
) -> dict[str, Any]:
    """Try each known portal entry point until one returns valid JSON."""
    last_status = 0
    last_body = ""
    for path in PORTAL_PATHS:
        url = f"{portal_root}/{path}"
        try:
            resp = await client.get(
                url, params=params, headers=_build_headers(mac, token)
            )
        except httpx.RequestError as exc:
            last_body = str(exc)
            continue
        last_status = resp.status_code
        last_body = resp.text[:300]
        if resp.status_code == 200 and resp.text.strip():
            try:
                return resp.json()
            except Exception:
                continue
    raise HTTPException(
        502,
        f"Portal returned no valid JSON (status={last_status}, body={last_body!r})",
    )


async def _open_session() -> httpx.AsyncClient:
    return httpx.AsyncClient(
        follow_redirects=True,
        timeout=httpx.Timeout(20.0, connect=10.0, read=20.0),
    )


async def _full_auth(
    portal_root: str,
    mac: str,
    *,
    force: bool = False,
) -> tuple[httpx.AsyncClient, str, dict]:
    """Run handshake → get_profile, return live client + token + profile.

    Reuses a cached token for the same (portal, mac) pair when it's still
    fresh, so rapid zapping doesn't pay the full handshake every time.
    Pass `force=True` to bypass the token cache (e.g. for the
    `Test Connection` button or the explicit `/handshake` endpoint).
    """
    if not force:
        cached_token = _cache_get(_TOKEN_CACHE, (portal_root, mac))
        if cached_token:
            return await _open_session(), cached_token, {}

    client = await _open_session()
    try:
        # Step 1 — handshake to obtain token
        hs = await _portal_get(
            client,
            portal_root,
            {
                "type": "stb",
                "action": "handshake",
                "token": "",
                "JsHttpRequest": "1-xml",
            },
            mac,
        )
        token = (hs.get("js") or {}).get("token") or hs.get("token")
        if not token:
            raise HTTPException(401, "Stalker portal refused handshake (no token returned)")

        # Step 2 — fetch profile (some portals require this before data calls)
        prof = await _portal_get(
            client,
            portal_root,
            {
                "type": "stb",
                "action": "get_profile",
                "JsHttpRequest": "1-xml",
                "hd": "1",
                "ver": "ImageDescription: 0.2.18-r14-pub-250; ImageDate: Fri Jan 15 15:20:44 EET 2016; PORTAL version: 5.5.0; API Version: JS API version: 343; STB API version: 146; Player Engine version: 0x566",
                "num_banks": "2",
                "sn": "062017A0006789",
                "stb_type": "MAG250",
                "client_type": "STB",
                "image_version": "218",
                "video_out": "hdmi",
                "device_id": "",
                "device_id2": "",
                "signature": "",
                "auth_second_step": "1",
                "hw_version": "1.7-BD-00",
                "not_valid_token": "0",
                "metrics": "{\"mac\":\"" + mac + "\",\"sn\":\"062017A0006789\",\"type\":\"STB\",\"model\":\"MAG250\",\"uid\":\"\",\"random\":\"\"}",
                "hw_version_2": "9d04a84afda8a2bd6dadc6a06b01bdcd1d3bc1e5",
                "timestamp": "1500000000",
                "api_signature": "262",
                "prehash": "",
            },
            mac,
            token,
        )
        _cache_put(_TOKEN_CACHE, (portal_root, mac), token, _TOKEN_TTL)
        return client, token, prof.get("js") or {}
    except HTTPException:
        await client.aclose()
        raise
    except Exception as exc:  # noqa: BLE001
        await client.aclose()
        raise HTTPException(502, f"Stalker auth failed: {exc}")


@router.post("/handshake")
async def stalker_handshake(body: StalkerRequest) -> dict:
    """Validate that a portal accepts the MAC and return a token + profile.

    Always performs a fresh handshake (bypasses the token cache) so the
    `Test Connection` button in the UI gives an accurate live status.
    """
    portal_root = _normalise_portal(body.portal_url)
    client, token, profile = await _full_auth(portal_root, body.mac, force=True)
    try:
        return {
            "ok": True,
            "token": token,
            "portal_root": portal_root,
            "mac": body.mac,
            "profile": {
                "id": profile.get("id"),
                "mac": profile.get("mac") or body.mac,
                "name": profile.get("fname") or profile.get("name"),
                "phone": profile.get("phone"),
                "tariff_plan": profile.get("tariff_plan"),
                "blocked": bool(profile.get("blocked", 0)),
                "expire_billing_date": profile.get("end_date") or profile.get("expire_billing_date"),
            },
        }
    finally:
        await client.aclose()


@router.post("/all-channels")
async def stalker_all_channels(body: StalkerRequest) -> dict:
    """Return the full live-channel list for a MAC/portal pair."""
    portal_root = _normalise_portal(body.portal_url)
    client, token, _ = await _full_auth(portal_root, body.mac)
    try:
        data = await _portal_get(
            client,
            portal_root,
            {
                "type": "itv",
                "action": "get_all_channels",
                "JsHttpRequest": "1-xml",
            },
            body.mac,
            token,
        )
        raw = ((data.get("js") or {}).get("data")) or []

        # Fetch genres so we can attach human-readable category labels.
        genres_raw: list[dict] = []
        try:
            g = await _portal_get(
                client,
                portal_root,
                {
                    "type": "itv",
                    "action": "get_genres",
                    "JsHttpRequest": "1-xml",
                },
                body.mac,
                token,
            )
            genres_raw = (g.get("js") or []) if isinstance(g.get("js"), list) else []
        except Exception:
            genres_raw = []

        gmap = {str(x.get("id")): str(x.get("title") or x.get("name") or "Uncategorized") for x in genres_raw}

        channels: list[dict] = []
        for ch in raw:
            tv_genre = str(ch.get("tv_genre_id") or "")
            channels.append({
                "id": str(ch.get("id") or ch.get("xmltv_id") or ""),
                "name": ch.get("name") or "",
                "logo": ch.get("logo") or ch.get("logo_big") or "",
                "category": gmap.get(tv_genre, "Uncategorized"),
                "categoryId": tv_genre or "0",
                "cmd": ch.get("cmd") or "",
                "epgChannelId": ch.get("xmltv_id") or "",
                "tv_archive": int(ch.get("tv_archive") or 0),
                "censored": int(ch.get("censored") or 0),
            })
        return {"channels": channels, "total": len(channels)}
    finally:
        await client.aclose()


@router.post("/vod-categories")
async def stalker_vod_categories(body: StalkerRequest) -> dict:
    """Return the VOD category list (movies)."""
    portal_root = _normalise_portal(body.portal_url)
    client, token, _ = await _full_auth(portal_root, body.mac)
    try:
        data = await _portal_get(
            client,
            portal_root,
            {
                "type": "vod",
                "action": "get_categories",
                "JsHttpRequest": "1-xml",
            },
            body.mac,
            token,
        )
        cats = data.get("js") or []
        if not isinstance(cats, list):
            cats = []
        return {
            "categories": [
                {
                    "id": str(c.get("id") or ""),
                    "name": c.get("title") or c.get("name") or "Uncategorized",
                    "censored": int(c.get("censored") or 0),
                }
                for c in cats
            ],
            "total": len(cats),
        }
    finally:
        await client.aclose()


@router.post("/resolve-stream")
async def stalker_resolve_stream(body: dict) -> dict:
    """Resolve a Stalker channel `cmd` into a real playable URL.

    Body: { portal_url, mac, cmd }

    The resolved URL is cached for ~30 s per (portal, mac, cmd) so rapid
    channel zapping (back-and-forth between two channels, double-clicks,
    React StrictMode double-fires, etc.) doesn't trigger a fresh
    `create_link` round-trip every time.
    """
    portal_url = (body or {}).get("portal_url") or ""
    mac = ((body or {}).get("mac") or "").upper()
    cmd = (body or {}).get("cmd") or ""
    if not portal_url or not mac or not cmd:
        raise HTTPException(400, "portal_url, mac and cmd are required")
    if not MAC_RE.match(mac):
        raise HTTPException(400, "MAC must be in 00:1A:79:XX:XX:XX format")
    portal_root = _normalise_portal(portal_url)

    cached = _cache_get(_URL_CACHE, (portal_root, mac, cmd))
    if cached:
        return {"url": cached, "cached": True}

    client, token, _ = await _full_auth(portal_root, mac)
    try:
        data = await _portal_get(
            client,
            portal_root,
            {
                "type": "itv",
                "action": "create_link",
                "cmd": cmd,
                "JsHttpRequest": "1-xml",
            },
            mac,
            token,
        )
        url = ((data.get("js") or {}).get("cmd")) or ""
        # Server returns "ffmpeg http://...." or just the URL — strip the prefix
        m = re.match(r"^\s*ffmpeg\s+(.+)$", url)
        if m:
            url = m.group(1)
        url = url.strip()
        if not url:
            raise HTTPException(502, "Portal did not return a playable URL")
        _cache_put(_URL_CACHE, (portal_root, mac, cmd), url, _URL_TTL)
        return {"url": url, "cached": False}
    finally:
        await client.aclose()
