"""Tests for ``mgf.apiprobe.checks.security``."""

from __future__ import annotations

from tests.unit.checks.conftest import empty_ctx, make_response

from mgf.apiprobe.checks.security import (
    BodyEchoesSecret,
    InsecureCookieAttributes,
    MissingContentTypeOptions,
    MissingCsp,
    MissingFrameOptions,
    MissingHsts,
    MissingPermissionsPolicy,
    MissingReferrerPolicy,
)
from mgf.apiprobe.core.finding import RootCause, Severity
from mgf.apiprobe.core.probe import Probe


class TestMissingHsts:
    def test_silent_on_http(self) -> None:
        probe = Probe.get("http://x/y").id("p1")
        resp = make_response(url="http://x/y")
        findings = list(MissingHsts().run(probe, resp, empty_ctx()))
        assert findings == []

    def test_silent_when_hsts_present(self) -> None:
        probe = Probe.get("https://x/y").id("p1")
        resp = make_response(
            url="https://x/y",
            headers={"Strict-Transport-Security": "max-age=31536000"},
        )
        findings = list(MissingHsts().run(probe, resp, empty_ctx()))
        assert findings == []

    def test_emits_on_https_without_hsts(self) -> None:
        probe = Probe.get("https://x/y").id("p1")
        resp = make_response(url="https://x/y")
        findings = list(MissingHsts().run(probe, resp, empty_ctx()))
        assert len(findings) == 1
        f = findings[0]
        assert f.code == "security.headers.missing-hsts"
        assert f.severity is Severity.WARN
        assert f.root_cause is RootCause.PROVIDER_SECURITY
        assert "OWASP-API-7" in f.threat_refs


class TestMissingContentTypeOptions:
    def test_silent_when_set_to_nosniff(self) -> None:
        probe = Probe.get("https://x/y").id("p1")
        resp = make_response(headers={"X-Content-Type-Options": "nosniff"})
        findings = list(MissingContentTypeOptions().run(probe, resp, empty_ctx()))
        assert findings == []

    def test_silent_case_insensitive(self) -> None:
        probe = Probe.get("https://x/y").id("p1")
        resp = make_response(headers={"X-Content-Type-Options": "NOSNIFF"})
        findings = list(MissingContentTypeOptions().run(probe, resp, empty_ctx()))
        assert findings == []

    def test_emits_when_missing(self) -> None:
        probe = Probe.get("https://x/y").id("p1")
        findings = list(MissingContentTypeOptions().run(probe, make_response(), empty_ctx()))
        assert len(findings) == 1

    def test_emits_when_other_value(self) -> None:
        probe = Probe.get("https://x/y").id("p1")
        # X-Content-Type-Options: anything-other-than-nosniff is treated
        # as missing — only `nosniff` is meaningful per the spec.
        resp = make_response(headers={"X-Content-Type-Options": "wrong"})
        findings = list(MissingContentTypeOptions().run(probe, resp, empty_ctx()))
        assert len(findings) == 1


class TestMissingFrameOptions:
    def test_silent_when_xfo_set(self) -> None:
        probe = Probe.get("https://x/y").id("p1")
        resp = make_response(headers={"X-Frame-Options": "DENY"})
        findings = list(MissingFrameOptions().run(probe, resp, empty_ctx()))
        assert findings == []

    def test_silent_when_csp_frame_ancestors(self) -> None:
        probe = Probe.get("https://x/y").id("p1")
        resp = make_response(
            headers={"Content-Security-Policy": "default-src 'self'; frame-ancestors 'none'"}
        )
        findings = list(MissingFrameOptions().run(probe, resp, empty_ctx()))
        assert findings == []

    def test_emits_when_neither_present(self) -> None:
        probe = Probe.get("https://x/y").id("p1")
        findings = list(MissingFrameOptions().run(probe, make_response(), empty_ctx()))
        assert len(findings) == 1
        f = findings[0]
        assert f.severity is Severity.INFO  # informational, not warn

    def test_emits_when_csp_lacks_frame_ancestors(self) -> None:
        probe = Probe.get("https://x/y").id("p1")
        resp = make_response(headers={"Content-Security-Policy": "default-src 'self'"})
        findings = list(MissingFrameOptions().run(probe, resp, empty_ctx()))
        assert len(findings) == 1


