"""``Suite`` — the run orchestrator.

See: ``DESIGN.md`` §3.5 (Suite as primitive) + §6 (async layering).

A Suite holds a tuple of probes + a tuple of checks + a settings
object. Calling :meth:`run` executes every probe (concurrently up to
``settings.max_concurrency``), runs each probe's response through the
check chain, and aggregates the findings into a :class:`Report`.

The Suite consumes a :class:`Transport` via dependency injection —
defaults to the httpx-backed transport when not provided. Tests
inject stub transports; replay (v0.3) injects a cassette-backed
transport.

v0.1 surface (HANDOFF.md §6 cut):
  - sequential probe ordering preserved per-probe
  - concurrent probe execution capped by settings.max_concurrency
  - severity_overrides applied per-finding (DESIGN.md §3.2.2)
  - transport errors yield a synthetic Finding with RootCause.NETWORK
  - filter probes by tags (only_tags / exclude_tags)

Deferred:
  - Scenarios (multi-probe with shared Context mutation) — v0.2
  - Severity promotion audit logging — v0.4
  - Replay/cassette transport injection — v0.3
"""

from __future__ import annotations

import asyncio
import dataclasses
import logging
import random
from datetime import UTC, datetime
from typing import TYPE_CHECKING

from mgf.common.observability import operation_span

from mgf.apiprobe.core.context import Context, resolve_template
from mgf.apiprobe.core.finding import Finding, RootCause, Severity
from mgf.apiprobe.core.report import Report
from mgf.apiprobe.exceptions import SuiteLogicError, TransportError

if TYPE_CHECKING:
    from collections.abc import Iterable, Sequence

    from mgf.common.progress import CancellationToken

    from mgf.apiprobe.config import ApiprobeSettings
    from mgf.apiprobe.core.check import Check
    from mgf.apiprobe.core.probe import Probe
    from mgf.apiprobe.core.response import Response
    from mgf.apiprobe.core.transport import Transport


_LOGGER = logging.getLogger("mgf.apiprobe.suite")

# EH-11: transient-failure retry backoff — exponential, full jitter, finite cap.
_RETRY_BASE_SECONDS = 0.1
_RETRY_CAP_SECONDS = 10.0


def _retry_backoff(attempt: int) -> float:
    """Seconds to wait before retry ``attempt`` (0-indexed): full jitter over an
    exponentially-growing, capped ceiling (EH-11 — bounds upstream-amplifying load)."""
    ceiling = min(_RETRY_CAP_SECONDS, _RETRY_BASE_SECONDS * (2.0**attempt))
    return random.uniform(0.0, ceiling)  # noqa: S311 — backoff jitter, not cryptographic


