"""Media discovery from HTML, Open Graph, JSON-LD and microdata."""

from __future__ import annotations

import re
from dataclasses import dataclass
from pathlib import PurePosixPath
from typing import Any
from urllib.parse import urldefrag, urljoin, urlsplit

from bs4 import BeautifulSoup, Tag

from app.adapters.base import MediaCandidate, VideoPageLink
from app.services.caption_parser import (
    CaptionResult,
    SelectorAdapter,
    iter_jsonld_objects,
    normalize_text,
)
from app.utils.files import ALLOWED_MEDIA_EXTENSIONS

_MEDIA_MIME_BY_EXT = {
    ".mp4": "video/mp4",
    ".m4v": "video/mp4",
    ".webm": "video/webm",
    ".mkv": "video/x-matroska",
    ".mov": "video/quicktime",
    ".avi": "video/x-msvideo",
    ".flv": "video/x-flv",
    ".mpg": "video/mpeg",
    ".mpeg": "video/mpeg",
    ".ts": "video/mp2t",
    ".m2ts": "video/mp2t",
    ".ogv": "video/ogg",
    ".m3u8": "application/vnd.apple.mpegurl",
    ".mpd": "application/dash+xml",
}
_STREAM_EXTENSIONS = {".m3u8", ".mpd"}
_ALL_MEDIA_EXTENSIONS = ALLOWED_MEDIA_EXTENSIONS | _STREAM_EXTENSIONS
_DURATION_ISO_RE = re.compile(
    r"^P(?:(?P<days>\d+)D)?(?:T(?:(?P<hours>\d+)H)?(?:(?P<minutes>\d+)M)?"
    r"(?:(?P<seconds>[\d.]+)S)?)?$",
    re.IGNORECASE,
)
_VIDEO_OBJECT_TYPES = {"videoobject", "clip", "movie"}
_OG_VIDEO_PROPS = (
    "og:video",
    "og:video:url",
    "og:video:secure_url",
    "twitter:player:stream",
)
_OG_IMAGE_PROPS = ("og:image", "og:image:secure_url", "twitter:image")


@dataclass(slots=True)
class _RawVideo:
    media_url: str | None
    title: str
    caption: str | None
    thumbnail_url: str | None
    duration_seconds: float | None
    media_type: str
    file_size: int | None
    source: str
    identity_url: str | None = None
    direct_file: bool = False


def parse_duration(value: Any) -> float | None:
    if value is None:
        return None
    if isinstance(value, (int, float)):
        return float(value) if value > 0 else None
    text = str(value).strip()
    if not text:
        return None
    if text.isdigit():
        result = float(text)
        return result if result > 0 else None
    match = _DURATION_ISO_RE.fullmatch(text)
    if not match:
        return None
    days = int(match.group("days") or 0)
    hours = int(match.group("hours") or 0)
    minutes = int(match.group("minutes") or 0)
    seconds = float(match.group("seconds") or 0)
    total = days * 86400 + hours * 3600 + minutes * 60 + seconds
    return total if total > 0 else None


def _clean_url(base_url: str, raw: Any) -> str | None:
    if not isinstance(raw, str):
        return None
    value = raw.strip()
    if not value or value.startswith(("javascript:", "data:", "blob:", "file:", "mailto:")):
        return None
    absolute = urldefrag(urljoin(base_url, value))[0]
    if urlsplit(absolute).scheme.lower() not in {"http", "https"}:
        return None
    return absolute


def _extension(url: str) -> str:
    return PurePosixPath(urlsplit(url).path).suffix.lower()


def _is_direct_file(url: str) -> bool:
    return _extension(url) in _ALL_MEDIA_EXTENSIONS


def _media_type(url: str | None, hint: str | None = None) -> str:
    if hint:
        cleaned = hint.split(";", 1)[0].strip().lower()
        if cleaned.startswith("video/") or cleaned in {
            "application/vnd.apple.mpegurl",
            "application/x-mpegurl",
            "application/dash+xml",
        }:
            return cleaned
    if url:
        extension = _extension(url)
        if extension in _MEDIA_MIME_BY_EXT:
            return _MEDIA_MIME_BY_EXT[extension]
    return "video/unknown"


