"""Schema checks — body shape + JSON parseability.

See: ``DESIGN.md`` §7.1.

3 checks, all keyed off ``probe.expected_schema``:

  - ``SchemaShapeMismatch`` — the schema's ``validate()`` returns issues
  - ``BodyInvalidJson``      — body advertises JSON but doesn't parse
  - ``BodyEmptyOnSuccess``   — schema expected but 2xx body is empty
"""

from __future__ import annotations

from typing import TYPE_CHECKING, ClassVar

from mgf.apiprobe.core.check import Check
from mgf.apiprobe.core.finding import RootCause, Severity

if TYPE_CHECKING:
    from collections.abc import Iterable

    from mgf.apiprobe.core.context import Context
    from mgf.apiprobe.core.finding import Finding
    from mgf.apiprobe.core.probe import Probe
    from mgf.apiprobe.core.response import Response


def _is_json_response(response: Response) -> bool:
    """Heuristic: response has a JSON-shaped Content-Type."""
    ct = response.header("Content-Type")
    return ct is not None and "json" in ct.lower()


class SchemaShapeMismatch(Check):
    """Body fails the probe's :class:`SchemaSource` validation."""

    code: ClassVar[str] = "schema.body.shape-mismatch"
    severity_default: ClassVar[Severity] = Severity.CRITICAL
    root_cause_default: ClassVar[RootCause] = RootCause.PROVIDER_CONTRACT

    def run(
        self,
        probe: Probe,
        response: Response,
        ctx: Context,
    ) -> Iterable[Finding]:
        if probe.expected_schema is None or not response.body:
            return
        if not _is_json_response(response):
            return  # ContentTypeJsonExpected covers the wrong-CT case.
        try:
            payload = response.json()
        except ValueError:
            return  # BodyInvalidJson covers this case.
        issues = probe.expected_schema.validate(payload)
        if not issues:
            return
        yield self.finding(
            probe_id=probe.probe_id,
            message=(
                f"Response body fails {probe.expected_schema.describe()}: "
                f"{len(issues)} issue{'s' if len(issues) != 1 else ''}."
            ),
            evidence={
                "schema": probe.expected_schema.describe(),
                "issues": [
                    {
                        "path": i.path,
                        "message": i.message,
                        "expected": i.expected,
                        "actual": i.actual,
                    }
                    for i in issues
                ],
            },
            remediation=(
                "Inspect each issue's path + expected/actual fields. "
                "Either the provider has drifted (find owner) or the "
                "schema is stale (update the model)."
            ),
        )


class BodyInvalidJson(Check):
    """Response advertises JSON but body doesn't parse."""

    code: ClassVar[str] = "schema.body.invalid-json"
    severity_default: ClassVar[Severity] = Severity.CRITICAL
    root_cause_default: ClassVar[RootCause] = RootCause.PROVIDER_CONTRACT

    def run(
        self,
        probe: Probe,
        response: Response,
        ctx: Context,
    ) -> Iterable[Finding]:
        if not response.body or not _is_json_response(response):
            return
        try:
            response.json()
        except ValueError as exc:
            # Truncate body for evidence — large unparseable blobs blow up reports.
            snippet = response.body[:256]
            yield self.finding(
                probe_id=probe.probe_id,
                message=(f"Response Content-Type advertises JSON but body does not parse: {exc}."),
                evidence={
                    "content_type": response.header("Content-Type"),
                    "body_bytes": len(response.body),
                    "body_snippet": snippet.decode("utf-8", errors="replace"),
                    "parse_error": str(exc),
                },
                remediation=(
                    "Provider returned malformed JSON. Common causes: "
                    "trailing commas, unescaped quotes, BOM bytes, or a "
                    "stack trace dumped where JSON should be."
                ),
            )


class BodyEmptyOnSuccess(Check):
    """Schema expected but 2xx response body is empty."""

    code: ClassVar[str] = "schema.body.empty-on-success"
    severity_default: ClassVar[Severity] = Severity.WARN
    root_cause_default: ClassVar[RootCause] = RootCause.PROVIDER_CONTRACT

    def run(
        self,
        probe: Probe,
        response: Response,
        ctx: Context,
    ) -> Iterable[Finding]:
        if probe.expected_schema is None or not response.is_success:
            return
        if response.body:
            return
        # 204 No Content is a legitimate empty 2xx; skip.
        if response.status == 204:
            return
        yield self.finding(
            probe_id=probe.probe_id,
            message=(
                f"Probe expected a {probe.expected_schema.describe()} body but "
                f"response was empty (status {response.status})."
            ),
            evidence={
                "status": response.status,
                "schema": probe.expected_schema.describe(),
            },
            remediation=(
                "Provider returned an empty body where a schema-conformant "
                "body was expected. If the operation should return 204, "
                "update the probe's expect_status(...) to include 204."
            ),
        )


__all__ = [
    "BodyEmptyOnSuccess",
    "BodyInvalidJson",
    "SchemaShapeMismatch",
]
