"""Generic embed-iframe stream extractor.

Wzorzec stosowany przez większość aggregator tubes (latestpornvideo, xmoviesforyou,
watchporn, siska, porn4days, porndish, xxxfreewatch, latestleaks, mypornerleak,
porndittcom, fpoxxx, hdporn92, sxyland, 0dayxx, ...):

  1. Fetch page HTML
  2. Znajdź `<iframe src=...>` wskazujący na hoster (StreamWish/doodporn/luluvdo/
     mediafire/sdefx/playmogo/...). Iframe path moze być `/e/<id>`, `/embed/<id>`,
     `/video/embed/<id>` LUB goły slug `/<id>` (sdefx.cloud, niektóre playmogo).
  3. Filtruj iframe-y reklamowe (ad domains + `?key=`, `?idzone=` parametry).
  4. Spróbuj `extract_stream_from_hoster` (P.A.C.K.E.R. unpack JWPlayer) → m3u8/mp4.
  5. Jeśli nie wyciągnęło URL (CAPTCHA, multi-step JS, anti-bot), fallback do
     hoster type — mobile otworzy w WebView, hoster JS sam zrobi swoje.

`referer` dla hoster fetchu to `https://<page_host>/` — komputujemy automatycznie
z `page_url`, więc rejestrujemy ten sam extractor pod wieloma sitetag-ami bez
osobnych config'ów.
"""
from __future__ import annotations

import logging
import re
from urllib.parse import urlparse

from app.extractors._fetch import fetch_tube_html
from app.extractors._models import StreamSource
from app.extractors.hoster import extract_stream_from_hoster

log = logging.getLogger(__name__)


# Każdy iframe na stronie — szeroki match. Filtrowanie reklamowych zachodzi niżej.
# Generic TLD `[a-z]{2,8}` żeby pokryć egzotyczne hosting domains (.yt, .ws, .li,
# .stream, .pro, .cloud, .live).
ANY_IFRAME_RE = re.compile(
    r'<iframe[^>]+src=["\'](?P<url>(?:https?:)?(?://)?[a-z0-9.-]+\.[a-z]{2,8}/[^"\']+)',
    re.IGNORECASE,
)

# Path patterns for "this is a player embed" — covers most hoster URL shapes:
#   /e/<id>, /embed/<id>, /video/embed/<id>           → StreamWish, doodporn, dood.yt, luluvdo
#   /embed-<id>(.html)?                                → xtapes.porn, niektóre stare hosty
#   /player/<id>(.php)?(?...)?                         → xtremestream.xyz, niektóre custom hosty
#   /v/<id>, /watch/<id>                               → emergency catch — niektóre tubes
PLAYER_PATH_RE = re.compile(
    r'/(?:e|embed|video/embed|v|watch|t|f|d|stream)/[a-zA-Z0-9_\-]+'
    r'|/embed-[a-zA-Z0-9_\-]+'
    r'|/player(?:/[a-zA-Z0-9_/.\-]+)?',
    re.IGNORECASE,
)

# Bare-slug player path (sdefx.cloud, niektóre playmogo): single path segment after host,
# alphanumeric + dash. Żadnego `/folder/file` ani query stringa — to wykrycie hosterów
# które nie używają standardowego `/e/<id>` patternu.
BARE_SLUG_PATH_RE = re.compile(r'^/[a-zA-Z0-9_\-]{4,}/?$')

# Ad iframes — domeny reklam + parametry (idzone, key=) — odsiewamy nawet jeśli mają
# inne path patterns. Lista ze stron które rzeczywiście aggregator tubes serwują.
AD_DOMAIN_RE = re.compile(
    r'(?:willingcease|propellerads|popads|popcash|trafficstars|exoclick|adsterra|'
    r'happyleafmotion|adskeeper|hilltopads|juicyads|trafficjunky|adblade|'
    # Cam-widget / smartpop / pop-under sieci embed'owane przez aggregator tubes
    # (porndish→mavrtracktor, xmoviesforyou→adtng+bluetrafficstream). Brak ich w
    # liście powodował że `raw_iframes_count` rosło → fall-through do "all blacklisted"
    # → None zamiast page-as-hoster fallback (WebView na page'a aggregatora).
    r'mavrtracktor|adtng|bluetrafficstream|smartpop|chaturbate|stripchat|streamate|'
    r'mypornclub|cdntrafficstars|trafficfactory|popcrn|popmyads|adcash)\.[a-z]{2,8}',
    re.IGNORECASE,
)
AD_QUERY_RE = re.compile(r'[?&](?:idzone|adkey|key|aff_id|adsrc)=', re.IGNORECASE)

