"""Unit tests for :mod:`mgf.common.observability.crash_reporter`.

The conftest sets the test app name to ``mgftest``; the crash dir
and env-var name therefore use that prefix.
"""

from __future__ import annotations

import json
from pathlib import Path

import pytest

from mgf.common.observability import crash_reporter
from mgf.common.observability.crash_reporter import report_now

_APP = "mgftest"
_SINK_ENV = f"{_APP.upper()}_CRASH_SINK"


@pytest.fixture(autouse=True)
def _isolate_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "state"))
    monkeypatch.delenv(_SINK_ENV, raising=False)


def _trigger() -> BaseException:
    """Helper to produce an exception with a real traceback + cause."""
    try:
        try:
            raise RuntimeError("inner cause")
        except RuntimeError as inner:
            raise ValueError("outer message") from inner
    except ValueError as outer:
        return outer


# ---------------------------------------------------------------------------
# Local write
# ---------------------------------------------------------------------------


def test_report_now_writes_local_file(tmp_path: Path) -> None:
    exc = _trigger()
    path = report_now(exc, source="unit-test")
    assert path != Path("/dev/null")
    assert path.exists()
    assert path.suffix == ".json"


def test_report_contains_documented_fields(tmp_path: Path) -> None:
    exc = _trigger()
    path = report_now(exc, source="unit-test")
    payload = json.loads(path.read_text())

    assert payload["app"] == _APP
    assert payload["source"] == "unit-test"
    assert "timestamp" in payload
    assert "report_id" in payload
    assert "version" in payload

    assert payload["exception"]["type"] == "builtins.ValueError"
    assert "outer message" in payload["exception"]["message"]

    assert payload["cause"] is not None
    assert payload["cause"]["type"] == "builtins.RuntimeError"
    assert "inner cause" in payload["cause"]["message"]

    assert isinstance(payload["traceback"], list)
    assert any("ValueError" in line for line in payload["traceback"])

    assert "python" in payload["platform"]
    assert "system" in payload["platform"]


def test_report_filename_is_filesystem_portable(tmp_path: Path) -> None:
    exc = _trigger()
    path = report_now(exc, source="x")
    # No colons in the filename (Windows-portable).
    assert ":" not in path.name


def test_report_file_mode_is_0o600(tmp_path: Path) -> None:
    exc = _trigger()
    path = report_now(exc, source="x")
    mode = path.stat().st_mode & 0o777
    assert mode == 0o600


# ---------------------------------------------------------------------------
# Atomic write
# ---------------------------------------------------------------------------


def test_no_tmp_files_left_behind_on_success(tmp_path: Path) -> None:
    exc = _trigger()
    report_now(exc, source="x")
    crash_dir = tmp_path / "state" / _APP / "crashes"
    leftover_tmp = list(crash_dir.glob("*.tmp"))
    assert leftover_tmp == []


# ---------------------------------------------------------------------------
# Never raises
# ---------------------------------------------------------------------------


