"""POST /scenes/{scene_id}/playback/{playback_id}/resolve — rozwiązuje stream URL.

Mobile apka woła ten endpoint na klik "Watch" — backend ekstraktuje świeży
stream URL (m3u8/mp4) z page tube'a i zwraca go. Mobile otwiera URL przez
Linking.openURL → Android player chooser (MX Player / VLC / browser).

Stream URLs są podpisane/expire (zwykle ~kilka godzin) — nie cache'ujemy ich
w DB, tylko resolve on-demand. Logika ekstrakcji per-tube w `app.extractors`.

**Dead-link detection**: gdy hoster embed page mówi "Video deleted/not found",
oznaczamy `PlaybackSource.dead_at = now()` — API dalej go nie listuje, mobile
nie pokaże martwego buttonu.
"""
from __future__ import annotations

import logging
import re
import uuid
from datetime import UTC, datetime
from typing import Annotated, Any

from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel
from sqlalchemy.orm import Session

from app.api.schemas import PlaybackSourceOut
from app.auth import require_api_key
from app.db import get_session
from app.extractors import (
    HosterDead,
    StreamSource,
    TubePageError,
    extract_stream_from_hoster,
    try_extract,
)
from app.models.playback_source import PlaybackSource

log = logging.getLogger(__name__)

router = APIRouter(prefix="/scenes", tags=["playback"], dependencies=[Depends(require_api_key)])

# CDN-domain allowlist dla mobile direct fetch — token IS time-bound (nie IP-bound),
# zweryfikowane cross-IP curl test 2026-05-18. Mobile ExoPlayer pobiera manifest+segments
# bezpośrednio z CDN, **zero VPS bandwidth**. Critical dla public release (TB+/miesiąc).
#
# Verified time-bound:
#   - xvideos-cdn.com, xnxx-cdn.com (WGCZ Holding) — signed token w path + exp_time
#   - phncdn.com (pornhub), ypncdn.com (youporn), rdtcdn.com (redtube) — validfrom+validto+hash
#   - privatehost.com (pornhat CDN) — sign + exp_time, brak Referer requirement
#   - sxyprn.com — signed path
#   - eporner.com CDN — IP literal w path ale CDN go ignoruje
#
# NIE w allowlist (IP-bound, wymagają proxy):
#   - premilkyway.com (latestpornvideo) — 403 cross-IP
#   - tnmr.org (mypornerleak) — 403 cross-IP
#   - porntrex.com/get_file — single-use token (410 po reuse)
#   - freshporno.org/get_file — cv= signed token IP-bound
#   - sn.porn-xp.com, porn00.org — force_proxy explicit
_TIME_BOUND_CDN_RE = re.compile(
    r"\b(?:"
    r"xvideos-cdn|xnxx-cdn|phncdn|ypncdn|rdtcdn"  # mainstream
    r"|privatehost"  # pornhat
    r")\.[a-z]{2,4}"
    r"|(?:^|/)(?:sxyprn\.com|[\w\-]+\.eporner\.com)/",
    re.IGNORECASE,
)

# IP-BOUND CDN signature — token bind do requester IP, cross-IP fetch = 403.
# Wymaga mobile WebView fallback (mobile extract z phone session, nie VPS).
# Shared KVS infrastructure across multiple hosters (luluvid movies, mypornerleak,
# latestpornvideo) — wszystkie używają tego samego CDN pool.
_IP_BOUND_CDN_RE = re.compile(
    r"\b(?:"
    r"premilkyway\.com"     # latestpornvideo
    r"|tnmr\.org"           # mypornerleak legacy + luluvid movies (cdn-tnmr.org)
    r"|acek-cdn\.com"       # mypornerleak current
    r")\b",
    re.IGNORECASE,
)