# Direct stream URLs na page — niektóre aggregator tubes (porn4days, xmoviesforyou)
# wstawiają full mp4/m3u8 URL bezpośrednio w HTML/JS (download link, video.js source,
# JWPlayer config). Skanujemy je przed iframe processing — direct URL > iframe wymagający
# WebView. Wymagamy że path zawiera quality marker (`<digits>p.mp4`) lub `.m3u8` żeby
# uniknąć false-positives z thumbnail/preview generation endpoints.
_DIRECT_MP4_RE = re.compile(
    r'(?P<url>https?://[a-zA-Z0-9.\-]+\.[a-z]{2,8}(?:/[^"\'\s<>]+)?/[^"\'\s<>]*?'
    r'(?:480p|720p|1080p|2160p|360p|240p|144p)[^"\'\s<>]*\.mp4(?:\?[^"\'\s<>]*)?)',
    re.IGNORECASE,
)
_DIRECT_M3U8_RE = re.compile(
    r'(?P<url>https?://[a-zA-Z0-9.\-]+\.[a-z]{2,8}/[^"\'\s<>]+?\.m3u8(?:\?[^"\'\s<>]*)?)',
    re.IGNORECASE,
)

# Hosty którym `_DIRECT_*_RE` regex matchuje (URL kończy się .mp4 z quality
# marker w path) ALE które NIE serwują direct stream:
#   1. File hosters (rapidgator/k2s/mediafire/...) — file storage, premium auth gate
#      Pattern: `https://rapidgator.net/file/<hash>/scene.1080p.mp4` — to download URL,
#      mobile dostaje login page (HTML), nie video.
#   2. Embed page'y z mp4-suffix path (playmogo/dood/filemoon /d/<id>/<filename>.mp4) —
#      faktycznie HTML embed page, CAPTCHA-walled.
# Bez filter Stage 0.5 zwracał tę URL jako type=mp4 i ExoPlayer dostawał HTML zamiast
# video → black screen / playback failed.
_NOT_DIRECT_STREAM_RE = re.compile(
    r'(?:'
    r'rapidgator\.net|rg\.to|k2s\.cc|keep2share|nitroflare|turbobit|hexupload|'
    r'1fichier|uploaded\.(?:net|to)|ul\.to|mega\.(?:co\.)?nz|mediafire|fastleech|'
    # DoodStream rebrand /d/ download pages (HTML, not mp4)
    r'playmogo\.|d0000d|dooood|d0o0d|do0od|do7go|doodstream|doodporn|dood\.(?:la|li|ws|so|to|watch|work|yt|re)|'
    # File hide embed pages
    r'streamhide|vidhide|filemoon|kerapoxy|moonseries'
    r')',
    re.IGNORECASE,
)

# JS-defined server URLs — niektóre aggregator tubes (porn4days) trzymają backup
# hosterów w JavaScript variables, a iframe renderuje tylko pierwszy. Reszta jest
# dostępna przez clickable "Server 2"/"Server 3" buttons które JS-em swapują src
# iframe'a. Pattern: `const SERVER<N>_URL = "https://..."` lub `var server<N> =`.
# Dla nas to są dodatkowe iframe URLs które trzeba zebrać jak inne — extract_stream_from_hoster
# spróbuje je rozpakować, a fallback to type=hoster dla mobile WebView.
_JS_SERVER_URL_RE = re.compile(
    r'(?:const|let|var)?\s*(?:SERVER\d+_URL|server\d+(?:_url)?|src\d+|stream_?\d+|video_?\d+)\s*=\s*'
    r'["\'](?P<url>https?://[^"\']+)["\']',
    re.IGNORECASE,
)