class TestBodyEchoesSecret:
    def test_silent_when_no_body(self) -> None:
        probe = Probe.get("https://x/y").id("p1").header("Authorization", "Bearer abc12345")
        resp = make_response(body=b"")
        findings = list(BodyEchoesSecret().run(probe, resp, empty_ctx()))
        assert findings == []

    def test_silent_when_no_authorization_sent(self) -> None:
        probe = Probe.get("https://x/y").id("p1")
        resp = make_response(body=b"abc12345 in body")
        findings = list(BodyEchoesSecret().run(probe, resp, empty_ctx()))
        assert findings == []

    def test_silent_when_secret_too_short(self) -> None:
        # Minimum threshold is 8 chars; "short" is below that.
        probe = Probe.get("https://x/y").id("p1").header("Authorization", "Bearer short")
        resp = make_response(body=b"short string in body")
        findings = list(BodyEchoesSecret().run(probe, resp, empty_ctx()))
        assert findings == []

    def test_silent_when_only_scheme_in_body(self) -> None:
        # The literal word "Bearer" appearing in error text shouldn't fire.
        probe = Probe.get("https://x/y").id("p1").header("Authorization", "Bearer abc12345")
        resp = make_response(body=b'{"error": "Bearer auth required"}')
        findings = list(BodyEchoesSecret().run(probe, resp, empty_ctx()))
        assert findings == []

    def test_emits_when_secret_in_body(self) -> None:
        secret = "very-secret-token-value-9876"
        probe = Probe.get("https://x/y").id("p1").header("Authorization", f"Bearer {secret}")
        resp = make_response(body=f'{{"echo": "{secret}"}}'.encode())
        findings = list(BodyEchoesSecret().run(probe, resp, empty_ctx()))
        assert len(findings) == 1
        f = findings[0]
        assert f.code == "security.body.echoes-secret"
        assert f.severity is Severity.CRITICAL
        # Secret value MUST NOT be in evidence — that would compound the leak.
        assert secret not in str(f.evidence)
        assert "secret_length" in f.evidence
        assert f.evidence["secret_length"] == len(secret)

    def test_handles_multiple_authorization_headers(self) -> None:
        # Edge case: probe sends two auth headers (shouldn't, but might);
        # check should still find the leaking one.
        probe = (
            Probe.get("https://x/y")
            .id("p1")
            .header("Authorization", "Bearer not-leaked-1")
            .header("Authorization", "Bearer leaked-token-9999")
        )
        resp = make_response(body=b'{"echo": "leaked-token-9999"}')
        findings = list(BodyEchoesSecret().run(probe, resp, empty_ctx()))
        assert len(findings) == 1


# ---------------------------------------------------------------------------
# P-1 — v0.5.0 new security-header checks
# ---------------------------------------------------------------------------


class TestMissingCsp:
    def test_silent_on_non_browser_content(self) -> None:
        # JSON API responses don't fire — browser-shaped check.
        probe = Probe.get("https://x/y").id("p1")
        resp = make_response(headers={"Content-Type": "application/json"})
        findings = list(MissingCsp().run(probe, resp, empty_ctx()))
        assert findings == []

    def test_silent_when_csp_present(self) -> None:
        probe = Probe.get("https://x/y").id("p1")
        resp = make_response(
            headers={"Content-Type": "text/html", "Content-Security-Policy": "default-src 'self'"},
        )
        findings = list(MissingCsp().run(probe, resp, empty_ctx()))
        assert findings == []

    def test_silent_when_report_only_present(self) -> None:
        probe = Probe.get("https://x/y").id("p1")
        resp = make_response(
            headers={
                "Content-Type": "text/html",
                "Content-Security-Policy-Report-Only": "default-src 'self'",
            },
        )
        findings = list(MissingCsp().run(probe, resp, empty_ctx()))
        assert findings == []

    def test_fires_on_html_without_csp(self) -> None:
        probe = Probe.get("https://x/y").id("p1")
        resp = make_response(headers={"Content-Type": "text/html; charset=utf-8"})
        findings = list(MissingCsp().run(probe, resp, empty_ctx()))
        assert len(findings) == 1
        assert findings[0].code == "security.headers.missing-csp"
        assert findings[0].severity == Severity.WARN


