import copy
import datetime as dt
import json
import logging
import re
from typing import Protocol
from urllib.parse import urljoin

import dateparser
import jmespath
from bs4 import BeautifulSoup, Tag

from mariner.models import html as html_model
from mariner.models import json as json_model
from mariner.models import torrent, tracker
from mariner.services import filters, templater

log = logging.getLogger(__name__)

# Jackett YAML selectors assume browser-like parsing where <tbody> is
# auto-inserted into <table> elements.  Neither lxml nor html.parser do
# this, so we strip the implicit "> tbody" segments from CSS selectors.
_IMPLICIT_TBODY = re.compile(r"\s*>\s*tbody\s*>", re.IGNORECASE)

# Trackers often append category info after a dash, e.g.
# "26 days ago - in Anime." — strip everything from " - in " onward.
_TRAILING_CATEGORY = re.compile(r"\s+-\s+in\s+.*", re.IGNORECASE)


class Parser(Protocol):
    """Protocol for tracker result parsers."""

    def parse_results(
        self,
        body: str,
        base_url: str | None = None,
    ) -> list[torrent.Torrent]: ...


class HTML:
    """Service to parse HTML search results from trackers."""

    def __init__(self, tracker: tracker.Definition) -> None:
        self._tracker = tracker

    def parse_results(
        self,
        body: str,
        base_url: str | None = None,
    ) -> list[torrent.Torrent]:
        """Parse HTML into a list of Torrent objects.

        Args:
            body: The raw HTML content from a tracker search page.
            base_url: The base URL of the tracker, used to resolve
                relative URLs in download/magnet fields.

        Returns:
            A list of parsed Torrent objects.

        """
        soup = BeautifulSoup(body, "lxml")
        selector = _IMPLICIT_TBODY.sub(" > ", self._tracker.spec.rows_selector)
        rows = soup.select(selector)
        merged = self._merge_rows(rows)

        results = []
        for row in merged:
            try:
                parsed = self._parse_row(row, base_url)
            except (ValueError, TypeError) as e:
                log.warning("Skipping row due to parse error: %s", e)
                continue
            if parsed is not None:
                results.append(parsed)
        return results

    def _merge_rows(self, rows: list[Tag]) -> list[Tag]:
        """Merge consecutive rows when the spec has rows_after > 0.

        Some trackers (e.g. tokyotosho) split each result across
        multiple <tr> elements. ``rows_after`` indicates how many
        subsequent sibling rows should be folded into the first.
        We create a synthetic <tr> containing all <td> cells.
        """
        after = self._tracker.spec.rows_after
        if after == 0:
            return rows

        merged = []
        step = after + 1
        for i in range(0, len(rows), step):
            group = rows[i : i + step]
            if len(group) < step:
                break
            combined = Tag(name="tr")
            for row in group:
                for td in row.find_all("td", recursive=False):
                    combined.append(copy.copy(td))
            merged.append(combined)
        return merged

    def _parse_row(
        self,
        row: Tag,
        base_url: str | None = None,
    ) -> torrent.Torrent | None:
        """Parse a single result row into a Torrent, or None if title
        is missing.

        Raises:
            ValueError: If a numeric field cannot be parsed.

        """
        assert isinstance(self._tracker.spec.fields, html_model.SearchFields)
        fields = self._tracker.spec.fields

        name = self._extract_field(row, fields.title)
        if not name:
            log.debug("Skipping row with missing title: %s", row)
            return None

        tracker_id = self._tracker.metadata.id
        magnet = self._extract_field(row, fields.magnet)
        download = self._extract_field(row, fields.download)
        details = self._extract_field(row, fields.details)
        if base_url:
            if download:
                download = normalize_url(base_url, download)
            if magnet:
                magnet = normalize_url(base_url, magnet)
            if details:
                details = normalize_url(base_url, details)

        if self._tracker.spec.download and download and download == details:
            download = None

        size = parse_size(self._extract_field(row, fields.size))
        date = normalize_date(self._extract_field(row, fields.date) or "")
        seeders = parse_int(self._extract_field(row, fields.seeders))
        leechers = parse_int(self._extract_field(row, fields.leechers))

        return torrent.Torrent(
            name=name,
            tracker=tracker_id,
            magnet=magnet,
            download=download,
            details_url=details,
            size=size,
            date=date,
            seeders=seeders,
            leechers=leechers,
        )

    @staticmethod
    def _extract_field(row: Tag, field: html_model.Field | None) -> str | None:
        """Extract a single field value from a row, then apply its
        filter chain.
        """
        if field is None:
            return None

        selector = _IMPLICIT_TBODY.sub(" > ", field.selector)
        element = row.select_one(selector)
        if element is None:
            return None

        if field.remove:
            element = copy.copy(element)
            for match in element.select(field.remove):
                match.decompose()

        if field.attribute:
            raw = element.get(field.attribute)
            if isinstance(raw, list):
                value = raw[0] if raw else None
            else:
                value = raw
        else:
            value = element.get_text(strip=True)

        if value is None:
            return None

        for f in field.filters:
            result = filters.apply(value, f)
            if result is None:
                return None
            value = result

        return value