# Anchor-href hoster links — xmoviesforyou pattern: scene page nie ma <iframe> playera,
# tylko serię `<a href="https://playmogo.com/d/...">MIXDROP</a>` download buttons.
# Match na anchor wskazujący na typowe hoster domeny — daje fallback gdy brak iframe.
_ANCHOR_HOSTER_RE = re.compile(
    r'<a\s+[^>]*href=["\'](?P<url>https?://(?:'
    r'playmogo|luluvid|doodporn|doodstream|dood\.[a-z]+|streamtape|streamta\.pe|'
    r'filemoon|streamwish|sdefx|veev|turbovidhls|gounlimited|iceyfile|hlswish|'
    r'mixdrop|voe|vidoza|mediafire|asnwish|obeywish|streamruby|hqq\.[a-z]+|'
    r'feurl|streamhide|krakenfiles|earnvids|jollytuna|peekvids|playerwish'
    r')\.[a-z]{2,8}/[^"\']+)["\']',
    re.IGNORECASE,
)

# Iframe src ukryty w escape'owanym JS string literal — porndish pattern:
#   `const doodstreamContent = "<iframe ... src=\"https://playmogo.com/e/xyz\" ...></iframe>";`
# Server-side widzimy tylko pusty `<div id="iframeHolder">` (iframe wstrzykuje się JS-em
# po kliknięciu "Video Player 1" button). Match na `src=\"<url>\"` w raw HTML source —
# `\"` to backslash-quote z JSON-style escape'owania, `\/` to backslash-slash.
# Po match'u czyścimy `\/` → `/` w captured URL.
_JS_ESCAPED_IFRAME_SRC_RE = re.compile(
    r'src=\\"(?P<url>https?:[^"]+?)\\"',
    re.IGNORECASE,
)
# Data attribute pattern (mypornerleak's `data-embed="https://cdnstream.top/e/..."`).
# Tube renderuje `<div class="iframeholder" data-embed="...">` + JS które po user click
# wstawia iframe ze stored URL'em. Bez tego nasz ANY_IFRAME_RE widzi tylko pusty
# holder + ad iframes → falls back na page-as-hoster (mobile WebView na aggregator
# stronę = full-screen ad redirect, bug-report f1a01585 2026-05-17).
_DATA_EMBED_RE = re.compile(
    r'data-embed=["\'](?P<url>(?:https?:)?(?://)?[a-z0-9.-]+\.[a-z]{2,8}/[^"\']+)',
    re.IGNORECASE,
)