class TestMissingReferrerPolicy:
    def test_silent_when_present(self) -> None:
        probe = Probe.get("https://x/y").id("p1")
        resp = make_response(headers={"Referrer-Policy": "strict-origin"})
        findings = list(MissingReferrerPolicy().run(probe, resp, empty_ctx()))
        assert findings == []

    def test_fires_when_missing(self) -> None:
        probe = Probe.get("https://x/y").id("p1")
        resp = make_response(headers={})
        findings = list(MissingReferrerPolicy().run(probe, resp, empty_ctx()))
        assert len(findings) == 1
        assert findings[0].code == "security.headers.missing-referrer-policy"
        assert findings[0].severity == Severity.INFO


class TestMissingPermissionsPolicy:
    def test_silent_on_non_browser_content(self) -> None:
        probe = Probe.get("https://x/y").id("p1")
        resp = make_response(headers={"Content-Type": "application/json"})
        findings = list(MissingPermissionsPolicy().run(probe, resp, empty_ctx()))
        assert findings == []

    def test_silent_when_present(self) -> None:
        probe = Probe.get("https://x/y").id("p1")
        resp = make_response(
            headers={"Content-Type": "text/html", "Permissions-Policy": "camera=()"},
        )
        findings = list(MissingPermissionsPolicy().run(probe, resp, empty_ctx()))
        assert findings == []

    def test_fires_on_html_without_header(self) -> None:
        probe = Probe.get("https://x/y").id("p1")
        resp = make_response(headers={"Content-Type": "text/html"})
        findings = list(MissingPermissionsPolicy().run(probe, resp, empty_ctx()))
        assert len(findings) == 1
        assert findings[0].code == "security.headers.missing-permissions-policy"


class TestInsecureCookieAttributes:
    def test_silent_when_no_set_cookie(self) -> None:
        probe = Probe.get("https://x/y").id("p1")
        resp = make_response(headers={})
        findings = list(InsecureCookieAttributes().run(probe, resp, empty_ctx()))
        assert findings == []

    def test_silent_when_all_attrs_present(self) -> None:
        probe = Probe.get("https://x/y").id("p1")
        resp = make_response(
            headers={"Set-Cookie": "session=abc; Secure; HttpOnly; SameSite=Lax"},
        )
        findings = list(InsecureCookieAttributes().run(probe, resp, empty_ctx()))
        assert findings == []

    def test_fires_on_missing_httponly(self) -> None:
        probe = Probe.get("https://x/y").id("p1")
        resp = make_response(
            headers={"Set-Cookie": "session=abc; Secure; SameSite=Lax"},
        )
        findings = list(InsecureCookieAttributes().run(probe, resp, empty_ctx()))
        assert len(findings) == 1
        assert "HttpOnly" in findings[0].evidence["missing_attributes"]
        assert findings[0].evidence["cookie_name"] == "session"

    def test_fires_on_missing_secure_over_https(self) -> None:
        probe = Probe.get("https://x/y").id("p1")
        resp = make_response(
            url="https://x/y",
            headers={"Set-Cookie": "session=abc; HttpOnly; SameSite=Lax"},
        )
        findings = list(InsecureCookieAttributes().run(probe, resp, empty_ctx()))
        assert len(findings) == 1
        assert "Secure" in findings[0].evidence["missing_attributes"]

    def test_silent_on_missing_secure_over_http(self) -> None:
        # Secure attr only relevant over HTTPS.
        probe = Probe.get("http://x/y").id("p1")
        resp = make_response(
            url="http://x/y",
            headers={"Set-Cookie": "session=abc; HttpOnly; SameSite=Lax"},
        )
        findings = list(InsecureCookieAttributes().run(probe, resp, empty_ctx()))
        assert findings == []  # Secure not expected on plaintext anyway

    def test_fires_on_missing_samesite(self) -> None:
        probe = Probe.get("https://x/y").id("p1")
        resp = make_response(
            headers={"Set-Cookie": "session=abc; Secure; HttpOnly"},
        )
        findings = list(InsecureCookieAttributes().run(probe, resp, empty_ctx()))
        assert len(findings) == 1
        assert "SameSite" in findings[0].evidence["missing_attributes"]