class JSON:
    """Service to parse JSON search results from trackers."""

    def __init__(self, tracker: tracker.Definition) -> None:
        self._tracker = tracker

    def parse_results(
        self,
        body: str,
        base_url: str | None = None,
    ) -> list[torrent.Torrent]:
        """Parse a JSON response into a list of Torrent objects.

        Args:
            body: The raw JSON response from a tracker search endpoint.
            base_url: The base URL of the tracker, used to resolve
                relative URLs in download/magnet fields.

        Returns:
            A list of parsed Torrent objects.

        """
        try:
            data = json.loads(body)
        except json.JSONDecodeError as e:
            log.warning("Failed to parse JSON response: %s", e)
            return []

        rows_selector = self._tracker.spec.rows_selector
        rows = jmespath.search(rows_selector, data)
        if not rows or not isinstance(rows, list):
            log.debug("No rows found with selector %r", rows_selector)
            return []

        results = []
        for row in rows:
            try:
                parsed = self._parse_row(row, base_url)
            except (ValueError, TypeError) as e:
                log.warning("Skipping row due to parse error: %s", e)
                continue
            if parsed is not None:
                results.append(parsed)
        return results

    def _parse_row(
        self,
        row: dict,
        base_url: str | None = None,
    ) -> torrent.Torrent | None:
        """Parse a single JSON row into a Torrent.

        Extraction happens in two passes:
        1. Selector-based fields (direct key lookups)
        2. Template-based fields (computed from previously extracted values)
        """
        assert isinstance(self._tracker.spec.fields, json_model.SearchFields)
        json_fields = self._tracker.spec.fields.all_fields
        config = self._tracker.spec.config

        extracted = self._extract_all_fields(row, json_fields, config)

        name = extracted.get("title")
        if not name:
            log.debug("Skipping row with missing title")
            return None

        tracker_id = self._tracker.metadata.id
        download = extracted.get("download")
        magnet = self._build_magnet(extracted)
        if base_url:
            if download:
                download = normalize_url(base_url, download)
            if magnet and not magnet.startswith("magnet:"):
                magnet = normalize_url(base_url, magnet)

        size = parse_size(extracted.get("size"))
        date = normalize_date(extracted.get("date") or "")
        seeders = parse_int(extracted.get("seeders"))
        leechers = parse_int(extracted.get("leechers"))

        return torrent.Torrent(
            name=name,
            tracker=tracker_id,
            magnet=magnet,
            download=download,
            size=size,
            date=date,
            seeders=seeders,
            leechers=leechers,
        )

    def _extract_all_fields(
        self,
        row: dict,
        json_fields: dict[str, json_model.Field],
        config: dict[str, str],
    ) -> dict[str, str]:
        """Extract all fields from a JSON row in two passes.

        Pass 1 extracts selector-based fields (direct key lookups).
        Pass 2 evaluates template-based fields using Pass 1 results.
        """
        extracted: dict[str, str] = {}

        for name, field in json_fields.items():
            if field.selector is not None:
                value = self._extract_selector(row, field)
                if value is not None:
                    extracted[name] = value

        template_ctx = {"Result": extracted, "Config": config}

        for name, field in json_fields.items():
            if name in extracted:
                continue
            if field.text is not None:
                value = self._extract_template(field, template_ctx)
                if value is not None:
                    extracted[name] = value
            elif field.optional and field.default is not None:
                rendered = templater.render(field.default, template_ctx)
                extracted[name] = rendered

        return extracted

    @staticmethod
    def _extract_selector(row: dict, field: json_model.Field) -> str | None:
        """Extract a value by selector from a JSON row."""
        if field.selector is None:
            return None

        value = jmespath.search(field.selector, row)
        if value is None:
            return None

        str_value = str(value)

        for f in field.filters:
            result = filters.apply(str_value, f)
            if result is None:
                return None
            str_value = result

        return str_value

    @staticmethod
    def _extract_template(
        field: json_model.Field,
        ctx: dict,
    ) -> str | None:
        """Evaluate a template-based field."""
        if field.text is None:
            return None

        try:
            value = templater.render(str(field.text), ctx)
        except Exception as e:
            log.debug("Template rendering failed for %r: %s", field.text, e)
            if field.optional:
                return field.default
            return None

        if not value and field.optional:
            return field.default

        for f in field.filters:
            result = filters.apply(value, f)
            if result is None:
                return None
            value = result

        if not value:
            log.debug("Template %r rendered to empty string", field.text)
            return None
        return value

    @staticmethod
    def _build_magnet(extracted: dict[str, str]) -> str | None:
        """Build a magnet URI from the infohash field if present."""
        infohash = extracted.get("infohash")
        if not infohash:
            return None
        if infohash.startswith("magnet:"):
            return infohash
        return f"magnet:?xt=urn:btih:{infohash}"