class StreamLink(BaseModel):
    """Pojedynczy variant stream URL (różne quality / kontener).

    `stream_url` = URL do video file (mp4/m3u8/webm) — proxy-wrapped URL przez backend
    VPS (`/proxy/{token}/play.ext`). Bezpieczny fallback gdy CDN binduje URL do IP
    extractora (np. fpo.xxx z kt_remote_ips cookie). Bandwidth idzie przez VPS.

    `direct_url` + `headers` = surowy CDN URL z headers do bezpośredniego fetchu z
    urządzenia. Większość tube CDN (xhamster, redtube, watchporn, eporner) zwraca
    poprawnie content gdy mobile player wysyła `Referer` + `User-Agent` z `headers`.
    Mobile próbuje direct PIERWSZY — gdy CDN zwróci 403/410 (IP-bound), spada na
    `stream_url` (proxy). Daje 0 bandwidth na VPS-ie dla większości scen.

    `embed_url` = URL do embed/hoster page (HTML, np. StreamWish, doodporn) — mobile
    otwiera w WebView. Type: 'hoster'.
    """

    stream_url: str | None = None
    embed_url: str | None = None
    direct_url: str | None = None
    headers: dict[str, str] | None = None
    quality: str | None = None
    type: str | None = None  # mime/ext, np. 'video/mp4', 'application/x-mpegURL'
    raw: dict[str, Any] | None = None


class ResolveOut(BaseModel):
    source: PlaybackSourceOut
    best: StreamLink | None = None
    links: list[StreamLink] = []


movies_router = APIRouter(
    prefix="/movies", tags=["movies-playback"], dependencies=[Depends(require_api_key)]
)


