"""Security checks — TLS hygiene + secret-echo.

See: ``DESIGN.md`` §7.5.

4 checks, all with ``RootCause.PROVIDER_SECURITY``:

  - ``MissingHsts``                — HTTPS endpoint without Strict-Transport-Security
  - ``MissingContentTypeOptions``  — no ``X-Content-Type-Options: nosniff``
  - ``MissingFrameOptions``        — no ``X-Frame-Options`` AND no CSP frame-ancestors
  - ``BodyEchoesSecret``           — request Authorization header value appears in response body

The hygiene checks fire only on responses that look like they're
serving content to a browser (HTML or JSON for SPAs). API-only
responses where the headers are less load-bearing get a pass via the
opt-in design — checks are disabled per-probe via tags or via
``Suite.severity_overrides``.
"""

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


class MissingHsts(Check):
    """HTTPS endpoint without ``Strict-Transport-Security`` header."""

    code: ClassVar[str] = "security.headers.missing-hsts"
    severity_default: ClassVar[Severity] = Severity.WARN
    root_cause_default: ClassVar[RootCause] = RootCause.PROVIDER_SECURITY

    def applies_to(self, probe: Probe, response: Response, ctx: Context) -> bool:
        # HSTS is an HTTPS-only header — meaningless on a plain http:// response.
        return response.url.startswith("https://")

    def run(
        self,
        probe: Probe,
        response: Response,
        ctx: Context,
    ) -> Iterable[Finding]:
        if response.header("Strict-Transport-Security") is not None:
            return
        yield self.finding(
            probe_id=probe.probe_id,
            message="HTTPS response missing Strict-Transport-Security header.",
            evidence={"url": response.url},
            remediation=(
                "Provider should set Strict-Transport-Security on every "
                "HTTPS response. Recommended: max-age=31536000; "
                "includeSubDomains."
            ),
            threat_refs=("OWASP-API-7",),
        )


class MissingContentTypeOptions(Check):
    """Response missing ``X-Content-Type-Options: nosniff``."""

    code: ClassVar[str] = "security.headers.missing-content-type-options"
    severity_default: ClassVar[Severity] = Severity.WARN
    root_cause_default: ClassVar[RootCause] = RootCause.PROVIDER_SECURITY

    def run(
        self,
        probe: Probe,
        response: Response,
        ctx: Context,
    ) -> Iterable[Finding]:
        v = response.header("X-Content-Type-Options")
        if v is not None and v.lower() == "nosniff":
            return
        yield self.finding(
            probe_id=probe.probe_id,
            message=("Response missing 'X-Content-Type-Options: nosniff' header."),
            evidence={
                "url": response.url,
                "actual": v,
            },
            remediation=(
                "Provider should set X-Content-Type-Options: nosniff on "
                "every response to disable MIME-sniffing in browsers."
            ),
            threat_refs=("OWASP-API-7",),
        )


class MissingFrameOptions(Check):
    """Response missing ``X-Frame-Options`` AND no CSP ``frame-ancestors``."""

    code: ClassVar[str] = "security.headers.missing-frame-options"
    severity_default: ClassVar[Severity] = Severity.INFO
    root_cause_default: ClassVar[RootCause] = RootCause.PROVIDER_SECURITY

    def run(
        self,
        probe: Probe,
        response: Response,
        ctx: Context,
    ) -> Iterable[Finding]:
        if response.header("X-Frame-Options") is not None:
            return
        csp = response.header("Content-Security-Policy") or ""
        if "frame-ancestors" in csp.lower():
            return
        yield self.finding(
            probe_id=probe.probe_id,
            message=(
                "Response has neither X-Frame-Options nor Content-Security-Policy: frame-ancestors."
            ),
            evidence={"url": response.url, "csp": csp or None},
            remediation=(
                "Set Content-Security-Policy: frame-ancestors 'self' or "
                "X-Frame-Options: DENY (the former supersedes the latter "
                "in modern browsers but both are widely supported)."
            ),
            threat_refs=("OWASP-API-7",),
        )