def test_report_never_raises_when_state_dir_unwritable(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    # Replace the helper with one that returns a path inside a file
    # (so mkdir fails). report_now must still return without raising.
    # We patch ``default_crash_dir`` at its import site in the
    # crash_reporter module — that's the symbol ``_write_local`` calls.
    a_file = tmp_path / "blocker"
    a_file.write_text("x")
    monkeypatch.setattr(crash_reporter, "default_crash_dir", lambda: a_file / "subdir")
    exc = _trigger()
    path = report_now(exc, source="x")
    assert path == Path("/dev/null")  # sentinel — local write failed


# ---------------------------------------------------------------------------
# Remote shipping (HTTP)
# ---------------------------------------------------------------------------


def test_remote_http_sink_called_when_url_set(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    posted: list[bytes] = []

    class _FakeResp:
        def __enter__(self) -> _FakeResp:
            return self

        def __exit__(self, *args: object) -> None:
            return None

    def fake_urlopen(req: object, timeout: float) -> _FakeResp:
        posted.append(req.data)  # type: ignore[attr-defined]
        return _FakeResp()

    monkeypatch.setenv(_SINK_ENV, "https://example.invalid/hook")
    monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)

    exc = _trigger()
    report_now(exc, source="x")
    assert posted, "HTTP sink was not invoked"
    assert b"outer message" in posted[0]


def test_unknown_sink_logs_warning_and_does_not_raise(
    monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
    monkeypatch.setenv(_SINK_ENV, "weird-sink-name")
    exc = _trigger()
    with caplog.at_level("WARNING"):
        path = report_now(exc, source="x")
    assert path != Path("/dev/null")  # local still succeeded
    assert any("unknown crash sink" in rec.message for rec in caplog.records)


# ---------------------------------------------------------------------------
# SH-01 — redaction on the crash-report path
# ---------------------------------------------------------------------------


class TestSH01RedactsBeforeEgress:
    """SH-01: the crash reporter MUST run its payload through a
    Redactor BEFORE the local file write and BEFORE remote
    shipping, so a secret in ``str(exc)`` / ``exc.args`` / the
    traceback / the cause chain never leaves the process
    unredacted. Fail-safe on redactor failure (emit a minimal
    payload), not fail-open."""

    def test_jwt_in_exception_message_is_redacted_in_local_file(self, tmp_path: Path) -> None:
        """A JWT-shaped substring of ``str(exc)`` MUST land in the
        local crash file as ``[REDACTED]`` — the BUILTIN JWT
        pattern always applies, even without a wired bootstrap."""
        # A real JWT shape: eyJ<8+>.<8+>.<8+>
        jwt_secret = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJzZWNyZXQifQ.signaturevaluexyz"
        try:
            raise ValueError(f"auth failed: token={jwt_secret}")
        except ValueError as exc:
            path = report_now(exc, source="ra-sh01")

        assert path != Path("/dev/null")
        body = path.read_text()
        # The bare JWT MUST NOT appear in the on-disk report.
        assert jwt_secret not in body, f"JWT secret leaked to local crash file: {body}"
        # The redactor's placeholder MUST appear in its place.
        report = json.loads(body)
        assert "[REDACTED]" in report["exception"]["message"]

    def test_bearer_in_exception_message_is_redacted_for_remote_sink(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """A Bearer-shaped substring in ``str(exc)`` MUST be
        redacted BEFORE the HTTP sink receives the payload — the
        whole point of the fix is the off-box egress path."""
        posted: list[bytes] = []

        class _FakeResp:
            def __enter__(self) -> _FakeResp:
                return self

            def __exit__(self, *args: object) -> None:
                return None

        def fake_urlopen(req: object, timeout: float) -> _FakeResp:
            posted.append(req.data)  # type: ignore[attr-defined]
            return _FakeResp()

        monkeypatch.setenv(_SINK_ENV, "https://example.invalid/hook")
        monkeypatch.setattr("urllib.request.urlopen", fake_urlopen)

        bearer = "Bearer abcdef123456ABCDEF"
        try:
            raise ValueError(f"upstream rejected: {bearer}")
        except ValueError as exc:
            report_now(exc, source="ra-sh01")

        assert posted, "HTTP sink was not invoked"
        body = posted[0]
        assert b"abcdef123456ABCDEF" not in body, (
            f"Bearer secret leaked to remote sink: {body[:200]!r}"
        )
        assert b"[REDACTED]" in body

    def test_uses_bootstrap_wired_redactor_when_active(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """When a bootstrap is active with consumer-supplied
        ``extra_keys`` / ``extra_value_patterns``, the crash
        reporter MUST use THAT redactor (not the default), so
        consumer secrets — DSNs, custom token shapes — are
        scrubbed from crash reports the same way they're scrubbed
        from logs."""
        import re as _re

        from mgf.common.observability.redaction import (
            REDACTED_PLACEHOLDER,
            Redactor,
        )

        # Build a redactor with a consumer-shaped pattern (postgres
        # DSN) that the BUILTIN set does NOT cover.
        consumer_redactor = Redactor(
            extra_value_patterns=(_re.compile(r"postgres://[^@\s]+@\S+"),),
        )

        # Stub the active-redactor lookup so the crash reporter
        # finds OUR redactor. This is the integration point —
        # under real bootstrap, ``get_active_redactor`` walks
        # ``logging.root.handlers``; here we inject directly so
        # the test doesn't depend on logging configuration.
        monkeypatch.setattr(
            "mgf.common.process._streamer.get_active_redactor",
            lambda: consumer_redactor,
        )

        secret_dsn = "postgres://svc:hunter2@db.internal:5432/prod"
        try:
            raise ConnectionError(f"failed to connect to {secret_dsn}")
        except ConnectionError as exc:
            path = report_now(exc, source="ra-sh01")

        body = path.read_text()
        assert "hunter2" not in body, "consumer-supplied pattern was not applied to crash report"
        report = json.loads(body)
        assert REDACTED_PLACEHOLDER in report["exception"]["message"]

    def test_falls_back_to_default_redactor_when_no_active_one(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """When no bootstrap is active (early-startup crash,
        ``setup_logging=False`` framework-owned shape),
        ``get_active_redactor`` returns ``None`` and the crash
        reporter falls back to ``Redactor.default()``. BUILTIN
        patterns still apply."""
        monkeypatch.setattr(
            "mgf.common.process._streamer.get_active_redactor",
            lambda: None,
        )

        openai_key = "sk-" + "a" * 40
        try:
            raise RuntimeError(f"oops: api={openai_key}")
        except RuntimeError as exc:
            path = report_now(exc, source="ra-sh01")

        body = path.read_text()
        assert openai_key not in body
        assert "[REDACTED]" in body


class TestSH01FailSafeOnRedactorFailure:
    """SH-01: if the Redactor itself raises (custom regex
    backtrack, bad input, etc.), the crash reporter MUST emit a
    MINIMAL report — never the raw unredacted payload (fail-safe,
    not fail-open). The ``report_now`` "never raises" invariant
    MUST also hold."""

    def test_minimal_report_emitted_when_redactor_raises(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        # Force every redaction attempt to raise.
        def bad_redact(self: object, payload: object) -> None:
            raise RuntimeError("synthetic redactor failure (SH-01)")

        from mgf.common.observability.redaction import Redactor

        monkeypatch.setattr(Redactor, "redact", bad_redact)

        secret = "Bearer s3cr3t-token-1234567890"
        try:
            raise ValueError(f"upstream auth failed: {secret}")
        except ValueError as exc:
            path = report_now(exc, source="ra-sh01")

        assert path != Path("/dev/null")
        report = json.loads(path.read_text())

        # Minimal payload structure: status flag + error type +
        # safe top-level fields preserved.
        assert report["redaction_status"] == "failed"
        assert report["redaction_error_type"] == "RuntimeError"
        assert report["source"] == "ra-sh01"
        assert report["exception"]["type"].endswith("ValueError")
        # The sentinel preserved the JSON shape.
        assert "REDACTION FAILED" in report["exception"]["message"]
        assert report["traceback"] == ["[REDACTION FAILED — payload withheld]"]
        assert report["cause"] is None

        # The actual secret MUST NOT appear anywhere in the file.
        body = path.read_text()
        assert "s3cr3t-token-1234567890" not in body, f"fail-safe leaked secret: {body}"

    def test_redaction_error_message_not_leaked(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """If the redactor's OWN exception message happens to
        carry a secret (e.g. it crashed on a regex that included
        the secret in its error), we drop the message and keep
        only the TYPE name. Defence in depth."""
        secret = "ghp_abcdef1234567890abcdef1234567890abcdef"

        def bad_redact_with_secret_in_error(
            self: object,
            payload: object,
        ) -> None:
            raise RuntimeError(f"regex bombed on input: {secret}")

        from mgf.common.observability.redaction import Redactor

        monkeypatch.setattr(Redactor, "redact", bad_redact_with_secret_in_error)

        try:
            raise ValueError("trigger")
        except ValueError as exc:
            path = report_now(exc, source="ra-sh01")

        body = path.read_text()
        assert secret not in body, f"redactor's own error message leaked a secret: {body}"

    def test_report_now_still_never_raises_when_redactor_raises(
        self, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """EH-05 invariant: even when the SH-01 redactor pass
        fails, ``report_now`` MUST return a path (real or
        sentinel), never propagate."""

        def bad_redact(self: object, payload: object) -> None:
            raise RuntimeError("synthetic")

        from mgf.common.observability.redaction import Redactor

        monkeypatch.setattr(Redactor, "redact", bad_redact)

        # Should NOT raise.
        try:
            raise ValueError("trigger")
        except ValueError as exc:
            path = report_now(exc, source="ra-sh01")
        # Sanity — we got back a Path, either real or sentinel.
        assert isinstance(path, Path)


# ---------------------------------------------------------------------------
# SH-06: bounded repr() on exception args
# ---------------------------------------------------------------------------


class TestSH06SafeReprOnExceptionArgs:
    """SH-06: the crash report formats ``exc.args`` through
    ``_safe_repr``, not bare ``repr()``.

    Why: ``repr()`` of a custom class can surface arbitrary object
    state via its ``__repr__`` (e.g., a connection wrapper that
    embeds ``password=...`` in its repr). The Redactor backstop
    pattern-matches BUILTIN value-shapes (Bearer / JWT / PEM /
    etc.) and walks structured payloads — but it does NOT
    pattern-match arbitrary ``key=value`` substrings a custom
    ``__repr__`` might construct. Bounding what is repr'd is
    the primary defense; redaction stays defense-in-depth.
    """

    def test_primitives_keep_full_repr(self, tmp_path: Path) -> None:
        """str / int / bool / None / bytes: repr is structurally
        fixed — keep it."""
        try:
            raise ValueError("hello", 42, True, None, b"bytes")
        except ValueError as exc:
            path = report_now(exc, source="sh06-primitives")

        payload = json.loads(path.read_text())
        args = payload["exception"]["args"]
        assert args == ["'hello'", "42", "True", "None", "b'bytes'"]

    def test_pathlib_uuid_datetime_keep_full_repr(self, tmp_path: Path) -> None:
        """Stdlib types with structurally-fixed reprs are on
        the allowlist."""
        from datetime import UTC, datetime
        from pathlib import Path
        from uuid import UUID

        sentinel_uuid = UUID("12345678-1234-5678-1234-567812345678")
        sentinel_dt = datetime(2026, 5, 14, tzinfo=UTC)
        try:
            raise ValueError(Path("/etc/passwd"), sentinel_uuid, sentinel_dt)
        except ValueError as exc:
            path = report_now(exc, source="sh06-stdlib")

        payload = json.loads(path.read_text())
        args = payload["exception"]["args"]
        # repr(Path(...)) / repr(UUID(...)) / repr(datetime(...)) are
        # stable — we don't pin the exact format string here (Path's
        # repr differs across platforms), but each MUST contain the
        # value and MUST NOT be the <TypeName> sentinel.
        assert all(not a.startswith("<") for a in args)
        assert "passwd" in args[0]
        assert "12345678" in args[1]
        assert "2026" in args[2]

    def test_custom_class_repr_does_not_leak_state_in_args(self, tmp_path: Path) -> None:
        """The core SH-06 guarantee: a custom class whose
        ``__repr__`` contains secret-shaped state lands in the
        report's ``args`` FIELD as ``<TypeName>``, not as the
        leaky repr.

        Scope note: this assertion is specifically about the
        ``exception.args`` field — the structured, predictable
        place where the v0.1 code called bare ``repr()``. The
        traceback field (``traceback.format_exception``) ALSO
        contains the leaky repr because Python builds the
        traceback line from ``repr(exc)`` upstream of the
        crash reporter, and the BUILTIN value-patterns in the
        Redactor do not pattern-match arbitrary ``key=value``
        substrings. Closing the traceback gap is broader scope
        than SH-06 (would need a key=value redactor pattern
        with all its false-positive risk).
        """

        class FakeConnection:
            def __init__(self, user: str, password: str) -> None:
                self._user = user
                self._password = password

            def __repr__(self) -> str:
                return f"FakeConnection(user={self._user!r}, password={self._password!r})"

        leaky = FakeConnection("alice", "hunter2-secret-value")
        try:
            raise ValueError(leaky)
        except ValueError as exc:
            path = report_now(exc, source="sh06-custom")

        payload = json.loads(path.read_text())
        args = payload["exception"]["args"]
        # The defining assertion for SH-06: ``args`` lands as the
        # TypeName sentinel, NOT the leaky repr string.
        assert args == ["<FakeConnection>"]
        assert "hunter2-secret-value" not in json.dumps(args)
        assert "FakeConnection(" not in json.dumps(args)

    def test_container_args_use_typename_sentinel(self, tmp_path: Path) -> None:
        """dict / list / tuple / set are NOT on the safe-repr
        allowlist — their reprs delegate to element reprs, and
        a custom-class element inside the container would leak
        through. Containers land as ``<TypeName>``, accepting
        the small diagnostic-info loss for the bounded-leak
        guarantee."""
        try:
            raise ValueError({"k": "v"}, [1, 2, 3], (4, 5))
        except ValueError as exc:
            path = report_now(exc, source="sh06-containers")

        payload = json.loads(path.read_text())
        args = payload["exception"]["args"]
        assert args == ["<dict>", "<list>", "<tuple>"]

    def test_mixed_args_partition_correctly(self, tmp_path: Path) -> None:
        """Each arg is handled independently; a custom-class arg
        next to a primitive does not contaminate the primitive's
        full repr."""

        class Opaque:
            def __repr__(self) -> str:
                return "Opaque(secret='abc123')"

        try:
            raise ValueError("plain string", 99, Opaque())
        except ValueError as exc:
            path = report_now(exc, source="sh06-mixed")

        payload = json.loads(path.read_text())
        args = payload["exception"]["args"]
        assert args == ["'plain string'", "99", "<Opaque>"]
        # SH-06 scope check: Opaque's secret never appears in the
        # args field. (The traceback field can still contain it;
        # see test_custom_class_repr_does_not_leak_state_in_args
        # for the scope note.)
        assert "abc123" not in json.dumps(args)


# ---------------------------------------------------------------------------
# PC-04: end-to-end latency budget for `report_now()`.
# ---------------------------------------------------------------------------


_CRASH_REPORT_MEDIAN_BUDGET_MS = 10.0  # measured ~2.8 ms; 3.5x cushion
_CRASH_REPORT_P99_BUDGET_MS = 25.0  # measured ~3.6 ms; 7x cushion


@pytest.mark.perf
def test_report_now_latency_under_budget(tmp_path: Path) -> None:
    """PC-04: ``report_now()`` end-to-end latency over 100 calls
    with a representative exception (3-level cause chain, mixed
    primitive + custom-class args after SH-06) stays under budget.

    The crash reporter runs at the worst possible time — inside
    an ``except`` branch, often with the process about to exit,
    sometimes from a signal-handler context. A regression to 100 ms
    median (a new synchronous remote sink, an unbounded
    cause-chain walker, a backtracking regex in a custom redactor)
    would let SIGKILL race the final write — tail-end crashes
    silently lose their reports.

    EH-05 pins "never raises"; this pins "fast enough to finish
    before the signal handler cascades."

    Marker: ``perf`` — nightly sweep, not every PR.
    """
    import statistics
    import time

    class Opaque:
        def __repr__(self) -> str:
            return "Opaque(state='deeply-nested')"

    def _trigger_mixed_args() -> BaseException:
        """A representative exception: 3-level cause chain, args
        of mixed primitive + custom-class types (the SH-06 shape)."""
        try:
            try:
                try:
                    raise OSError(2, "no such file", "/synthetic/path")
                except OSError as e1:
                    raise RuntimeError("inner cause") from e1
            except RuntimeError as e2:
                raise ValueError("outer message", 99, Opaque()) from e2
        except ValueError as outer:
            return outer

    # Warm-up — pay first-call costs (atomic_write path resolution,
    # redactor compile, sink-registry lookup) before measurement.
    for _ in range(5):
        report_now(_trigger_mixed_args(), source="pc-04-warm")

    n = 100
    latencies_ms: list[float] = []
    for _ in range(n):
        exc = _trigger_mixed_args()
        t0 = time.perf_counter()
        report_now(exc, source="pc-04-measure")
        latencies_ms.append((time.perf_counter() - t0) * 1000.0)
    latencies_ms.sort()

    median_ms = statistics.median(latencies_ms)
    # 99th percentile via index — accept index n*0.99 (1-indexed),
    # clamped to the last element.
    p99_idx = min(int(n * 0.99) - 1, n - 1)
    p99_ms = latencies_ms[p99_idx]

    assert median_ms < _CRASH_REPORT_MEDIAN_BUDGET_MS, (
        f"PC-04: report_now median latency budget breach: "
        f"{median_ms:.2f} ms (budget "
        f"{_CRASH_REPORT_MEDIAN_BUDGET_MS:.0f} ms). The crash "
        f"reporter is on the SIGKILL-race path; a 5x regression "
        f"here means tail-end crashes silently lose reports. "
        f"Common causes: new synchronous remote sink, an unbounded "
        f"cause-chain walker, or a backtracking regex in a custom "
        f"Redactor pattern."
    )
    assert p99_ms < _CRASH_REPORT_P99_BUDGET_MS, (
        f"PC-04: report_now p99 latency budget breach: "
        f"{p99_ms:.2f} ms (budget {_CRASH_REPORT_P99_BUDGET_MS:.0f} "
        f"ms). The tail latency matters more than the median here "
        f"— it's the worst-case at signal-handler time."
    )


class TestCrashDirRetention:
    """OP-08: the crash dir is bounded — the writer prunes to the most
    recent N reports."""

    def test_prune_keeps_most_recent(self, tmp_path: Path) -> None:
        import time as _t

        d = tmp_path / "crashes"
        d.mkdir()
        # 10 reports, distinct mtimes (oldest first).
        for i in range(10):
            p = d / f"r{i:02d}.json"
            p.write_text("{}", encoding="utf-8")
            _t.sleep(0.001)
        crash_reporter._prune_crash_dir(d, keep=3)
        remaining = sorted(p.name for p in d.glob("*.json"))
        assert remaining == ["r07.json", "r08.json", "r09.json"]

    def test_prune_is_fail_open_on_missing_dir(self, tmp_path: Path) -> None:
        # Must not raise even if the dir vanished.
        crash_reporter._prune_crash_dir(tmp_path / "gone", keep=5)
