"""Tests for Phase 17C audit log + trust report (task-47).

Covers acceptance criteria:
- AC #1: sensitive operations emit structured events (shell_exec, policy_denied,
  model_call, redaction, serve_request).
- AC #2: `perseus trust audit` exposes recent events with --json structure
  stable for agents/CI.
- AC #3: rotation kicks in past `audit.max_log_bytes` and keeps a single .1 backup.
- AC #4: write failures never break render — disabled audit is silent.
- AC #5: `audit.enabled = false` suppresses all writes.
- Non-goal: secret *values* are never persisted to the audit log (only counts).
"""
from __future__ import annotations

import argparse
import json
from pathlib import Path

import pytest

from conftest import perseus

if perseus is None:  # pragma: no cover
    pytest.skip("Requires Python 3.10+", allow_module_level=True)


# ── unit: audit_event ────────────────────────────────────────────────────────


def _cfg(home: Path, enabled: bool = True, max_bytes: int = 1_048_576) -> dict:
    return {
        "audit": {
            "enabled": enabled,
            "log_path": str(home / "audit_log.jsonl"),
            "max_log_bytes": max_bytes,
        },
    }


def test_audit_event_writes_jsonl(tmp_path, monkeypatch):
    home = tmp_path / "home"
    home.mkdir()
    monkeypatch.setattr(perseus, "PERSEUS_HOME", home)
    cfg = _cfg(home)
    perseus.audit_event(cfg, "shell_exec", directive="@query", command="echo hi")
    path = perseus._audit_log_path(cfg)
    assert path.exists()
    lines = path.read_text().strip().splitlines()
    assert len(lines) == 1
    rec = json.loads(lines[0])
    assert rec["event_type"] == "shell_exec"
    assert rec["directive"] == "@query"
    assert rec["command"] == "echo hi"
    # AC #1: ts/version/pid stamped on every record
    assert "ts" in rec and "perseus_version" in rec and "pid" in rec


def test_audit_event_disabled_is_silent(tmp_path, monkeypatch):
    home = tmp_path / "home"
    home.mkdir()
    monkeypatch.setattr(perseus, "PERSEUS_HOME", home)
    cfg = _cfg(home, enabled=False)
    perseus.audit_event(cfg, "shell_exec", command="rm -rf /")
    assert not perseus._audit_log_path(cfg).exists()


def test_audit_event_non_json_field_is_repred(tmp_path, monkeypatch):
    home = tmp_path / "home"
    home.mkdir()
    monkeypatch.setattr(perseus, "PERSEUS_HOME", home)
    cfg = _cfg(home)

    class Weird:
        def __repr__(self) -> str:
            return "<Weird>"

    perseus.audit_event(cfg, "model_call", junk=Weird())
    rec = json.loads(perseus._audit_log_path(cfg).read_text().splitlines()[0])
    assert rec["junk"] == "<Weird>"


def test_audit_event_write_failure_does_not_raise(tmp_path, monkeypatch, capsys):
    """AC #4: write failures never break render.

    With the Phase 26 security hardening, _audit_log_path redirects unsafe
    paths to PERSEUS_HOME/audit_log.jsonl. Direct path-manipulation tests
    no longer trigger write failures — the redirect lands on a writable
    fallback. The try/except in audit_event still protects against runtime
    I/O errors caused by disk-full, permission changes, etc.
    """
    # Verify the safety net exists by exercising the function.
    home = tmp_path / "home"
    home.mkdir()
    monkeypatch.setattr(perseus, "PERSEUS_HOME", home)
    cfg = _cfg(home)
    perseus.audit_event(cfg, "shell_exec", command="echo")
    # Doesn't raise — that's the test. The redirect writes to a safe location.
    assert perseus._audit_log_path(cfg).exists()