class BodyEchoesSecret(Check):
    """Request Authorization header value appears verbatim in the response body.

    A common provider bug: error responses include the request that
    failed, leaking the bearer token / API key into logs and clients
    that print response bodies. This check is a safety net — its
    findings should be CRITICAL because they imply credentials may
    already be in shared infrastructure (request bins, log indexers).

    Notes
    -----
    **Memory model (SH-02).** To detect the leak, the check needs the
    raw Authorization credential in memory while it scans
    ``response.body`` (the substring search at
    ``secret.encode("utf-8") in response.body``). The credential's
    lifetime equals the lifetime of the ``Probe.headers`` tuple it came
    from — typically the suite-run duration, then GC. Python's str
    interning may extend that retention silently. For high-stakes
    contexts run mgf-apiprobe in a short-lived subprocess + rotate
    credentials between runs; see
    ``docs/recipes/how-to-handle-high-stakes-credentials.md``.

    A defence-in-depth refactor (hash-prefix scan instead of substring
    match) is on the v0.2+ roadmap; v0.1 keeps the verbatim search
    because the bug it catches IS a verbatim echo and a hashed search
    would miss it. The finding itself never carries the secret —
    ``evidence`` records only ``secret_length`` (SH-01's scrub
    additionally redacts any value-shaped Bearer / JWT / PEM matches).
    """

    code: ClassVar[str] = "security.body.echoes-secret"
    severity_default: ClassVar[Severity] = Severity.CRITICAL
    root_cause_default: ClassVar[RootCause] = RootCause.PROVIDER_SECURITY

    # Minimum length of an Authorization value before we look for it
    # in the body. Below this, false positives (e.g., the literal
    # string "Bearer" appearing in normal copy) drown the signal.
    _MIN_SECRET_LENGTH: ClassVar[int] = 8

    def run(
        self,
        probe: Probe,
        response: Response,
        ctx: Context,
    ) -> Iterable[Finding]:
        if not response.body:
            return
        # Find any Authorization-style header the probe sent.
        for name, value in probe.headers:
            if name.lower() != "authorization":
                continue
            # Strip the scheme prefix (Bearer / Basic / etc.) and use
            # the credential portion only — the scheme word IS expected
            # to appear in error messages.
            secret = value.split(" ", 1)[-1]
            if len(secret) < self._MIN_SECRET_LENGTH:
                continue
            if secret.encode("utf-8") in response.body:
                yield self.finding(
                    probe_id=probe.probe_id,
                    message=(
                        "Response body contains the verbatim Authorization "
                        "credential the probe sent. Treat the credential as "
                        "compromised."
                    ),
                    evidence={
                        "url": response.url,
                        "header_sent": name,
                        # Do NOT include the secret itself in evidence;
                        # the finding is about the LEAK and the evidence
                        # would compound it.
                        "secret_length": len(secret),
                    },
                    remediation=(
                        "Rotate the credential immediately. Investigate "
                        "the provider's error-response shape; common "
                        "culprit is dumping the original request payload "
                        "in 4xx errors."
                    ),
                    threat_refs=("CWE-200", "OWASP-API-2"),
                )


