"""Tests for turnstone.core.audit."""

import json

import pytest

from turnstone.core.audit import record_audit
from turnstone.core.storage._sqlite import SQLiteBackend


@pytest.fixture
def storage(tmp_path):
    path = str(tmp_path / "test.db")
    backend = SQLiteBackend(path)
    yield backend
    backend.close()


def test_record_audit_basic(storage):
    record_audit(
        storage, "user-1", "user.create", "user", "u123", {"username": "alice"}, "127.0.0.1"
    )
    events = storage.list_audit_events()
    assert len(events) == 1
    ev = events[0]
    assert ev["user_id"] == "user-1"
    assert ev["action"] == "user.create"
    assert ev["resource_type"] == "user"
    assert ev["resource_id"] == "u123"
    assert ev["ip_address"] == "127.0.0.1"
    detail = json.loads(ev["detail"])
    assert detail["username"] == "alice"


def test_record_audit_no_detail(storage):
    record_audit(storage, "user-1", "token.revoke", "token", "t456")
    events = storage.list_audit_events()
    assert len(events) == 1
    assert events[0]["detail"] == "{}"


def test_record_audit_silent_on_failure():
    """record_audit should not raise even if storage is broken."""

    class BrokenStorage:
        def record_audit_event(self, **kw):
            raise RuntimeError("boom")

    # Should not raise
    record_audit(BrokenStorage(), "u1", "test.action")


def test_record_audit_generates_unique_ids(storage):
    record_audit(storage, "u1", "a.one")
    record_audit(storage, "u1", "a.two")
    events = storage.list_audit_events()
    assert len(events) == 2
    assert events[0]["event_id"] != events[1]["event_id"]


# ---------------------------------------------------------------------------
# Credential redaction at the audit boundary
# ---------------------------------------------------------------------------


def test_record_audit_redacts_passwords_by_default(storage):
    """Detail strings go through redact_credentials by default."""
    record_audit(
        storage,
        "u1",
        "coordinator.spawn",
        detail={
            "initial_message": "connect via postgresql://alice:s3cret@db.example.com/app",
        },
    )
    event = storage.list_audit_events()[0]
    detail = json.loads(event["detail"])
    # Exact redaction text comes from output_guard._redact_credentials —
    # assert the token is stripped rather than the exact marker so
    # this test doesn't break if the marker format evolves.
    assert "s3cret" not in detail["initial_message"]
    assert "REDACTED" in detail["initial_message"]


def test_record_audit_redacts_nested_strings(storage):
    """Walker descends into lists / nested dicts."""
    record_audit(
        storage,
        "u1",
        "tasks.update",
        detail={
            "tasks": [
                {"title": "normal task"},
                {"title": "pull secret from AWS_SECRET_ACCESS_KEY=AKIAEXAMPLE123"},
            ],
        },
    )
    event = storage.list_audit_events()[0]
    detail = json.loads(event["detail"])
    assert detail["tasks"][0]["title"] == "normal task"
    assert "AKIAEXAMPLE123" not in detail["tasks"][1]["title"]


def test_record_audit_raw_detail_preserves_payload(storage):
    """`raw_detail=True` bypasses the scrub — operator-originated detail only."""
    secret_like = "postgresql://alice:s3cret@db.example.com/app"
    record_audit(
        storage,
        "admin-1",
        "investigation.note",
        detail={"note": secret_like},
        raw_detail=True,
    )
    event = storage.list_audit_events()[0]
    detail = json.loads(event["detail"])
    assert detail["note"] == secret_like


def test_record_audit_strips_control_chars(storage):
    """CR/LF/NUL/DEL and C0 controls are replaced with spaces so a
    downstream exporter that prints raw detail strings can't re-surface
    log-injection.  Tab/newline are deliberately preserved."""
    record_audit(
        storage,
        "u1",
        "coordinator.note",
        detail={
            "msg": "hello\r\nInjected: bad\x00 escape \x1b[31mred\x1b[0m\x7f",
            "ok_tab": "a\tb\nc",
        },
    )
    event = storage.list_audit_events()[0]
    detail = json.loads(event["detail"])
    # CR / NUL / ESC / DEL scrubbed to spaces; tab + newline kept.
    assert "\r" not in detail["msg"]
    assert "\x00" not in detail["msg"]
    assert "\x1b" not in detail["msg"]
    assert "\x7f" not in detail["msg"]
    assert "hello" in detail["msg"]
    assert detail["ok_tab"] == "a\tb\nc"


def test_record_audit_clean_strings_roundtrip_unchanged(storage):
    """Detail strings with no credential patterns and no control chars
    pass through unchanged — the fast-path / scrub must not corrupt the
    common case."""
    clean = {"note": "hello world", "code": "import foo", "state": "ok"}
    record_audit(storage, "u1", "coordinator.note", detail=clean)
    event = storage.list_audit_events()[0]
    assert json.loads(event["detail"]) == clean


def test_record_audit_fast_path_skips_no_string_detail(storage):
    """A detail carrying only scalars (no strings anywhere) must persist
    identically — exercises the ``_has_any_string`` fast path."""
    record_audit(
        storage,
        "u1",
        "coordinator.metric",
        detail={"spawned": 5, "ok": True, "parent": None, "tail": [1, 2, 3]},
    )
    event = storage.list_audit_events()[0]
    assert json.loads(event["detail"]) == {
        "spawned": 5,
        "ok": True,
        "parent": None,
        "tail": [1, 2, 3],
    }


def test_record_audit_redacts_dict_keys(storage):
    """Walker descends into dict keys too — a caller using
    model-controlled text as a key can't leak it verbatim."""
    record_audit(
        storage,
        "u1",
        "coordinator.note",
        detail={"postgresql://alice:s3cret@db.example.com/app": True},
    )
    event = storage.list_audit_events()[0]
    detail = json.loads(event["detail"])
    assert all("s3cret" not in k for k in detail)


def test_record_audit_walks_set_and_frozenset(storage):
    """Walker handles set/frozenset values (docstring promise)."""
    record_audit(
        storage,
        "u1",
        "coordinator.note",
        detail={"tags": frozenset({"ak_" + "x" * 40, "plain"})},
    )
    event = storage.list_audit_events()[0]
    detail = json.loads(event["detail"])
    # The credential-looking AK token gets scrubbed; the plain one survives.
    tags = detail["tags"]
    assert "plain" in tags


def test_record_audit_leaves_non_string_scalars_alone(storage):
    """Non-string scalars (int / bool / None) pass through unchanged."""
    record_audit(
        storage,
        "u1",
        "coordinator.spawn",
        detail={"budget_ok": True, "spawned": 5, "parent": None},
    )
    event = storage.list_audit_events()[0]
    detail = json.loads(event["detail"])
    assert detail == {"budget_ok": True, "spawned": 5, "parent": None}