class Suite:
    """A collection of probes + checks + an execution model.

    Construct with positional or keyword args::

        suite = Suite(
            probes=[probe1, probe2],
            checks=DEFAULT_CHECKS,
            settings=ApiprobeSettings(max_concurrency=4),
        )
        report = await suite.run(transport=my_transport)

    The Suite is itself stateless: ``run()`` may be called multiple
    times, each producing a fresh Report.
    """

    def __init__(
        self,
        *,
        probes: Sequence[Probe],
        checks: Sequence[Check],
        settings: ApiprobeSettings | None = None,
        severity_overrides: dict[str, Severity] | None = None,
    ) -> None:
        from mgf.apiprobe.config import ApiprobeSettings

        self.probes: tuple[Probe, ...] = tuple(probes)
        self.checks: tuple[Check, ...] = tuple(checks)
        self.settings: ApiprobeSettings = settings or ApiprobeSettings()
        self.severity_overrides: dict[str, Severity] = dict(severity_overrides or {})

        # RA-02: enforce probe_id + check.code uniqueness at construction.
        # Two probes / checks sharing an identifier produce findings that
        # downstream consumers (the JSON writer, the human reporter, a
        # CI script filtering by probe_id) can't disambiguate. Raising
        # at suite-build time turns a silent reporting confusion into a
        # loud configuration error.
        _assert_unique_probe_ids(self.probes)
        _assert_unique_check_codes(self.checks)

        # PAPER-39: validate the dependency graph at suite-build time
        # so dangling references (probe_id pointing at a probe that
        # isn't in this Suite) and cycles fail loud before the first
        # request is sent. Computes the topological order as a
        # side-effect and stashes it on the Suite so run() doesn't
        # repeat the work.
        self._topological_order: tuple[Probe, ...] = _validate_and_topologically_sort(
            self.probes,
        )

    async def run(
        self,
        *,
        transport: Transport,
        context: Context | None = None,
        only_tags: Iterable[str] | None = None,
        exclude_tags: Iterable[str] | None = None,
        cancel: CancellationToken | None = None,
    ) -> Report:
        """Execute every probe and return the :class:`Report`.

        ``transport`` is required — there's no implicit default in
        v0.1. Consumers building production suites pass an
        :class:`mgf.apiprobe.transport.HttpxTransport`; tests pass
        stubs that subclass :class:`Transport`.

        ``only_tags`` and ``exclude_tags`` filter the probe list
        before execution. Empty / ``None`` means "no filter on this
        side"; both filters can combine.

        ``cancel`` (DP-09): an optional
        :class:`mgf.common.progress.CancellationToken`. Once cancelled, probes not
        yet started are skipped and the partial report is returned (cooperative;
        in-flight requests run to completion).
        """
        # LG-11: log operation start/end at INFO with a stable `operation` field
        # (the run crosses the network boundary). The OTel span supplies trace
        # context (LG-03) + contextvar correlation (LG-14); the completion record
        # also carries a stable `event` (LG-15). INFO is below the default level,
        # so this is silent in tests until logging is configured at the entry.
        _LOGGER.info(
            "suite run started",
            extra={"operation": "apiprobe.suite.run", "probes": len(self.probes)},
        )
        with operation_span("apiprobe.suite.run", probes=len(self.probes)):
            report = await self._run_impl(
                transport=transport,
                context=context,
                only_tags=only_tags,
                exclude_tags=exclude_tags,
                cancel=cancel,
            )
        _LOGGER.info(
            "suite run completed",
            extra={
                "operation": "apiprobe.suite.run",
                "event": "apiprobe.suite.completed",
                "probes": len(self.probes),
                "findings": len(report.findings),
            },
        )
        return report

    async def _run_impl(
        self,
        *,
        transport: Transport,
        context: Context | None = None,
        only_tags: Iterable[str] | None = None,
        exclude_tags: Iterable[str] | None = None,
        cancel: CancellationToken | None = None,
    ) -> Report:
        ctx = context or Context.empty()
        only = frozenset(only_tags) if only_tags else None
        exclude = frozenset(exclude_tags) if exclude_tags else None

        # PAPER-39: when any probe declares a dependency, fall through
        # to the serial topological executor so values extracted from
        # one probe's response can thread into a dependent's
        # path / headers. The parallel-gather path below is the
        # fast default for the common case (no dependencies); the
        # serial path is opt-in via .depends_on().
        if any(p.depends_on_probes for p in self.probes):
            probes_to_run = [
                p for p in self._topological_order if _passes_tag_filter(p, only, exclude)
            ]
            started_at = datetime.now(UTC)
            topo_results, _final_ctx = await self._run_topological(
                probes_to_run,
                ctx,
                transport,
                cancel,
            )
            return self._build_report(topo_results, started_at)

        probes_to_run = [p for p in self.probes if _passes_tag_filter(p, only, exclude)]

        started_at = datetime.now(UTC)
        # EH-13: upstream-amplifying load is bounded by (a) max_concurrency — the
        # call-rate bound capping concurrent requests — and (b) the finite retry
        # budget with backoff (_retry_backoff / EH-11). Recorded in lieu of a circuit
        # breaker (TESTING/ERROR_HANDLING EH-13 "documented retry-budget + call-rate bound").
        semaphore = asyncio.Semaphore(self.settings.max_concurrency)

        async def _execute(probe: Probe) -> tuple[list[Finding], bool]:
            async with semaphore:
                if cancel is not None and cancel.is_cancelled():
                    return [], False  # DP-09: skip probes not yet started once cancelled
                # RA-01: defensive wrap around _run_one_probe. The
                # method catches TransportError internally and returns
                # a synthetic Finding, but `Check.run` is permitted to
                # raise (a malformed regex in schema.py, a JSON-parse
                # failure in contract.py, a third-party check that
                # crashes on an edge-case Response). Without this
                # wrap, ONE buggy check on ONE probe cancels the
                # whole gather + drops every sibling probe's results.
                # Synthesise a CRITICAL `suite.probe.exception`
                # finding so the operator sees the bug AND every
                # other probe still runs to completion. Belt + braces:
                # `gather(return_exceptions=True)` below catches
                # anything that escapes this wrap too.
                try:
                    # PAPER-39: _run_one_probe now returns a 3-tuple
                    # (findings, had_transport_error, response_or_None).
                    # The parallel path doesn't extract from responses
                    # — drop the third element to keep the gather()
                    # downstream shape unchanged.
                    findings, had_te, _resp = await self._run_one_probe(
                        probe,
                        ctx,
                        transport,
                    )
                    return findings, had_te
                except Exception as exc:
                    _LOGGER.warning(
                        "probe %s failed unexpectedly: %s",
                        probe.probe_id,
                        exc,
                        exc_info=True,
                        extra={
                            "event": "apiprobe.probe.exception",
                            "probe": probe.probe_id,
                            "fingerprint": type(exc).__name__,
                        },
                    )
                    return (
                        [
                            Finding(
                                code="suite.probe.exception",
                                severity=Severity.CRITICAL,
                                root_cause=RootCause.SUITE_LOGIC,
                                message=(f"Probe execution raised {type(exc).__name__}: {exc}."),
                                evidence={
                                    "probe_id": probe.probe_id or "",
                                    "exception_type": type(exc).__name__,
                                },
                                remediation=(
                                    "A check or transport step raised "
                                    "unexpectedly. Inspect the WARNING "
                                    "log line (with exc_info) for the "
                                    "traceback. (RA-01.)"
                                ),
                                probe_id=probe.probe_id,
                            )
                        ],
                        True,
                    )

        # RA-01: return_exceptions=True so a stray exception that
        # escapes the _execute wrap above doesn't cancel the rest.
        gathered = await asyncio.gather(
            *(_execute(p) for p in probes_to_run),
            return_exceptions=True,
        )
        results: list[tuple[list[Finding], bool]] = []
        for probe, result in zip(probes_to_run, gathered, strict=True):
            if isinstance(result, BaseException):
                # Should be unreachable given _execute's own try/except,
                # but kept as a final safety net so a future refactor
                # that drops the inner wrap still doesn't lose probes.
                _LOGGER.error(
                    "probe %s escaped the defensive wrap: %s",
                    probe.probe_id,
                    result,
                    exc_info=result,
                    extra={
                        "event": "apiprobe.probe.escaped_wrap",
                        "probe": probe.probe_id,
                        "fingerprint": type(result).__name__,  # LG-17: stable failure identity
                    },
                )
                results.append(
                    (
                        [
                            Finding(
                                code="suite.probe.exception",
                                severity=Severity.CRITICAL,
                                root_cause=RootCause.SUITE_LOGIC,
                                message=(
                                    f"Probe execution raised {type(result).__name__}: {result}."
                                ),
                                probe_id=probe.probe_id,
                            )
                        ],
                        True,
                    )
                )
            else:
                results.append(result)
        finished_at = datetime.now(UTC)

        all_findings: list[Finding] = []
        transport_errors = 0
        for findings, had_transport_error in results:
            all_findings.extend(findings)
            if had_transport_error:
                transport_errors += 1

        # SH-06: surface TLS-verification-disabled as a Finding so
        # operators reading the report have a visible signal that the
        # security claims are weaker than they'd assume from "all
        # probes passed". Emitted once per run, at the front of the
        # findings list so it's the first thing a JSON reader sees.
        if not self.settings.verify_tls:
            all_findings.insert(
                0,
                Finding(
                    code="harness.config.tls-disabled",
                    severity=Severity.WARN,
                    root_cause=RootCause.INFRASTRUCTURE,
                    message=(
                        "TLS verification disabled for this suite run. "
                        "Every probe ran over potentially-MITM'd traffic; "
                        "report claims about TLS-protected endpoints are "
                        "unverifiable."
                    ),
                    evidence={"verify_tls": "false"},
                    remediation=(
                        "Set ApiprobeSettings(verify_tls=True) (the default) "
                        "for production runs. Self-signed dev environments "
                        "should ship their CA cert via the SSL_CERT_FILE / "
                        "REQUESTS_CA_BUNDLE env var so verification "
                        "succeeds without disabling it. (SH-06.)"
                    ),
                ),
            )

        return Report(
            findings=tuple(all_findings),
            started_at=started_at,
            finished_at=finished_at,
            probe_count=len(probes_to_run),
            transport_errors=transport_errors,
        )

    def _build_report(
        self,
        results: list[tuple[list[Finding], bool]],
        started_at: datetime,
    ) -> Report:
        """Aggregate per-probe results into a :class:`Report`.

        Extracted from :meth:`run` so the parallel-gather path and the
        PAPER-39 topological-serial path can share the post-processing
        (TLS-disabled surfacing, transport-error counting, finished-at
        stamping). The serial path passes its own results list; the
        parallel path inlines the same aggregation (kept inline there
        for now to minimise diff churn against the well-tested
        parallel code).
        """
        finished_at = datetime.now(UTC)
        all_findings: list[Finding] = []
        transport_errors = 0
        for findings, had_transport_error in results:
            all_findings.extend(findings)
            if had_transport_error:
                transport_errors += 1

        if not self.settings.verify_tls:
            all_findings.insert(
                0,
                Finding(
                    code="harness.config.tls-disabled",
                    severity=Severity.WARN,
                    root_cause=RootCause.INFRASTRUCTURE,
                    message=(
                        "TLS verification disabled for this suite run. "
                        "Every probe ran over potentially-MITM'd traffic; "
                        "report claims about TLS-protected endpoints are "
                        "unverifiable."
                    ),
                    evidence={"verify_tls": "false"},
                    remediation=(
                        "Set ApiprobeSettings(verify_tls=True) (the default) "
                        "for production runs. Self-signed dev environments "
                        "should ship their CA cert via the SSL_CERT_FILE / "
                        "REQUESTS_CA_BUNDLE env var so verification "
                        "succeeds without disabling it. (SH-06.)"
                    ),
                ),
            )

        return Report(
            findings=tuple(all_findings),
            started_at=started_at,
            finished_at=finished_at,
            probe_count=len(results),
            transport_errors=transport_errors,
        )

    async def _run_topological(
        self,
        probes_to_run: list[Probe],
        initial_ctx: Context,
        transport: Transport,
        cancel: CancellationToken | None = None,
    ) -> tuple[list[tuple[list[Finding], bool]], Context]:
        """Run probes in dependency order, threading extracted values.

        Walks ``probes_to_run`` in the suite's pre-computed topological
        order. For each probe:

          1. If any direct dependency previously failed (transport
             error / extraction error / template error / upstream
             skip), THIS probe is skipped with a synthetic
             ``suite.deps.skipped_due_to_upstream`` Finding —
             running half-resolved probes against a real provider
             would produce confusing 404s and obscure the real
             cause.
          2. Otherwise, the runner evaluates each
             :class:`~mgf.apiprobe.core.probe.ProbeDependency`'s
             JSONPath against the cached upstream response and
             adds the extracted value to the Context.
          3. Template ``{var}`` placeholders in path / header
             values / query values are resolved against the
             Context to produce the final probe shape.
          4. The (resolved) probe is sent via the transport; its
             response is cached under the probe's id for any
             downstream dependents.

        PAPER-39.
        """
        results: list[tuple[list[Finding], bool]] = []
        ctx = initial_ctx
        response_cache: dict[str, Response] = {}
        # Track probes whose dependency chain failed; their direct or
        # transitive dependents cascade the skip.
        failed_ids: set[str] = set()

        for probe in probes_to_run:
            if cancel is not None and cancel.is_cancelled():
                break  # DP-09: stop the dependency chain once cancelled
            # Step 1 — cascade skip from a failed dependency.
            cascade_from = next(
                (dep.probe_id for dep in probe.depends_on_probes if dep.probe_id in failed_ids),
                None,
            )
            if cascade_from is not None:
                results.append(
                    (
                        [_make_cascade_skip_finding(probe, cascade_from)],
                        False,
                    )
                )
                if probe.probe_id is not None:
                    failed_ids.add(probe.probe_id)
                continue

            # Step 2 — extract upstream response values into Context.
            try:
                ctx = _extract_into_context(probe, ctx, response_cache)
            except _ExtractionError as exc:
                results.append(
                    (
                        [_make_extraction_failed_finding(probe, exc)],
                        False,
                    )
                )
                if probe.probe_id is not None:
                    failed_ids.add(probe.probe_id)
                continue

            # Step 3 — substitute {var} placeholders in path / headers
            # / queries.
            try:
                resolved_probe = _resolve_probe(probe, ctx)
            except KeyError as exc:
                results.append(
                    (
                        [_make_template_unresolved_finding(probe, exc, ctx)],
                        False,
                    )
                )
                if probe.probe_id is not None:
                    failed_ids.add(probe.probe_id)
                continue

            # Step 4 — send + check. Capture the response so any
            # downstream probe whose dep points at this one can
            # extract from it.
            probe_findings, had_transport_error, response = await self._run_one_probe(
                resolved_probe, ctx, transport
            )
            results.append((probe_findings, had_transport_error))

            if had_transport_error:
                if probe.probe_id is not None:
                    failed_ids.add(probe.probe_id)
                continue

            if response is not None and probe.probe_id is not None:
                response_cache[probe.probe_id] = response

        return results, ctx

    async def _send_with_retries(
        self,
        probe: Probe,
        ctx: Context,
        transport: Transport,
    ) -> Response:
        """Send a probe, retrying transient :class:`TransportError`s (EH-11).

        Budget = ``(probe.retries if set else settings.max_retries)`` extra attempts;
        waits ``_retry_backoff(attempt)`` (exponential + full jitter, capped) between
        tries; re-raises the last error once the budget is exhausted.
        """
        retries = probe.retries if probe.retries is not None else self.settings.max_retries
        attempt = 0
        while True:
            try:
                return await transport.send(probe, ctx)
            except TransportError:
                if attempt >= retries:
                    raise
                await asyncio.sleep(_retry_backoff(attempt))
                attempt += 1

    async def _run_one_probe(
        self,
        probe: Probe,
        ctx: Context,
        transport: Transport,
    ) -> tuple[list[Finding], bool, Response | None]:
        """Send one probe + run the checks.

        Returns a 3-tuple ``(findings, had_transport_error, response)``.
        The ``response`` is ``None`` on transport failure and on no
        success otherwise; it's exposed so the topological runner
        (PAPER-39) can extract jsonpath values from it for dependent
        probes. Other callers can ignore the third element.

        Transport-level failures yield a single synthetic Finding with
        ``RootCause.NETWORK`` and DO NOT run the check chain (a probe
        with no Response can't be checked).
        """
        try:
            response = await self._send_with_retries(probe, ctx, transport)
        except TransportError as exc:
            _LOGGER.warning(
                "probe %s transport error: %s",
                probe.probe_id,
                exc,
                extra={"event": "apiprobe.probe.transport_error", "probe": probe.probe_id},
            )
            return (
                [
                    Finding(
                        code="transport.network.failure",
                        severity=Severity.CRITICAL,
                        root_cause=RootCause.NETWORK,
                        message=f"Transport failed: {exc.cause}.",
                        evidence={"url": exc.url, "cause": exc.cause},
                        remediation=(
                            "Verify the URL resolves, the TLS chain is "
                            "valid, and the provider is reachable. "
                            "Check the harness's network egress rules."
                        ),
                        probe_id=probe.probe_id,
                    )
                ],
                True,
                None,
            )

        findings: list[Finding] = []
        for check in self.checks:
            # Per-check isolation: a check that raises AFTER yielding some
            # findings must not lose them, and must not skip the remaining
            # checks. Without this inner guard the exception escapes to the
            # per-probe wrap, which discards every finding collected so far
            # and replaces them with one "probe crashed" finding — silently
            # dropping REAL contract violations (a false-negative). Here the
            # already-yielded findings are kept, a synthetic per-check error
            # finding is appended, and sibling checks still run.
            try:
                # Applicability gate (PAPER-03 B): a check that doesn't apply
                # to this response is skipped entirely — no finding, not even a
                # passing one (e.g. an HSTS check on a plain http:// response).
                if not check.applies_to(probe, response, ctx):
                    continue
                for finding in check.run(probe, response, ctx):
                    findings.append(self._apply_severity_override(finding))
            except Exception as exc:
                check_code = getattr(type(check), "code", type(check).__name__)
                _LOGGER.warning(
                    "check %s raised on probe %s: %s",
                    check_code,
                    probe.probe_id,
                    exc,
                    exc_info=True,
                    extra={
                        "event": "apiprobe.check.exception",
                        "check": check_code,
                        "probe": probe.probe_id,
                        "fingerprint": type(exc).__name__,
                    },
                )
                findings.append(
                    Finding(
                        code="suite.probe.exception",
                        severity=Severity.CRITICAL,
                        root_cause=RootCause.SUITE_LOGIC,
                        message=(
                            f"Check {check_code!r} raised "
                            f"{type(exc).__name__}: {exc}. Findings it had "
                            f"already produced are retained; remaining checks "
                            f"still ran."
                        ),
                        evidence={
                            "check_code": check_code,
                            "exception_type": type(exc).__name__,
                        },
                        remediation=(
                            "A check raised unexpectedly. Inspect the WARNING "
                            "log line (with exc_info) for the traceback. This "
                            "is a bug in the check, not (necessarily) a target "
                            "API fault. (RA-01.)"
                        ),
                        probe_id=probe.probe_id,
                    )
                )
        return findings, False, response

    def _apply_severity_override(self, finding: Finding) -> Finding:
        """Apply ``severity_overrides`` per-finding-code.

        DESIGN.md §3.2.2: suites can promote/demote any check's
        default severity. The audit-trail log line below (RA-03)
        records every actual override at WARNING level so a future
        report consumer can correlate report-severity with the
        original check-emitted severity.
        """
        override = self.severity_overrides.get(finding.code)
        if override is None or override is finding.severity:
            return finding
        # RA-03: audit-log every applied severity change. WARNING
        # level so a SIEM can filter to the override stream; the
        # `audit=true` extra matches mgf-common's LG-04 audit-event
        # convention. A suite that demotes CRITICAL → INFO leaves
        # a paper trail even though the report itself shows the
        # demoted severity.
        _LOGGER.warning(
            "severity override applied: code=%s original=%s overridden=%s",
            finding.code,
            finding.severity.value,
            override.value,
            extra={
                "audit": True,
                "event": "mgf_apiprobe.severity_override",
                "finding_code": finding.code,
                "severity_original": finding.severity.value,
                "severity_overridden": override.value,
                "probe_id": finding.probe_id or "",
            },
        )
        # Frozen dataclass — return a new instance with severity replaced.
        from dataclasses import replace

        return replace(finding, severity=override)