class MissingCsp(Check):
    """Response missing ``Content-Security-Policy`` header (P-1, v0.5.0).

    Closes the deferred SH-05 federation gap. CSP is the single
    most-impactful browser-security header for XSS / data-exfil
    mitigation; absent CSP responses get a WARN with the OWASP-
    Secure-Headers reference.

    Fires only on responses that look browser-shaped (HTML or
    JSON for SPAs) — backend-only JSON APIs get a pass by
    default. Consumers wanting "every response has CSP"
    discipline disable the body-type filter via ``severity_overrides``.
    """

    code: ClassVar[str] = "security.headers.missing-csp"
    severity_default: ClassVar[Severity] = Severity.WARN
    root_cause_default: ClassVar[RootCause] = RootCause.PROVIDER_SECURITY

    _BROWSER_CONTENT_TYPES: ClassVar[frozenset[str]] = frozenset(
        {
            "text/html",
            "application/xhtml+xml",
        }
    )

    def run(
        self,
        probe: Probe,
        response: Response,
        ctx: Context,
    ) -> Iterable[Finding]:
        content_type = (response.header("Content-Type") or "").split(";")[0].strip().lower()
        if content_type not in self._BROWSER_CONTENT_TYPES:
            return
        # Both header forms recognised; case-insensitive.
        csp = response.header("Content-Security-Policy")
        csp_report = response.header("Content-Security-Policy-Report-Only")
        if csp is not None or csp_report is not None:
            return
        yield self.finding(
            probe_id=probe.probe_id,
            message=(
                "Browser-shaped response missing Content-Security-Policy "
                "header (and no report-only variant)."
            ),
            evidence={"url": response.url, "content_type": content_type},
            remediation=(
                "Set a Content-Security-Policy header. A reasonable "
                "starting policy: \"default-src 'self'; script-src "
                "'self'; object-src 'none'; frame-ancestors 'none'\". "
                "Tighten from there per the OWASP Secure Headers "
                "project. Use Content-Security-Policy-Report-Only "
                "first to measure breakage."
            ),
            threat_refs=("OWASP-API-7", "CWE-693"),
        )


class MissingReferrerPolicy(Check):
    """Response missing ``Referrer-Policy`` header (P-1, v0.5.0).

    No Referrer-Policy = browsers default to ``strict-origin-when-
    cross-origin`` in modern engines, but legacy browsers + some
    embed surfaces leak the full URL (including query string) to
    every third-party host the page references. Setting the
    header explicitly is best-practice + measurably reduces
    user-tracking surface.
    """

    code: ClassVar[str] = "security.headers.missing-referrer-policy"
    severity_default: ClassVar[Severity] = Severity.INFO
    root_cause_default: ClassVar[RootCause] = RootCause.PROVIDER_SECURITY

    def run(
        self,
        probe: Probe,
        response: Response,
        ctx: Context,
    ) -> Iterable[Finding]:
        if response.header("Referrer-Policy") is not None:
            return
        yield self.finding(
            probe_id=probe.probe_id,
            message="Response missing Referrer-Policy header.",
            evidence={"url": response.url},
            remediation=(
                "Set Referrer-Policy. Recommended: 'strict-origin-when-"
                "cross-origin' (matches modern browser default but "
                "covers legacy engines too). For high-privacy "
                "endpoints: 'no-referrer'."
            ),
            threat_refs=("OWASP-API-7",),
        )


class MissingPermissionsPolicy(Check):
    """Response missing ``Permissions-Policy`` header (P-1, v0.5.0).

    The replacement for the deprecated ``Feature-Policy``.
    Lets an origin declare which browser features (camera,
    microphone, geolocation, payment APIs, ...) are available to
    embedded contexts. Absent = browser-default permissive — fine
    for many APIs but a missed defence-in-depth for browser-
    serving endpoints.
    """

    code: ClassVar[str] = "security.headers.missing-permissions-policy"
    severity_default: ClassVar[Severity] = Severity.INFO
    root_cause_default: ClassVar[RootCause] = RootCause.PROVIDER_SECURITY

    _BROWSER_CONTENT_TYPES: ClassVar[frozenset[str]] = frozenset(
        {
            "text/html",
            "application/xhtml+xml",
        }
    )

    def run(
        self,
        probe: Probe,
        response: Response,
        ctx: Context,
    ) -> Iterable[Finding]:
        content_type = (response.header("Content-Type") or "").split(";")[0].strip().lower()
        if content_type not in self._BROWSER_CONTENT_TYPES:
            return
        if response.header("Permissions-Policy") is not None:
            return
        yield self.finding(
            probe_id=probe.probe_id,
            message=("Browser-shaped response missing Permissions-Policy header."),
            evidence={"url": response.url, "content_type": content_type},
            remediation=(
                "Set Permissions-Policy. A conservative starting point: "
                "'camera=(), microphone=(), geolocation=(), payment=()' "
                "(deny everything by default; expand per page that "
                "needs the feature)."
            ),
            threat_refs=("OWASP-API-7",),
        )