def _title_of(tag: Tag) -> str:
    for parent in tag.parents:
        if not isinstance(parent, Tag):
            continue
        if parent.name not in {"article", "li", "section", "div", "figure"}:
            continue
        heading = parent.find(["h1", "h2", "h3", "h4", "h5"])
        if isinstance(heading, Tag):
            title = normalize_text(heading.get_text(" ", strip=True))
            if title:
                return title
        itemprop_name = parent.find(attrs={"itemprop": "name"})
        if isinstance(itemprop_name, Tag):
            title = normalize_text(
                str(itemprop_name.get("content") or itemprop_name.get_text(" ", strip=True))
            )
            if title:
                return title
    return ""


def _card_description(tag: Tag) -> str:
    for parent in tag.parents:
        if not isinstance(parent, Tag):
            continue
        if parent.name not in {"article", "li", "section", "div", "figure"}:
            continue
        for candidate in parent.select(
            "[itemprop='description'], .description, .caption, .excerpt, p"
        ):
            if isinstance(candidate, Tag):
                text = normalize_text(candidate.get_text(" ", strip=True))
                if len(text) >= 20:
                    return text
    return ""


def _meta_contents(
    soup: BeautifulSoup, properties: tuple[str, ...], base_url: str
) -> list[str]:
    values: list[str] = []
    for prop in properties:
        for attrs in ({"property": prop}, {"name": prop}):
            for tag in soup.find_all("meta", attrs=attrs):
                content = _clean_url(base_url, tag.get("content"))
                if content:
                    values.append(content)
    return values