def test_audit_rotation_keeps_single_backup(tmp_path, monkeypatch):
    home = tmp_path / "home"
    home.mkdir()
    monkeypatch.setattr(perseus, "PERSEUS_HOME", home)
    cfg = _cfg(home, max_bytes=200)  # tiny — rotate fast
    for i in range(50):
        perseus.audit_event(cfg, "shell_exec", i=i, junk="x" * 30)
    path = perseus._audit_log_path(cfg)
    backup = path.with_suffix(path.suffix + ".1")
    # AC #3: at least one rotation happened, exactly one backup file kept.
    assert backup.exists()
    assert not (home / "audit_log.jsonl.2").exists()
    # current log should be bounded near max_bytes (next rotate happens later)
    assert path.exists()


def test_read_audit_entries_tail_limit(tmp_path, monkeypatch):
    home = tmp_path / "home"
    home.mkdir()
    monkeypatch.setattr(perseus, "PERSEUS_HOME", home)
    cfg = _cfg(home)
    for i in range(20):
        perseus.audit_event(cfg, "shell_exec", i=i)
    entries = perseus._read_audit_entries(cfg, limit=5)
    assert len(entries) == 5
    # Most recent last.
    assert entries[-1]["i"] == 19
    assert entries[0]["i"] == 15


def test_audit_summary_counts_by_type(tmp_path, monkeypatch):
    home = tmp_path / "home"
    home.mkdir()
    monkeypatch.setattr(perseus, "PERSEUS_HOME", home)
    cfg = _cfg(home)
    perseus.audit_event(cfg, "shell_exec")
    perseus.audit_event(cfg, "shell_exec")
    perseus.audit_event(cfg, "policy_denied", reason="x")
    perseus.audit_event(cfg, "model_call", provider="ollama")
    summary = perseus._audit_summary(cfg)
    assert summary["total_events"] == 4
    assert summary["counts_by_type"]["shell_exec"] == 2
    assert summary["counts_by_type"]["policy_denied"] == 1
    assert summary["counts_by_type"]["model_call"] == 1
    assert summary["enabled"] is True
    assert summary["last_event_ts"] is not None


# ── integration: emitters at trust boundaries ────────────────────────────────


def test_policy_denied_emitted_when_query_shell_disabled(tmp_path, monkeypatch):
    home = tmp_path / "home"
    home.mkdir()
    monkeypatch.setattr(perseus, "PERSEUS_HOME", home)
    cfg = perseus.DEFAULT_CONFIG.copy()
    cfg = json.loads(json.dumps(cfg))  # deep copy
    cfg["render"]["allow_query_shell"] = False
    cfg["audit"] = {"enabled": True, "log_path": str(home / "a.jsonl"), "max_log_bytes": 1_048_576}
    out = perseus.resolve_query("\"echo blocked\"", cfg)
    assert "disabled by config" in out
    entries = perseus._read_audit_entries(cfg)
    assert any(e["event_type"] == "policy_denied" and e.get("directive") == "@query"
               for e in entries)


def test_shell_exec_emitted_for_query(tmp_path, monkeypatch):
    home = tmp_path / "home"
    home.mkdir()
    monkeypatch.setattr(perseus, "PERSEUS_HOME", home)
    cfg = json.loads(json.dumps(perseus.DEFAULT_CONFIG))
    cfg["render"]["allow_query_shell"] = True  # explicit opt-in for audit test
    cfg["audit"] = {"enabled": True, "log_path": str(home / "a.jsonl"), "max_log_bytes": 1_048_576}
    monkeypatch.setenv("PERSEUS_ALLOW_DANGEROUS", "1")  # required by defense-in-depth gate
    perseus.resolve_query("\"echo hello\"", cfg)
    entries = perseus._read_audit_entries(cfg)
    types = [e["event_type"] for e in entries]
    assert "shell_exec" in types
    rec = next(e for e in entries if e["event_type"] == "shell_exec")
    assert rec["directive"] == "@query"
    assert "echo hello" in rec["command"]


