"""``Probe`` — one HTTP request + the response we expect.

See: ``DESIGN.md`` §3.1 (Probe is a frozen dataclass with a fluent
builder; carries request shape AND expectations).

A Probe is **immutable**. Builder methods return a new Probe with the
field updated::

    p = Probe.get("/users").id("list-users").base("https://api.example.com")
    p2 = p.header("Accept", "application/json")  # p2 is new; p unchanged

Tuple-of-tuple fields (headers, query, tags, expected_*) are append-only
through builders. To remove an entry, build a new Probe from scratch.

Current surface is the load-bearing minimum (HANDOFF.md §6):

  - Constructors: ``get``, ``post``, ``put``, ``patch``, ``delete``
  - Identity: ``id``, ``base``, ``with_description``, ``with_tags``
  - Request: ``header``, ``query``, ``body`` (JSON only in v0.1)
  - Expectations: ``expect_status``, ``expect_header``
  - Auth: ``with_auth`` (slot is filled; concrete AuthFlow impls land in Phase 1.5)
  - Transport: ``with_timeout``

Deferred to later phases:
  - ``expect_schema`` (Phase 1.4 — checks own schema validation)
  - ``expect_latency_ms`` (Phase 1.4 — checks own latency budget)
  - ``form``, ``body_bytes``, ``multipart``, ``body_streamed`` (v0.2)
  - ``wait_for``, LRO patterns (v0.2)
  - ``extract`` (v0.2 — needs Context)
  - Variable interpolation ``{{var}}`` (v0.2 — needs Context)
"""

from __future__ import annotations

import re
from dataclasses import dataclass, replace
from typing import TYPE_CHECKING, Any, Self
from urllib.parse import urljoin

from mgf.apiprobe.exceptions import ApiprobeConfigError

if TYPE_CHECKING:
    from collections.abc import Iterable, Mapping

    from pydantic import BaseModel

    from mgf.apiprobe.core.auth import AuthFlow
    from mgf.apiprobe.core.tag import Tag
    from mgf.apiprobe.schema.base import SchemaSource


# Methods we ship constructors for. Probes for less-common methods
# (HEAD, OPTIONS) can be built via the private constructor.
_SUPPORTED_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE"})


@dataclass(frozen=True, slots=True)
class ProbeDependency:
    """One probe-to-probe data dependency declaration (PAPER-39).

    Attached to a :class:`Probe` via :meth:`Probe.depends_on`. The
    suite's runner sees these at run time: it ensures the depended-on
    probe runs first, then evaluates each :attr:`extract` JSONPath
    against the dependency's response body and adds the resulting
    values to the suite-wide :class:`Context` as
    ``{var_name}``-substitutable template variables.

    Frozen tuple-of-tuples representation for hashability — same
    discipline as :class:`Probe.headers`.
    """

    probe_id: str
    """The id of the probe this dependency points at. Must match the
    ``probe_id`` of another probe in the same Suite. Resolved at
    Suite construction; an unknown id raises
    :class:`SuiteLogicError`."""

    extract: tuple[tuple[str, str], ...]
    """Variable-name → JSONPath pairs. The path is evaluated against
    the dependency's response body (parsed as JSON); the resulting
    value is stored in the Context under ``var_name`` and is then
    available as ``{var_name}`` template substitution in the
    dependent probe's path / headers."""