def _passes_tag_filter(
    probe: Probe,
    only: frozenset[str] | None,
    exclude: frozenset[str] | None,
) -> bool:
    if exclude and any(t in exclude for t in probe.tags):
        return False
    if only is None:
        return True
    return any(t in only for t in probe.tags)


def _assert_unique_probe_ids(probes: tuple[Probe, ...]) -> None:
    """RA-02: raise SuiteLogicError on duplicate `probe.probe_id`.

    Probes without an explicit id (`probe_id=None`) are skipped —
    the Suite auto-generates a fallback id at run time, so the
    None case isn't a collision the operator can do anything about.
    """
    seen: set[str] = set()
    for p in probes:
        if p.probe_id is None:
            continue
        if p.probe_id in seen:
            raise SuiteLogicError(
                f"probe_id collision: two probes share id "
                f"{p.probe_id!r}. Reports key findings by probe_id; "
                f"duplicates produce ambiguous output downstream. "
                f"Rename one of the probes."
            )
        seen.add(p.probe_id)


def _assert_unique_check_codes(checks: tuple[Check, ...]) -> None:
    """RA-02: raise SuiteLogicError on duplicate `check.code`.

    Two checks sharing a code produce findings whose `code` field
    points at the wrong check; the severity_override map indexes
    by code so the override applies to both unexpectedly. Catching
    at suite-build time turns a silent confusion into a loud
    configuration error.
    """
    seen: set[str] = set()
    for c in checks:
        if c.code in seen:
            raise SuiteLogicError(
                f"check code collision: two checks share code "
                f"{c.code!r}. Findings + severity overrides index "
                f"by code; duplicates produce ambiguous behaviour. "
                f"Rename one of the checks."
            )
        seen.add(c.code)