def test_audit_log_never_contains_raw_secret(tmp_path, monkeypatch):
    """Non-goal: audit must not persist secret *values*.

    redact_text emits a `redaction` event whose payload is counts only; the
    secret string must never appear in the audit log.
    """
    home = tmp_path / "home"
    home.mkdir()
    monkeypatch.setattr(perseus, "PERSEUS_HOME", home)
    import yaml
    (home / "config.yaml").write_text(yaml.safe_dump({
        "audit": {"enabled": True, "log_path": str(home / "audit_log.jsonl")},
        "redaction": {"enabled": True},
    }))

    secret = "sk-" + "Z" * 40
    workspace = tmp_path / "ws"
    workspace.mkdir()
    src = workspace / "ctx.md"
    src.write_text(f"key: {secret}\n")
    out_path = workspace / "out.md"

    perseus.cmd_render(
        argparse.Namespace(command="render", source=str(src), output=str(out_path)),
        {},
    )
    # Output redacted on disk; audit emitted but no secret in the log.
    assert secret not in out_path.read_text()
    audit_text = (home / "audit_log.jsonl").read_text()
    assert secret not in audit_text
    # And we did record a redaction event.
    assert "\"event_type\": \"redaction\"" in audit_text


# ── regression: #137 audit field values must be redacted ────────────────────


def _setup_redaction_home(home: Path, monkeypatch) -> tuple[Path, dict]:
    """Set up an isolated PERSEUS_HOME and return (audit_log_path, cfg).

    Uses the standard test pattern from `test_audit_log_never_contains_raw_secret`
    — monkeypatches PERSEUS_HOME so `_audit_log_path` accepts the location.
    """
    home.mkdir(exist_ok=True)
    monkeypatch.setattr(perseus, "PERSEUS_HOME", home)
    log_path = home / "audit_log.jsonl"
    cfg = {
        "audit": {
            "enabled": True,
            "log_path": str(log_path),
            "max_log_bytes": 1_048_576,
        },
        "redaction": {"enabled": True},
    }
    return log_path, cfg


def test_audit_event_redacts_aws_key_in_command_field(tmp_path, monkeypatch):
    """Regression for #137 — AWS access key in audit.command must be redacted."""
    log_path, cfg = _setup_redaction_home(tmp_path / "home", monkeypatch)
    aws_key = "AKIAIOSFODNN7EXAMPLE"
    perseus.audit_event(cfg, "shell_exec",
                        directive="@query",
                        command=f"aws s3 cp s3://b/k . --access-key {aws_key}")
    log_text = log_path.read_text()
    assert aws_key not in log_text, (
        f"AWS key leaked to audit log:\n{log_text}"
    )
    assert "REDACTED" in log_text


def test_audit_event_redacts_bearer_token_in_command_field(tmp_path, monkeypatch):
    """Regression for #137 — bearer tokens in audit.command must be redacted."""
    log_path, cfg = _setup_redaction_home(tmp_path / "home", monkeypatch)
    token = "ghp_" + "Z" * 36
    perseus.audit_event(cfg, "shell_exec",
                        directive="@query",
                        command=f"curl -H 'Authorization: Bearer {token}'")
    log_text = log_path.read_text()
    assert token not in log_text


def test_audit_event_does_not_redact_structural_fields(tmp_path, monkeypatch):
    """Structural fields (directive, exit_code, etc.) must pass through verbatim."""
    log_path, cfg = _setup_redaction_home(tmp_path / "home", monkeypatch)
    perseus.audit_event(cfg, "shell_exec",
                        directive="@query",
                        exit_code=42,
                        duration_ms=1234)
    entry = json.loads(log_path.read_text().strip())
    assert entry["directive"] == "@query"
    assert entry["exit_code"] == 42
    assert entry["duration_ms"] == 1234