def normalize_date(raw: str) -> dt.datetime | None:
    """Parse a human-readable date string into a datetime.

    Handles ISO dates, relative dates ("26 days ago", "Last Month",
    "Yesterday"), and strips trailing category text
    ("- in Anime.").

    Args:
        raw: The raw date string from the tracker.

    Returns:
        A datetime object, or None if the string is empty or
        cannot be parsed.

    """
    if not raw:
        return None

    cleaned = _TRAILING_CATEGORY.sub("", raw).strip()
    if not cleaned:
        return None

    parsed = dateparser.parse(cleaned)
    if parsed:
        return parsed.replace(microsecond=0)

    log.debug("Could not parse date: %r", raw)
    return None


def normalize_url(base_url: str, url: str) -> str:
    """Normalize a URL by resolving it against a base URL if it's relative.

    Args:
        base: The base URL of the tracker.
        url: The URL to normalize, which may be relative or absolute.

    Returns:
        An absolute URL suitable for downloading or magnet links.

    """
    if url.startswith(("http://", "https://", "magnet:")):
        return url
    return urljoin(base_url, url)


def parse_int(value: str | None) -> int:
    """Parse a human-readable integer string (e.g. "1,234") into an int.

    Args:
        value: The string to parse. None is treated as 0.

    Returns:
        The parsed integer.

    Raises:
        ValueError: If the string cannot be parsed as an integer.

    """
    if value is None:
        return 0
    cleaned = value.replace(",", "").strip()
    return int(cleaned)


_SIZE_UNITS: dict[str, int] = {
    "b": 1,
    "kb": 1_000,
    "mb": 1_000_000,
    "gb": 1_000_000_000,
    "tb": 1_000_000_000_000,
    "kib": 1 << 10,
    "mib": 1 << 20,
    "gib": 1 << 30,
    "tib": 1 << 40,
}

_SIZE_RE = re.compile(r"^([\d.,]+)\s*([a-zA-Z]+)$")


def parse_size(value: str | None) -> int:
    """Parse a human-readable size string into bytes.

    Handles bare integers ("4700000000"), human-readable sizes
    ("2.1 GB", "500 MB"), and comma-separated numbers ("1,024").

    Args:
        value: The size string to parse. None is treated as 0.

    Returns:
        The size in bytes.

    """
    if not value:
        return 0

    cleaned = value.replace(",", "").strip()
    if not cleaned:
        return 0

    try:
        return int(cleaned)
    except ValueError:
        pass

    try:
        return int(float(cleaned))
    except ValueError:
        pass

    match = _SIZE_RE.match(cleaned)
    if match:
        number = float(match.group(1))
        unit = match.group(2).lower()
        multiplier = _SIZE_UNITS.get(unit)
        if multiplier is not None:
            return int(number * multiplier)

    log.debug("Could not parse size: %r", value)
    return 0