@movies_router.post("/{movie_id}/playback/{playback_id}/resolve", response_model=ResolveOut)
def resolve_movie_playback(
    movie_id: uuid.UUID,
    playback_id: uuid.UUID,
    session: Annotated[Session, Depends(get_session)],
) -> ResolveOut:
    """Movies playback resolve — analog do `/scenes/{id}/playback/{pb}/resolve`.

    Origin patterns:
      - 'paradisehill'  → tylko page_url (Yii2 player wymaga login session, więc
        mobile dostaje page_url jako embed_url, otwiera w WebView).
      - 'mangoporn:host', 'streamporn:host', 'pandamovies:host' → embed_url to URL
        embedu hostera (doodstream/lulustream/rpmplay/itp.). Próbujemy lokalnie
        wyciągnąć direct stream URL przez generic packer (`extract_stream_from_hoster`),
        z fallback na embed-only gdy się nie uda. Mobile w PlayerScreen.WebViewMode
        wyciągnie wtedy URL JS-em (jak ze scenami).
    """
    from app.models.movie_playback_source import MoviePlaybackSource

    pb = session.get(MoviePlaybackSource, playback_id)
    if pb is None or pb.movie_id != movie_id:
        raise HTTPException(status_code=404, detail="movie playback source not found")
    if pb.dead_at is not None:
        raise HTTPException(
            status_code=410,
            detail=f"playback dead: {pb.dead_reason or 'unknown'}",
        )

    referer = pb.page_url
    links: list[StreamLink] = []

    if pb.origin == "paradisehill":
        # Paradisehill: pobierz page, parsuj `var videoList = [...]` żeby dostać N parts.
        # Każdy part to direct mp4 z paradisehill CDN (v1.paradisehill.cc), serwowane
        # bez auth — 200 OK z plain User-Agent + Referer.
        # Bug-reports `c5693926`/`418270e4`/`3c999b27` 2026-05-21 ("ładuje tylko 1 z N").
        # Poprzednio: tylko WebView fallback → mobile gra 1. part w playerze paradisehilla,
        # nie ma sposobu przejść do następnego.
        try:
            from app.connectors.paradisehill import fetch_and_extract_parts
            parts = fetch_and_extract_parts(pb.page_url)
        except Exception as e:
            log.warning("paradisehill parts extract failed for %s: %s", pb.page_url, e)
            parts = []
        if parts:
            for url, label in parts:
                # NIE proxifikujemy tutaj — outer `_proxify_link` poniżej (linia 247) opakuje
                # wszystkie linki. Double-wrap → token wewnątrz tokena (broken proxy URL).
                links.append(
                    StreamLink(
                        stream_url=url,
                        embed_url=None,
                        quality=label,
                        type="mp4",
                        raw={"origin": pb.origin, "part_label": label},
                    )
                )
        else:
            # Fallback: brak videoList (np. login-only movie) — WebView na całość.
            links = [
                StreamLink(
                    stream_url=None,
                    embed_url=pb.page_url,
                    quality=pb.quality,
                    type="hoster",
                    raw={"origin": pb.origin},
                )
            ]
    else:
        # dooplay mirror sources: spróbuj direct stream extract z hoster URL
        target = pb.embed_url or pb.page_url
        stream: str | None = None
        try:
            stream = extract_stream_from_hoster(target, referer=referer)
        except HosterDead as e:
            # Hoster wprost mówi "video deleted" — oznacz dead, NIE proponuj
            # embed fallback (mobile ExoPlayer dostałby 404 HTML page i
            # próbowałby zapisać jako .bin file; bug-report 2026-05-16
            # "streamtape ściąga hurtowo pliki .bin").
            pb.dead_at = datetime.now(UTC)
            pb.dead_reason = str(e)[:512]
            session.commit()
            log.info("marked movie playback %s dead (origin=%s reason=%s)", pb.id, pb.origin, e)
            raise HTTPException(status_code=410, detail=f"playback dead: {e}") from e
        except Exception as e:
            log.warning("movie hoster extract failed for %s: %s", target, e)
        if stream and _IP_BOUND_CDN_RE.search(stream):
            # IP-bound CDN (luluvid → cdn-tnmr.org, etc.) — token bind do VPS IP,
            # mobile direct = 403. Skip stream, fallback na embed_url (mobile WebView).
            log.info(
                "movie playback %s: stream URL IP-bound CDN — skip, WebView fallback",
                pb.id,
            )
            stream = None
        # Mixdrop mxcontent CDN wymaga curl_cffi JA3 → wymusza VPS proxy.
        # Pre-2026-05-25 skipowaliśmy ten path "Bandwidth + anonimowość > UX",
        # ale bug-report b1b5e1a2 zgłosił że Mixdrop WebView fallback = czarny
        # ekran (recaptcha/adblock-detect blokują player init w in-app WebView).
        # Movie ma zwykle 10+ alt hosterów, ale jeśli WebView fallback nie
        # działa, user widzi tylko czarny ekran zamiast jakiejkolwiek alternatywy.
        # Backend ma pełen pipeline: mixdrop.py extract → raw={proxy_impersonate:
        # True, refetch_url} → stream_proxy._curl_cffi_stream z Chrome JA3 +
        # auto-refetch on token expire. Włączamy go z powrotem.
        # Bandwidth cost: ~485 MB/movie play; przy ~3 plays/day = 1.5 GB/day
        # (acceptable na 8GB/m Hetzner plan z 20 TB transfer).
        if stream:
            type_hint = "m3u8" if ".m3u8" in stream.lower() else "mp4"
            raw_meta: dict = {"origin": pb.origin, "host": target}
            # seekplayer-engine (#hash family: easyvidplayer/player4me/seekplayer/
            # embedseek/upns — ~322k sources) zwraca master.m3u8 na raw-IP CDN
            # (185.237.x/203.188.x/45.156.x `/v4/<tok>/<exp>/pp/<hash>/master.m3u8`).
            # Zweryfikowane cross-IP (curl_cffi chrome + Bright Data, 2026-06-06):
            # manifest + variant + fMP4 segment WSZYSTKIE 200 z innego IP, a cert jest
            # VALID (verify=True OK — IP-SAN, nie self-signed jak głosił stary docstring).
            # Token jest TIME-bound (`<exp>` unix ts), NIE IP-bound. → mobile ExoPlayer
            # gra direct z CDN, zero VPS bandwidth (był to największy movie proxy-sink).
            # Proxy (`stream_url`) zostaje jako fallback (stream_proxy IP-host gałąź
            # robi verify=False). Device-verified na emulatorze przed deployem.
            from app.extractors.hosters import seekplayer_engine
            if seekplayer_engine.matches(target):
                raw_meta["mobile_direct_ok"] = True
            links.append(
                StreamLink(
                    stream_url=stream,
                    embed_url=None,
                    quality=pb.quality,
                    type=type_hint,
                    raw=raw_meta,
                )
            )
        # Zawsze dorzucamy embed jako fallback — mobile WebView może wyłapać URL z JS-a
        if pb.embed_url:
            links.append(
                StreamLink(
                    stream_url=None,
                    embed_url=pb.embed_url,
                    quality=pb.quality,
                    type="hoster",
                    raw={"origin": pb.origin},
                )
            )

    if not links:
        raise HTTPException(status_code=502, detail="no playable links")

    links = [_proxify_link(link, referer) for link in links]
    # Dla paradisehill multipart: `_pick_best` wybiera "Part N" z najwyższą cyfrą (parsuje
    # quality jako int), ale user chce zacząć od Part 1. Override: zawsze links[0].
    if pb.origin == "paradisehill" and len(links) > 1 and any(
        (link.raw or {}).get("part_label") for link in links
    ):
        best = links[0]
    else:
        best = _pick_best(links) if links else None
    return ResolveOut(
        source=PlaybackSourceOut.model_validate(pb),
        best=best,
        links=links,
    )