class MediaAdapter:
    """Extract raw media candidates without trusting or downloading them."""

    def __init__(self, caption_parser: SelectorAdapter | None = None) -> None:
        self._caption_parser = caption_parser

    def extract(
        self,
        soup: BeautifulSoup,
        url: str,
        caption: CaptionResult,
        adapter: SelectorAdapter | None = None,
        video_page_selectors: tuple[str, ...] = (),
    ) -> tuple[list[MediaCandidate], list[VideoPageLink]]:
        raws: list[_RawVideo] = []
        page_thumbnail = self._page_thumbnail(soup, url)

        # Rich JSON-LD VideoObject entries usually provide the most specific data.
        for obj in iter_jsonld_objects(soup):
            raw = self._from_jsonld(obj, url, page_thumbnail)
            if raw is not None:
                raws.append(raw)

        # Standard Open Graph / Twitter video metadata.
        og_type = ""
        for meta in soup.find_all("meta"):
            prop = str(meta.get("property") or meta.get("name") or "").lower()
            if prop == "og:video:type":
                og_type = str(meta.get("content") or "")
        for media_url in _meta_contents(soup, _OG_VIDEO_PROPS, url):
            raws.append(
                _RawVideo(
                    media_url=media_url,
                    title=caption.title,
                    caption=caption.caption,
                    thumbnail_url=page_thumbnail,
                    duration_seconds=None,
                    media_type=_media_type(media_url, og_type),
                    file_size=None,
                    source="open_graph",
                    direct_file=_is_direct_file(media_url),
                )
            )

        # Actual <video>/<source> elements.
        for video_tag in soup.find_all("video"):
            if not isinstance(video_tag, Tag):
                continue
            poster = _clean_url(url, video_tag.get("poster")) or page_thumbnail
            duration = parse_duration(video_tag.get("duration"))
            title = normalize_text(str(video_tag.get("title") or "")) or _title_of(
                video_tag
            ) or caption.title
            card_caption = _card_description(video_tag) or caption.caption
            src = _clean_url(url, video_tag.get("src"))
            if src:
                raws.append(
                    _RawVideo(
                        media_url=src,
                        title=title,
                        caption=card_caption,
                        thumbnail_url=poster,
                        duration_seconds=duration,
                        media_type=_media_type(src),
                        file_size=None,
                        source="video_tag",
                        direct_file=_is_direct_file(src),
                    )
                )
            for source_tag in video_tag.find_all("source"):
                if not isinstance(source_tag, Tag):
                    continue
                source_src = _clean_url(url, source_tag.get("src"))
                if not source_src:
                    continue
                raws.append(
                    _RawVideo(
                        media_url=source_src,
                        title=title,
                        caption=card_caption,
                        thumbnail_url=poster,
                        duration_seconds=duration,
                        media_type=_media_type(source_src, str(source_tag.get("type") or "")),
                        file_size=None,
                        source="source_tag",
                        direct_file=_is_direct_file(source_src),
                    )
                )

        # Microdata VideoObject content.
        for node in soup.select("[itemtype*='VideoObject' i]"):
            if not isinstance(node, Tag):
                continue
            content = node.select_one("[itemprop='contentUrl']")
            if isinstance(content, Tag):
                content_value = content.get("content") or content.get("href") or content.get_text()
                media = _clean_url(url, content_value)
                name_tag = node.select_one("[itemprop='name']")
                desc_tag = node.select_one("[itemprop='description']")
                name = normalize_text(
                    str(name_tag.get("content") or name_tag.get_text(" ", strip=True))
                ) if isinstance(name_tag, Tag) else caption.title
                desc = normalize_text(
                    str(desc_tag.get("content") or desc_tag.get_text(" ", strip=True))
                ) if isinstance(desc_tag, Tag) else caption.caption
                if media:
                    raws.append(
                        _RawVideo(
                            media_url=media,
                            title=name or caption.title,
                            caption=desc or caption.caption,
                            thumbnail_url=page_thumbnail,
                            duration_seconds=None,
                            media_type=_media_type(media),
                            file_size=None,
                            source="microdata",
                            direct_file=_is_direct_file(media),
                        )
                    )

        # Direct file links are allowed only when their extension is a known media type.
        for anchor in soup.find_all("a", href=True):
            if not isinstance(anchor, Tag):
                continue
            href = _clean_url(url, anchor.get("href"))
            if not href or not _is_direct_file(href):
                continue
            if href == url:
                continue
            title = _title_of(anchor) or normalize_text(anchor.get_text(" ", strip=True))
            title = title or caption.title
            raws.append(
                _RawVideo(
                    media_url=href,
                    title=title,
                    caption=_card_description(anchor) or caption.caption,
                    thumbnail_url=page_thumbnail,
                    duration_seconds=None,
                    media_type=_media_type(href),
                    file_size=None,
                    source="direct_link",
                    identity_url=href,
                    direct_file=True,
                )
            )

        # Explicitly configured video-detail links (never guessed by the core).
        page_links: list[VideoPageLink] = []
        page_seen: set[str] = set()

        def add_page_link(
            candidate_url: str | None,
            title: str = "",
            desc: str = "",
            thumbnail: str | None = None,
            source: str = "adapter",
        ) -> None:
            if not candidate_url or candidate_url == url or _is_direct_file(candidate_url):
                return
            if candidate_url in page_seen:
                return
            page_seen.add(candidate_url)
            page_links.append(
                VideoPageLink(
                    url=candidate_url,
                    title=normalize_text(title)[:300],
                    caption=normalize_text(desc),
                    thumbnail_url=thumbnail,
                    source=source,
                )
            )

        for obj in iter_jsonld_objects(soup):
            types = self._types(obj)
            if not types & _VIDEO_OBJECT_TYPES:
                continue
            page_url = _clean_url(url, obj.get("url") or obj.get("@id"))
            content_present = bool(obj.get("contentUrl"))
            if page_url and not content_present:
                add_page_link(
                    page_url,
                    str(obj.get("name") or caption.title),
                    str(obj.get("description") or ""),
                    page_thumbnail,
                    source="jsonld",
                )
        for selector in video_page_selectors:
            try:
                nodes = soup.select(selector)
            except Exception:
                continue
            for node in nodes:
                if isinstance(node, Tag):
                    href = node.get("href") if node.name == "a" else None
                    page_url = _clean_url(url, href)
                    add_page_link(
                        page_url,
                        _title_of(node) or normalize_text(node.get_text(" ", strip=True)),
                        _card_description(node),
                        page_thumbnail,
                        source="adapter_selector",
                    )
        for node in soup.select("[itemtype*='VideoObject' i]"):
            if not isinstance(node, Tag) or node.select_one("[itemprop='contentUrl']"):
                continue
            link = node.select_one("a[itemprop='url'], meta[itemprop='url']")
            if isinstance(link, Tag):
                value = link.get("href") if link.name == "a" else link.get("content")
                add_page_link(_clean_url(url, value), caption.title, caption.caption, page_thumbnail,
                              source="microdata")

        candidates = self._normalize_raws(raws, url, caption)
        # Direct duplicate page links from page links that also became media candidates.
        media_urls = {candidate.media_url for candidate in candidates if candidate.media_url}
        video_pages = [link for link in page_links if link.url not in media_urls]
        return candidates, video_pages

    @staticmethod
    def _types(obj: dict[str, Any]) -> set[str]:
        raw = obj.get("@type") or obj.get("type") or ""
        values = raw if isinstance(raw, list) else [raw]
        return {str(value).lower().rsplit("/", 1)[-1] for value in values}

    def _page_thumbnail(self, soup: BeautifulSoup, base_url: str) -> str | None:
        for prop in _OG_IMAGE_PROPS:
            for attrs in ({"property": prop}, {"name": prop}):
                tag = soup.find("meta", attrs=attrs)
                if isinstance(tag, Tag):
                    value = _clean_url(base_url, tag.get("content"))
                    if value:
                        return value
        image = soup.select_one("[itemprop='thumbnailUrl']")
        if isinstance(image, Tag):
            return _clean_url(base_url, image.get("content") or image.get("src") or image.get("href"))
        return None

    def _from_jsonld(
        self, obj: dict[str, Any], url: str, page_thumbnail: str | None
    ) -> _RawVideo | None:
        if not (self._types(obj) & _VIDEO_OBJECT_TYPES):
            return None
        content = obj.get("contentUrl")
        if isinstance(content, list):
            content = next((item for item in content if isinstance(item, str)), None)
        media_url = _clean_url(url, content)
        if not media_url:
            return None
        thumbnail = obj.get("thumbnailUrl") or obj.get("thumbnail")
        if isinstance(thumbnail, list):
            thumbnail = thumbnail[0] if thumbnail else None
        if isinstance(thumbnail, dict):
            thumbnail = thumbnail.get("url")
        thumbnail_url = _clean_url(url, thumbnail) or page_thumbnail
        title = normalize_text(str(obj.get("name") or obj.get("headline") or ""))
        caption = normalize_text(str(obj.get("description") or ""))
        identity = _clean_url(url, obj.get("url") or obj.get("@id"))
        return _RawVideo(
            media_url=media_url,
            title=title,
            caption=caption or None,
            thumbnail_url=thumbnail_url,
            duration_seconds=parse_duration(obj.get("duration")),
            media_type=_media_type(media_url, str(obj.get("encodingFormat") or "")),
            file_size=int(obj["contentSize"]) if str(obj.get("contentSize", "")).isdigit() else None,
            source="jsonld",
            identity_url=identity,
            direct_file=_is_direct_file(media_url),
        )

    @staticmethod
    def _normalize_raws(raws: list[_RawVideo], page_url: str, page_caption: CaptionResult) -> list[MediaCandidate]:
        by_media: dict[str, _RawVideo] = {}
        no_media: list[_RawVideo] = []
        for raw in raws:
            if not raw.media_url:
                no_media.append(raw)
                continue
            existing = by_media.get(raw.media_url)
            if existing is None or (not existing.caption and raw.caption):
                by_media[raw.media_url] = raw
        ordered = list(by_media.values()) + no_media
        multi = len(ordered) > 1
        candidates: list[MediaCandidate] = []
        for raw in ordered:
            if not raw.media_url:
                continue
            identity = raw.identity_url
            if not identity:
                if raw.direct_file or multi:
                    identity = raw.media_url
                else:
                    identity = page_url
            title = normalize_text(raw.title) or page_caption.title
            caption = normalize_text(raw.caption) if raw.caption else page_caption.caption
            if not caption:
                caption = title
            candidates.append(
                MediaCandidate(
                    page_url=page_url,
                    source_url=identity,
                    title=title,
                    caption=caption,
                    media_url=raw.media_url,
                    thumbnail_url=raw.thumbnail_url,
                    duration_seconds=raw.duration_seconds,
                    media_type=raw.media_type,
                    file_size=raw.file_size,
                    source=raw.source,
                )
            )
        return candidates
