"""Browser-like HTTP session that simulates a real browser client.

``BrowserHTTPSession`` extends :class:`~yggdrasil.io.http_.session.HTTPSession`
with everything needed to impersonate a browser:

* Realistic ``User-Agent`` strings via :class:`~yggdrasil.io.user_agents.UserAgentGenerator`
* Full browser header suite per request (``Accept``, ``Accept-Language``,
  ``Accept-Encoding``, ``Cache-Control``, ``Upgrade-Insecure-Requests``,
  ``Sec-Fetch-*``, ``sec-ch-ua``, ``sec-ch-ua-mobile``, ``sec-ch-ua-platform``)
* Persistent cookie jar with automatic ``Set-Cookie`` parsing
* Referrer tracking and ``Sec-Fetch-Site`` computation
* Navigation helpers: :meth:`navigate`, :meth:`follow_link`, :meth:`submit_form`
* Convenience wrappers: :meth:`get`, :meth:`post`, :meth:`head`
* User-agent presets via :meth:`set_browser_preset` and rotation via
  :meth:`rotate_user_agent`
* Browser / platform introspection: :attr:`browser_name`, :attr:`platform`,
  :attr:`is_mobile`, :attr:`sec_ch_ua`, :attr:`sec_ch_ua_platform`
"""
from __future__ import annotations

import re
from dataclasses import dataclass, field
from typing import Any, Mapping, Optional
from urllib.parse import urlencode, urlsplit

from yggdrasil.io.request import PreparedRequest
from yggdrasil.io.send_config import SendConfig
from yggdrasil.io.url import URL
from yggdrasil.io.user_agents import UserAgentGenerator

from .response import HTTPResponse
from .session import HTTPSession

__all__ = ["BrowserHTTPSession"]

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

_BROWSER_ACCEPT = (
    "text/html,application/xhtml+xml,application/xml;q=0.9,"
    "image/avif,image/webp,image/apng,*/*;q=0.8,"
    "application/signed-exchange;v=b3;q=0.7"
)

_BROWSER_ACCEPT_ENCODING = "gzip, deflate, br, zstd"

# User-agent pattern regexes
_CHROME_RE = re.compile(r"Chrome/(\d+)\.")
_EDGE_RE = re.compile(r"Edg(?:e)?/(\d+)\.")
_FIREFOX_RE = re.compile(r"Firefox/(\d+)\.")
_SAFARI_RE = re.compile(r"\bSafari/")
_WINDOWS_RE = re.compile(r"Windows NT")
_MAC_RE = re.compile(r"Macintosh|Mac OS X")
_LINUX_RE = re.compile(r"Linux")
_ANDROID_RE = re.compile(r"Android")
_IOS_RE = re.compile(r"iPhone|iPad|iPod")
_MOBILE_RE = re.compile(r"Mobile|Android")


# ---------------------------------------------------------------------------
# BrowserHTTPSession
# ---------------------------------------------------------------------------