# Blacklista hosterów — wyłączamy je z fallbacku iframe→hoster (WebView). Dwie kategorie:
#
# 1) Dead — w 100% przypadków zwracają 403/404/CAPTCHA, mobile pokazuje czarny ekran.
#    xtapes.porn → 301 → camcaps.to → 403 (zmigrowane 2026-04, infrastruktura zniknęła).
#
# 2) Malware — działają, ale serwują drive-by downloads (.reg/.exe/.msi) i pop-unders
#    przez chain reklamowy. yt-dlp odmawia ich ekstrakcji (oznaczone jako piracy), więc
#    nie ma mode'u "direct mp4 do ExoPlayera bez WebView" — jedyna opcja to WebView,
#    a WebView pozwala JS-owi hostera ściągnąć user execu plik. Bezpieczniej wyciąć je
#    całkowicie i pokazać user'owi page_url aggregator tube'a (rzadziej malicious niż
#    sam hoster). User nadal może tam wybrać alt player jeśli istnieje.
#    streamtape.com — notorious dla drive-by .reg downloads (zgłoszone 2026-05-07
#    porn4days.pw → streamtape.com/e/<id> → popup + ściąganie .reg).
# Hostname boundary: `(?:^|//|\.)` PRZED domain + `/` PO żeby `/filemoon.html`
# w path nie matchował, tylko prawdziwy hostname (`https://filemoon.to/...` lub
# `cdn.filemoon.to/...`). Bez tego DEAD_HOSTER_RE potencjalnie false-positive
# blacklistował legit URL-e z fragmentami w ścieżce (code-review #17).
DEAD_HOSTER_RE = re.compile(
    r'(?:^|//|\.)'
    r'(?:'
    # camcaps.to dead. xtapes.porn ZNIESIONE 2026-05-15 (Chrome DevTools verify:
    # → reelshdd.com/<id>.mp4 z residential IP = działa, tylko VPS blocked).
    r'camcaps\.to'                                                         # dead
    r'|streamtape\.[a-z]+|streamta\.pe|streamtap\.com|streamcrypt\.net'    # malware
    r'|scloud\.ninja|stape\.fun|tapecontent\.net|streamtapeadblock\.[a-z]+'  # streamtape mirrors
    r'|openload\.co|openload\.io|oload\.[a-z]+'                            # openload (offline od 2019)
    # filemoon — NIE jest na blacklist. ~2026-05 rebrand na SPA "Byse Frontend"
    # zabił stary P.A.C.K.E.R.-JWPlayer embed (stąd wcześniejsze błędne uznanie
    # za "globalny shutdown"), ale video żyje za prywatnym JSON API. RE 2026-05-22:
    # POST /api/videos/<code>/embed/playback {"fingerprint":{}} → AES-256-GCM →
    # m3u8. URL jest IP-bound, więc resolver MUSI iść z urządzenia użytkownika:
    # filemoon przelatuje jako type='hoster' → mobile/src/lib/filemoonHoster.ts.
    r')'
    r'(?:[:/]|$)',  # port, path, lub end-of-string
    re.IGNORECASE,
)

# CAPTCHA-walled hosterzy — DoodStream variants serwują 5KB Cloudflare Turnstile
# challenge response na server-side requests z VPS IP. Na phone (T-Mobile/PLAY)
# czasem przechodzi, mobile-side resolver (mobile/src/lib/doodstream.ts) ma szansę.
# ALE: gdy scene ma alt-hostera (np. luluvid, filemoon), tamten zazwyczaj nie ma
# CAPTCHA gate → ExoPlayer odpala bezpośrednio. Sortujemy więc DoodStream NA KONIEC
# listy — Stage 1 (server-side extract) i Stage 2 (mobile hoster picker) tryują
# najpierw clean hosty, fallback na Dood. Wzorzec sync z mobile/src/lib/doodstream.ts.
CAPTCHA_HOSTER_RE = re.compile(
    r'(?:'
    r'(?:playmogo|doodstream|doodporn|ds2play|ds2video|d000d|d0o0d|do0od|do7go|dooood|d0000d)\.[a-z]{2,8}'
    r'|dood\.(?:la|li|ws|so|to|watch|work|yt|re)'
    r')',
    re.IGNORECASE,
)

# IP-BOUND CDN URLs — stream URLs które bindują się do requester IP (VPS resolve
# → mobile 403). Stage 1 server-side ekstrakta `extract_stream_from_hoster` zwraca
# tę URL, ale mobile direct nie pobierze. Lepiej dropować mp4/m3u8 wynik i upaść
# na hoster fallback (mobile WebView wyciągnie URL z phone IP/session).
#
# Bandwidth cost (public release): te tubes by szli całością przez VPS proxy.
# Skip ich z Stage 1 → mobile WebView → 0 VPS bandwidth.
_IP_BOUND_CDN_RE = re.compile(
    r"\b(?:"
    r"premilkyway\.com"     # latestpornvideo
    r"|tnmr\.org"           # mypornerleak (legacy CDN)
    r"|acek-cdn\.com"       # mypornerleak (current CDN, shared KVS infra)
    # xtremestream.xyz (perverzija) — `player/xs1.php?data=` to NIE direct mp4 tylko
    # IP-bound player endpoint (403 cross-IP z VPS). Stage 1 zwracał go jako type=mp4
    # → natywny player ładował HTML-player w nieskończoność (reports 06-24 "perverzija
    # nie działa"/"loading w nieskończoność"). Skip → hoster fallback: WebView ładuje
    # index.php playera z residential IP telefonu, xs1.php gra w jego sesji.
    r"|xtremestream\.[a-z]{2,8}"
    # URL signature shared across these CDNs: `/hls2/<XX>/<scene_id>/.../master.m3u8?t=<token>&s=<ts>&e=<exp>&srv=<srv>&asn=`
    # — `asn` query param = Autonomous System Number bind. Generic match jako safety net.
    r")\b",
    re.IGNORECASE,
)


