from __future__ import annotations

from dataclasses import dataclass, asdict
from datetime import datetime, timezone
from html import unescape
from urllib.parse import parse_qs, urlencode, urljoin, urlparse
from hashlib import sha1
from pathlib import Path
import re

import requests
from bs4 import BeautifulSoup

from models import norm
from item_catalog import VariantSelection


BASE_URL = "https://r21.bloodwars.pl/test_items.php/"

# Internal catalogue abbreviations/legacy spellings mapped to official page text.
BASE_ALIASES = {
    "shurek": "shuriken",
    "karabin m.": "karabin myśliwski",
    "polautomat s.": "półautomat snajperski",
    "karabin s.": "karabin snajperski",
    "miotacz p.": "miotacz płomieni",
}

OFFICIAL_CATEGORY_LABELS = (
    "Hełm",
    "Zbroja",
    "Spodnie",
    "Amulet",
    "Pierścień",
    "Broń biała jednoręczna",
    "Broń biała dwuręczna",
    "Broń palna jednoręczna",
    "Broń palna dwuręczna",
    "Broń dystansowa dwuręczna",
)


class OfficialStatsParseError(RuntimeError):
    """The official page was downloaded, but its result section could not be parsed."""


@dataclass
class OfficialStats:
    cache_key: str
    url: str
    category: str
    prefix: str
    base: str
    suffix: str
    quality: str
    upgrade: int
    legendary: bool
    player_level: int
    item_name: str = ""
    item_category: str = ""
    damage: str = ""
    defense: str = ""
    features: str = ""
    requirements: str = ""
    price: str = ""
    sell_price: str = ""
    mana: str = ""
    nanites: str = ""
    zone_item: str = ""
    zone_prefix: str = ""
    zone_suffix: str = ""
    zone_total: str = ""
    rarity_item: str = ""
    rarity_prefix: str = ""
    rarity_suffix: str = ""
    rarity_total: str = ""
    raw_text: str = ""
    main_image_url: str = ""
    prefix_image_url: str = ""
    suffix_image_url: str = ""
    fetched_at: str = ""

    def to_dict(self) -> dict:
        return asdict(self)

    def is_valid(self) -> bool:
        # Every generated tester result has a name and a category/gender line.
        # Requiring those two fields prevents login/error/changed-layout pages from
        # being silently stored as successful cache entries.
        return bool(self.item_name.strip() and self.item_category.strip())