def _requester_tag(request: Request) -> str:
    """Audit tag dla mark-dead: IP (X-Forwarded-For preferred dla nginx proxy)
    + skrócony User-Agent. Zapisywane w dead_reason + log dla post-mortem
    gdyby leaked APK key był używany do masowego psucia danych."""
    fwd = request.headers.get("x-forwarded-for", "")
    ip = fwd.split(",")[0].strip() if fwd else (request.client.host if request.client else "?")
    ua = (request.headers.get("user-agent") or "")[:40]
    return f"ip={ip} ua={ua}"


@router.post(
    "/{scene_id}/playback/{playback_id}/mark-dead",
    status_code=status.HTTP_204_NO_CONTENT,
)
def mark_playback_dead(
    scene_id: uuid.UUID,
    playback_id: uuid.UUID,
    request: Request,
    session: Annotated[Session, Depends(get_session)],
) -> None:
    """User-triggered mark dead — long-press na playback chip w mobile.

    Bug-report 2026-05-12 (dd17c709): "Eporner to nie temporary. Więc długie
    przytrzymanie na linku celem usunięcia też byłoby ok". Backend mark-dead
    flow działał tylko dla resolve failures (HosterDead/TubePageError). User
    może teraz oznaczać linki które działają backendowi (200 OK) ale są broken
    w praktyce (np. źle zmatchowana scena, ad-redirect, hoster zwraca placeholder).

    Audit: zapisujemy requester IP+UA w dead_reason+log żeby leaked APK key
    nie mógł silently masowo niszczyć danych bez ścieżki dochodzenia.
    """
    pb = session.get(PlaybackSource, playback_id)
    if pb is None or pb.scene_id != scene_id:
        raise HTTPException(status_code=404, detail="playback source not found for scene")
    if pb.dead_at is None:
        tag = _requester_tag(request)
        pb.dead_at = datetime.now(UTC)
        pb.dead_reason = f"user-marked dead (mobile long-press) {tag}"[:512]
        session.commit()
        log.info("user marked playback %s dead (origin=%s %s)", pb.id, pb.origin, tag)


@movies_router.post(
    "/{movie_id}/playback/{playback_id}/mark-dead",
    status_code=status.HTTP_204_NO_CONTENT,
)
def mark_movie_playback_dead(
    movie_id: uuid.UUID,
    playback_id: uuid.UUID,
    request: Request,
    session: Annotated[Session, Depends(get_session)],
) -> None:
    """User-triggered mark dead dla movie playback (long-press w MovieDetail)."""
    from app.models.movie_playback_source import MoviePlaybackSource

    pb = session.get(MoviePlaybackSource, playback_id)
    if pb is None or pb.movie_id != movie_id:
        raise HTTPException(status_code=404, detail="movie playback source not found")
    if pb.dead_at is None:
        tag = _requester_tag(request)
        pb.dead_at = datetime.now(UTC)
        pb.dead_reason = f"user-marked dead (mobile long-press) {tag}"[:512]
        session.commit()
        log.info("user marked movie playback %s dead (origin=%s %s)", pb.id, pb.origin, tag)