def _hoster_priority(url: str) -> int:
    """Niższa wartość = wcześniej w liście. CAPTCHA-walled hosty (DoodStream variants) na końcu."""
    if CAPTCHA_HOSTER_RE.search(url):
        return 1
    return 0


def _is_player_iframe(url: str) -> bool:
    """Heurystyka: czy iframe wygląda na player a nie reklamę."""
    if AD_DOMAIN_RE.search(url):
        return False
    if AD_QUERY_RE.search(url):
        return False
    if DEAD_HOSTER_RE.search(url):
        return False
    # Player path — `/e/`, `/embed/`, `/video/embed/`
    if PLAYER_PATH_RE.search(url):
        return True
    # Bare slug — `<host>/<id>` (no folder, no query) — sdefx.cloud-style.
    parsed = urlparse(url if url.startswith("http") else "https:" + url.lstrip("/"))
    if BARE_SLUG_PATH_RE.match(parsed.path) and not parsed.query:
        return True
    return False


def _extract_direct_stream_urls(page_html: str, page_url: str) -> list[StreamSource]:
    """Stage 0.5: scan page for direct mp4/m3u8 URLs (porn4days→iceyfile/gounlimited
    download links, xmoviesforyou native player). Returns deduplicated sources
    ordered by quality desc.

    Returns empty list jeśli żadnych nie znaleziono lub wszystkie są na blacklisted
    hosterach (streamtape itp.).
    """
    seen: set[str] = set()
    sources: list[tuple[int, StreamSource]] = []  # (quality_int, source) for sorting

    quality_map = {
        "2160p": 2160, "1080p": 1080, "720p": 720,
        "480p": 480, "360p": 360, "240p": 240, "144p": 144,
    }

    page_host = (urlparse(page_url).hostname or "").lstrip("www.")
    page_referer = f"https://{page_host}/" if page_host else page_url

    for m in _DIRECT_MP4_RE.finditer(page_html):
        url = m.group("url")
        if url in seen:
            continue
        seen.add(url)
        if DEAD_HOSTER_RE.search(url) or AD_DOMAIN_RE.search(url):
            continue
        # Filter pseudo-direct URLs: file hosters (rapidgator/k2s — premium auth gate)
        # i embed pages z .mp4 suffix (playmogo/dood /d/ — to HTML, nie video).
        if _NOT_DIRECT_STREAM_RE.search(url):
            continue
        # Quality wykrycie z nazwy pliku
        q_int = 0
        q_label = "mp4"
        for q_str, q_val in quality_map.items():
            if q_str in url.lower():
                q_int = q_val
                q_label = q_str
                break
        sources.append((q_int, StreamSource(link=url, type="mp4", quality=q_label, referer=page_referer)))

    for m in _DIRECT_M3U8_RE.finditer(page_html):
        url = m.group("url")
        if url in seen:
            continue
        seen.add(url)
        if DEAD_HOSTER_RE.search(url) or AD_DOMAIN_RE.search(url):
            continue
        if _NOT_DIRECT_STREAM_RE.search(url):
            continue
        # m3u8 traktujemy jako wyższą jakość (adaptive)
        sources.append((10000, StreamSource(link=url, type="m3u8", quality="auto", referer=page_referer)))

    sources.sort(key=lambda x: -x[0])
    return [s for _, s in sources]