@dataclass(frozen=True, slots=True)
class Probe:
    """One HTTP request + the response we expect.

    Construct via one of the classmethod constructors (:meth:`get`,
    :meth:`post`, …); never call ``Probe(...)`` directly — the
    positional-args shape is reserved for internal ``replace`` calls.
    """

    method: str
    """HTTP method. Uppercase. Validated at construction."""

    path: str
    """URL path or full URL.

    If absolute (starts with ``http://`` / ``https://``), :attr:`base_url`
    is ignored. Otherwise :meth:`absolute_url` joins ``base_url`` + path.
    """

    probe_id: str | None = None
    """Stable identifier. Required for cassette matching (v0.3+) and
    Finding correlation. v0.1 lets it default to ``None``; the suite
    auto-generates a fallback at run time when unset."""

    base_url: str | None = None
    """Base URL prefix. Set via :meth:`base`."""

    headers: tuple[tuple[str, str], ...] = ()
    """Request headers as ``((name, value), ...)``. Tuple-of-tuples for hashability."""

    query_params: tuple[tuple[str, str], ...] = ()
    """Query parameters as ``((name, value), ...)``. Values stringified at construction."""

    json_body: object | None = None
    """JSON-serializable payload. v0.1 supports JSON only; other body
    shapes (form, multipart, streamed) land in v0.2."""

    expected_statuses: tuple[int, ...] = ()
    """Status codes that are accepted. Empty tuple means "any status accepted"
    — checks may still flag a status via :class:`Finding`. Multiple values
    via repeated :meth:`expect_status` calls or one variadic call."""

    expected_headers: tuple[str, ...] = ()
    """Header NAMES that MUST be present in the response (case-insensitive
    on check-time lookup). Use :meth:`expect_header` to add."""

    tags: tuple[str, ...] = ()
    """Tags (free-form strings or :class:`Tag` enum values)."""

    description: str | None = None
    """Optional human-readable description. Surfaces in reports."""

    timeout_seconds: float | None = None
    """Per-probe timeout override; falls back to ``ApiprobeSettings.timeout_seconds``."""

    retries: int | None = None
    """Per-probe transient-failure retry count; falls back to ``ApiprobeSettings.max_retries``.
    Retries use exponential backoff with full jitter and a finite cap (EH-11)."""

    auth: AuthFlow | None = None
    """Auth flow. The concrete classes (:class:`BearerAuth`, …) land in Phase 1.5."""

    expected_schema: SchemaSource | None = None
    """Expected response body shape. v0.1 supports
    :class:`mgf.apiprobe.schema.PydanticSchemaSource`; OpenAPI + JSON Schema
    sources land in v0.2 / v0.4."""

    expected_latency_ms: float | None = None
    """Latency budget in milliseconds. The ``LatencyOverBudget`` check
    fires a Finding when ``response.elapsed_ms > expected_latency_ms``.
    Per-percentile budgets (``p50`` / ``p99``) are deferred to v0.2."""

    depends_on_probes: tuple[ProbeDependency, ...] = ()
    """Probe-to-probe data dependencies (PAPER-39).

    Each :class:`ProbeDependency` declares a probe_id this probe
    depends on, plus a mapping of variable names to JSONPath
    expressions for values to extract from the dependency's
    response into the suite's :class:`Context`. The extracted
    values are then available as ``{var_name}`` template
    placeholders in this probe's ``path`` / ``header`` / etc.

    Set via :meth:`depends_on`. The suite topologically orders
    probes by these declarations; cycles and unknown probe_id
    references raise :class:`SuiteLogicError` at suite
    construction."""

    # ── Constructors ─────────────────────────────────────────────────────

    @classmethod
    def _new(cls, method: str, path: str) -> Self:
        method = method.upper()
        if method not in _SUPPORTED_METHODS:
            raise ApiprobeConfigError(
                f"Probe.{method.lower()}() is not a v0.1 supported method; "
                f"supported: {sorted(_SUPPORTED_METHODS)}"
            )
        if not path:
            raise ApiprobeConfigError("Probe path must not be empty")
        # PAPER-42: auto-derive a sensible default probe_id from
        # method + path so findings reference each probe by a
        # human-readable label even when the consumer didn't call
        # `.id()`. The README quickstart used to elide .id() in
        # passing, leading to opaque report labels for consumers
        # who followed it literally. Consumers calling .id()
        # explicitly override this default via the builder.
        return cls(method=method, path=path, probe_id=_derive_default_id(method, path))

    @classmethod
    def get(cls, path: str) -> Self:
        """Build a GET probe."""
        return cls._new("GET", path)

    @classmethod
    def post(cls, path: str) -> Self:
        """Build a POST probe."""
        return cls._new("POST", path)

    @classmethod
    def put(cls, path: str) -> Self:
        """Build a PUT probe."""
        return cls._new("PUT", path)

    @classmethod
    def patch(cls, path: str) -> Self:
        """Build a PATCH probe."""
        return cls._new("PATCH", path)

    @classmethod
    def delete(cls, path: str) -> Self:
        """Build a DELETE probe."""
        return cls._new("DELETE", path)

    # ── Identity / metadata builders ─────────────────────────────────────

    def id(self, probe_id: str) -> Self:
        """Set the probe id. Required for cassette matching (v0.3+)."""
        return replace(self, probe_id=probe_id)

    def base(self, base_url: str) -> Self:
        """Set the base URL prefix.

        Combined with :attr:`path` via :meth:`absolute_url`. If
        :attr:`path` is already absolute, ``base_url`` is ignored
        (with a warning surfaced by checks at suite build time —
        not raised here, since "redundant base" is harmless).
        """
        return replace(self, base_url=base_url)

    def with_description(self, text: str) -> Self:
        """Set the probe's description (surfaces in reports)."""
        return replace(self, description=text)

    def with_tags(self, *tags: str | Tag) -> Self:
        """Append tags. Accepts :class:`Tag` enum values or free-form strings."""
        new_tags = self.tags + tuple(str(t) for t in tags)
        return replace(self, tags=new_tags)

    def with_timeout(self, seconds: float) -> Self:
        """Set per-probe timeout override.

        Raises :class:`ApiprobeConfigError` for non-positive values.
        """
        if seconds <= 0:
            raise ApiprobeConfigError(f"timeout must be positive (got {seconds!r})")
        return replace(self, timeout_seconds=seconds)

    def retry(self, count: int) -> Self:
        """Set the per-probe transient-failure retry count (EH-11).

        ``0`` disables retries. Retries use exponential backoff with full jitter and a
        finite cap. Raises :class:`ApiprobeConfigError` for negatives.
        """
        if count < 0:
            raise ApiprobeConfigError(f"retries must be >= 0 (got {count!r})")
        return replace(self, retries=count)

    def with_auth(self, flow: AuthFlow) -> Self:
        """Attach an auth flow. Concrete flows land in Phase 1.5."""
        return replace(self, auth=flow)

    def depends_on(
        self,
        probe_id: str,
        *,
        extract: Mapping[str, str],
    ) -> Self:
        """Declare that this probe depends on another probe's response.

        ``probe_id`` is the id of another probe in the same Suite.
        ``extract`` maps variable names to JSONPath expressions
        evaluated against the dependency's response body; the
        resulting values are added to the Suite's
        :class:`Context` after the dependency runs and can be
        referenced as ``{var_name}`` template placeholders in this
        probe's ``path`` / ``header`` / etc.

        Example — fetch a room after creating it::

            create = (Probe.post('/rooms/')
                      .body({'name': 'general'})
                      .id('create-room')
                      .expect_status(201))
            verify = (Probe.get('/rooms/{slug}/messages/')
                      .id('verify-room')
                      .depends_on('create-room', extract={'slug': '$.slug'}))

        Example — bearer-style auth with a non-standard scheme::

            auth_probe = (Probe.post('/auth/token/')
                          .body({'username': 'alice', 'password': '...'})
                          .id('auth-bootstrap')
                          .expect_status(200))
            profile = (Probe.get('/me/')
                       .id('profile')
                       .depends_on('auth-bootstrap', extract={'tok': '$.token'})
                       .header('Authorization', 'Token {tok}'))

        Multiple ``.depends_on()`` calls accumulate: a probe can
        depend on N other probes.

        Validation: ``probe_id`` references must point to probes
        in the same Suite, and the dependency graph must be acyclic.
        Both invariants are enforced at Suite construction with
        :class:`SuiteLogicError`. JSONPath expressions are
        validated at suite-run time (not here) — early lex of every
        expression at probe-construction is deferred to v0.5 once
        the jsonpath surface settles.

        PAPER-39.
        """
        dep = ProbeDependency(
            probe_id=probe_id,
            extract=tuple(extract.items()),
        )
        return replace(
            self,
            depends_on_probes=(*self.depends_on_probes, dep),
        )

    # ── Request shape builders ────────────────────────────────────────────

    def header(self, name: str, value: str) -> Self:
        """Append a request header. Duplicates are preserved (HTTP allows them)."""
        return replace(self, headers=(*self.headers, (name, value)))

    def query(self, name: str, value: Any) -> Self:
        """Append a query parameter. Non-string values are str()-coerced."""
        return replace(self, query_params=(*self.query_params, (name, str(value))))

    def body(self, payload: object) -> Self:
        """Set the JSON body. Replaces any previously set body.

        Raises :class:`ApiprobeConfigError` on a method that doesn't
        carry a body in normal usage (GET, DELETE) — those CAN carry
        bodies per RFC, but doing so is almost always a bug.
        """
        if self.method in {"GET", "DELETE"} and payload is not None:
            raise ApiprobeConfigError(
                f"{self.method} probes should not carry a JSON body "
                f"(probe path={self.path!r}); use POST/PUT/PATCH instead"
            )
        return replace(self, json_body=payload)

    # ── Expectation builders ──────────────────────────────────────────────

    def expect_status(self, *statuses: int) -> Self:
        """Append accepted status codes.

        Variadic so common patterns read fluently::

            probe.expect_status(200)
            probe.expect_status(200, 304)

        Multiple calls accumulate: ``probe.expect_status(200).expect_status(304)``
        is equivalent to ``probe.expect_status(200, 304)``.
        """
        for s in statuses:
            if not (100 <= s < 600):
                raise ApiprobeConfigError(f"expect_status: {s!r} is not a valid HTTP status code")
        return replace(self, expected_statuses=self.expected_statuses + tuple(statuses))

    def expect_header(self, name: str) -> Self:
        """Append an expected response header name (presence-only).

        For value-level header expectations, use a check that reads
        the response and emits a Finding.
        """
        if not name:
            raise ApiprobeConfigError("expect_header: name must not be empty")
        return replace(self, expected_headers=(*self.expected_headers, name))

    def expect_schema(self, schema: SchemaSource | type[BaseModel]) -> Self:
        """Set the expected response body schema.

        Accepts a :class:`SchemaSource` directly OR a Pydantic
        :class:`BaseModel` subclass (auto-wrapped in
        :class:`mgf.apiprobe.schema.PydanticSchemaSource`).

        Replaces any previously set schema; one schema per probe.
        """
        from pydantic import BaseModel as _BaseModel

        from mgf.apiprobe.schema.base import SchemaSource as _SchemaSource
        from mgf.apiprobe.schema.pydantic import PydanticSchemaSource

        source: SchemaSource
        if isinstance(schema, _SchemaSource):
            source = schema
        elif isinstance(schema, type) and issubclass(schema, _BaseModel):
            source = PydanticSchemaSource(schema)
        else:
            raise ApiprobeConfigError(
                f"expect_schema requires a SchemaSource or pydantic BaseModel "
                f"subclass; got {schema!r}"
            )
        return replace(self, expected_schema=source)

    def expect_latency_ms(self, threshold_ms: float) -> Self:
        """Set the latency budget. Findings fire on ``elapsed_ms > threshold_ms``.

        Raises :class:`ApiprobeConfigError` for non-positive values.
        """
        if threshold_ms <= 0:
            raise ApiprobeConfigError(
                f"expect_latency_ms: threshold must be positive (got {threshold_ms!r})"
            )
        return replace(self, expected_latency_ms=threshold_ms)

    # ── Materialisation ───────────────────────────────────────────────────

    def absolute_url(self) -> str:
        """Return the absolute URL of the request.

        Raises :class:`ApiprobeConfigError` if :attr:`path` is relative
        and :attr:`base_url` is unset.
        """
        if self.path.startswith(("http://", "https://")):
            return self.path
        if not self.base_url:
            raise ApiprobeConfigError(
                f"probe path {self.path!r} is relative but base_url is not set; "
                f"call .base('https://...') or pass an absolute URL to .get()/.post()/..."
            )
        # urljoin requires the base to end with `/` for path-only joins
        # to behave as expected. We normalise.
        base = self.base_url if self.base_url.endswith("/") else self.base_url + "/"
        path = self.path.lstrip("/")
        return urljoin(base, path)

    def with_tags_iter(self, tags: Iterable[str | Tag]) -> Self:
        """Append tags from an iterable. Convenience for the variadic :meth:`with_tags`."""
        return self.with_tags(*tags)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