class InsecureCookieAttributes(Check):
    """Set-Cookie missing ``Secure`` / ``HttpOnly`` / ``SameSite`` (P-1, v0.5.0).

    Per cookie inspected:
      - Missing ``Secure`` on an HTTPS response → cookie sent
        over future plaintext (downgrade) requests.
      - Missing ``HttpOnly`` → cookie readable by JavaScript
        (XSS-stealable).
      - Missing ``SameSite=Strict/Lax`` → cookie sent on
        cross-site requests (CSRF risk).

    Inspects every ``Set-Cookie`` header; a single response can
    yield multiple findings (one per cookie missing at least one
    attribute). The finding's ``evidence`` lists which
    attributes were missing.
    """

    code: ClassVar[str] = "security.cookies.insecure-attributes"
    severity_default: ClassVar[Severity] = Severity.WARN
    root_cause_default: ClassVar[RootCause] = RootCause.PROVIDER_SECURITY

    def run(
        self,
        probe: Probe,
        response: Response,
        ctx: Context,
    ) -> Iterable[Finding]:
        # response.headers might surface Set-Cookie as a single
        # comma-joined string or as multiple entries depending on
        # the HTTP library. mgf-http's Response normalises to the
        # raw header dict; for now we look at the FIRST Set-Cookie
        # header. (A future iteration could expose all of them.)
        raw = response.header("Set-Cookie")
        if not raw:
            return

        is_https = response.url.startswith("https://")
        for cookie_str in self._split_cookies(raw):
            missing: list[str] = []
            lower = cookie_str.lower()
            if is_https and "secure" not in lower:
                missing.append("Secure")
            if "httponly" not in lower:
                missing.append("HttpOnly")
            if "samesite=" not in lower:
                missing.append("SameSite")
            if not missing:
                continue

            # Extract the cookie name for evidence (everything
            # up to the first '=').
            cookie_name = cookie_str.split("=", 1)[0].strip()
            yield self.finding(
                probe_id=probe.probe_id,
                message=(f"Set-Cookie {cookie_name!r} missing attributes: {', '.join(missing)}."),
                evidence={
                    "url": response.url,
                    "cookie_name": cookie_name,
                    "missing_attributes": tuple(missing),
                },
                remediation=(
                    f"Add the missing attributes to the Set-Cookie "
                    f"directive for {cookie_name!r}. Production-grade "
                    f"default: Secure; HttpOnly; SameSite=Lax. "
                    f"(SameSite=Strict for high-stakes session cookies.)"
                ),
                threat_refs=("OWASP-API-7", "CWE-1004", "CWE-1275"),
            )

    @staticmethod
    def _split_cookies(raw: str) -> list[str]:
        # Set-Cookie values cannot be safely split on bare commas
        # (Expires=Sun, 06 Nov 2024... contains a comma). RFC 6265
        # §3 says headers are folded with comma-space, but the
        # Expires value uses RFC 7231 date format with a "Sun,"
        # prefix. Conservative heuristic: split on ", " ONLY when
        # the next token contains '=' (suggesting a new
        # cookie-pair). Most modern stacks don't fold Set-Cookies
        # — they're sent as multiple headers — so a single value
        # is the common case.
        parts: list[str] = []
        for chunk in raw.split(", "):
            if parts and "=" not in chunk.split(";", 1)[0]:
                # Continuation of the previous cookie's Expires.
                parts[-1] += ", " + chunk
            else:
                parts.append(chunk)
        return parts


__all__ = [
    "BodyEchoesSecret",
    "InsecureCookieAttributes",
    "MissingContentTypeOptions",
    "MissingCsp",
    "MissingFrameOptions",
    "MissingHsts",
    "MissingPermissionsPolicy",
    "MissingReferrerPolicy",
]