def extract(
    page_url: str,
    *,
    timeout: float = 60.0,
) -> list[StreamSource] | None:
    page_html = fetch_tube_html(page_url, timeout=timeout)

    # Stage 0.5: direct .mp4/.m3u8 URLs on page (porn4days→iceyfile, niektóre xmoviesforyou
    # native players). Wymagamy quality marker w path (`<N>p.mp4`) żeby uniknąć
    # thumbnail-preview false-positives. Jeśli znaleziono → zwracamy, skipping iframe
    # processing — direct stream URL > WebView fallback.
    direct_sources = _extract_direct_stream_urls(page_html, page_url)
    if direct_sources:
        log.info("embed_iframe: found %d direct stream URLs on %s",
                 len(direct_sources), page_url)
        return direct_sources

    # Znajdź WSZYSTKIE iframe-y które wyglądają na player. Wcześniej braliśmy tylko
    # pierwszy, ale niektóre tubes (siskavideo) mają kilku hosterów na stronie —
    # gdy pierwszy ma CF challenge / ad-heavy player (playmogo), drugi (luluvid)
    # może być cleaner. Dedupe po URL żeby nie dublować tego samego playera.
    # Trzymamy też raw_iframes (pre-filter) żeby odróżnić "page nie ma iframe-a" od
    # "page miał iframe ale został zablacklistowany jako malware/dead".
    raw_iframes_count = 0
    iframe_urls: list[str] = []
    seen: set[str] = set()
    for m in ANY_IFRAME_RE.finditer(page_html):
        candidate = m.group("url").strip()
        # Reklamowe iframe-y nie liczą się jako "raw iframe" (są zawsze obecne).
        if AD_DOMAIN_RE.search(candidate) or AD_QUERY_RE.search(candidate):
            continue
        raw_iframes_count += 1
        if not _is_player_iframe(candidate):
            continue
        if candidate.startswith("//"):
            candidate = "https:" + candidate
        elif not candidate.startswith("http"):
            candidate = "https://" + candidate
        if candidate in seen:
            continue
        seen.add(candidate)
        iframe_urls.append(candidate)

    # JS-hidden backup servers (porn4days SERVER<N>_URL pattern). Niektóre tube'y
    # renderują tylko 1 iframe a backup hosterów trzymają w `const SERVER2_URL = "..."`
    # JS variable + clickable "Server 2" button. Bez tego ekstraktor widzi tylko
    # SERVER1 (najczęściej iceyfile/streamtape) — gdy ten 404 / malware-blocked,
    # cały scrape ginie. Backupy mogą być na czystych hosterach (turbovidhls/veev.to).
    for m in _JS_SERVER_URL_RE.finditer(page_html):
        candidate = m.group("url").strip()
        if AD_DOMAIN_RE.search(candidate) or AD_QUERY_RE.search(candidate):
            continue
        if not _is_player_iframe(candidate):
            continue
        if candidate.startswith("//"):
            candidate = "https:" + candidate
        if candidate in seen:
            continue
        seen.add(candidate)
        iframe_urls.append(candidate)

    # Anchor-href hoster links (xmoviesforyou pattern). Tube page nie ma iframe playera,
    # tylko `<a href="https://playmogo.com/d/...">` download buttons. Bez tego
    # extractor zwraca tylko page-as-hoster → WebView na catalog page, user musi
    # manualnie klikać hoster button. Po wyciągnięciu user dostaje hoster bezpośrednio
    # w mobile sources list.
    for m in _ANCHOR_HOSTER_RE.finditer(page_html):
        candidate = m.group("url").strip()
        if AD_DOMAIN_RE.search(candidate) or AD_QUERY_RE.search(candidate):
            continue
        if not _is_player_iframe(candidate):
            continue
        if candidate.startswith("//"):
            candidate = "https:" + candidate
        if candidate in seen:
            continue
        seen.add(candidate)
        iframe_urls.append(candidate)

    # Data attribute embed (mypornerleak: `data-embed="https://cdnstream.top/e/..."`).
    # Iframe sam wstawia się przez muliframe.js po user click; HTML server-side
    # ma tylko placeholder div + data-embed URL.
    for m in _DATA_EMBED_RE.finditer(page_html):
        candidate = m.group("url").strip()
        if AD_DOMAIN_RE.search(candidate) or AD_QUERY_RE.search(candidate):
            continue
        if not _is_player_iframe(candidate):
            continue
        if candidate.startswith("//"):
            candidate = "https:" + candidate
        elif not candidate.startswith("http"):
            candidate = "https://" + candidate
        if candidate in seen:
            continue
        seen.add(candidate)
        iframe_urls.append(candidate)

    # Escape'owane iframe src w JS string literals (porndish pattern). Iframe wstrzykuje
    # się do DOM po kliknięciu user'a — server-side HTML widzimy tylko `<div id="iframeHolder">`,
    # a iframe URL jest w `const doodstreamContent = "<iframe ... src=\"https:\/\/...\" ...>"`.
    # Po match'u czyścimy backslash escape: `\/` → `/`, `\\\"` → `"` (jeśli)
    for m in _JS_ESCAPED_IFRAME_SRC_RE.finditer(page_html):
        candidate = m.group("url").strip()
        # Unescape JSON-style: \/ → /, \" → "
        candidate = candidate.replace("\\/", "/").replace('\\"', '"')
        if AD_DOMAIN_RE.search(candidate) or AD_QUERY_RE.search(candidate):
            continue
        if not _is_player_iframe(candidate):
            continue
        if candidate.startswith("//"):
            candidate = "https:" + candidate
        if candidate in seen:
            continue
        seen.add(candidate)
        iframe_urls.append(candidate)

    # Stable-sort: CAPTCHA-walled hosty (DoodStream variants) NA KONIEC. Zachowujemy
    # DOM order wśród non-CAPTCHA — preferred player tube'a (zazwyczaj pierwszy) zostaje.
    # Powód: siska/xmoviesforyou wstawiają playmogo (dood) jako pierwszy iframe, luluvid
    # jako drugi — bez sortu mobile bierze playmogo → CAPTCHA → czarny ekran. Z sortem
    # luluvid (lub inny clean hoster) ląduje na pozycji 1, ExoPlayer ma szansę.
    iframe_urls.sort(key=_hoster_priority)

    if not iframe_urls:
        # Wszystkie iframe-y na stronie zostały odsiane (DEAD_HOSTER_RE — np. streamtape,
        # xtapes.porn). Page-as-hoster nie pomoże bo WebView na aggregator page i tak
        # załaduje ten sam (zablacklistowany) hoster. Lepiej zwrócić None żeby mobile
        # dostał 501 i pokazał error — user wybierze inną źródło, nie dostanie malware.
        if raw_iframes_count > 0:
            log.info(
                "embed_iframe: %d iframes in %s but all blacklisted (malware/dead) — None",
                raw_iframes_count, page_url,
            )
            return None
        # Brak iframe-ów w ogóle (JS-only render np. mypornerleak's muliframe.js,
        # CloudFlare JS challenge na xxxfree.watch, login-walled tubes). Page-as-hoster
        # ma sens — WebView przejdzie CF challenge, wyrenderuje JS, da user'owi player.
        log.info("embed_iframe: no iframes in %s — page-as-hoster fallback", page_url)
        page_host_label = (urlparse(page_url).hostname or "").lstrip("www.").split(".")[0] or "page"
        return [StreamSource(link=page_url, type="hoster", quality=f"{page_host_label} (page)")]

    host = urlparse(page_url).hostname or ""
    referer = f"https://{host}/" if host else page_url

    # Stage 0: yt-dlp generic na PAGE URL (nie iframe). Niektóre tube'y (pornditt,
    # latestleaks) mają KVS player config (`kt_player(`, `license_code:`) wprost na
    # scene page'u — embed iframe to czysta CSS shell. yt-dlp generic potrafi
    # zdeszyfrować KVS URL z page'a, więc wolimy to niż WebView fallback.
    # Referer = pełny page URL (nie host root) — KVS get_file/ URL jest signed
    # względem konkretnej page'a, host root daje 410.
    if all(marker in page_html for marker in ("kt_player(", "license_code")):
        from app.extractors.hoster import _try_ytdlp_hoster
        ytdlp_url = _try_ytdlp_hoster(page_url, timeout=timeout)
        if ytdlp_url:
            type_hint = "m3u8" if ".m3u8" in ytdlp_url.lower() else "mp4"
            return [StreamSource(link=ytdlp_url, type=type_hint, referer=page_url)]

    # Stage 1: spróbuj wyekstraktować direct video URL z każdego iframe'a po kolei.
    # Direct mp4/m3u8 idzie PIERWSZY w wyniku (ExoPlayer natywnie >> WebView), ale
    # NIE pomijamy reszty iframe — dodajemy je jako hoster fallback. Powód: niektóre
    # CDN-y file storage (vidnest.live, iceyfile) blokują Hetzner ASN — Stage 1 wyciąga
    # URL z embed page, ale ani VPS proxy ani mobile go nie pobierze (No route to host
    # albo 403). Bez iframe fallbacku mobile dostaje 503 i utyka. Z fallbackiem chain:
    # direct → proxy → iframe1 (WebView) → iframe2 (WebView) → page (WebView).
    for idx, iframe_url in enumerate(iframe_urls):
        try:
            stream_url = extract_stream_from_hoster(
                iframe_url, referer=referer, timeout=timeout,
            )
        except Exception as e:
            log.warning("embed_iframe: extract failed for %s: %s", iframe_url, e)
            continue
        if stream_url:
            # IP-bound CDN check — skip Stage 1 result, force fallback na hoster
            # (mobile WebView pobiera embed page z phone IP). Critical dla public
            # release: premilkyway/tnmr.org bind token do requester IP.
            if _IP_BOUND_CDN_RE.search(stream_url):
                log.info(
                    "embed_iframe: stream URL %s is IP-bound CDN — skip Stage 1, fall to hoster",
                    stream_url[:80],
                )
                continue
            type_hint = "m3u8" if ".m3u8" in stream_url.lower() else "mp4"
            iframe_host = urlparse(iframe_url).hostname or ""
            stream_referer = f"https://{iframe_host}/" if iframe_host else iframe_url
            sources = [StreamSource(link=stream_url, type=type_hint, referer=stream_referer)]
            # Dodaj pozostałe iframe-y (z wyłączeniem tego z którego wyciągnięto stream)
            # jako hoster fallback. WebView załaduje embed page z własną sesją/IP/cookies,
            # może pobrać video gdy VPS proxy blocked.
            for u in iframe_urls:
                if u == iframe_url:
                    continue
                host_label = (urlparse(u).hostname or "").lstrip("www.").split(".")[0] or None
                sources.append(StreamSource(link=u, type="hoster", quality=host_label))
            page_host_label = (urlparse(page_url).hostname or "").lstrip("www.").split(".")[0] or "page"
            sources.append(StreamSource(link=page_url, type="hoster", quality=f"{page_host_label} (page)"))
            return sources

    # Stage 2: wszystkie iframe-y zwróć jako hoster sources — mobile dostaje listę
    # alternatyw, użytkownik może switchować gdy pierwszy ma overlay/CF challenge.
    # Pierwszy w liście ma być najczystszy — ale bez wiedzy a priori które są ad-heavy
    # zostawiamy kolejność DOM (zwykle authoring tube wstawia preferred player jako
    # pierwszy, a drugi jako backup). Quality ustawiamy na nazwę hosta iframe'a żeby
    # PlaybackQualityModal pokazał użytkownikowi rozróżnialne opcje (np. "playmogo"
    # vs "luluvid"). Na końcu page_url jako safety-net — gdy wszystkie iframe'y są
    # martwe (np. sxyland → xtapes.porn 301→camcaps.to 403), mobile WebView otworzy
    # główną stronę aggregator tube'a, gdzie user może wybrać alternatywny player.
    sources = []
    for u in iframe_urls:
        host_label = (urlparse(u).hostname or "").lstrip("www.").split(".")[0] or None
        sources.append(StreamSource(link=u, type="hoster", quality=host_label))
    page_host_label = (urlparse(page_url).hostname or "").lstrip("www.").split(".")[0] or "page"
    sources.append(StreamSource(link=page_url, type="hoster", quality=f"{page_host_label} (page)"))
    return sources
