"""``Finding`` — the structured result of a check failure.

See: ``DESIGN.md`` §3.2 (the Finding shape table) and §3.2.1 (the
``RootCause`` taxonomy).

A check that detects an issue yields one or more ``Finding`` objects.
The shape is deliberately rigid: every public field is part of the
contract. ``code`` in particular is a public identifier — once a check
publishes its code, renaming or removing it is a breaking change
(rule SP-AP-03 in ``docs/standards/SCOPE.md``).
"""

from __future__ import annotations

import copy
from dataclasses import dataclass, field
from enum import StrEnum
from typing import Any
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit

from mgf.common.observability.redaction import Redactor

# Module-level Redactor instance. The default policy covers Bearer
# tokens, JWTs, PEM blocks, and the standard secret key-names
# (Authorization, X-Api-Key, …). A single shared instance is safe —
# Redactor is a frozen dataclass with no per-call state.
_EVIDENCE_REDACTOR = Redactor.default()


def _mask_url(value: str) -> str:
    """Mask credentials in a URL string (query-param VALUES + userinfo).

    SH-01 complement to the Redactor: a probe authenticated via
    ``ApiKeyAuth(query=...)`` puts the key in the request URL, and checks
    capture ``response.url`` into evidence. The Redactor only catches
    secret-SHAPED values (Bearer/JWT/sk-…) and secret KEY-names — a
    custom-format key like ``?api_key=plainvalue`` matches neither and
    would reach the persisted report verbatim. Mask query-param values
    (keys kept for debuggability) and drop any userinfo. Non-URL strings
    and unparseable input pass through unchanged.
    """
    if "://" not in value:
        return value
    try:
        parts = urlsplit(value)
    except ValueError:
        return value
    if not parts.query and "@" not in parts.netloc:
        return value
    netloc = parts.netloc.rsplit("@", 1)[-1] if "@" in parts.netloc else parts.netloc
    query = (
        # "REDACTED" (not "<redacted>") — urlencode would percent-encode
        # the angle brackets into noise; a clean token reads better in a URL.
        urlencode([(k, "REDACTED") for k, _ in parse_qsl(parts.query, keep_blank_values=True)])
        if parts.query
        else parts.query
    )
    return urlunsplit((parts.scheme, netloc, parts.path, query, parts.fragment))


def _mask_urls_in_place(node: Any) -> Any:
    """Recursively mask URL credentials in string values of a dict/list."""
    if isinstance(node, dict):
        return {k: _mask_urls_in_place(v) for k, v in node.items()}
    if isinstance(node, list):
        return [_mask_urls_in_place(v) for v in node]
    if isinstance(node, str):
        return _mask_url(node)
    return node


def _scrub_evidence(payload: Any) -> Any:
    """Return a deep copy of ``payload`` with sensitive values redacted.

    Two passes: (1) mask URL credentials (query-param values + userinfo)
    that the pattern-based Redactor can't recognise; (2) apply
    mgf-common's standard Redactor (Bearer/JWT/secret key-names, etc.).
    Non-dict / non-list scalar payloads return unchanged.

    SH-01: Finding `evidence` is a free-form dict that checks populate
    with response content, request headers, URL fragments, etc. Without
    this scrub, a check that captures an `Authorization` header value
    into evidence (intentionally for `BodyEchoesSecret`, or accidentally
    for a future check) writes the raw secret to the report — and a
    query-string API key in a captured URL would slip past the Redactor.
    Consumer-side log + report aggregators then see the raw secret.

    Called from `Finding.__post_init__`. Idempotent — running it twice
    on an already-redacted dict is a no-op.
    """
    if isinstance(payload, dict):
        scrubbed: dict[str, Any] = _mask_urls_in_place(copy.deepcopy(payload))
        _EVIDENCE_REDACTOR.redact(scrubbed)
        return scrubbed
    return payload


class Severity(StrEnum):
    """Finding severity. CI exit policy keys off the highest severity.

    Default policy: ``critical`` fails the suite (exit non-zero),
    ``warn`` is reported, ``info`` is reported quietly. Suites can
    promote / demote per-code via ``Suite.severity_overrides``
    (DESIGN.md §3.2.2). Promotion is logged at INFO with
    ``audit=true`` per the DESIGN — making a thing fail more often
    deserves a paper trail.

    String-valued so ``json.dumps`` produces stable values; sorting
    callers should rely on the explicit ordering helper, not enum
    member order.
    """

    INFO = "info"
    WARN = "warn"
    CRITICAL = "critical"


# Total ordering on Severity for "≥ X" comparisons (e.g., the suite's
# fail-on-severity threshold). Defined out-of-class to avoid mypy's
# warnings on str-Enum comparison overloads.
_SEVERITY_ORDER: dict[Severity, int] = {
    Severity.INFO: 0,
    Severity.WARN: 1,
    Severity.CRITICAL: 2,
}