# ---------------------------------------------------------------------------
# PAPER-39: dependency-graph validation + topological order
# ---------------------------------------------------------------------------


def _validate_and_topologically_sort(
    probes: tuple[Probe, ...],
) -> tuple[Probe, ...]:
    """Validate dependencies + return probes in topological order.

    Two invariants enforced (raising :class:`SuiteLogicError`):

      1. Every ``probe.depends_on_probes`` entry must reference a
         ``probe_id`` of another probe in this Suite. Dangling
         references would crash at run time when the runner tried
         to look up a non-existent upstream response.
      2. The dependency graph must be acyclic. A cycle means no
         topological order exists; running anyway would risk
         infinite-loops or "missing variable" errors that obscure
         the real cause.

    Returns the probes in topological order: probes with no
    unresolved dependencies first, then their dependents, etc.
    For probes with no ``depends_on`` declaration, the order
    preserves the input order. This matters when no probe has
    deps — the topological order matches the user-provided one,
    so existing test assertions about probe ordering still hold.

    Uses Kahn's algorithm (BFS-based topological sort) for O(V+E)
    cycle detection. PAPER-39.
    """
    # Build probe_id → Probe map. Probes without an id get a
    # generated key — they can't be depended-on, but they can have
    # deps (uncommon but legal).
    by_id: dict[str, Probe] = {}
    for p in probes:
        if p.probe_id is not None:
            by_id[p.probe_id] = p

    # Validate every dep reference resolves.
    for p in probes:
        for dep in p.depends_on_probes:
            if dep.probe_id not in by_id:
                raise SuiteLogicError(
                    f"probe {p.probe_id!r} depends on probe id "
                    f"{dep.probe_id!r} but no probe in this suite "
                    f"declares that id. Check the spelling of the "
                    f".depends_on() argument; available probe ids: "
                    f"{sorted(by_id.keys())!r}.",
                )

    # Kahn's algorithm. in_degree counts unresolved deps; ready
    # holds probes with zero unresolved deps; output is the topo
    # order being built.
    in_degree: dict[int, int] = {id(p): len(p.depends_on_probes) for p in probes}
    # Adjacency: upstream_probe_id → list of (downstream) Probes.
    adjacency: dict[str, list[Probe]] = {pid: [] for pid in by_id}
    for p in probes:
        for dep in p.depends_on_probes:
            adjacency[dep.probe_id].append(p)

    # Preserve input order for ready probes (so the no-deps case
    # produces the same order as before PAPER-39).
    ready: list[Probe] = [p for p in probes if in_degree[id(p)] == 0]
    output: list[Probe] = []
    while ready:
        current = ready.pop(0)
        output.append(current)
        if current.probe_id is None:
            continue
        for downstream in adjacency.get(current.probe_id, ()):
            in_degree[id(downstream)] -= 1
            if in_degree[id(downstream)] == 0:
                ready.append(downstream)

    if len(output) != len(probes):
        # Some probes never reached zero deps — a cycle exists.
        # Build a helpful error message naming the probes in the
        # cycle (those with in_degree > 0).
        cycle_members = sorted(p.probe_id or "<unnamed>" for p in probes if in_degree[id(p)] > 0)
        raise SuiteLogicError(
            f"cycle detected in probe dependency graph. Probes "
            f"involved: {cycle_members!r}. A cycle means no "
            f"topological order exists; the runner would either "
            f"infinite-loop or surface confusing 'missing "
            f"variable' errors. Break the cycle by removing one "
            f"of the .depends_on() declarations."
        )

    return tuple(output)