@router.post("/{scene_id}/playback/{playback_id}/resolve", response_model=ResolveOut)
def resolve_playback(
    scene_id: uuid.UUID,
    playback_id: uuid.UUID,
    session: Annotated[Session, Depends(get_session)],
) -> ResolveOut:
    pb = session.get(PlaybackSource, playback_id)
    if pb is None or pb.scene_id != scene_id:
        raise HTTPException(status_code=404, detail="playback source not found for scene")
    if pb.dead_at is not None:
        raise HTTPException(
            status_code=410,
            detail=f"playback source marked dead: {pb.dead_reason or 'unknown'}",
        )

    page_url = pb.page_url

    sitetag: str | None = None
    if pb.origin.startswith("pornapp:"):
        # Legacy origin format — pre-pornapp-removal migration. Po Fazie 2 zostanie tylko `tube:`.
        sitetag = pb.origin.split(":", 1)[1]
    elif pb.origin.startswith("tube:"):
        sitetag = pb.origin.split(":", 1)[1]

    if sitetag is None:
        raise HTTPException(
            status_code=501,
            detail=f"resolve not implemented for origin '{pb.origin}'",
        )

    try:
        sources = try_extract(sitetag, page_url)
    except HosterDead as e:
        pb.dead_at = datetime.now(UTC)
        pb.dead_reason = str(e)[:512]
        session.commit()
        log.info("marked playback %s dead (origin=%s reason=%s)", pb.id, pb.origin, e)
        raise HTTPException(status_code=410, detail=f"playback dead: {e}") from e
    except TubePageError as e:
        # Tube page is gone (404/410) — mark dead, propagate as 410. Inne 5xx → 502.
        if e.status_code in (404, 410):
            reason = f"tube page {e.status_code} {pb.page_url}"
            pb.dead_at = datetime.now(UTC)
            pb.dead_reason = reason[:512]
            session.commit()
            log.info("marked playback %s dead (origin=%s reason=%s)", pb.id, pb.origin, reason)
            raise HTTPException(status_code=410, detail=f"playback dead: {reason}") from e
        log.warning("tube fetch http error %s for %s", e.status_code, pb.page_url)
        raise HTTPException(
            status_code=502,
            detail=f"tube fetch failed: HTTP {e.status_code}",
        ) from e

    if not sources:
        # Extractor None — TRANSIENT failure (network glitch, tube chwilowy 503,
        # ad-network response zmieniony, race condition). NIE oznaczamy `dead_at`
        # bo wcześniej powodowało false-positive permanent dead dla freshporno scen
        # które działały przy następnym attempt (bug-report 2026-05-12).
        #
        # Permanent dead idzie TYLKO z explicit signals:
        #   - HosterDead exception (hoster page mówi "video deleted")
        #   - TubePageError 404/410 (page nie istnieje)
        # Reszta = transient, mobile dostaje 501 → user może retry.
        log.info(
            "extractor None for playback %s (origin=%s) — transient, not marking dead",
            pb.id, pb.origin,
        )
        # 503 (not 410!) żeby mobile NIE pokazało "Tube usunął ten film" — ten kod
        # jest dla permanent removal. 503 = transient, user może retry.
        # Sentry filtruje HTTPException 502/503/504 w `_sentry_before_send` (main.py) —
        # bez tego GOON-3 spam-floodował issue list (16 events/5h dla expected case).
        raise HTTPException(
            status_code=503,
            detail="extraction failed temporarily — retry possible",
        )

    # Per-source referer: niektóre extractory (yt-dlp, embed-iframe) zwracają stream
    # URL którego CDN expectuje Referera embed page'a (host iframe), nie oryginalnej
    # strony tube'a. Np. 0dayxx page → watchporn.to/embed iframe → stream URL chce
    # `Referer: watchporn.to/` (z `Referer: 0dayxx.com` CDN zwraca 410). StreamSource.
    # referer trzyma tę informację; fallback na page_url gdy extractor nie ustawił.
    proxified: list[StreamLink] = []
    for s in sources:
        link = _stream_source_to_link(s)
        proxified.append(_proxify_link(link, s.referer or page_url))
    links = proxified
    best = _pick_best(links) if links else None
    return ResolveOut(
        source=PlaybackSourceOut.model_validate(pb),
        best=best,
        links=links,
    )


DEFAULT_PLAYER_UA = (
    "Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 "
    "(KHTML, like Gecko) Chrome/140.0.0.0 Mobile Safari/537.36"
)