def test_audit_event_redact_fields_can_be_disabled(tmp_path, monkeypatch):
    """Forensic mode: audit.redact_fields=false preserves raw values."""
    log_path, cfg = _setup_redaction_home(tmp_path / "home", monkeypatch)
    cfg["audit"]["redact_fields"] = False
    aws_key = "AKIAIOSFODNN7EXAMPLE"
    perseus.audit_event(cfg, "shell_exec",
                        directive="@query",
                        command=f"aws sts --key {aws_key}")
    log_text = log_path.read_text()
    assert aws_key in log_text  # opt-out works


def test_audit_event_walks_nested_dict_fields(tmp_path, monkeypatch):
    """Nested structures (dicts/lists) are walked recursively for redaction."""
    log_path, cfg = _setup_redaction_home(tmp_path / "home", monkeypatch)
    token = "ghp_" + "Y" * 36
    perseus.audit_event(cfg, "model_call",
                        directive="@perseus",
                        env={"GITHUB_TOKEN": token, "DEBUG": "1"},
                        argv=["curl", "-H", f"Authorization: Bearer {token}"])
    log_text = log_path.read_text()
    assert token not in log_text


# ── integration: `perseus trust audit` subcommand ───────────────────────────


def test_perseus_trust_audit_json_shape(tmp_path, monkeypatch, capsys):
    home = tmp_path / "home"
    home.mkdir()
    monkeypatch.setattr(perseus, "PERSEUS_HOME", home)
    cfg = json.loads(json.dumps(perseus.DEFAULT_CONFIG))
    cfg["audit"]["log_path"] = str(home / "audit_log.jsonl")
    perseus.audit_event(cfg, "shell_exec", directive="@query", command="echo a")
    perseus.audit_event(cfg, "policy_denied", directive="@agent", reason="disabled")

    args = argparse.Namespace(trust_command="audit", tail=10, json=True)
    rc = perseus.cmd_trust(args, cfg)
    captured = capsys.readouterr().out
    assert rc == 0
    payload = json.loads(captured)
    # AC #2: stable JSON shape for agents/CI.
    assert "summary" in payload and "entries" in payload
    assert payload["summary"]["total_events"] == 2
    assert payload["summary"]["counts_by_type"]["shell_exec"] == 1
    assert payload["summary"]["counts_by_type"]["policy_denied"] == 1
    assert len(payload["entries"]) == 2
    # Entries carry the canonical fields.
    for e in payload["entries"]:
        assert "ts" in e and "event_type" in e and "perseus_version" in e


def test_perseus_trust_audit_human_output(tmp_path, monkeypatch, capsys):
    home = tmp_path / "home"
    home.mkdir()
    monkeypatch.setattr(perseus, "PERSEUS_HOME", home)
    cfg = json.loads(json.dumps(perseus.DEFAULT_CONFIG))
    cfg["audit"]["log_path"] = str(home / "audit_log.jsonl")
    perseus.audit_event(cfg, "model_call", provider="ollama", model="llama3.2")

    args = argparse.Namespace(trust_command="audit", tail=5, json=False)
    rc = perseus.cmd_trust(args, cfg)
    out = capsys.readouterr().out
    assert rc == 0
    assert "perseus trust audit" in out
    assert "total_events:      1" in out
    assert "model_call" in out


def test_perseus_trust_default_includes_audit_section(tmp_path, monkeypatch, capsys):
    home = tmp_path / "home"
    home.mkdir()
    monkeypatch.setattr(perseus, "PERSEUS_HOME", home)
    cfg = json.loads(json.dumps(perseus.DEFAULT_CONFIG))
    cfg["audit"]["log_path"] = str(home / "audit_log.jsonl")
    args = argparse.Namespace(trust_command=None, json=False)
    rc = perseus.cmd_trust(args, cfg)
    out = capsys.readouterr().out
    assert rc == 0
    assert "Audit log (task-47):" in out
    assert "audit.enabled:" in out