# ---------------------------------------------------------------------------
# PAPER-39: per-probe resolution + extraction
# ---------------------------------------------------------------------------


class _ExtractionError(Exception):
    """Raised when a probe's depends_on extract fails at run time.

    Caught by :meth:`Suite._run_topological` to synthesise a
    consumer-visible :class:`Finding` and cascade-skip downstream
    dependents. Carries the offending upstream probe id, the
    JSONPath expression that failed, the variable name it was
    bound to, and the cause summary (one of "no-such-probe",
    "body-not-json", "jsonpath-no-match", "jsonpath-parse-error").
    """

    def __init__(
        self,
        *,
        upstream_probe_id: str,
        var_name: str,
        jsonpath_expr: str,
        cause: str,
    ) -> None:
        self.upstream_probe_id = upstream_probe_id
        self.var_name = var_name
        self.jsonpath_expr = jsonpath_expr
        self.cause = cause
        super().__init__(
            f"extraction failed: probe={upstream_probe_id!r} "
            f"var={var_name!r} expr={jsonpath_expr!r} cause={cause}"
        )


def _extract_into_context(
    probe: Probe,
    ctx: Context,
    response_cache: dict[str, Response],
) -> Context:
    """Evaluate each of ``probe``'s dependencies and thread values into Context.

    For every :class:`ProbeDependency` on ``probe``, looks up the
    cached upstream response, parses its body as JSON, evaluates
    each (var_name, jsonpath_expr) pair, and adds the result to
    a new Context. Returns the updated Context (the input is
    unchanged — Context is frozen).

    Raises :class:`_ExtractionError` if the upstream response
    isn't cached, the body isn't JSON, the JSONPath expression
    doesn't parse, or the expression finds no match in the body.

    PAPER-39.
    """
    from jsonpath_ng import parse as _jsonpath_parse
    from jsonpath_ng.exceptions import JsonPathParserError

    new_ctx = ctx
    for dep in probe.depends_on_probes:
        upstream = response_cache.get(dep.probe_id)
        if upstream is None:
            raise _ExtractionError(
                upstream_probe_id=dep.probe_id,
                var_name="(any)",
                jsonpath_expr="(any)",
                cause="no-such-probe",
            )

        try:
            body = upstream.json()
        except (ValueError, UnicodeDecodeError):
            raise _ExtractionError(
                upstream_probe_id=dep.probe_id,
                var_name="(any)",
                jsonpath_expr="(any)",
                cause="body-not-json",
            ) from None

        for var_name, jsonpath_expr in dep.extract:
            try:
                parsed = _jsonpath_parse(jsonpath_expr)
            except (JsonPathParserError, Exception) as exc:
                # jsonpath-ng can raise plain Exception on bad input.
                raise _ExtractionError(
                    upstream_probe_id=dep.probe_id,
                    var_name=var_name,
                    jsonpath_expr=jsonpath_expr,
                    cause=f"jsonpath-parse-error: {type(exc).__name__}",
                ) from exc

            matches = parsed.find(body)
            if not matches:
                raise _ExtractionError(
                    upstream_probe_id=dep.probe_id,
                    var_name=var_name,
                    jsonpath_expr=jsonpath_expr,
                    cause="jsonpath-no-match",
                )
            value = matches[0].value
            new_ctx = new_ctx.with_added(var_name, value)
    return new_ctx