class OfficialItemClient:
    def __init__(self, rules, timeout: float = 25.0):
        self.rules = rules
        self.timeout = timeout
        self.session = requests.Session()
        self.session.headers.update(
            {
                "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                              "AppleWebKit/537.36 Chrome/126 Safari/537.36 "
                              "BW-Crafting-Planner/0.12",
                "Accept-Language": "pl-PL,pl;q=0.9,en;q=0.7",
                "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
            }
        )
        self._base_links: dict[str, str] | None = None
        self._affix_maps: dict[str, tuple[dict[str, int], dict[str, int]]] = {}

    @staticmethod
    def _query_value(href: str, key: str, default: int = 0) -> int:
        try:
            values = parse_qs(urlparse(href).query).get(key)
            return int(values[0]) if values else default
        except (TypeError, ValueError):
            return default

    def _get(self, url: str) -> str:
        response = self.session.get(url, timeout=self.timeout)
        response.raise_for_status()
        # BloodWars declares UTF-8. Prefer the declared encoding and only use
        # apparent_encoding when the server supplied none.
        if not response.encoding:
            response.encoding = response.apparent_encoding or "utf-8"
        return response.text

    def _load_base_links(self) -> dict[str, str]:
        if self._base_links is not None:
            return self._base_links
        html = self._get(BASE_URL)
        soup = BeautifulSoup(html, "html.parser")
        mapping: dict[str, str] = {}
        for anchor in soup.find_all("a", href=True):
            href = urljoin(BASE_URL, anchor["href"])
            if "baseType=" not in href:
                continue
            text = norm(anchor.get_text(" ", strip=True))
            if text:
                mapping[text] = href
        if not mapping:
            raise OfficialStatsParseError(
                "Oficjalny tester został pobrany, ale nie znaleziono listy baz przedmiotów. "
                "Strona mogła zmienić układ albo zwrócić stronę błędu."
            )
        self._base_links = mapping
        return mapping

    def _official_base_text(self, base: str) -> str:
        return BASE_ALIASES.get(base, self.rules.display(base)).casefold()

    def resolve_base_url(self, base: str) -> str:
        links = self._load_base_links()
        wanted = norm(self._official_base_text(base))
        if wanted in links:
            return links[wanted]
        # Defensive fallback for punctuation/abbreviation differences.
        for text, href in links.items():
            if wanted in text or text in wanted:
                return href
        raise KeyError(f"Nie znaleziono bazy '{self.rules.display(base)}' na oficjalnej stronie.")

    def _load_affix_maps(self, base_url: str) -> tuple[dict[str, int], dict[str, int]]:
        base_type = str(self._query_value(base_url, "baseType", -1))
        if base_type in self._affix_maps:
            return self._affix_maps[base_type]
        html = self._get(base_url)
        soup = BeautifulSoup(html, "html.parser")
        prefixes: dict[str, int] = {"": 0, norm("brak"): 0, norm("(brak)"): 0}
        suffixes: dict[str, int] = {"": 0, norm("brak"): 0, norm("(brak)"): 0}
        for anchor in soup.find_all("a", href=True):
            href = urljoin(base_url, anchor["href"])
            text = norm(anchor.get_text(" ", strip=True))
            if not text:
                continue
            parsed = parse_qs(urlparse(href).query)
            if "prefix" in parsed:
                prefixes[text] = self._query_value(href, "prefix", 0)
            if "sufix" in parsed:
                suffixes[text] = self._query_value(href, "sufix", 0)
        self._affix_maps[base_type] = (prefixes, suffixes)
        return prefixes, suffixes

    @staticmethod
    def _resolve_text_id(wanted: str, mapping: dict[str, int]) -> int:
        wanted_n = norm(wanted)
        if not wanted_n:
            return 0
        if wanted_n in mapping:
            return mapping[wanted_n]
        for text, value in mapping.items():
            if wanted_n == text or wanted_n in text or text in wanted_n:
                return value
        raise KeyError(f"Nie znaleziono opcji '{wanted}'.")

    def build_url(self, selection: VariantSelection) -> str:
        base_url = self.resolve_base_url(selection.identity.base)
        prefixes, suffixes = self._load_affix_maps(base_url)
        base_type = self._query_value(base_url, "baseType", 0)
        prefix_text = ""
        if selection.identity.prefix:
            prefix_text = self.rules.prefix_display(selection.identity.prefix, selection.identity.base)
        suffix_text = self.rules.display(selection.identity.suffix) if selection.identity.suffix else ""
        prefix_id = self._resolve_text_id(prefix_text, prefixes)
        suffix_id = self._resolve_text_id(suffix_text, suffixes)
        params = {
            "baseType": base_type,
            "class": selection.class_id,
            "legendary": 1 if selection.legendary else 0,
            "playerLvl": max(1, int(selection.player_level)),
            "prefix": prefix_id,
            "sufix": suffix_id,
        }
        return BASE_URL + "?" + urlencode(params)

    def fetch(self, selection: VariantSelection) -> OfficialStats:
        url = self.build_url(selection)
        html = self._get(url)
        stats = self.parse(html, selection, url)
        if not stats.is_valid():
            raise OfficialStatsParseError(
                "Strona oficjalnego testera została pobrana, ale nie udało się odczytać "
                "nazwy i kategorii przedmiotu. Wynik nie został zapisany w cache. "
                "Spróbuj otworzyć oficjalną stronę z aplikacji; jeśli przedmiot jest tam "
                "widoczny, parser wymaga aktualizacji."
            )
        return stats

    @staticmethod
    def _clean(value: str) -> str:
        value = unescape(value or "")
        value = re.sub(r"\s+", " ", value).strip(" \t\r\n;,")
        return value

    @staticmethod
    def _standalone_result_section(flat_text: str) -> str:
        """Return the generated-item part of the page, excluding menu/catalog text.

        The real page frequently puts labels and values in separate HTML nodes.
        Parsing line-by-line therefore produces tokens such as ``Przedmiot:`` and
        the item name on different lines. A whitespace-normalised result section
        is stable across both inline and split-node layouts.
        """
        matches = list(
            re.finditer(
                r"(?<!STREFA - )(?<!Rzadkość - )\bPrzedmiot\s*:",
                flat_text,
                flags=re.IGNORECASE,
            )
        )
        if not matches:
            return flat_text
        # There should be one standalone result heading. Selecting the last one
        # is defensive against any explanatory text earlier on the page.
        return flat_text[matches[-1].start():]

    @classmethod
    def _extract_between(cls, text: str, label: str, next_labels: tuple[str, ...]) -> str:
        if next_labels:
            lookahead = "|".join(re.escape(x) + r"\s*:" for x in next_labels)
            pattern = rf"{re.escape(label)}\s*:\s*(.*?)(?=\s+(?:{lookahead})|$)"
        else:
            pattern = rf"{re.escape(label)}\s*:\s*(.*?)$"
        match = re.search(pattern, text, flags=re.IGNORECASE | re.DOTALL)
        return cls._clean(match.group(1)) if match else ""

    @staticmethod
    def _normalise_image_url(value: str, page_url: str) -> str:
        """Resolve BloodWars image paths against the server root.

        The tester lives under ``test_items.php/`` and currently exposes affix
        images through nested links such as ``href="gfx/items/...jpg"``. A
        regular ``urljoin`` would incorrectly produce
        ``/test_items.php/gfx/items/...`` instead of ``/gfx/items/...``.
        """
        value = unescape(value or "").strip().strip("'\"")
        if not value:
            return ""
        parsed = urlparse(page_url)
        if value.startswith("//"):
            return f"{parsed.scheme}:{value}"
        absolute = urlparse(value)
        if absolute.scheme and absolute.netloc and "/test_items.php/gfx/" in absolute.path:
            fixed_path = absolute.path.replace("/test_items.php/gfx/", "/gfx/", 1)
            return absolute._replace(path=fixed_path).geturl()
        clean = value
        while clean.startswith("./"):
            clean = clean[2:]
        if clean.startswith("test_items.php/"):
            clean = clean[len("test_items.php/"):]
        if clean.startswith("gfx/"):
            return f"{parsed.scheme}://{parsed.netloc}/{clean}"
        return urljoin(page_url, value)

    @classmethod
    def _background_url(cls, node, page_url: str) -> str:
        """Extract an item image URL from style, img/source, or nested link.

        Older fixtures put ``background:url(...)`` directly on the container.
        The live tester also uses clickable ``Image`` links inside affix
        containers. Supporting both layouts keeps main, prefix, and suffix
        graphics working without relying on one exact HTML shape.
        """
        if node is None:
            return ""

        candidates = [node]
        try:
            candidates.extend(node.select("[style], img[src], source[src], [data-src], a[href]"))
        except Exception:
            pass

        for candidate in candidates:
            style = candidate.get("style", "") or ""
            match = re.search(r"url\(\s*(?:'|\")?([^)'\"]+)(?:'|\")?\s*\)", style, re.I)
            if match:
                return cls._normalise_image_url(match.group(1), page_url)

            for attribute in ("src", "data-src", "href"):
                value = candidate.get(attribute, "") or ""
                lowered = value.casefold()
                if "gfx/items/" in lowered or re.search(r"\.(?:jpe?g|png|gif|webp|bmp)(?:[?#].*)?$", lowered):
                    return cls._normalise_image_url(value, page_url)
        return ""

    def download_images(self, stats: OfficialStats, cache_dir: Path) -> dict[str, str]:
        cache_dir = Path(cache_dir)
        cache_dir.mkdir(parents=True, exist_ok=True)
        paths: dict[str, str] = {}
        for key, url in (
            ("main", stats.main_image_url),
            ("prefix", stats.prefix_image_url),
            ("suffix", stats.suffix_image_url),
        ):
            if not url:
                continue
            suffix = Path(urlparse(url).path).suffix.lower() or ".img"
            destination = cache_dir / f"{key}_{sha1(url.encode('utf-8')).hexdigest()[:20]}{suffix}"
            if destination.exists():
                # ``head`` is bytes. bytes supports lower(), but not casefold().
                # v0.14.3 accidentally called casefold() here and every live image
                # download failed before prefix/suffix files could be cached.
                head = destination.read_bytes()[:256].lstrip().lower()
                if head.startswith(b"<!doctype html") or head.startswith(b"<html"):
                    destination.unlink(missing_ok=True)
            if not destination.exists():
                response = self.session.get(url, timeout=self.timeout)
                response.raise_for_status()
                content = response.content
                head = content[:256].lstrip().lower()
                content_type = (response.headers.get("Content-Type") or "").casefold()
                if "text/html" in content_type or head.startswith(b"<!doctype html") or head.startswith(b"<html"):
                    raise OfficialStatsParseError(
                        f"Serwer zwrócił stronę HTML zamiast grafiki {key}: {url}"
                    )
                destination.write_bytes(content)
            paths[key] = str(destination)
        return paths

    @classmethod
    def parse(cls, html: str, selection: VariantSelection, url: str) -> OfficialStats:
        soup = BeautifulSoup(html, "html.parser")
        tokens = [cls._clean(str(value)) for value in soup.stripped_strings]
        tokens = [value for value in tokens if value]
        flat_page = cls._clean(" ".join(tokens))
        result = cls._standalone_result_section(flat_page)

        stats = OfficialStats(
            cache_key=selection.cache_key(),
            url=url,
            category=selection.identity.category,
            prefix=selection.identity.prefix,
            base=selection.identity.base,
            suffix=selection.identity.suffix,
            quality=selection.quality,
            upgrade=selection.upgrade,
            legendary=selection.legendary,
            player_level=selection.player_level,
            raw_text="\n".join(tokens),
            fetched_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
        )

        category_alt = "|".join(re.escape(value) for value in OFFICIAL_CATEGORY_LABELS)
        # Ranged weapons add a subtype between the category and gender, for
        # example ``Broń dystansowa dwuręczna (ciężka) (PŁEĆ: Dowolna)`` or
        # ``... (lekka) (...)``.  Older versions expected the gender block
        # immediately after the category, which made every ranged result look
        # unparseable even though the official tester returned valid stats.
        category_pattern = (
            rf"(?:{category_alt})"
            r"(?:\s*\((?!\s*PŁEĆ\b)[^)]*\))*"
            r"\s*\(\s*PŁEĆ\s*:\s*[^)]*\)"
        )
        # Image alt text may appear between the item name and category. It is
        # intentionally consumed but not required.
        header = re.search(
            rf"\bPrzedmiot\s*:\s*(?P<name>.*?)\s+(?:Image\s+)*"
            rf"(?P<category>{category_pattern})",
            result,
            flags=re.IGNORECASE | re.DOTALL,
        )
        if header:
            stats.item_name = cls._clean(header.group("name"))
            stats.item_category = cls._clean(header.group("category"))
        else:
            # Fallback for fixtures or future layouts without the gender line.
            match = re.search(
                r"\bPrzedmiot\s*:\s*(.*?)(?=\s+(?:Obrażenia|Obrona|Cechy|Wymagania|Cena)\s*:|$)",
                result,
                flags=re.IGNORECASE | re.DOTALL,
            )
            if match:
                stats.item_name = cls._clean(match.group(1))

        stats.damage = cls._extract_between(
            result, "Obrażenia", ("Obrona", "Cechy", "Wymagania", "Cena")
        )
        stats.defense = cls._extract_between(
            result, "Obrona", ("Obrażenia", "Cechy", "Wymagania", "Cena")
        )
        stats.features = cls._extract_between(result, "Cechy", ("Wymagania", "Cena"))
        stats.requirements = cls._extract_between(result, "Wymagania", ("Cena",))

        # The real HTML may place the punctuation separators in separate text
        # nodes. Extracting each value by the next label works whether the page
        # renders ``Cena: value ; Cena sprzedaży: value`` in one node or many.
        stats.price = cls._extract_between(result, "Cena", ("Cena sprzedaży",))
        stats.sell_price = cls._extract_between(result, "Cena sprzedaży", ("Mana",))
        stats.mana = cls._extract_between(result, "Mana", ("Nanity",))
        stats.nanites = cls._extract_between(result, "Nanity", ("STREFA - Przedmiot",))

        stats.zone_item = cls._extract_between(
            result, "STREFA - Przedmiot", ("STREFA - prefix", "STREFA - suffix", "STREFA - PODSUMOWANIE")
        )
        stats.zone_prefix = cls._extract_between(
            result, "STREFA - prefix", ("STREFA - suffix", "STREFA - PODSUMOWANIE")
        )
        stats.zone_suffix = cls._extract_between(
            result, "STREFA - suffix", ("STREFA - PODSUMOWANIE",)
        )
        stats.zone_total = cls._extract_between(
            result, "STREFA - PODSUMOWANIE", ("Rzadkość - Przedmiot",)
        )
        stats.rarity_item = cls._extract_between(
            result, "Rzadkość - Przedmiot", ("Rzadkość - prefix", "Rzadkość - suffix", "Rzadkość - PODSUMOWANIE")
        )
        stats.rarity_prefix = cls._extract_between(
            result, "Rzadkość - prefix", ("Rzadkość - suffix", "Rzadkość - PODSUMOWANIE")
        )
        stats.rarity_suffix = cls._extract_between(
            result, "Rzadkość - suffix", ("Rzadkość - PODSUMOWANIE",)
        )
        stats.rarity_total = cls._extract_between(result, "Rzadkość - PODSUMOWANIE", tuple())

        # The official tester uses the same item-image structure as the in-game
        # Awakening tooltip. Select the last container defensively because the
        # page may also contain catalogue previews.
        image_containers = soup.select(".itemImagesContainer")
        if image_containers:
            image_container = image_containers[-1]
            stats.main_image_url = cls._background_url(
                image_container.select_one(".itemMainImageContainer"), url
            )
            affixes = image_container.select(".itemAffixContainer")
            affix_urls = [cls._background_url(node, url) for node in affixes]
            if len(affix_urls) > 1:
                stats.prefix_image_url = affix_urls[0]
                stats.suffix_image_url = affix_urls[1]
            elif affix_urls:
                # Some layouts omit the empty affix cell completely. Use the
                # selected identity to decide whether the one image is prefix
                # or suffix instead of always misclassifying it as a prefix.
                if selection.identity.suffix and not selection.identity.prefix:
                    stats.suffix_image_url = affix_urls[0]
                else:
                    stats.prefix_image_url = affix_urls[0]

        return stats