# Compiled at module load: lower-than-tens-of-microseconds cost per
# Probe construction. Matches any run of characters that aren't safe
# for a probe id (anything outside ASCII alnum / underscore).
_PROBE_ID_UNSAFE = re.compile(r"[^A-Za-z0-9_]+")


def _derive_default_id(method: str, path: str) -> str:
    """Return a probe-id derived from ``method`` and ``path``.

    Used by :meth:`Probe._new` to seed ``probe_id`` when the consumer
    didn't call :meth:`Probe.id`. Result shape:

      - ``GET /api/v1/me/profile/``  →  ``GET_api_v1_me_profile``
      - ``POST /users/``             →  ``POST_users``
      - ``GET /``                    →  ``GET_root``
      - ``GET /a%20b/``              →  ``GET_a_20b``

    Always stable, always human-readable, always ASCII. Caller can
    override via :meth:`Probe.id` for prettier names.

    Collision semantics: two probes whose method + path produce the
    same derived id are still rejected by
    :func:`_assert_unique_probe_ids` at Suite construction; the
    error message guides the consumer to set explicit :meth:`Probe.id`
    on at least one. This is correct behaviour — two probes hitting
    the same endpoint shape but expecting different responses
    (e.g., authed vs unauth) genuinely need disambiguation.

    PAPER-42.
    """
    cleaned = _PROBE_ID_UNSAFE.sub("_", path).strip("_")
    return f"{method}_{cleaned}" if cleaned else f"{method}_root"


__all__ = ["Probe", "ProbeDependency"]