def _resolve_probe(probe: Probe, ctx: Context) -> Probe:
    """Return a new Probe with ``{var}`` placeholders substituted.

    Substitutes path, header values, and query-param values from
    ``ctx.variables``. Header NAMES and query-param NAMES are NOT
    substituted (consumers identifying headers by stable name
    shouldn't have to opt in to per-call header renaming).

    JSON body templating is deferred — JSON values can be any
    type, and string-substituting a serialised body has obvious
    edge cases (e.g., a substituted value that happens to be a
    string with quotes). Body templating lands in a follow-up.

    Raises :class:`KeyError` (propagated from
    :func:`resolve_template`) if any placeholder references a
    variable that isn't in the Context.

    PAPER-39.
    """
    # The fast path (no template references anywhere in path /
    # headers / queries) returns the original Probe object — no
    # allocation cost.
    has_path_template = "{" in probe.path
    has_header_template = any("{" in v for _k, v in probe.headers)
    has_query_template = any("{" in v for _k, v in probe.query_params)
    if not (has_path_template or has_header_template or has_query_template):
        return probe

    new_path = resolve_template(probe.path, ctx)
    new_headers = tuple((k, resolve_template(v, ctx)) for k, v in probe.headers)
    new_queries = tuple((k, resolve_template(v, ctx)) for k, v in probe.query_params)
    return dataclasses.replace(
        probe,
        path=new_path,
        headers=new_headers,
        query_params=new_queries,
    )