def _proxify_link(link: StreamLink, referer: str) -> StreamLink:
    """Wzbogaca StreamLink o:
      - `stream_url`: proxy URL (fallback gdy direct fails)
      - `direct_url`: surowy CDN URL (preferowany — 0 VPS bandwidth)
      - `headers`: Referer + User-Agent dla direct fetch
    Mobile player próbuje direct PIERWSZY, fallback na stream_url po błędzie 403/410.
    """
    if not link.stream_url:
        return link
    from app.api.stream_proxy import make_token

    raw_url = link.stream_url
    # Extractor flags w raw:
    #  - proxy_impersonate: curl_cffi chrome JA3 (mxcontent etc.)
    #  - refetch_url: embed URL do re-extract gdy token expired (same-session
    #    cookie binding dla mixdrop). Bez tego mp4 token + brak cookies → 403.
    use_impersonate = bool(link.raw and link.raw.get("proxy_impersonate"))
    # force_proxy=True (extractor flag) → direct_url=proxied od razu. Dla CDN-ów
    # gdzie token IS bound do VPS IP (porn00 v-acctoken, pornxp sv.porn-xp.com
    # signed path) — mobile direct ZAWSZE 403, więc nie ma sensu próbować.
    # Bez tego: każdy playback "mrugnie" (direct fail → fallback na proxy).
    force_proxy = bool(link.raw and link.raw.get("force_proxy"))
    # mobile_direct_ok=True (extractor flag) → m3u8 może iść direct do mobile bo
    # CDN URL ma time-bound (nie IP-bound) signed token. Mobile ExoPlayer pobiera
    # manifest+segments bezpośrednio z CDN, zero VPS bandwidth.
    mobile_direct_ok = bool(link.raw and link.raw.get("mobile_direct_ok"))
    # Auto-detect time-bound CDN po domain — bez per-extractor flag setting.
    # Critical dla public release: wszystkie mainstream tubes (xvideos/xnxx/pornhub/
    # youporn/redtube + pornhat) zwracają time-bound URLs które działają cross-IP.
    if not mobile_direct_ok and raw_url and _TIME_BOUND_CDN_RE.search(raw_url):
        mobile_direct_ok = True
    refetch_url = (link.raw or {}).get("refetch_url")
    refetch_hoster = (link.raw or {}).get("refetch_hoster")
    token = make_token(
        raw_url, referer, impersonate=use_impersonate,
        refresh=refetch_url, refresh_hoster=refetch_hoster,
    )
    # Decyzja na BASIE link.type (zaufanie do extractora), z fallback path-hint.
    # Pornhat: raw URL `.../get_file/.../<id>.mp4/` ale CDN 302 → HLS manifest.
    # Extractor markuje type='m3u8' żeby ExoPlayer użył HlsMediaSource (bez tego
    # path `.mp4` mylił player → "no extractors").
    type_lower = (link.type or "").lower()
    if type_lower in {"m3u8", "hls", "mpd"}:
        ext = "m3u8" if type_lower in {"m3u8", "hls"} else "mpd"
    elif ".m3u8" in raw_url.lower():
        ext = "m3u8"
    elif ".mpd" in raw_url.lower():
        ext = "mpd"
    else:
        ext = "mp4"
    proxied = f"/proxy/{token}/play.{ext}"
    # `direct_url`: surowy CDN URL — mobile próbuje go PIERWSZY (0 VPS bandwidth).
    # ALE: dla type=m3u8/hls/mpd manifest URL musi być rewritowany żeby segmenty/keys
    # też leciały przez proxy (inne IP może też mieć rate limit/token issues), plus
    # ExoPlayer wybiera extractor po URL extension — `.mp4` w direct URL pornhat
    # → Mp4Extractor → fail bo content to HLS. Dla m3u8/mpd zwracamy proxied JAKO
    # direct (mobile używa go bezpośrednio, 1 hop przez VPS ale to jedyny sposób
    # żeby manifest+segments były spójne i ExoPlayer wybrał HlsMediaSource).
    # Dla CDNs które wymagają chrome JA3 (mxcontent) direct_url też zawsze przez
    # proxy — bez tego mobile direct fetch z OkHttp JA3 dostaje 403 → fallback proxy
    # → extra round-trip + ExoPlayer "no extractors" przed retry.
    # mobile_direct_ok overrides m3u8 default-to-proxy: gdy CDN ma time-bound token
    # (nie IP-bound), mobile ExoPlayer może pobrać manifest direct bez VPS proxy.
    is_manifest_type = type_lower in {"m3u8", "hls", "mpd"}
    is_hls = type_lower in {"m3u8", "hls"}
    # ExoPlayer (expo-video 2.0.6, brak contentType) zgaduje typ z URL: ścieżka kończąca
    # się `.m3u8` → HLS (gra direct, 0 VPS), inaczej → progressive Mp4Extractor → fail
    # → fallback na pełny proxy → CAŁE wideo przez VPS (pornhat `...mp4,?...`: 466 hitów
    # /48h, audit 2026-06-11). Dla takich time-bound HLS dajemy manifest-passthrough pod
    # `play.m3u8`: segmenty zostają absolutne na CDN → telefon ciągnie je direct, przez
    # VPS leci tylko ~1KB manifestu. Manifesty z poprawnym `.m3u8` zostają w pełni direct.
    raw_path = raw_url.split("?", 1)[0].lower()
    hls_needs_passthrough = is_hls and mobile_direct_ok and not raw_path.endswith(".m3u8")
    if use_impersonate or force_proxy or (is_manifest_type and not mobile_direct_ok):
        direct_for_mobile = proxied
    elif hls_needs_passthrough:
        direct_for_mobile = f"/proxy/hls/{token}/play.m3u8"
    else:
        direct_for_mobile = raw_url
    return StreamLink(
        stream_url=proxied,
        embed_url=link.embed_url,
        direct_url=direct_for_mobile,
        headers={"Referer": referer, "User-Agent": DEFAULT_PLAYER_UA},
        quality=link.quality,
        type=link.type,
        raw=link.raw,
    )