def severity_at_least(actual: Severity, threshold: Severity) -> bool:
    """Return True if ``actual`` is at or above ``threshold``.

    Used by the suite-exit-code translator: if any finding has
    ``severity_at_least(f.severity, settings.fail_on_severity)``, the
    suite exits non-zero.
    """
    return _SEVERITY_ORDER[actual] >= _SEVERITY_ORDER[threshold]


class RootCause(StrEnum):
    """Failure attribution per DESIGN.md §3.2.1.

    Every Finding carries one. Dashboards aggregate by RootCause to
    answer questions like "where are our failures coming from this
    week" — provider quality, our suite's bugs, network flakes,
    drift, etc.

    A check declares a default RootCause; an individual finding can
    override (e.g. a generic latency check defaults to
    ``PROVIDER_QUALITY`` but escalates to ``NETWORK`` if latency was
    zero / negative — implying the response never arrived).
    """

    NETWORK = "network"
    """Connection refused, DNS, TLS handshake, timeout. The probe never got a real response."""

    AUTH = "auth"
    """401/403 in a flow that should be authenticated; token expiry, scope mismatch."""

    RATE_LIMIT = "rate_limit"
    """429 / Retry-After. Suite was throttled."""

    PROVIDER_5XX = "provider_5xx"
    """Provider returned 5xx. Their fault until proven otherwise."""

    PROVIDER_CONTRACT = "provider_contract"
    """4xx/2xx with shape violations — provider's schema lies or has drifted."""

    PROVIDER_QUALITY = "provider_quality"
    """Latency, headers-case, missing trace id — non-functional."""

    PROVIDER_SECURITY = "provider_security"
    """Secret echo, weak headers, error leaks."""

    PROVIDER_OBSERVABILITY = "provider_observability"
    """Missing request id, missing rate-limit headers."""

    CLIENT_REQUEST_SHAPE = "client_request_shape"
    """Our probe sent the wrong shape (mutation tests find these on purpose)."""

    CASSETTE_DRIFT = "cassette_drift"
    """Replay differs from cassette. (Surfaces in v0.3+ when replay lands.)"""

    SPEC_DRIFT = "spec_drift"
    """Spec disagrees with reality. (Surfaces in v0.4+ when drift detection lands.)"""

    SUITE_LOGIC = "suite_logic"
    """Suite itself has a bug — extraction failed, scenario state corrupt."""

    INFRASTRUCTURE = "infrastructure"
    """Harness/runtime issue (plugin load failure, transport error not attributable to provider)."""

    UNKNOWN = "unknown"
    """Use sparingly; the harness logs at WARN when this is emitted so the taxonomy can be refined."""


@dataclass(frozen=True, slots=True)
class Finding:
    """One structured finding. Frozen — checks emit; consumers read.

    All public fields are part of the v0.1 contract. ``since`` is set
    by the replay subsystem (v0.3+) when the finding is correlated to
    a cassette baseline; v0.1 always leaves it ``None``.

    Minimal example::

        Finding(
            code="contract.status.unexpected",
            severity=Severity.CRITICAL,
            root_cause=RootCause.PROVIDER_CONTRACT,
            message="Expected status 200, got 500.",
            evidence={"expected": 200, "actual": 500},
            remediation="Check provider service health; retry after 30s.",
        )
    """

    code: str
    """Stable dotted lowercase identifier. Public contract per SP-AP-03."""

    severity: Severity
    """info / warn / critical — see :class:`Severity`."""

    root_cause: RootCause
    """Failure attribution — see :class:`RootCause`."""

    message: str
    """Human-readable, one sentence, ends with a period (rule EH-09)."""

    evidence: dict[str, Any] = field(default_factory=dict)
    """Machine-readable JSON-able dict. Status, headers, body diff, expected/actual."""

    remediation: str = ""
    """Actionable next step. May be empty for findings that speak for themselves."""

    probe_id: str | None = None
    """Identifier of the producing probe. Set by the suite when it dispatches checks."""

    threat_refs: tuple[str, ...] = ()
    """Optional mappings to OWASP API Top 10, CWE IDs, etc."""

    tags: tuple[str, ...] = ()
    """Optional tags inherited from the probe + check (read/write, public/private, ...)."""

    since: str | None = None
    """First seen in cassette ID, when known. Set by the replay subsystem (v0.3+)."""

    def __post_init__(self) -> None:
        """SH-01: scrub `evidence` through mgf-common's Redactor.

        Replaces any secret-shaped values (Bearer tokens, JWTs, PEM
        blocks) AND values under secret-shaped keys (Authorization,
        X-Api-Key, password, …) with `<redacted>`. The Finding stays
        useful for debugging — code, severity, root_cause, message,
        and non-sensitive evidence fields are untouched — while the
        report itself can be safely shipped to log aggregators.

        Frozen dataclass: we use `object.__setattr__` to update
        evidence after construction. Idempotent — running twice on
        an already-scrubbed dict is a no-op.
        """
        scrubbed = _scrub_evidence(self.evidence)
        if scrubbed is not self.evidence:
            object.__setattr__(self, "evidence", scrubbed)


__all__ = [
    "Finding",
    "RootCause",
    "Severity",
    "severity_at_least",
]