def _make_cascade_skip_finding(probe: Probe, upstream_id: str) -> Finding:
    """Synthesise a `suite.deps.skipped_due_to_upstream` finding."""
    return Finding(
        code="suite.deps.skipped_due_to_upstream",
        severity=Severity.CRITICAL,
        root_cause=RootCause.SUITE_LOGIC,
        message=(
            f"Probe {probe.probe_id!r} skipped because its dependency "
            f"{upstream_id!r} failed (or was itself skipped). Running "
            f"with stale or missing template variables would produce "
            f"a confusing 404 / 400 against the provider; the suite "
            f"refuses."
        ),
        evidence={
            "probe_id": probe.probe_id or "",
            "failed_upstream": upstream_id,
        },
        remediation=(
            "Inspect the upstream probe's finding(s) to see why it "
            "failed; this probe will run once the upstream is healthy."
        ),
        probe_id=probe.probe_id,
    )


def _make_extraction_failed_finding(
    probe: Probe,
    exc: _ExtractionError,
) -> Finding:
    """Synthesise a `suite.deps.extraction_failed` finding."""
    return Finding(
        code="suite.deps.extraction_failed",
        severity=Severity.CRITICAL,
        root_cause=RootCause.SUITE_LOGIC,
        message=(
            f"Probe {probe.probe_id!r} could not extract dependency "
            f"data from upstream probe {exc.upstream_probe_id!r}: "
            f"{exc.cause}."
        ),
        evidence={
            "probe_id": probe.probe_id or "",
            "upstream_probe_id": exc.upstream_probe_id,
            "var_name": exc.var_name,
            "jsonpath": exc.jsonpath_expr,
            "cause": exc.cause,
        },
        remediation={
            "no-such-probe": (
                "The upstream probe's response wasn't cached — check "
                "that the upstream probe ran successfully and that "
                "its id matches the .depends_on() argument exactly."
            ),
            "body-not-json": (
                "The upstream probe's response body isn't valid JSON. "
                "JSONPath extraction requires a JSON-shaped body; "
                "consider asserting Content-Type before this probe "
                "via expect_header('Content-Type')."
            ),
            "jsonpath-no-match": (
                "The JSONPath expression matched zero nodes in the "
                "upstream response body. Check the expression against "
                "the actual response shape (run the upstream probe "
                "standalone and inspect its body via apiprobe's "
                "--debug output)."
            ),
        }.get(
            exc.cause,
            f"JSONPath parsing failed: {exc.cause}. Verify the "
            f"expression syntax against jsonpath-ng's grammar.",
        ),
        probe_id=probe.probe_id,
    )


def _make_template_unresolved_finding(
    probe: Probe,
    exc: KeyError,
    ctx: Context,
) -> Finding:
    """Synthesise a `suite.deps.template_unresolved` finding."""
    return Finding(
        code="suite.deps.template_unresolved",
        severity=Severity.CRITICAL,
        root_cause=RootCause.SUITE_LOGIC,
        message=str(exc.args[0]) if exc.args else "template unresolved",
        evidence={
            "probe_id": probe.probe_id or "",
            "available_vars": ", ".join(sorted(ctx.variables.keys())),
        },
        remediation=(
            "A probe's path or header references a {var} placeholder "
            "that no upstream probe extracted. Check that every {var} "
            "used in path / headers / queries has a matching "
            "extract={'var': '$.path'} entry in some .depends_on() "
            "declaration."
        ),
        probe_id=probe.probe_id,
    )


__all__ = ["Suite"]