def _stream_source_to_link(s: StreamSource) -> StreamLink:
    """Mapowanie StreamSource (z extractorów) na StreamLink (response API).

    Hoster type → embed_url (mobile otworzy WebView). mp4/m3u8/mpd → stream_url
    (mobile odtworzy w native playerze przez /proxy).
    """
    is_hoster = (s.type or "").lower() == "hoster"
    return StreamLink(
        stream_url=None if is_hoster else s.link,
        embed_url=s.link if is_hoster else None,
        quality=s.quality,
        type=s.type,
        raw=s.raw,
    )


def _pick_best(links: list[StreamLink]) -> StreamLink | None:
    """Wybiera najlepszą jakość. Preferencje:
    1. Najpierw direct video (`stream_url` niepuste); fallback na embed-only gdy żaden
       nie ma direct (mobile pokaże "Open in browser").
    2. Najwyższe quality (parsowane jako int z '720p' / '1080p' / '4k')
    3. Preferuj mp4 nad m3u8 jeśli ten sam quality (mp4 łatwiejsze dla MX Player)
    """
    direct = [link for link in links if link.stream_url]
    pool = direct or [link for link in links if link.embed_url]
    if not pool:
        return None

    def score(link: StreamLink) -> tuple[int, int]:
        q_int = _quality_to_int(link.quality)
        url_low = (link.stream_url or link.embed_url or "").lower()
        type_low = (link.type or "").lower()
        is_mp4 = ".mp4" in url_low or "mp4" in type_low or "direct" in type_low
        type_priority = 1 if is_mp4 else 0
        return (q_int, type_priority)

    return max(pool, key=score)


_QUALITY_DIGITS_RE = re.compile(r"\d+")


def _quality_to_int(q: str | None) -> int:
    """Wyciąga liczbę pikseli z różnych formatów: '720p', '1080p Full HD', '4K', 'HD'."""
    if not q:
        return 0
    s = q.lower().strip()
    if "4k" in s or "uhd" in s:
        return 2160
    if "2k" in s or "qhd" in s:
        return 1440
    m = _QUALITY_DIGITS_RE.search(s)
    if m:
        return int(m.group(0))
    if "fhd" in s:
        return 1080
    if "hd" in s:
        return 720
    if "sd" in s:
        return 480
    return 0