@dataclass
class BrowserHTTPSession(HTTPSession):
    """An :class:`~yggdrasil.io.http_.session.HTTPSession` that impersonates
    a real browser client.

    Features
    --------
    * Realistic ``User-Agent`` strings generated by
      :class:`~yggdrasil.io.user_agents.UserAgentGenerator`.
    * Full browser header suite sent with every request: ``Accept``,
      ``Accept-Language``, ``Accept-Encoding``, ``Cache-Control``,
      ``Upgrade-Insecure-Requests``, ``Sec-Fetch-*``, and Chromium client-hint
      headers (``sec-ch-ua``, ``sec-ch-ua-mobile``, ``sec-ch-ua-platform``).
    * Persistent cookie jar with automatic ``Set-Cookie`` parsing.
    * Referrer tracking and ``Sec-Fetch-Site`` computation.
    * Navigation helpers: :meth:`navigate`, :meth:`follow_link`,
      :meth:`submit_form`, plus :meth:`get`, :meth:`post`, :meth:`head`
      convenience wrappers.
    * User-agent management: :meth:`set_user_agent`, :meth:`rotate_user_agent`,
      :meth:`set_browser_preset`.
    * Browser / platform introspection: :attr:`browser_name`, :attr:`platform`,
      :attr:`is_mobile`, :attr:`sec_ch_ua`, :attr:`sec_ch_ua_platform`.

    Quick start::

        session = HTTPSession(base_url="https://example.com")
        browser = session.to_browser()
        resp = browser.navigate("https://example.com/login")
        resp = browser.submit_form(
            "https://example.com/login",
            {"username": "alice", "password": "secret"},
        )

    Or construct directly::

        browser = BrowserHTTPSession(base_url="https://example.com")
        browser.set_browser_preset("firefox", platform="linux")
    """

    # ── Configuration fields ──────────────────────────────────────────────────

    user_agent: Optional[str] = field(default=None)
    """Explicit ``User-Agent`` string.  ``None`` (default) = auto-generate."""

    accept_language: str = field(default="en-US,en;q=0.9")
    """Value for the ``Accept-Language`` request header."""

    accept: str = field(default=_BROWSER_ACCEPT)
    """Value for the ``Accept`` request header."""

    ua_seed: Optional[int] = field(default=None)
    """Optional RNG seed passed to :class:`UserAgentGenerator` for
    deterministic user-agent generation."""

    # ── Runtime state ─────────────────────────────────────────────────────────

    _cookies: dict[str, str] = field(
        default_factory=dict, init=False, repr=False, compare=False
    )
    _referrer: Optional[str] = field(
        default=None, init=False, repr=False, compare=False
    )
    _ua_generator: Optional[UserAgentGenerator] = field(
        default=None, init=False, repr=False, compare=False
    )

    # ── Lifecycle ─────────────────────────────────────────────────────────────

    def __post_init__(self) -> None:
        self._ua_generator = UserAgentGenerator(seed=self.ua_seed)
        if self.user_agent is None:
            self.user_agent = self._ua_generator.random()
        super().__post_init__()

    # ── UA introspection ──────────────────────────────────────────────────────

    @property
    def is_mobile(self) -> bool:
        """``True`` when the active user-agent string looks like a mobile
        browser (Android / iPhone / iPad / Mobile token)."""
        return bool(_MOBILE_RE.search(self.user_agent or ""))

    @property
    def platform(self) -> str:
        """Operating-system name detected from the active user-agent.

        Returns one of ``"Windows"``, ``"macOS"``, ``"Linux"``,
        ``"Android"``, ``"iOS"``, or ``"Unknown"``.
        """
        ua = self.user_agent or ""
        if _ANDROID_RE.search(ua):
            return "Android"
        if _IOS_RE.search(ua):
            return "iOS"
        if _WINDOWS_RE.search(ua):
            return "Windows"
        if _MAC_RE.search(ua):
            return "macOS"
        if _LINUX_RE.search(ua):
            return "Linux"
        return "Unknown"

    @property
    def browser_name(self) -> str:
        """Best-effort browser name detected from the active user-agent string.

        Returns one of ``"Edge"``, ``"Chrome"``, ``"Firefox"``, ``"Safari"``,
        or ``"Unknown"``.
        """
        ua = self.user_agent or ""
        if _EDGE_RE.search(ua):
            return "Edge"
        if _CHROME_RE.search(ua):
            return "Chrome"
        if _FIREFOX_RE.search(ua):
            return "Firefox"
        if _SAFARI_RE.search(ua):
            return "Safari"
        return "Unknown"

    @property
    def sec_ch_ua(self) -> Optional[str]:
        """Chromium-style ``sec-ch-ua`` header derived from the active UA.

        Returns ``None`` for non-Chromium browsers (Firefox, Safari) because
        they do not send this header.

        Examples
        --------
        Chrome 124::

            '"Google Chrome";v="124", "Chromium";v="124", "Not-A.Brand";v="99"'

        Edge 124::

            '"Microsoft Edge";v="124", "Chromium";v="124", "Not-A.Brand";v="99"'
        """
        ua = self.user_agent or ""
        edge_m = _EDGE_RE.search(ua)
        chrome_m = _CHROME_RE.search(ua)
        if edge_m:
            v = edge_m.group(1)
            return (
                f'"Microsoft Edge";v="{v}", '
                f'"Chromium";v="{v}", '
                '"Not-A.Brand";v="99"'
            )
        if chrome_m:
            v = chrome_m.group(1)
            return (
                f'"Google Chrome";v="{v}", '
                f'"Chromium";v="{v}", '
                '"Not-A.Brand";v="99"'
            )
        return None

    @property
    def sec_ch_ua_platform(self) -> str:
        """Platform string used in the ``sec-ch-ua-platform`` client-hint
        header (e.g. ``"Windows"``, ``"macOS"``, ``"Linux"``)."""
        _map = {
            "Windows": "Windows",
            "macOS": "macOS",
            "Linux": "Linux",
            "Android": "Android",
            "iOS": "iOS",
        }
        return _map.get(self.platform, "Unknown")

    # ── Cookie jar ────────────────────────────────────────────────────────────

    @property
    def cookies(self) -> dict[str, str]:
        """Snapshot of the current session cookie jar (name → value)."""
        return dict(self._cookies)

    def set_cookie(self, name: str, value: str) -> None:
        """Add or update *name* in the session cookie jar."""
        self._cookies[name] = value

    def delete_cookie(self, name: str) -> None:
        """Remove *name* from the session cookie jar (no-op if absent)."""
        self._cookies.pop(name, None)

    def clear_cookies(self) -> None:
        """Empty the session cookie jar."""
        self._cookies.clear()

    def _serialize_cookies(self) -> str:
        """Serialize the cookie jar into a ``Cookie`` header value."""
        return "; ".join(f"{k}={v}" for k, v in self._cookies.items())

    @staticmethod
    def _parse_set_cookie(header: str) -> tuple[str, str]:
        """Parse a single ``Set-Cookie`` header value into ``(name, value)``.

        Attributes (``Path``, ``Expires``, ``HttpOnly``, ``Secure``, …) are
        intentionally ignored for simplicity.
        """
        name_value, _, _ = header.partition(";")
        name_value = name_value.strip()
        if "=" in name_value:
            name, _, value = name_value.partition("=")
            return name.strip(), value.strip()
        return name_value, ""

    def _update_cookies_from_response(self, response: HTTPResponse) -> None:
        """Extract ``Set-Cookie`` headers from *response* and merge them into
        the cookie jar.

        urllib3 may collapse multiple ``Set-Cookie`` values with a comma, so
        this method splits on ``","`` as a best-effort fallback.
        """
        raw = (
            response.headers.get("Set-Cookie")
            or response.headers.get("set-cookie")
        )
        if not raw:
            return
        for cookie_header in raw.split(","):
            try:
                name, value = self._parse_set_cookie(cookie_header.strip())
                if name:
                    self._cookies[name] = value
            except Exception:
                continue

    # ── Referrer ──────────────────────────────────────────────────────────────

    @property
    def referrer(self) -> Optional[str]:
        """URL of the last successfully navigated page, sent as the
        ``Referer`` header on subsequent requests."""
        return self._referrer

    def set_referrer(self, url: str) -> None:
        """Manually set the ``Referer`` header for subsequent requests."""
        self._referrer = str(url) if url else None

    def clear_referrer(self) -> None:
        """Reset the referrer so the next request carries no ``Referer``
        header."""
        self._referrer = None

    # ── User-agent management ─────────────────────────────────────────────────

    def set_user_agent(self, ua: str) -> None:
        """Explicitly set the ``User-Agent`` string."""
        self.user_agent = str(ua)

    def rotate_user_agent(self, seed: Optional[int] = None) -> str:
        """Generate and apply a new random ``User-Agent`` string.

        Parameters
        ----------
        seed:
            Optional integer seed for deterministic generation.

        Returns
        -------
        str
            The newly applied user-agent string.
        """
        self._ua_generator = UserAgentGenerator(seed=seed)
        self.user_agent = self._ua_generator.random()
        return self.user_agent

    def set_browser_preset(
        self,
        browser: str = "chrome",
        *,
        platform: str = "windows",
        seed: Optional[int] = None,
    ) -> str:
        """Set ``User-Agent`` to a plausible UA for a specific
        *browser* / *platform* combination.

        The method samples :class:`~yggdrasil.io.user_agents.UserAgentGenerator`
        up to 20 times until a UA matching the requested browser is found.

        Parameters
        ----------
        browser:
            ``"chrome"``, ``"edge"``, ``"firefox"``, or ``"safari"``.
        platform:
            ``"windows"``, ``"mac"``, ``"linux"``, ``"android"``, or
            ``"ios"``.
        seed:
            Optional base integer seed for reproducibility.

        Returns
        -------
        str
            The applied user-agent string.

        Examples
        --------
        ::

            browser.set_browser_preset("firefox", platform="linux")
            browser.set_browser_preset("edge", platform="windows", seed=42)
        """
        from random import Random

        browser_l = browser.lower()
        platform_l = platform.lower()

        _platform_method_map: dict[str, str] = {
            "windows": "_desktop_windows",
            "mac": "_desktop_mac",
            "linux": "_desktop_linux",
            "android": "_android",
            "ios": "_ios",
        }
        method_name = _platform_method_map.get(platform_l, "_desktop_windows")

        ua: str = ""
        for attempt in range(20):
            current_seed = (seed + attempt) if seed is not None else attempt
            gen = UserAgentGenerator(seed=current_seed)
            r = Random(current_seed)
            ua = getattr(gen, method_name)(r)
            ua_lower = ua.lower()

            if browser_l == "edge" and "edg/" in ua_lower:
                self.user_agent = ua
                return ua
            if (
                browser_l in ("chrome", "chromium")
                and "chrome/" in ua_lower
                and "edg/" not in ua_lower
            ):
                self.user_agent = ua
                return ua
            if browser_l == "firefox" and "firefox/" in ua_lower:
                self.user_agent = ua
                return ua
            if (
                browser_l == "safari"
                and "safari/" in ua_lower
                and "chrome/" not in ua_lower
                and "firefox/" not in ua_lower
            ):
                self.user_agent = ua
                return ua

        # Fallback: apply the last generated UA regardless of browser match
        self.user_agent = ua
        return ua

    # ── Header construction ───────────────────────────────────────────────────

    def _compute_sec_fetch_site(self, request: PreparedRequest) -> str:
        """Compute the ``Sec-Fetch-Site`` value by comparing the active
        referrer against the target URL.

        Returns one of ``"none"`` (no referrer), ``"same-origin"``,
        ``"same-site"``, or ``"cross-site"``.

        The same-site check uses a two-label registrable-domain heuristic
        (e.g. ``www.example.com`` and ``api.example.com`` are same-site
        because both share the ``example.com`` registrable domain).
        """
        if not self._referrer:
            return "none"
        try:
            ref = urlsplit(self._referrer)
            req = urlsplit(request.url.to_string())
            if ref.netloc == req.netloc:
                return "same-origin"
            ref_host = ref.hostname or ""
            req_host = req.hostname or ""
            if ref_host and req_host:
                if self._registrable_domain(ref_host) == self._registrable_domain(req_host):
                    return "same-site"
            return "cross-site"
        except Exception:
            return "none"

    @staticmethod
    def _registrable_domain(host: str) -> str:
        """Return a best-effort registrable domain (last two DNS labels).

        This is a lightweight heuristic — it does not consult the public
        suffix list.  Sufficient for ``Sec-Fetch-Site`` simulation in the
        common single-TLD case (``example.com``, ``example.co``, …).
        """
        parts = host.rsplit(".", 2)
        return ".".join(parts[-2:]) if len(parts) >= 2 else host


    def _browser_headers_for_request(
        self, request: PreparedRequest
    ) -> dict[str, str]:
        """Build the complete browser header dictionary for *request*.

        This includes ``User-Agent``, ``Accept``, ``Accept-Language``,
        ``Accept-Encoding``, ``Cache-Control``, ``Connection``,
        ``Upgrade-Insecure-Requests``, all ``Sec-Fetch-*`` headers,
        Chromium client-hint headers (when applicable), ``Cookie``, and
        ``Referer``.
        """
        headers: dict[str, str] = {
            "User-Agent": self.user_agent or "",
            "Accept": self.accept,
            "Accept-Language": self.accept_language,
            "Accept-Encoding": _BROWSER_ACCEPT_ENCODING,
            "Connection": "keep-alive",
            "Upgrade-Insecure-Requests": "1",
            "Cache-Control": "max-age=0",
            "Sec-Fetch-Dest": "document",
            "Sec-Fetch-Mode": "navigate",
            "Sec-Fetch-Site": self._compute_sec_fetch_site(request),
            "Sec-Fetch-User": "?1",
        }

        # Chromium UA client-hint headers (Chrome / Edge only)
        ch_ua = self.sec_ch_ua
        if ch_ua:
            headers["sec-ch-ua"] = ch_ua
            headers["sec-ch-ua-mobile"] = "?1" if self.is_mobile else "?0"
            headers["sec-ch-ua-platform"] = f'"{self.sec_ch_ua_platform}"'

        # Cookies
        if self._cookies:
            headers["Cookie"] = self._serialize_cookies()

        # Referrer
        if self._referrer:
            headers["Referer"] = self._referrer

        return headers

    def _build_request_headers(
        self,
        request: PreparedRequest,
    ) -> dict[str, str]:
        """Return the merged header dict for *request*.

        Browser defaults are set first; user-supplied :attr:`send_headers`
        override them so that explicit customisation always takes precedence.
        """
        headers = self._browser_headers_for_request(request)
        if self.send_headers:
            # User-set headers win over browser defaults
            headers.update(self.send_headers)
        return headers

    # ── URL resolution ────────────────────────────────────────────────────────

    def _resolve_url(self, url: URL | str) -> URL:
        """Resolve *url* to an absolute :class:`~yggdrasil.io.url.URL`.

        Resolution rules (evaluated in order):

        1. **Absolute** ``URL`` objects (has scheme + host) → returned unchanged.
        2. **Relative** ``URL`` objects → joined against :attr:`base_url`.
        3. ``https://…`` / ``http://…`` strings → parsed and returned.
        4. ``//…`` (protocol-relative) → ``https:`` prepended, then parsed.
        5. Starts with ``/``, ``./``, or ``../`` → absolute/relative path,
           joined against :attr:`base_url`.
        6. Windows drive-letter pattern (e.g. ``C:/path``) → :exc:`ValueError`.
        7. First segment before ``/`` contains ``"."`` → schemaless hostname
           (e.g. ``"example.com/path"``), ``https://`` prepended.
        8. Otherwise → relative path segment (e.g. ``"api/resource"``), joined
           against :attr:`base_url`.  :exc:`ValueError` if no :attr:`base_url`.

        Parameters
        ----------
        url:
            Raw URL string or :class:`~yggdrasil.io.url.URL` object.

        Returns
        -------
        URL
            Fully resolved, absolute URL object.

        Raises
        ------
        ValueError
            For Windows local paths or relative paths when no :attr:`base_url`
            is configured.
        """
        # ── URL objects ────────────────────────────────────────────────────
        if isinstance(url, URL):
            if url.is_absolute:
                return url
            # Relative URL object — join with base_url
            if self.base_url:
                return self.base_url.join(url.to_string())
            return url

        s = str(url).strip()
        if not s:
            raise ValueError("URL must not be empty.")

        # ── Already has explicit http/https ────────────────────────────────
        if s.startswith(("https://", "http://")):
            return URL.parse(s)

        # ── Protocol-relative ──────────────────────────────────────────────
        if s.startswith("//"):
            return URL.parse("https:" + s)

        # ── Explicit relative paths (/path, ./path, ../path) ──────────────
        if s.startswith(("/", "./", "../")):
            if self.base_url:
                return self.base_url.join(s)
            raise ValueError(
                f"Cannot resolve path-relative URL {s!r}: no base_url is set on "
                "this session. Set base_url or pass a full URL."
            )

        # ── Windows drive-letter detection (e.g. "C:/…", "D:\\…") ─────────
        if len(s) >= 3 and s[0].isalpha() and s[1] == ":" and s[2] in ("/", "\\"):
            raise ValueError(
                f"URL looks like a local Windows path, not an HTTP URL: {s!r}. "
                "Pass a full URL with scheme (e.g. 'https://host/path')."
            )

        # ── No explicit scheme — hostname vs relative path heuristic ───────
        # If the first segment (before the first "/") contains a "." that is
        # not a leading dot, treat it as a schemaless hostname.
        # Examples:
        #   "example.com/path"      → first_seg="example.com"  → has dot → hostname
        #   "api.example.com/v1"    → first_seg="api.example.com" → has dot → hostname
        #   "api/controlador.cgi"   → first_seg="api" → no dot → relative path
        #   "v1/resource"           → first_seg="v1"  → no dot → relative path
        first_seg = s.split("/", 1)[0] if "/" in s else s
        if "." in first_seg and not first_seg.startswith("."):
            return URL.parse("https://" + s)

        # ── Relative path segment (e.g. "api/resource", "page") ───────────
        if self.base_url:
            return self.base_url.join(s)

        raise ValueError(
            f"Cannot resolve relative URL {s!r}: no base_url is set on this "
            "session. Set base_url (e.g. HTTPSession(base_url='https://api.example.com')) "
            "or pass a full URL."
        )

    @staticmethod
    def _apply_params(url: URL, params: Mapping[str, Any]) -> URL:
        """Append *params* as query-string key/value pairs to *url*.

        Existing query parameters on *url* are preserved.  Multi-valued
        parameters can be expressed as a list or tuple.

        Parameters
        ----------
        url:
            Target URL.
        params:
            Mapping of query parameter names to values (scalar or sequence).

        Returns
        -------
        URL
            New URL with the extra query parameters appended.
        """
        for key, value in params.items():
            key_s = str(key)
            if isinstance(value, (list, tuple)):
                for v in value:
                    url = url.add_query_item(key_s, str(v), replace=False)
            else:
                url = url.add_query_item(key_s, str(value), replace=False)
        return url

    def get(
        self,
        url: URL | str,
        *,
        params: Optional[Mapping[str, Any]] = None,
        headers: Optional[Mapping[str, str]] = None,
        config: SendConfig | Mapping[str, Any] | None = None,
        **send_kwargs: Any,
    ) -> HTTPResponse:
        """Issue a browser-like ``GET`` request to *url*.

        Parameters
        ----------
        url:
            Destination URL.  Relative paths (``"api/resource"``) are joined
            against :attr:`base_url`.  Schemaless host strings
            (``"example.com/path"``) get ``https://`` prepended.
        params:
            Optional query-string parameters appended to the URL.
        headers:
            Optional per-request headers merged on top of browser defaults.
        config:
            :class:`~yggdrasil.io.send_config.SendConfig` or compatible dict.

        Returns
        -------
        HTTPResponse
        """
        resolved = self._resolve_url(url)
        if params:
            resolved = self._apply_params(resolved, params)
        request = PreparedRequest.prepare("GET", resolved, headers=headers)
        return self.send(request, config=config, **send_kwargs)  # type: ignore[return-value]

    def head(
        self,
        url: URL | str,
        *,
        params: Optional[Mapping[str, Any]] = None,
        headers: Optional[Mapping[str, str]] = None,
        config: SendConfig | Mapping[str, Any] | None = None,
        **send_kwargs: Any,
    ) -> HTTPResponse:
        """Issue a browser-like ``HEAD`` request to *url*.

        Parameters
        ----------
        url:
            Destination URL.  Relative paths are joined against :attr:`base_url`.
        params:
            Optional query-string parameters appended to the URL.
        headers:
            Optional per-request headers merged on top of browser defaults.
        config:
            :class:`~yggdrasil.io.send_config.SendConfig` or compatible dict.

        Returns
        -------
        HTTPResponse
        """
        resolved = self._resolve_url(url)
        if params:
            resolved = self._apply_params(resolved, params)
        request = PreparedRequest.prepare("HEAD", resolved, headers=headers)
        return self.send(request, config=config, **send_kwargs)  # type: ignore[return-value]

    def post(
        self,
        url: URL | str,
        *,
        params: Optional[Mapping[str, Any]] = None,
        body: Optional[Any] = None,
        json: Optional[Any] = None,
        headers: Optional[Mapping[str, str]] = None,
        config: SendConfig | Mapping[str, Any] | None = None,
        **send_kwargs: Any,
    ) -> HTTPResponse:
        """Issue a browser-like ``POST`` request to *url*.

        Parameters
        ----------
        url:
            Destination URL.  Relative paths are joined against :attr:`base_url`.
        params:
            Optional query-string parameters appended to the URL.
        body:
            Raw request body.  Mutually exclusive with *json*.
        json:
            JSON-serialisable body.  Sets ``Content-Type: application/json``.
        headers:
            Optional per-request headers merged on top of browser defaults.
        config:
            :class:`~yggdrasil.io.send_config.SendConfig` or compatible dict.

        Returns
        -------
        HTTPResponse
        """
        resolved = self._resolve_url(url)
        if params:
            resolved = self._apply_params(resolved, params)
        request = PreparedRequest.prepare(
            "POST", resolved, headers=headers, body=body, json=json
        )
        return self.send(request, config=config, **send_kwargs)  # type: ignore[return-value]

    # ── Navigation helpers ────────────────────────────────────────────────────

    def navigate(
        self,
        url: URL | str,
        *,
        method: str = "GET",
        params: Optional[Mapping[str, Any]] = None,
        body: Optional[Any] = None,
        json: Optional[Any] = None,
        headers: Optional[Mapping[str, str]] = None,
        update_referrer: bool = True,
        update_cookies: bool = True,
        raise_error: bool = True,
        config: SendConfig | Mapping[str, Any] | None = None,
        **send_kwargs: Any,
    ) -> HTTPResponse:
        """Navigate to *url*, optionally updating the referrer and cookie jar.

        The response is returned *after* updating session state so that
        subsequent requests benefit from the new referrer / cookies.

        Parameters
        ----------
        url:
            Destination URL (string or :class:`~yggdrasil.io.url.URL`).
            Relative paths are joined against :attr:`base_url`.
        method:
            HTTP method (default ``"GET"``).
        params:
            Optional query-string parameters appended to the URL.
        body:
            Raw request body.  Mutually exclusive with *json*.
        json:
            JSON-serialisable body.  Sets ``Content-Type: application/json``.
        headers:
            Optional per-request headers merged on top of browser defaults.
        update_referrer:
            When ``True`` (default) and the response is ``< 400``, update
            :attr:`referrer` to the request URL so the next request carries
            the correct ``Referer`` / ``Sec-Fetch-Site``.
        update_cookies:
            When ``True`` (default), extract and store ``Set-Cookie`` headers
            from the response automatically.
        raise_error:
            When ``True`` (default), call :meth:`~HTTPResponse.raise_for_status`
            after updating session state.
        config:
            :class:`~yggdrasil.io.send_config.SendConfig` or compatible dict.

        Returns
        -------
        HTTPResponse
        """
        resolved = self._resolve_url(url)
        if params:
            resolved = self._apply_params(resolved, params)
        request = PreparedRequest.prepare(
            method, resolved, headers=headers, body=body, json=json
        )
        # Send with raise_error=False so we can update state before raising
        result: HTTPResponse = self.send(  # type: ignore[assignment]
            request, config=config, raise_error=False, **send_kwargs
        )

        if update_cookies:
            self._update_cookies_from_response(result)
        if update_referrer and result.status_code < 400:
            self._referrer = request.url.to_string()

        if raise_error:
            result.raise_for_status()

        return result

    def follow_link(
        self,
        url: URL | str,
        *,
        from_url: Optional[str] = None,
        headers: Optional[Mapping[str, str]] = None,
        raise_error: bool = True,
        config: SendConfig | Mapping[str, Any] | None = None,
        **send_kwargs: Any,
    ) -> HTTPResponse:
        """Simulate clicking a hyperlink.

        Sets *from_url* (or the current :attr:`referrer`) as the ``Referer``
        before navigating, then updates :attr:`referrer` to *url* on a
        successful response.

        Parameters
        ----------
        url:
            The link destination.  Relative paths are joined against
            :attr:`base_url`.
        from_url:
            Explicit referrer to use before this navigation; when ``None``
            (default) the current :attr:`referrer` is kept.
        headers:
            Optional per-request headers merged on top of browser defaults.
        raise_error:
            Raise on HTTP error responses (default ``True``).
        config:
            :class:`~yggdrasil.io.send_config.SendConfig` or compatible dict.

        Returns
        -------
        HTTPResponse
        """
        if from_url is not None:
            self._referrer = from_url
        return self.navigate(
            url,
            method="GET",
            headers=headers,
            raise_error=raise_error,
            config=config,
            **send_kwargs,
        )

    def submit_form(
        self,
        url: URL | str,
        form_data: Mapping[str, Any],
        *,
        method: str = "POST",
        extra_headers: Optional[Mapping[str, str]] = None,
        raise_error: bool = True,
        config: SendConfig | Mapping[str, Any] | None = None,
        **send_kwargs: Any,
    ) -> HTTPResponse:
        """Simulate an HTML form submission.

        *form_data* is URL-encoded (``application/x-www-form-urlencoded``)
        and sent as the request body.  ``Set-Cookie`` headers from the
        response are automatically merged into the cookie jar.

        Parameters
        ----------
        url:
            Form action URL.  Relative paths are joined against :attr:`base_url`.
        form_data:
            Mapping of field names to values.
        method:
            HTTP method (default ``"POST"``).
        extra_headers:
            Additional headers merged on top of browser defaults.
        raise_error:
            Raise on HTTP error responses (default ``True``).
        config:
            :class:`~yggdrasil.io.send_config.SendConfig` or compatible dict.

        Returns
        -------
        HTTPResponse
        """
        encoded = urlencode(
            {str(k): str(v) for k, v in form_data.items()}
        ).encode("utf-8")

        merged_headers: dict[str, str] = {
            "Content-Type": "application/x-www-form-urlencoded",
        }
        if extra_headers:
            merged_headers.update(extra_headers)

        request = PreparedRequest.prepare(
            method, self._resolve_url(url), headers=merged_headers, body=encoded
        )
        result: HTTPResponse = self.send(  # type: ignore[assignment]
            request, config=config, raise_error=False, **send_kwargs
        )

        self._update_cookies_from_response(result)

        if raise_error:
            result.raise_for_status()

        return result



