"""Tests for the proxy layer.

Tests config loading, session management, OpenAI compat, and app routes.
"""

import asyncio
import io
import json
import logging
import tempfile
import time
from contextlib import aclosing
from datetime import datetime, timedelta, timezone
from pathlib import Path

import pytest

from aurora_lens.proxy.config import (
    ProxyConfig, UpstreamConfig, ListenConfig, GovernanceConfig,
    CorsConfig, HardeningConfig, load_config,
)
from aurora_lens.proxy.session_store import SessionRecord, SessionStoreError
from aurora_lens.proxy.session import SessionManager
from aurora_lens.proxy.openai_compat import (
    parse_chat_request,
    format_chat_response,
)
from aurora_lens.lens import Lens, LensResult
from aurora_lens.config import LensConfig
from aurora_lens.pef.span import Span
from aurora_lens.pef.state import PEFState
from aurora_lens.adapters.base import LLMAdapter, AdapterResponse
from aurora_lens.interpret.base import ExtractionBackend
from aurora_lens.interpret.schema import ExtractedClaim, ExtractionResult
from aurora_lens.govern.decision import GovernanceDecision, InterventionAction
from aurora_lens.govern.bridge import BuiltinBridge
from aurora_lens.verify.flags import Flag, FlagType


# ── Helpers ─────────────────────────────────────────────────────────

class MockAdapter(LLMAdapter):
    async def generate(self, messages: list[dict[str, str]], **kwargs) -> AdapterResponse:
        user_msg = next(
            (m.get("content", "") for m in reversed(messages) if m.get("role") == "user"),
            "",
        )
        return AdapterResponse(text=f"Echo: {user_msg}", model="mock")


class StreamMockAdapter(LLMAdapter):
    """Mock adapter that streams chunks. Tracks if closed early (client disconnect)."""

    def __init__(self, chunks: list[str]):
        self._chunks = chunks
        self.closed_early: bool | None = None  # True if generator exited before last yield

    async def generate(self, messages: list[dict[str, str]], **kwargs) -> AdapterResponse:
        return AdapterResponse(text="".join(self._chunks), model="mock")

    async def generate_stream(self, messages: list[dict[str, str]], **kwargs):
        self.closed_early = None
        try:
            for c in self._chunks:
                chunk_dict = {"choices": [{"delta": {"content": c}, "index": 0}]}
                yield (chunk_dict, c)
            self.closed_early = False  # completed normally
        finally:
            if self.closed_early is None:
                self.closed_early = True  # exited before completion (e.g. client disconnect)


class CancelledStreamAdapter(LLMAdapter):
    """Mock adapter that raises CancelledError mid-stream (simulates provider abort during buffering)."""

    def __init__(self, chunks: list[str], cancel_after: int = 1):
        self._chunks = chunks
        self._cancel_after = cancel_after  # raise after yielding this many chunks

    async def generate(self, messages: list[dict[str, str]], **kwargs) -> AdapterResponse:
        return AdapterResponse(text="".join(self._chunks), model="mock")

    async def generate_stream(self, messages: list[dict[str, str]], **kwargs):
        for i, c in enumerate(self._chunks):
            if i >= self._cancel_after:
                raise asyncio.CancelledError("simulated provider abort")
            chunk_dict = {"choices": [{"delta": {"content": c}, "index": 0}]}
            yield (chunk_dict, c)


class MockBackend(ExtractionBackend):
    async def extract(self, text, pef):
        return ExtractionResult()


def _make_config_factory():
    def factory():
        return LensConfig(
            adapter=MockAdapter(),
            extraction_backend=MockBackend(),
        )
    return factory


# ── Config Tests ────────────────────────────────────────────────────

class TestProxyConfig:

    def test_default_listen_and_governance(self):
        """Verify defaults for listen and governance when only upstream is provided."""
        config = ProxyConfig(
            upstream=UpstreamConfig(provider="openai", api_key="sk-test", model="gpt-4"),
        )
        assert config.listen.host == "0.0.0.0"
        assert config.listen.port == 8080
        assert config.governance.default_policy == "strict"
        assert config.governance.policy_version == "1.0"
        assert config.cors.enabled is False
        assert config.cors.allow_origins == ("*",)

    def test_from_mapping(self):
        raw = {
            "upstream": {
                "provider": "openai",
                "base_url": "https://custom.api/v1",
                "api_key": "sk-test",
                "model": "gpt-3.5-turbo",
            },
            "listen": {
                "host": "127.0.0.1",
                "port": 9090,
            },
            "governance": {
                "default_policy": "moderate",
                "audit_log": "/tmp/audit.jsonl",
            },
        }
        config = ProxyConfig.from_mapping(raw)
        assert config.upstream.provider == "openai"
        assert config.upstream.base_url == "https://custom.api/v1"
        assert config.upstream.api_key == "sk-test"
        assert config.upstream.model == "gpt-3.5-turbo"
        assert config.listen.host == "127.0.0.1"
        assert config.listen.port == 9090
        assert config.governance.default_policy == "moderate"
        assert config.governance.audit_log == "/tmp/audit.jsonl"
        assert config.governance.policy_version == "1.0"

    def test_cors_and_policy_version_from_mapping(self):
        raw = {
            "upstream": {"provider": "openai", "api_key": "sk-test", "model": "gpt-4"},
            "governance": {"policy_version": "2.0"},
            "cors": {"enabled": True, "allow_origins": ["https://app.example.com"]},
        }
        config = ProxyConfig.from_mapping(raw)
        assert config.governance.policy_version == "2.0"
        assert config.cors.enabled is True
        assert config.cors.allow_origins == ("https://app.example.com",)

    def test_auth_config_from_mapping(self):
        """Phase B: Auth config with keys, labels, optional policy override."""
        raw = {
            "upstream": {"provider": "openai", "api_key": "sk-test", "model": "gpt-4"},
            "auth": {
                "enabled": True,
                "keys": [
                    {"key": "key-1", "label": "app-prod", "policy": "strict"},
                    {"key": "key-2", "label": "app-staging"},
                ],
            },
        }
        config = ProxyConfig.from_mapping(raw)
        assert config.auth.enabled is True
        assert len(config.auth.keys) == 2
        assert config.auth.keys[0].key == "key-1"
        assert config.auth.keys[0].label == "app-prod"
        assert config.auth.keys[0].policy == "strict"
        assert config.auth.keys[1].policy is None

    def test_hardening_rate_limit_per_ip_and_trusted_proxies(self):
        """Phase B.4: rate_limit_per_ip and trusted_proxy_ips in hardening."""
        raw = {
            "upstream": {"provider": "openai", "api_key": "sk-test", "model": "gpt-4"},
            "hardening": {
                "rate_limit_per_ip": 60,
                "trusted_proxy_ips": ["10.0.0.1", "192.168.1.1"],
            },
        }
        config = ProxyConfig.from_mapping(raw)
        assert config.hardening.rate_limit_per_ip == 60
        assert config.hardening.trusted_proxy_ips == ("10.0.0.1", "192.168.1.1")

    def test_session_config_from_mapping(self):
        """Phase C: Session config with backend, ttl, redis_url."""
        raw = {
            "upstream": {"provider": "openai", "api_key": "sk-test", "model": "gpt-4"},
            "session": {
                "backend": "redis",
                "ttl_seconds": 7200,
                "redis_url": "redis://localhost:6379/0",
            },
        }
        config = ProxyConfig.from_mapping(raw)
        assert config.session.backend == "redis"
        assert config.session.ttl_seconds == 7200
        assert config.session.redis_url == "redis://localhost:6379/0"

    def test_anthropic_provider(self):
        raw = {
            "upstream": {
                "provider": "anthropic",
                "api_key": "sk-ant-test",
                "model": "claude-sonnet-4-5-20250929",
            },
        }
        config = ProxyConfig.from_mapping(raw)
        assert config.upstream.provider == "anthropic"
        assert config.upstream.model == "claude-sonnet-4-5-20250929"

    def test_load_config_from_yaml(self, tmp_path):
        yaml_content = """
upstream:
  provider: anthropic
  api_key: sk-yaml-key
  model: claude-sonnet-4-5-20250929
listen:
  port: 7070
governance:
  default_policy: moderate
"""
        yaml_file = tmp_path / "test_config.yaml"
        yaml_file.write_text(yaml_content)

        config = ProxyConfig.from_yaml(yaml_file)
        assert config.upstream.provider == "anthropic"
        assert config.upstream.api_key == "sk-yaml-key"
        assert config.listen.port == 7070
        assert config.governance.default_policy == "moderate"

    def test_env_var_expansion(self, monkeypatch, tmp_path):
        monkeypatch.setenv("TEST_API_KEY", "sk-from-env")
        yaml_content = """
upstream:
  provider: openai
  api_key: ${TEST_API_KEY}
  model: gpt-4
"""
        yaml_file = tmp_path / "test_env.yaml"
        yaml_file.write_text(yaml_content)

        config = ProxyConfig.from_yaml(yaml_file)
        assert config.upstream.api_key == "sk-from-env"

    def test_env_override(self, monkeypatch, tmp_path):
        monkeypatch.setenv("AURORA_LENS_UPSTREAM_API_KEY", "sk-override")
        monkeypatch.setenv("AURORA_LENS_LISTEN_PORT", "3000")
        yaml_content = """
upstream:
  provider: openai
  api_key: sk-original
  model: gpt-4
listen:
  port: 8080
"""
        yaml_file = tmp_path / "test_override.yaml"
        yaml_file.write_text(yaml_content)

        config = ProxyConfig.from_yaml(yaml_file)
        assert config.upstream.api_key == "sk-override"
        assert config.listen.port == 3000


# ── Aurora Headers Tests ──────────────────────────────────────────

class TestAuroraHeaders:
    """Unit tests for _aurora_headers()."""

    def test_all_required_fields_present(self):
        from aurora_lens.proxy.app import _aurora_headers

        h = _aurora_headers(
            outcome="PASS",
            trace_id="proxy:abc123",
            audit_sink="jsonl",
            provider="openai",
            policy="strict",
            policy_version="1.0",
        )
        assert h["Aurora-Outcome"] == "PASS"
        assert h["Aurora-Trace-Id"] == "proxy:abc123"
        assert h["Aurora-Audit-Sink"] == "jsonl"
        assert "Aurora-Timestamp" in h
        assert h["Aurora-Timestamp"].endswith("Z")
        assert h["Aurora-Upstream"] == "openai_compat"
        assert h["Aurora-Policy"] == "strict"
        assert h["Aurora-Policy-Version"] == "1.0"

    def test_provider_display_alias(self):
        """Aurora-Upstream shows adapter identity (claude, openai_compat), not vendor."""
        from aurora_lens.proxy.app import _provider_display

        assert _provider_display("openai") == "openai_compat"
        assert _provider_display("anthropic") == "claude"
        assert _provider_display("custom") == "custom"

    def test_provider_display_edge_cases(self):
        """Case-insensitive for known providers, handles None/empty, passthrough for unknowns."""
        from aurora_lens.proxy.app import _provider_display

        assert _provider_display("OpenAI") == "openai_compat"
        assert _provider_display("ANTHROPIC") == "claude"
        assert _provider_display("  anthropic  ") == "claude"
        assert _provider_display(" openai ") == "openai_compat"
        assert _provider_display(None) == "unknown"
        assert _provider_display("") == "unknown"
        assert _provider_display("   ") == "unknown"
        assert _provider_display("Custom") == "Custom"  # passthrough preserves original

    def test_audit_sink_always_present(self):
        from aurora_lens.proxy.app import _aurora_headers

        for sink in ("jsonl", "ledger", "none"):
            h = _aurora_headers(
                outcome="ERROR",
                trace_id="proxy:x",
                audit_sink=sink,
                provider="anthropic",
                policy="moderate",
                policy_version="1.0",
            )
            assert h["Aurora-Audit-Sink"] == sink

    def test_timestamp_ends_with_z(self):
        from aurora_lens.proxy.app import _aurora_headers

        h = _aurora_headers(
            outcome="ERROR",
            trace_id="proxy:x",
            audit_sink="none",
            provider="openai",
            policy="strict",
            policy_version="1.0",
        )
        assert h["Aurora-Timestamp"].endswith("Z")
        assert "+00:00" not in h["Aurora-Timestamp"]

    def test_optional_fields_omitted_when_none(self):
        from aurora_lens.proxy.app import _aurora_headers

        h = _aurora_headers(
            outcome="ERROR",
            trace_id="proxy:x",
            audit_sink="none",
            provider="openai",
            policy="strict",
            policy_version="1.0",
        )
        assert "Aurora-Audit-Id" not in h
        assert "Aurora-Session-Id" not in h
        assert "Aurora-Proxy-Ms" not in h

    def test_optional_fields_included_when_provided(self):
        from aurora_lens.proxy.app import _aurora_headers

        h = _aurora_headers(
            outcome="HARD_STOP",
            trace_id="proxy:x",
            audit_sink="ledger",
            provider="openai",
            policy="strict",
            policy_version="1.0",
            audit_id="cid:test:123",
            session_id="sess-abc",
            proxy_ms=42,
        )
        assert h["Aurora-Audit-Id"] == "cid:test:123"
        assert h["Aurora-Session-Id"] == "sess-abc"
        assert h["Aurora-Proxy-Ms"] == "42"


# ── Session Manager Tests ───────────────────────────────────────────

class TestSessionManager:

    def test_create_new_session(self):
        mgr = SessionManager(config_factory=_make_config_factory())
        with mgr.with_lock("session-1"):
            lens, is_new = mgr.get_or_create("session-1")
            assert is_new
            assert isinstance(lens, Lens)
            mgr.persist("session-1", lens)
        assert mgr.active_count == 1

    def test_get_existing_session(self):
        """Phase C: Same session returns Lens with same PEF state (from store)."""
        mgr = SessionManager(config_factory=_make_config_factory())
        with mgr.with_lock("session-1"):
            lens1, is_new1 = mgr.get_or_create("session-1")
            assert is_new1
            lens1.pef.advance_turn()
            mgr.persist("session-1", lens1)
        with mgr.with_lock("session-1"):
            lens2, is_new2 = mgr.get_or_create("session-1")
            assert not is_new2
            assert lens2.pef.current_turn == lens1.pef.current_turn
        assert mgr.active_count == 1

    def test_separate_sessions(self):
        mgr = SessionManager(config_factory=_make_config_factory())
        with mgr.with_lock("session-1"):
            lens1, _ = mgr.get_or_create("session-1")
            mgr.persist("session-1", lens1)
        with mgr.with_lock("session-2"):
            lens2, _ = mgr.get_or_create("session-2")
            mgr.persist("session-2", lens2)
        assert lens1 is not lens2
        assert mgr.active_count == 2

    def test_get_nonexistent_returns_none(self):
        mgr = SessionManager(config_factory=_make_config_factory())
        with mgr.with_lock("nonexistent"):
            assert mgr.get("nonexistent") is None

    def test_cleanup_expired(self):
        mgr = SessionManager(config_factory=_make_config_factory(), ttl_seconds=0)
        with mgr.with_lock("session-1"):
            lens, _ = mgr.get_or_create("session-1")
            mgr.persist("session-1", lens)
        time.sleep(0.01)
        removed = mgr.cleanup_expired()
        assert removed == 1
        assert mgr.active_count == 0

    def test_session_record_revision_increments(self):
        """SessionStore contract: revision is monotonic, caller increments on persist."""
        from aurora_lens.proxy.session_store import MemorySessionStore

        store = MemorySessionStore(ttl_seconds=3600)
        pef = PEFState()
        now = time.time()
        r0 = SessionRecord.from_pef(pef, expires_at=now + 3600, revision=0)
        store.put("s1", r0)
        rec = store.get("s1")
        assert rec is not None
        assert rec.revision == 0
        r1 = SessionRecord.from_pef(pef, expires_at=now + 3600, revision=1)
        store.put("s1", r1)
        rec = store.get("s1")
        assert rec is not None
        assert rec.revision == 1


# ── OpenAI Compat Tests ────────────────────────────────────────────

class TestParseRequest:

    def test_basic_request(self):
        body = {
            "model": "gpt-4",
            "messages": [
                {"role": "user", "content": "Hello world"},
            ],
        }
        parsed = parse_chat_request(body)
        assert parsed.user_message == "Hello world"
        assert parsed.model == "gpt-4"
        assert len(parsed.conversation_history) == 0

    def test_with_history(self):
        body = {
            "model": "gpt-4",
            "messages": [
                {"role": "user", "content": "First message"},
                {"role": "assistant", "content": "First response"},
                {"role": "user", "content": "Second message"},
            ],
        }
        parsed = parse_chat_request(body)
        assert parsed.user_message == "Second message"
        assert len(parsed.conversation_history) == 2

    def test_system_message_skipped(self):
        body = {
            "model": "gpt-4",
            "messages": [
                {"role": "system", "content": "You are helpful"},
                {"role": "user", "content": "Hello"},
            ],
        }
        parsed = parse_chat_request(body)
        assert parsed.user_message == "Hello"
        assert len(parsed.conversation_history) == 0

    def test_custom_session_id(self):
        body = {
            "model": "gpt-4",
            "messages": [{"role": "user", "content": "Hi"}],
            "aurora_session_id": "my-session-123",
        }
        parsed = parse_chat_request(body)
        assert parsed.session_id == "my-session-123"

    def test_multimodal_content(self):
        """parse_chat_request extracts text from multimodal content (list of parts)."""
        body = {
            "model": "gpt-4",
            "messages": [
                {"role": "user", "content": [{"type": "text", "text": "Hello from multimodal"}]},
            ],
        }
        parsed = parse_chat_request(body)
        assert parsed.user_message == "Hello from multimodal"

    def test_empty_messages_raises(self):
        """parse_chat_request raises ValueError when messages is empty and no fallback."""
        body = {"model": "gpt-4", "messages": []}
        with pytest.raises(ValueError, match="expected 'messages' list"):
            parse_chat_request(body)

    def test_input_fallback(self):
        """parse_chat_request accepts input/prompt/text as fallback when messages missing."""
        body = {"model": "gpt-4", "input": "Direct input text"}
        parsed = parse_chat_request(body)
        assert parsed.user_message == "Direct input text"

    def test_external_flags_parsed(self):
        """parse_chat_request extracts aurora.external_flags (Phase 9)."""
        body = {
            "model": "gpt-4",
            "messages": [{"role": "user", "content": "Hello"}],
            "aurora": {
                "external_flags": [
                    {"type": "SELF_HARM_INSTRUCTION", "evidence": ["matched span"], "source": "classifier:v1"},
                ],
            },
        }
        parsed = parse_chat_request(body)
        assert len(parsed.external_flags) == 1
        assert parsed.external_flags[0].flag_type.name == "SELF_HARM_INSTRUCTION"
        assert "matched span" in parsed.external_flags[0].evidence

    def test_external_flags_empty_when_missing(self):
        """No aurora.external_flags → empty list."""
        body = {"model": "gpt-4", "messages": [{"role": "user", "content": "Hi"}]}
        parsed = parse_chat_request(body)
        assert parsed.external_flags == []

    def test_external_flags_malformed_raises_422(self):
        """Malformed aurora.external_flags raises ValueError → 422."""
        # Missing type
        with pytest.raises(ValueError, match="type"):
            parse_chat_request({
                "messages": [{"role": "user", "content": "Hi"}],
                "aurora": {"external_flags": [{"evidence": ["x"]}]},
            })
        # Invalid type
        with pytest.raises(ValueError, match="unknown type"):
            parse_chat_request({
                "messages": [{"role": "user", "content": "Hi"}],
                "aurora": {"external_flags": [{"type": "UNKNOWN_FLAG", "evidence": ["x"]}]},
            })
        # evidence not a list
        with pytest.raises(ValueError, match="evidence"):
            parse_chat_request({
                "messages": [{"role": "user", "content": "Hi"}],
                "aurora": {"external_flags": [{"type": "SELF_HARM_INSTRUCTION", "evidence": "string"}]},
            })


class TestFormatResponse:

    def test_clean_response(self):
        result = LensResult(
            response="Hello!",
            flags=[],
            pef_snapshot="empty",
            turn=1,
            span=Span.PRESENT,
            model="gpt-4",
            action=InterventionAction.PASS,
            decision=GovernanceDecision(
                action=InterventionAction.PASS,
                flags=[],
                rationale="No verification flags",
            ),
        )
        body = format_chat_response(result)
        assert body["choices"][0]["message"]["content"] == "Hello!"
        assert body["aurora"]["governance"] == "PASS"
        assert "original_response" not in body["aurora"]

    def test_hard_stop_response(self):
        """Default (no operator detail): only governance action in aurora, no internal diagnostics."""
        flags = [Flag(
            flag_type=FlagType.CONTRADICTED_FACT,
            entity_name="Patient",
            claim="Patient HAS myocardial infarction",
            evidence="No evidence",
            severity="error",
        )]
        decision = GovernanceDecision(
            action=InterventionAction.HARD_STOP,
            flags=flags,
            rationale="Insufficient clinical evidence",
            policy="strict",
            original_response="Likely MI.",
        )
        result = LensResult(
            response="Blocked.",
            flags=flags,
            pef_snapshot="...",
            turn=2,
            span=Span.PRESENT,
            action=InterventionAction.HARD_STOP,
            decision=decision,
            original_response="Likely MI.",
        )
        body = format_chat_response(result)

        # Always HTTP 200 — governance action in body
        assert body["choices"][0]["message"]["content"] == "Blocked."
        assert body["aurora"]["governance"] == "HARD_STOP"
        # Two-plane contract: operator-plane fields not present in default response
        assert "flags" not in body["aurora"]
        assert "rationale" not in body["aurora"]
        assert "original_response" not in body["aurora"]
        assert "governance_note" not in body["aurora"]
        # HARD_STOP is not SOFT_CORRECT — no unverified hint
        assert "unverified" not in body["aurora"]

    def test_forensic_event_present_for_non_admit(self):
        """forensic_event is included in aurora for HARD_STOP, FORCE_REVISE, CONTAIN; not for PASS/SOFT_CORRECT."""
        flags = [Flag(
            flag_type=FlagType.CONTRADICTED_FACT,
            entity_name="Patient",
            claim="Patient HAS MI",
            evidence="No evidence",
            severity="error",
        )]
        decision = GovernanceDecision(
            action=InterventionAction.HARD_STOP,
            flags=flags,
            rationale="Insufficient evidence",
            policy="strict",
            original_response="Likely MI.",
        )
        decision.forensic_event = {
            "trace_id": "t1",
            "timestamp": "2026-02-24T07:15:23Z",
            "status": "STOP",
            "attempted_action": "respond",
            "domain": "governance",
            "subdomain": None,
            "failed_constraints": ["CONTRADICTED_FACT"],
            "state_hash": "abc123",
        }
        result = LensResult(
            response="Blocked.",
            flags=flags,
            pef_snapshot="...",
            turn=2,
            span=Span.PRESENT,
            action=InterventionAction.HARD_STOP,
            decision=decision,
            original_response="Likely MI.",
        )
        body = format_chat_response(result)
        assert "forensic_event" in body["aurora"]
        fe = body["aurora"]["forensic_event"]
        assert fe["trace_id"] == "t1"
        assert fe["timestamp"] == "2026-02-24T07:15:23Z"
        assert fe["status"] == "STOP"
        assert fe["attempted_action"] == "respond"
        assert fe["domain"] == "governance"
        assert fe["failed_constraints"] == ["CONTRADICTED_FACT"]
        assert fe["state_hash"] == "abc123"
        assert "subdomain" in fe

    def test_forensic_event_absent_for_pass_and_soft_correct(self):
        """PASS and SOFT_CORRECT responses do not include forensic_event."""
        for action in (InterventionAction.PASS, InterventionAction.SOFT_CORRECT):
            decision = GovernanceDecision(
                action=action,
                flags=[],
                rationale="",
                policy="strict",
            )
            result = LensResult(
                response="OK",
                flags=[],
                pef_snapshot="",
                turn=1,
                span=Span.PRESENT,
                action=action,
                decision=decision,
            )
            body = format_chat_response(result)
            assert "forensic_event" not in body["aurora"]

    def test_hard_stop_with_operator_detail(self):
        """include_operator_detail=True exposes flags/rationale/original_response."""
        flags = [Flag(
            flag_type=FlagType.CONTRADICTED_FACT,
            entity_name="Patient",
            claim="Patient HAS myocardial infarction",
            evidence="No evidence",
            severity="error",
        )]
        decision = GovernanceDecision(
            action=InterventionAction.HARD_STOP,
            flags=flags,
            rationale="Insufficient clinical evidence",
            policy="strict",
            original_response="Likely MI.",
        )
        result = LensResult(
            response="Blocked.",
            flags=flags,
            pef_snapshot="...",
            turn=2,
            span=Span.PRESENT,
            action=InterventionAction.HARD_STOP,
            decision=decision,
            original_response="Likely MI.",
        )
        body = format_chat_response(result, include_operator_detail=True)

        assert body["aurora"]["governance"] == "HARD_STOP"
        assert body["aurora"]["flags"] == ["CONTRADICTED_FACT"]
        assert body["aurora"]["rationale"] == "Insufficient clinical evidence"
        assert body["aurora"]["original_response"] == "Likely MI."
        assert body["aurora"]["policy"] == "strict"

    def test_soft_correct_unverified_hint(self):
        """SOFT_CORRECT adds aurora.unverified=True; internal note stays in audit log."""
        decision = GovernanceDecision(
            action=InterventionAction.SOFT_CORRECT,
            flags=[],
            rationale="Minor flags",
            governance_note="TIME_SMEAR: past fact presented as present",
        )
        result = LensResult(
            response="Clean content.",
            flags=[],
            pef_snapshot="",
            turn=1,
            span=Span.PRESENT,
            action=InterventionAction.SOFT_CORRECT,
            decision=decision,
        )
        body = format_chat_response(result)
        # User-plane: neutral boolean hint, no internal diagnostic
        assert body["aurora"]["unverified"] is True
        assert "governance_note" not in body["aurora"]
        # Content is NOT polluted
        assert body["choices"][0]["message"]["content"] == "Clean content."

    def test_soft_correct_operator_detail_includes_note(self):
        """include_operator_detail=True exposes governance_note for SOFT_CORRECT."""
        decision = GovernanceDecision(
            action=InterventionAction.SOFT_CORRECT,
            flags=[],
            rationale="Minor flags",
            governance_note="TIME_SMEAR: past fact presented as present",
        )
        result = LensResult(
            response="Clean content.",
            flags=[],
            pef_snapshot="",
            turn=1,
            span=Span.PRESENT,
            action=InterventionAction.SOFT_CORRECT,
            decision=decision,
        )
        body = format_chat_response(result, include_operator_detail=True)
        assert body["aurora"]["governance_note"] == "TIME_SMEAR: past fact presented as present"
        assert body["choices"][0]["message"]["content"] == "Clean content."

    def test_usage_propagated_when_present(self):
        """When LensResult has usage from adapter, format_chat_response reflects non-zero totals."""
        result = LensResult(
            response="Hello!",
            flags=[],
            pef_snapshot="empty",
            turn=1,
            span=Span.PRESENT,
            model="claude-sonnet-4-5-20250929",
            action=InterventionAction.PASS,
            decision=GovernanceDecision(
                action=InterventionAction.PASS,
                flags=[],
                rationale="No verification flags",
            ),
            usage={"input_tokens": 127, "output_tokens": 7},
        )
        body = format_chat_response(result)
        assert body["usage"]["prompt_tokens"] == 127
        assert body["usage"]["completion_tokens"] == 7
        assert body["usage"]["total_tokens"] == 134

    def test_usage_zero_when_absent(self):
        """When LensResult has no usage, format_chat_response returns zeros."""
        result = LensResult(
            response="Hi",
            flags=[],
            pef_snapshot="empty",
            turn=1,
            span=Span.PRESENT,
            model="mock",
            action=InterventionAction.PASS,
            decision=GovernanceDecision(
                action=InterventionAction.PASS,
                flags=[],
                rationale="No verification flags",
            ),
        )
        body = format_chat_response(result)
        assert body["usage"]["prompt_tokens"] == 0
        assert body["usage"]["completion_tokens"] == 0
        assert body["usage"]["total_tokens"] == 0


# ── App Integration Tests ───────────────────────────────────────────

class TestAppIntegration:
    """Integration tests using Starlette's test client."""

    def _get_test_client(self):
        """Create a test client with mock upstream."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        from aurora_lens.proxy.app import create_app
        from aurora_lens.proxy.config import ProxyConfig

        # We can't use a real upstream, so we monkeypatch create_app
        # Instead, we'll test the lower layers directly
        # and do a minimal app test
        return None

    def test_session_routing_via_app(self, monkeypatch):
        """SessionManager is wired; requests route through sessions.get_or_create(session_id)."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        from aurora_lens.proxy.app import create_app
        from aurora_lens.proxy.config import ProxyConfig

        def _mock_adapters(cfg):
            return MockAdapter(), MockAdapter()

        monkeypatch.setattr(
            "aurora_lens.proxy.app._build_provider_adapters",
            _mock_adapters,
        )

        cfg = ProxyConfig.from_mapping({
            "upstream": {"provider": "openai", "api_key": "sk-test", "model": "gpt-4"},
            "governance": {"default_policy": "strict", "audit_log": None},
            "extraction": {"backend": "llm"},
        })
        app = create_app(cfg)
        client = TestClient(app)

        # Session from body
        r1 = client.post(
            "/v1/chat/completions",
            json={
                "model": "gpt-4",
                "messages": [{"role": "user", "content": "Hello"}],
                "aurora_session_id": "sess-body-1",
            },
        )
        assert r1.status_code == 200

        # Different session
        r2 = client.post(
            "/v1/chat/completions",
            json={
                "model": "gpt-4",
                "messages": [{"role": "user", "content": "Hi"}],
                "aurora_session_id": "sess-body-2",
            },
        )
        assert r2.status_code == 200

        # Same session as r1 — reuses lens
        r3 = client.post(
            "/v1/chat/completions",
            json={
                "model": "gpt-4",
                "messages": [{"role": "user", "content": "Again"}],
                "aurora_session_id": "sess-body-1",
            },
        )
        assert r3.status_code == 200

    def test_session_id_aliases_turn_increments(self, monkeypatch, tmp_path):
        """Session ID from header, aurora_session_id, session_id, aurora.session_id — Turn 2 increments with each alias."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")
        pytest.importorskip("spacy")

        audit_file = tmp_path / "audit.jsonl"
        monkeypatch.setattr(
            "aurora_lens.proxy.app._build_provider_adapters",
            lambda cfg: (MockAdapter(), MockAdapter()),
        )
        cfg = ProxyConfig.from_mapping({
            "upstream": {"provider": "openai", "api_key": "sk-test", "model": "gpt-4"},
            "governance": {"audit_log": str(audit_file), "audit_backend": "jsonl"},
            "extraction": {"backend": "spacy"},
        })
        from aurora_lens.proxy.app import create_app
        app = create_app(cfg)
        client = TestClient(app)

        shared_sid = "alias-test-session"

        # Turn 1: x-aurora-session-id
        r1 = client.post(
            "/v1/chat/completions",
            json={"model": "gpt-4", "messages": [{"role": "user", "content": "Alice works at Acme."}]},
            headers={"x-aurora-session-id": shared_sid},
        )
        assert r1.status_code == 200

        # Turn 2: aurora_session_id (different alias, same session)
        r2 = client.post(
            "/v1/chat/completions",
            json={
                "model": "gpt-4",
                "messages": [
                    {"role": "user", "content": "Alice works at Acme."},
                    {"role": "assistant", "content": "Got it."},
                    {"role": "user", "content": "Where does Alice work?"},
                ],
                "aurora_session_id": shared_sid,
            },
        )
        assert r2.status_code == 200

        lines = [ln for ln in audit_file.read_text().splitlines() if ln.strip()]
        assert len(lines) >= 2
        entries = [json.loads(ln) for ln in lines[-2:]]
        turns = [e.get("turn") for e in entries if "turn" in e]
        assert 1 in turns and 2 in turns, f"Expected turn 1 and 2, got {turns}"

        # Turn 3: session_id alias (new session to avoid confusion)
        sid2 = "alias-session-id-body"
        r3 = client.post(
            "/v1/chat/completions",
            json={
                "model": "gpt-4",
                "messages": [{"role": "user", "content": "Bob is a teacher."}],
                "session_id": sid2,
            },
        )
        assert r3.status_code == 200
        r4 = client.post(
            "/v1/chat/completions",
            json={
                "model": "gpt-4",
                "messages": [
                    {"role": "user", "content": "Bob is a teacher."},
                    {"role": "assistant", "content": "OK."},
                    {"role": "user", "content": "What does Bob do?"},
                ],
                "session_id": sid2,
            },
        )
        assert r4.status_code == 200
        entries_sid = [json.loads(ln) for ln in audit_file.read_text().splitlines() if ln.strip()][-2:]
        turns_sid = [e.get("turn") for e in entries_sid if e.get("session_id") == sid2 and "turn" in e]
        assert 1 in turns_sid and 2 in turns_sid, f"session_id alias: expected turn 1,2 got {turns_sid}"

        # Turn 5: aurora.session_id alias
        sid3 = "alias-aurora-dot-session"
        r5 = client.post(
            "/v1/chat/completions",
            json={
                "model": "gpt-4",
                "messages": [{"role": "user", "content": "Carol is a doctor."}],
                "aurora": {"session_id": sid3},
            },
        )
        assert r5.status_code == 200
        r6 = client.post(
            "/v1/chat/completions",
            json={
                "model": "gpt-4",
                "messages": [
                    {"role": "user", "content": "Carol is a doctor."},
                    {"role": "assistant", "content": "Noted."},
                    {"role": "user", "content": "Who is Carol?"},
                ],
                "aurora": {"session_id": sid3},
            },
        )
        assert r6.status_code == 200
        all_entries = [json.loads(ln) for ln in audit_file.read_text().splitlines() if ln.strip()]
        entries_aurora = [e for e in all_entries if e.get("session_id") == sid3]
        turns_aurora = [e.get("turn") for e in entries_aurora if "turn" in e]
        assert 1 in turns_aurora and 2 in turns_aurora, f"aurora.session_id: expected turn 1,2 got {turns_aurora}"

    def test_aurora_headers_on_success(self, monkeypatch):
        """Aurora-* headers present on successful chat response."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        from aurora_lens.proxy.app import create_app, AURORA_HEADER_NAMES
        from aurora_lens.proxy.config import ProxyConfig

        def _mock_adapters(cfg):
            return MockAdapter(), MockAdapter()

        monkeypatch.setattr(
            "aurora_lens.proxy.app._build_provider_adapters",
            _mock_adapters,
        )

        cfg = ProxyConfig.from_mapping({
            "upstream": {"provider": "openai", "api_key": "sk-test", "model": "gpt-4"},
            "governance": {"default_policy": "strict", "audit_log": None},
            "extraction": {"backend": "llm"},
        })
        app = create_app(cfg)
        client = TestClient(app)

        r = client.post(
            "/v1/chat/completions",
            json={"model": "gpt-4", "messages": [{"role": "user", "content": "Hi"}]},
        )
        assert r.status_code == 200
        for name in ["Aurora-Outcome", "Aurora-Trace-Id", "Aurora-Audit-Sink", "Aurora-Timestamp",
                     "Aurora-Upstream", "Aurora-Policy", "Aurora-Policy-Version", "Aurora-Proxy-Ms"]:
            assert name in r.headers, f"Missing header {name}"
        assert r.headers["Aurora-Upstream"] == "openai_compat"
        assert r.headers["Aurora-Policy"] == "strict"
        assert r.headers["Aurora-Audit-Sink"] in ("jsonl", "ledger", "none")

    def test_aurora_headers_on_400_invalid_json(self, monkeypatch):
        """Aurora-* headers present on 400 error (invalid JSON)."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        from aurora_lens.proxy.app import create_app
        from aurora_lens.proxy.config import ProxyConfig

        cfg = ProxyConfig.from_mapping({
            "upstream": {"provider": "openai", "api_key": "sk-test", "model": "gpt-4"},
        })
        app = create_app(cfg)
        client = TestClient(app)

        r = client.post("/v1/chat/completions", content="not json", headers={"Content-Type": "application/json"})
        assert r.status_code == 400
        assert "Aurora-Outcome" in r.headers
        assert r.headers["Aurora-Outcome"] == "ERROR"
        assert "Aurora-Trace-Id" in r.headers

    def test_aurora_headers_on_422_invalid_payload(self, monkeypatch):
        """Aurora-* headers present on 422 error (invalid chat payload)."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        from aurora_lens.proxy.app import create_app
        from aurora_lens.proxy.config import ProxyConfig

        def _mock_adapters(cfg):
            return MockAdapter(), MockAdapter()

        monkeypatch.setattr(
            "aurora_lens.proxy.app._build_provider_adapters",
            _mock_adapters,
        )

        cfg = ProxyConfig.from_mapping({
            "upstream": {"provider": "openai", "api_key": "sk-test", "model": "gpt-4"},
        })
        app = create_app(cfg)
        client = TestClient(app)

        r = client.post(
            "/v1/chat/completions",
            json={"model": "gpt-4", "messages": []},
        )
        assert r.status_code == 422
        assert r.headers["Aurora-Outcome"] == "ERROR"
        assert "Aurora-Trace-Id" in r.headers
        assert "Aurora-Audit-Sink" in r.headers

    def test_aurora_headers_on_http_exception(self, monkeypatch):
        """HTTPException (e.g. 418) carries Aurora headers via exception handler."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        from fastapi import HTTPException

        from aurora_lens.proxy.app import create_app
        from aurora_lens.proxy.config import ProxyConfig

        def _mock_adapters(cfg):
            return MockAdapter(), MockAdapter()

        monkeypatch.setattr(
            "aurora_lens.proxy.app._build_provider_adapters",
            _mock_adapters,
        )

        cfg = ProxyConfig.from_mapping({
            "upstream": {"provider": "openai", "api_key": "sk-test", "model": "gpt-4"},
        })
        app = create_app(cfg)

        @app.get("/test-raise")
        def _raise():
            raise HTTPException(status_code=418, detail="I'm a teapot")

        client = TestClient(app)
        r = client.get("/test-raise")
        assert r.status_code == 418
        assert r.headers["Aurora-Outcome"] == "ERROR"
        assert "Aurora-Trace-Id" in r.headers
        assert "Aurora-Audit-Sink" in r.headers

    def test_aurora_headers_on_404_not_found(self, monkeypatch):
        """404 (route not found) carries Aurora headers via exception handler."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        from aurora_lens.proxy.app import create_app
        from aurora_lens.proxy.config import ProxyConfig

        monkeypatch.setattr(
            "aurora_lens.proxy.app._build_provider_adapters",
            lambda cfg: (MockAdapter(), MockAdapter()),
        )

        cfg = ProxyConfig.from_mapping({
            "upstream": {"provider": "openai", "api_key": "sk-test", "model": "gpt-4"},
        })
        app = create_app(cfg)
        client = TestClient(app)

        r = client.get("/v1/nonexistent")
        assert r.status_code == 404
        assert r.headers["Aurora-Outcome"] == "ERROR"
        assert "Aurora-Trace-Id" in r.headers
        assert "Aurora-Audit-Sink" in r.headers

    def test_session_id_from_header(self, monkeypatch):
        """Session ID from x-aurora-session-id header takes precedence."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        from aurora_lens.proxy.app import create_app
        from aurora_lens.proxy.config import ProxyConfig

        def _mock_adapters(cfg):
            return MockAdapter(), MockAdapter()

        monkeypatch.setattr(
            "aurora_lens.proxy.app._build_provider_adapters",
            _mock_adapters,
        )

        cfg = ProxyConfig.from_mapping({
            "upstream": {"provider": "openai", "api_key": "sk-test", "model": "gpt-4"},
            "governance": {"default_policy": "strict", "audit_log": None},
            "extraction": {"backend": "llm"},
        })
        app = create_app(cfg)
        client = TestClient(app)

        r = client.post(
            "/v1/chat/completions",
            json={"model": "gpt-4", "messages": [{"role": "user", "content": "Hi"}]},
            headers={"x-aurora-session-id": "header-session-99"},
        )
        assert r.status_code == 200

    def test_health_endpoint(self, monkeypatch, tmp_path):
        """Full /health returns status, sessions, policy, audit_writable."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        from aurora_lens.proxy.app import create_app

        monkeypatch.setattr(
            "aurora_lens.proxy.app._build_provider_adapters",
            lambda cfg: (MockAdapter(), MockAdapter()),
        )

        audit_file = tmp_path / "audit.jsonl"
        cfg = ProxyConfig.from_mapping({
            "upstream": {"provider": "openai", "api_key": "sk-test", "model": "gpt-4"},
            "governance": {"default_policy": "moderate", "audit_log": str(audit_file), "audit_backend": "jsonl"},
        })
        app = create_app(cfg)
        client = TestClient(app)

        r = client.get("/health")
        assert r.status_code == 200
        data = r.json()
        assert data["status"] == "ok"
        assert "sessions" in data
        assert data["policy"] == "moderate"
        assert data["audit_sink"] == "jsonl"
        assert data["audit_writable"] is True
        assert data["auto_verify"] is True
        assert data["auto_interpret"] is True
        assert data["extraction_backend"] == "spacy"  # default when not in config
        for name in ["Aurora-Outcome", "Aurora-Trace-Id", "Aurora-Audit-Sink", "Aurora-Timestamp",
                     "Aurora-Upstream", "Aurora-Policy", "Aurora-Policy-Version", "Aurora-Proxy-Ms"]:
            assert name in r.headers, f"Missing header {name}"

    def test_healthz_minimal(self, monkeypatch):
        """/healthz returns minimal ok for k8s."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        from aurora_lens.proxy.app import create_app

        monkeypatch.setattr(
            "aurora_lens.proxy.app._build_provider_adapters",
            lambda cfg: (MockAdapter(), MockAdapter()),
        )
        cfg = ProxyConfig.from_mapping({
            "upstream": {"provider": "openai", "api_key": "sk-test", "model": "gpt-4"},
        })
        app = create_app(cfg)
        client = TestClient(app)
        r = client.get("/healthz")
        assert r.status_code == 200
        assert r.json() == {"ok": True}
        for name in ["Aurora-Outcome", "Aurora-Trace-Id", "Aurora-Audit-Sink", "Aurora-Timestamp",
                     "Aurora-Upstream", "Aurora-Policy", "Aurora-Policy-Version", "Aurora-Proxy-Ms"]:
            assert name in r.headers, f"Missing header {name}"

    def test_policy_version_always_present(self, monkeypatch):
        """policy_version is always present (defaulted to 1.0 when not in config)."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        from aurora_lens.proxy.app import create_app

        monkeypatch.setattr(
            "aurora_lens.proxy.app._build_provider_adapters",
            lambda cfg: (MockAdapter(), MockAdapter()),
        )
        cfg = ProxyConfig.from_mapping({
            "upstream": {"provider": "openai", "api_key": "sk-test", "model": "gpt-4"},
        })
        app = create_app(cfg)
        client = TestClient(app)
        r = client.get("/health")
        assert r.status_code == 200
        assert "policy_version" in r.json()
        assert r.json()["policy_version"] == "1.0"
        assert r.headers["Aurora-Policy-Version"] == "1.0"

    def test_health_includes_extraction_and_auto_flags(self, monkeypatch):
        """/health returns auto_verify, auto_interpret, extraction_backend for runtime inspection."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        from aurora_lens.proxy.app import create_app

        monkeypatch.setattr(
            "aurora_lens.proxy.app._build_provider_adapters",
            lambda cfg: (MockAdapter(), MockAdapter()),
        )
        cfg = ProxyConfig.from_mapping({
            "upstream": {"provider": "openai", "api_key": "sk-test", "model": "gpt-4"},
            "extraction": {"backend": "spacy"},
        })
        app = create_app(cfg)
        client = TestClient(app)
        r = client.get("/health")
        assert r.status_code == 200
        data = r.json()
        assert data["extraction_backend"] == "spacy"
        assert data["auto_verify"] is True
        assert data["auto_interpret"] is True

    def test_audit_recent_with_entries(self, monkeypatch, tmp_path):
        """/v1/audit/recent returns last n entries from audit file."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        from aurora_lens.proxy.app import create_app

        monkeypatch.setattr(
            "aurora_lens.proxy.app._build_provider_adapters",
            lambda cfg: (MockAdapter(), MockAdapter()),
        )

        audit_file = tmp_path / "audit.jsonl"
        audit_file.write_text('{"schema_version":2,"outcome":"PASS"}\n{"schema_version":2,"outcome":"HARD_STOP"}\n')
        cfg = ProxyConfig.from_mapping({
            "upstream": {"provider": "openai", "api_key": "sk-test", "model": "gpt-4"},
            "governance": {"audit_log": str(audit_file)},
        })
        app = create_app(cfg)
        client = TestClient(app)

        r = client.get("/v1/audit/recent?n=10")
        assert r.status_code == 200
        data = r.json()
        assert "entries" in data
        assert len(data["entries"]) == 2
        assert data["entries"][0].get("outcome", data["entries"][0].get("action")) == "PASS"
        assert data["entries"][1].get("outcome", data["entries"][1].get("action")) == "HARD_STOP"
        assert data["n"] == 2
        for name in ["Aurora-Outcome", "Aurora-Trace-Id", "Aurora-Audit-Sink", "Aurora-Timestamp",
                     "Aurora-Upstream", "Aurora-Policy", "Aurora-Policy-Version", "Aurora-Proxy-Ms"]:
            assert name in r.headers, f"Missing header {name}"

    def test_audit_recent_skips_partial_trailing_line(self, monkeypatch, tmp_path):
        """Partial last line (concurrent write) is skipped; valid entries returned."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        from aurora_lens.proxy.app import create_app

        monkeypatch.setattr(
            "aurora_lens.proxy.app._build_provider_adapters",
            lambda cfg: (MockAdapter(), MockAdapter()),
        )

        audit_file = tmp_path / "audit.jsonl"
        audit_file.write_text('{"schema_version":2,"outcome":"PASS"}\n{"schema_version":2,"outcome":"HARD_STOP"}\n{"schema_version":2,"incomplete')
        cfg = ProxyConfig.from_mapping({
            "upstream": {"provider": "openai", "api_key": "sk-test", "model": "gpt-4"},
            "governance": {"audit_log": str(audit_file)},
        })
        app = create_app(cfg)
        client = TestClient(app)

        r = client.get("/v1/audit/recent?n=10")
        assert r.status_code == 200
        data = r.json()
        assert len(data["entries"]) == 2
        assert data["entries"][0].get("outcome", data["entries"][0].get("action")) == "PASS"
        assert data["entries"][1].get("outcome", data["entries"][1].get("action")) == "HARD_STOP"

    def test_health_audit_not_configured_status_ok(self, monkeypatch):
        """When audit_log not configured: audit_writable=null, status=ok (not degraded)."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        from aurora_lens.proxy.app import create_app

        monkeypatch.setattr(
            "aurora_lens.proxy.app._build_provider_adapters",
            lambda cfg: (MockAdapter(), MockAdapter()),
        )
        cfg = ProxyConfig.from_mapping({
            "upstream": {"provider": "openai", "api_key": "sk-test", "model": "gpt-4"},
            "governance": {"audit_log": None},
        })
        app = create_app(cfg)
        client = TestClient(app)
        r = client.get("/health")
        assert r.status_code == 200
        data = r.json()
        assert data["audit_writable"] is None
        assert data["status"] == "ok"

    def test_audit_recent_no_config_returns_404(self, monkeypatch):
        """/v1/audit/recent returns 404 when audit_log not configured."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        from aurora_lens.proxy.app import create_app

        monkeypatch.setattr(
            "aurora_lens.proxy.app._build_provider_adapters",
            lambda cfg: (MockAdapter(), MockAdapter()),
        )
        cfg = ProxyConfig.from_mapping({
            "upstream": {"provider": "openai", "api_key": "sk-test", "model": "gpt-4"},
            "governance": {"audit_log": None},
        })
        app = create_app(cfg)
        client = TestClient(app)
        r = client.get("/v1/audit/recent")
        assert r.status_code == 404

    def test_audit_anomaly_check_returns_rates_and_alerts(self, monkeypatch, tmp_path):
        """P.3: /v1/audit/anomaly-check computes rates; alerts when threshold exceeded."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        from aurora_lens.proxy.app import create_app

        monkeypatch.setattr(
            "aurora_lens.proxy.app._build_provider_adapters",
            lambda cfg: (MockAdapter(), MockAdapter()),
        )
        # 3 PASS, 2 FORCE_REVISE, 1 HARD_STOP with EXTRACTION_FAILED -> interv=3/6=50%, extr=1/6~17%
        # Timestamps relative to now so window=24h includes them (avoids flakiness when run on different days)
        base = datetime.now(timezone.utc) - timedelta(minutes=6)

        def ts(i: int) -> str:
            return (base + timedelta(minutes=i)).strftime("%Y-%m-%dT%H:%M:%SZ")

        entries = [
            f'{{"schema_version":2,"outcome":"PASS","timestamp":"{ts(0)}"}}\n',
            f'{{"schema_version":2,"outcome":"PASS","timestamp":"{ts(1)}"}}\n',
            f'{{"schema_version":2,"outcome":"PASS","timestamp":"{ts(2)}"}}\n',
            f'{{"schema_version":2,"outcome":"FORCE_REVISE","timestamp":"{ts(3)}"}}\n',
            f'{{"schema_version":2,"outcome":"FORCE_REVISE","timestamp":"{ts(4)}"}}\n',
            f'{{"schema_version":2,"outcome":"HARD_STOP","forensic_event":{{"failed_constraints":["EXTRACTION_FAILED"]}},"timestamp":"{ts(5)}"}}\n',
        ]
        audit_file = tmp_path / "audit.jsonl"
        audit_file.write_text("".join(entries))
        cfg = ProxyConfig.from_mapping({
            "upstream": {"provider": "openai", "api_key": "sk-test", "model": "gpt-4"},
            "governance": {
                "audit_log": str(audit_file),
                "audit_backend": "jsonl",
                "threshold_intervention_rate": 0.4,
                "threshold_extraction_failure_rate": 0.1,
            },
        })
        app = create_app(cfg)
        client = TestClient(app)

        r = client.get("/v1/audit/anomaly-check?window=24h")
        assert r.status_code == 200
        data = r.json()
        assert data["total_entries"] == 6
        assert data["intervention_rate"] == pytest.approx(0.5, rel=0.01)
        assert data["extraction_failure_rate"] == pytest.approx(1 / 6, rel=0.01)
        assert any("intervention_rate" in a for a in data["alerts"])
        assert any("extraction_failure_rate" in a for a in data["alerts"])

        # No threshold -> no alerts
        cfg2 = ProxyConfig.from_mapping({
            "upstream": {"provider": "openai", "api_key": "sk-test", "model": "gpt-4"},
            "governance": {"audit_log": str(audit_file), "audit_backend": "jsonl"},
        })
        app2 = create_app(cfg2)
        client2 = TestClient(app2)
        r2 = client2.get("/v1/audit/anomaly-check?window=24h")
        assert r2.status_code == 200
        assert r2.json()["alerts"] == []


class TestPhase5Hardening:
    """Phase 5: Input validation, rate limits, metrics."""

    def _make_app(self, monkeypatch, hardening: dict | None = None):
        from aurora_lens.proxy.app import create_app
        monkeypatch.setattr(
            "aurora_lens.proxy.app._build_provider_adapters",
            lambda cfg: (MockAdapter(), MockAdapter()),
        )
        cfg = ProxyConfig.from_mapping({
            "upstream": {"provider": "openai", "api_key": "sk-test", "model": "gpt-4"},
            "hardening": hardening or {},
        })
        return create_app(cfg)

    def test_payload_too_large_returns_413(self, monkeypatch):
        """Payload exceeding max_payload_bytes returns 413."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        app = self._make_app(monkeypatch, {"max_payload_bytes": 100})
        client = TestClient(app)
        # 100 bytes = limit; we need > 100
        body = json.dumps({"model": "gpt-4", "messages": [{"role": "user", "content": "x" * 50}]})
        assert len(body) > 100
        r = client.post("/v1/chat/completions", content=body, headers={"Content-Type": "application/json"})
        assert r.status_code == 413
        assert "Payload too large" in r.json()["error"]["message"]

    def test_too_many_messages_returns_422(self, monkeypatch):
        """Messages count exceeding max_messages returns 422."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        app = self._make_app(monkeypatch, {"max_messages": 3})
        client = TestClient(app)
        msgs = [{"role": "user", "content": "Hi"}] * 4
        r = client.post("/v1/chat/completions", json={"model": "gpt-4", "messages": msgs})
        assert r.status_code == 422
        assert "Too many messages" in r.json()["error"]["message"]

    def test_content_too_long_returns_422(self, monkeypatch):
        """Total content exceeding max_content_chars returns 422."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        app = self._make_app(monkeypatch, {"max_content_chars": 50})
        client = TestClient(app)
        r = client.post("/v1/chat/completions", json={
            "model": "gpt-4",
            "messages": [{"role": "user", "content": "x" * 51}],
        })
        assert r.status_code == 422
        assert "content too long" in r.json()["error"]["message"].lower()

    def test_rate_limit_global_returns_429(self, monkeypatch):
        """Global rate limit returns 429 when exceeded."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        app = self._make_app(monkeypatch, {"rate_limit_global": 2})
        client = TestClient(app)
        for _ in range(2):
            r = client.post("/v1/chat/completions", json={"model": "gpt-4", "messages": [{"role": "user", "content": "Hi"}]})
            assert r.status_code == 200
        r = client.post("/v1/chat/completions", json={"model": "gpt-4", "messages": [{"role": "user", "content": "Hi"}]})
        assert r.status_code == 429
        assert "Rate limit" in r.json()["error"]["message"]
        assert "Retry-After" in r.headers

    def test_rate_limit_per_session_returns_429(self, monkeypatch):
        """Per-session rate limit returns 429 when exceeded."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        app = self._make_app(monkeypatch, {"rate_limit_per_session": 2})
        client = TestClient(app)
        for _ in range(2):
            r = client.post(
                "/v1/chat/completions",
                json={"model": "gpt-4", "messages": [{"role": "user", "content": "Hi"}]},
                headers={"x-aurora-session-id": "session-1"},
            )
            assert r.status_code == 200
        r = client.post(
            "/v1/chat/completions",
            json={"model": "gpt-4", "messages": [{"role": "user", "content": "Hi"}]},
            headers={"x-aurora-session-id": "session-1"},
        )
        assert r.status_code == 429
        assert "Rate limit" in r.json()["error"]["message"]

    def test_metrics_endpoint_returns_prometheus_format(self, monkeypatch):
        """GET /metrics returns Prometheus exposition format."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        app = self._make_app(monkeypatch)
        client = TestClient(app)
        r = client.get("/metrics")
        assert r.status_code == 200
        assert r.headers.get("Content-Type", "").startswith("text/plain")
        text = r.text
        assert "aurora_lens_uptime_seconds" in text
        assert "# HELP" in text
        assert "# TYPE" in text

    def test_metrics_increments_on_chat_completion(self, monkeypatch):
        """chat_completions increments metrics."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        from aurora_lens.proxy.metrics import get_metrics
        get_metrics().reset()

        app = self._make_app(monkeypatch)
        client = TestClient(app)
        r = client.post("/v1/chat/completions", json={"model": "gpt-4", "messages": [{"role": "user", "content": "Hi"}]})
        assert r.status_code == 200

        r2 = client.get("/metrics")
        assert "chat_completions_total" in r2.text

    def test_metrics_help_type_once_per_base(self, monkeypatch):
        """HELP/TYPE emitted once per base metric, not per series."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        from aurora_lens.proxy.metrics import get_metrics
        m = get_metrics()
        m.reset()
        m.inc("chat_completions_total", labels={"outcome": "PASS"})
        m.inc("chat_completions_total", labels={"outcome": "REFUSE"})

        text = m.to_prometheus()
        help_count = text.count("# HELP aurora_lens_chat_completions_total")
        type_count = text.count("# TYPE aurora_lens_chat_completions_total")
        assert help_count == 1, "HELP should appear once per base"
        assert type_count == 1, "TYPE should appear once per base"
        assert "outcome=\"PASS\"" in text
        assert "outcome=\"REFUSE\"" in text

    def test_metrics_label_escaping(self):
        """Label values escape \\ \" and newlines."""
        from aurora_lens.proxy.metrics import get_metrics
        m = get_metrics()
        m.reset()
        m.inc("test_metric", labels={"x": 'a"b'})

        text = m.to_prometheus()
        # Unescaped " would break Prometheus; must be \"
        assert 'x="a\\"b"' in text


class TestPhaseBAuth:
    """Phase B: Inbound authentication."""

    def _make_app(self, monkeypatch, auth: dict | None = None, hardening: dict | None = None):
        from aurora_lens.proxy.app import create_app
        monkeypatch.setattr(
            "aurora_lens.proxy.app._build_provider_adapters",
            lambda cfg: (MockAdapter(), MockAdapter()),
        )
        cfg_map = {
            "upstream": {"provider": "openai", "api_key": "sk-test", "model": "gpt-4"},
            "hardening": hardening or {},
        }
        if auth is not None:
            cfg_map["auth"] = auth
        cfg = ProxyConfig.from_mapping(cfg_map)
        return create_app(cfg)

    def test_auth_disabled_allows_unauthenticated(self, monkeypatch):
        """When auth.enabled=false, requests without key succeed."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        app = self._make_app(monkeypatch, auth={"enabled": False})
        client = TestClient(app)
        r = client.post("/v1/chat/completions", json={"model": "gpt-4", "messages": [{"role": "user", "content": "Hi"}]})
        assert r.status_code == 200

    def test_auth_required_401_on_missing_key(self, monkeypatch):
        """When auth enabled, missing key returns 401."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        app = self._make_app(monkeypatch, auth={
            "enabled": True,
            "keys": [{"key": "secret-key-1", "label": "app-prod"}],
        })
        client = TestClient(app)
        r = client.post("/v1/chat/completions", json={"model": "gpt-4", "messages": [{"role": "user", "content": "Hi"}]})
        assert r.status_code == 401
        assert "Missing" in r.json()["error"]["message"]
        assert "Aurora-Trace-Id" in r.headers

    def test_auth_403_on_invalid_key(self, monkeypatch):
        """When auth enabled, invalid key returns 403."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        app = self._make_app(monkeypatch, auth={
            "enabled": True,
            "keys": [{"key": "secret-key-1", "label": "app-prod"}],
        })
        client = TestClient(app)
        r = client.post(
            "/v1/chat/completions",
            json={"model": "gpt-4", "messages": [{"role": "user", "content": "Hi"}]},
            headers={"Authorization": "Bearer wrong-key"},
        )
        assert r.status_code == 403
        assert "Invalid" in r.json()["error"]["message"]

    def test_auth_bearer_succeeds(self, monkeypatch):
        """Authorization: Bearer <key> authenticates successfully."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        app = self._make_app(monkeypatch, auth={
            "enabled": True,
            "keys": [{"key": "secret-key-1", "label": "app-prod"}],
        })
        client = TestClient(app)
        r = client.post(
            "/v1/chat/completions",
            json={"model": "gpt-4", "messages": [{"role": "user", "content": "Hi"}]},
            headers={"Authorization": "Bearer secret-key-1"},
        )
        assert r.status_code == 200

    def test_auth_x_api_key_succeeds(self, monkeypatch):
        """x-api-key header authenticates successfully."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        app = self._make_app(monkeypatch, auth={
            "enabled": True,
            "keys": [{"key": "secret-key-1", "label": "app-prod"}],
        })
        client = TestClient(app)
        r = client.post(
            "/v1/chat/completions",
            json={"model": "gpt-4", "messages": [{"role": "user", "content": "Hi"}]},
            headers={"x-api-key": "secret-key-1"},
        )
        assert r.status_code == 200

    def test_auth_health_bypass(self, monkeypatch):
        """Health endpoints bypass auth."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        app = self._make_app(monkeypatch, auth={
            "enabled": True,
            "keys": [{"key": "secret-key-1", "label": "app-prod"}],
        })
        client = TestClient(app)
        r = client.get("/health")
        assert r.status_code == 200
        r = client.get("/healthz")
        assert r.status_code == 200

    def test_auth_label_in_audit_entry(self, monkeypatch, tmp_path):
        """Auth key label appears in audit entry when auth enabled."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        audit_file = tmp_path / "audit.jsonl"
        monkeypatch.setattr(
            "aurora_lens.proxy.app._build_provider_adapters",
            lambda cfg: (MockAdapter(), MockAdapter()),
        )
        cfg = ProxyConfig.from_mapping({
            "upstream": {"provider": "openai", "api_key": "sk-test", "model": "gpt-4"},
            "governance": {"default_policy": "strict", "audit_log": str(audit_file), "audit_backend": "jsonl"},
            "auth": {"enabled": True, "keys": [{"key": "secret-key-1", "label": "app-production"}]},
        })
        from aurora_lens.proxy.app import create_app
        app = create_app(cfg)
        client = TestClient(app)

        # Use external_flags to trigger HARD_STOP so an audit entry is written (PASS does not log)
        r = client.post(
            "/v1/chat/completions",
            json={
                "model": "gpt-4",
                "messages": [{"role": "user", "content": "Hi"}],
                "aurora": {
                    "external_flags": [
                        {"type": "SELF_HARM_INSTRUCTION", "evidence": ["test span"], "severity": "error"},
                    ],
                },
            },
            headers={"Authorization": "Bearer secret-key-1"},
        )
        assert r.status_code == 200

        lines = [ln for ln in audit_file.read_text().splitlines() if ln.strip()]
        assert len(lines) >= 1
        entry = json.loads(lines[-1])
        assert entry.get("tenant_label") == "app-production"
        assert entry.get("outcome") == "HARD_STOP"

    def test_audit_provenance_extractor_backend_matches_config_and_no_llm_output(self, monkeypatch, tmp_path):
        """Proxy evidence: extractor_backend matches configured backend; provenance never llm_output for normal turns."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")
        pytest.importorskip("spacy")

        audit_file = tmp_path / "audit.jsonl"
        monkeypatch.setattr(
            "aurora_lens.proxy.app._build_provider_adapters",
            lambda cfg: (MockAdapter(), MockAdapter()),
        )
        cfg = ProxyConfig.from_mapping({
            "upstream": {"provider": "openai", "api_key": "sk-test", "model": "gpt-4"},
            "extraction": {"backend": "spacy"},
            "governance": {"default_policy": "strict", "audit_log": str(audit_file), "audit_backend": "jsonl"},
        })
        from aurora_lens.proxy.app import create_app
        app = create_app(cfg)
        client = TestClient(app)

        # Message that triggers spaCy extraction of claims; external_flags force HARD_STOP so pef_snapshot is stored
        r = client.post(
            "/v1/chat/completions",
            json={
                "model": "gpt-4",
                "messages": [{"role": "user", "content": "Emma has a red book."}],
                "aurora": {
                    "external_flags": [
                        {"type": "SELF_HARM_INSTRUCTION", "evidence": ["test"], "severity": "error"},
                    ],
                },
            },
        )
        assert r.status_code == 200

        lines = [ln for ln in audit_file.read_text().splitlines() if ln.strip()]
        assert len(lines) >= 1
        entry = json.loads(lines[-1])
        assert entry.get("outcome") == "HARD_STOP"

        snap = entry.get("pef_snapshot")
        assert snap is not None, "HARD_STOP entry should include pef_snapshot"
        rels = snap.get("relationships") or []
        # "Emma has a red book." should produce at least one claim via spaCy
        assert len(rels) >= 1, "Expected spaCy to extract claims from 'Emma has a red book.'"
        for rel in rels:
            assert rel.get("provenance") in ("user_input", "pre_populated", "system"), (
                "Normal turns must not mark LLM output as ground truth"
            )
            assert rel.get("provenance") != "llm_output"
            assert rel.get("extractor_backend") == "spacy", (
                "extractor_backend must match configured extraction backend"
            )

    def test_key_rotation_two_keys_same_label(self, monkeypatch):
        """Key rotation: two valid keys for same label work; deploy new without breaking old."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        app = self._make_app(monkeypatch, auth={
            "enabled": True,
            "keys": [
                {"key": "old-key", "label": "app-prod", "policy": "strict"},
                {"key": "new-key", "label": "app-prod", "policy": "strict"},
            ],
        })
        client = TestClient(app)

        r_old = client.post(
            "/v1/chat/completions",
            json={"model": "gpt-4", "messages": [{"role": "user", "content": "Hi"}]},
            headers={"Authorization": "Bearer old-key"},
        )
        r_new = client.post(
            "/v1/chat/completions",
            json={"model": "gpt-4", "messages": [{"role": "user", "content": "Hi"}]},
            headers={"Authorization": "Bearer new-key"},
        )
        assert r_old.status_code == 200
        assert r_new.status_code == 200

    def test_key_no_policy_uses_deployment_default(self, monkeypatch, tmp_path):
        """Key with no policy uses governance.default_policy (deployment default) deterministically."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        audit_file = tmp_path / "audit.jsonl"
        monkeypatch.setattr(
            "aurora_lens.proxy.app._build_provider_adapters",
            lambda cfg: (MockAdapter(), MockAdapter()),
        )
        cfg = ProxyConfig.from_mapping({
            "upstream": {"provider": "openai", "api_key": "sk-test", "model": "gpt-4"},
            "governance": {"default_policy": "strict", "audit_log": str(audit_file), "audit_backend": "jsonl"},
            "auth": {
                "enabled": True,
                "keys": [{"key": "no-policy-key", "label": "app-default"}],
            },
        })
        from aurora_lens.proxy.app import create_app
        app = create_app(cfg)
        client = TestClient(app)

        payload = {
            "model": "gpt-4",
            "messages": [{"role": "user", "content": "Hi"}],
            "aurora": {
                "external_flags": [
                    {"type": "UNBOUND_ENTITY", "evidence": ["test"], "severity": "warning"},
                ],
            },
        }
        r = client.post("/v1/chat/completions", json=payload, headers={"Authorization": "Bearer no-policy-key"})
        assert r.status_code == 200
        lines = [ln for ln in audit_file.read_text().splitlines() if ln.strip()]
        entry = json.loads(lines[-1])
        assert entry.get("policy_profile") == "strict"

    def test_per_key_policy_override(self, monkeypatch, tmp_path):
        """Per-key policy override: staging key gets moderate, production key gets strict."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        audit_file = tmp_path / "audit.jsonl"
        monkeypatch.setattr(
            "aurora_lens.proxy.app._build_provider_adapters",
            lambda cfg: (MockAdapter(), MockAdapter()),
        )
        cfg = ProxyConfig.from_mapping({
            "upstream": {"provider": "openai", "api_key": "sk-test", "model": "gpt-4"},
            "governance": {"default_policy": "strict", "audit_log": str(audit_file), "audit_backend": "jsonl"},
            "auth": {
                "enabled": True,
                "keys": [
                    {"key": "prod-key", "label": "app-production", "policy": "strict"},
                    {"key": "staging-key", "label": "app-staging", "policy": "moderate"},
                ],
            },
        })
        from aurora_lens.proxy.app import create_app
        app = create_app(cfg)
        client = TestClient(app)

        payload = {
            "model": "gpt-4",
            "messages": [{"role": "user", "content": "Hi"}],
            "aurora": {
                "external_flags": [
                    {"type": "UNBOUND_ENTITY", "evidence": ["test"], "severity": "warning"},
                ],
            },
        }

        # Production key (strict): UNBOUND_ENTITY -> FORCE_REVISE
        r1 = client.post("/v1/chat/completions", json=payload, headers={"Authorization": "Bearer prod-key"})
        assert r1.status_code == 200
        lines = [ln for ln in audit_file.read_text().splitlines() if ln.strip()]
        entry1 = json.loads(lines[-1])
        assert entry1.get("policy_profile") == "strict"
        assert entry1.get("outcome") == "FORCE_REVISE"

        # Staging key (moderate): UNBOUND_ENTITY -> SOFT_CORRECT
        r2 = client.post("/v1/chat/completions", json=payload, headers={"Authorization": "Bearer staging-key"})
        assert r2.status_code == 200
        lines = [ln for ln in audit_file.read_text().splitlines() if ln.strip()]
        entry2 = json.loads(lines[-1])
        assert entry2.get("policy_profile") == "moderate"
        assert entry2.get("outcome") == "SOFT_CORRECT"

    def test_rate_limit_per_ip_returns_429(self, monkeypatch):
        """Per-IP rate limit returns 429 when exceeded."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        app = self._make_app(monkeypatch, hardening={"rate_limit_per_ip": 2})
        client = TestClient(app)
        for _ in range(2):
            r = client.post("/v1/chat/completions", json={"model": "gpt-4", "messages": [{"role": "user", "content": "Hi"}]})
            assert r.status_code == 200
        r = client.post("/v1/chat/completions", json={"model": "gpt-4", "messages": [{"role": "user", "content": "Hi"}]})
        assert r.status_code == 429
        assert "Rate limit" in r.json()["error"]["message"]
        assert "per IP" in r.json()["error"]["message"].lower() or "Rate limit" in r.json()["error"]["message"]


class TestPhaseDAuditIntegrity:
    """Phase D: HMAC signing, rotation, verify endpoint."""

    def test_hmac_computed_correctly(self, tmp_path):
        """Entries with audit_signing_key include valid HMAC."""
        from aurora_lens.govern.audit_io import append_audit_entry, verify_audit_entries

        path = tmp_path / "audit.jsonl"
        key = b"test-signing-key-32bytes!!"
        entry = {"trace_id": "t1", "action": "PASS", "ts": "2026-01-01T00:00:00Z"}
        append_audit_entry(path, entry, signing_key=key)
        verified, n, failed = verify_audit_entries(path, 10, key)
        assert verified is True
        assert n == 1
        assert failed is None
        raw = path.read_text()
        data = json.loads(raw.strip())
        assert "hmac" in data
        assert data["trace_id"] == "t1"

    def test_tampered_entry_fails_verification(self, tmp_path):
        """Entry with tampered field fails verification."""
        from aurora_lens.govern.audit_io import append_audit_entry, verify_audit_entries

        path = tmp_path / "audit.jsonl"
        key = b"test-signing-key-32bytes!!"
        append_audit_entry(path, {"trace_id": "t1", "action": "PASS"}, signing_key=key)
        lines = path.read_text().splitlines()
        data = json.loads(lines[0])
        data["action"] = "TAMPERED"
        path.write_text(json.dumps(data, separators=(",", ":")) + "\n")
        verified, n, failed = verify_audit_entries(path, 10, key)
        assert verified is False
        assert failed == "t1"

    def test_rotation_fires_at_threshold(self, tmp_path):
        """Log rotates when size exceeds max_mb."""
        from aurora_lens.govern.audit_io import append_audit_entry

        path = tmp_path / "audit.jsonl"
        # Fill file to exceed 0.001 MB (~1 KB), then write with rotation enabled
        chunk = {"trace_id": "x", "payload": "y" * 400}  # ~450 bytes per entry
        append_audit_entry(path, chunk, max_mb=0)
        append_audit_entry(path, {**chunk, "i": 1}, max_mb=0)
        append_audit_entry(path, {**chunk, "i": 2}, max_mb=0)  # ~1.35 KB total
        size_before = path.stat().st_size
        rotated = Path(str(path) + ".1")
        append_audit_entry(path, {"trace_id": "new"}, max_mb=0)  # 0 = no rotation
        assert not rotated.exists()
        append_audit_entry(path, {"trace_id": "after"}, max_mb=1)  # 1 MB threshold
        assert path.exists()
        # Verify rotation logic runs (max_mb > 0) without error
        path2 = tmp_path / "audit2.jsonl"
        append_audit_entry(path2, {"a": 1}, max_mb=100)
        assert path2.exists()

    def test_verify_endpoint_returns_correct_result(self, monkeypatch, tmp_path):
        """GET /v1/audit/verify returns verified/entries."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        monkeypatch.setattr(
            "aurora_lens.proxy.app._build_provider_adapters",
            lambda cfg: (MockAdapter(), MockAdapter()),
        )
        audit_file = tmp_path / "audit.jsonl"
        key = "test-signing-key-for-verify"
        cfg = ProxyConfig.from_mapping({
            "upstream": {"provider": "openai", "api_key": "sk-test", "model": "gpt-4"},
            "governance": {
                "audit_log": str(audit_file),
                "audit_backend": "jsonl",
                "audit_signing_key": key,
            },
        })
        from aurora_lens.proxy.app import create_app
        app = create_app(cfg)
        client = TestClient(app)

        # Write a signed entry directly using the resolved key (apply_env_overrides may
        # have substituted AURORA_LENS_AUDIT_SIGNING_KEY from the environment).
        from aurora_lens.govern.audit_io import append_audit_entry
        resolved_key = (cfg.governance.audit_signing_key or key).encode("utf-8")
        append_audit_entry(audit_file, {"trace_id": "v1", "action": "PASS"}, signing_key=resolved_key)

        r = client.get("/v1/audit/verify?n=10")
        assert r.status_code == 200
        data = r.json()
        assert data["verified"] is True
        assert data["entries"] == 1

    def test_verify_endpoint_requires_signing_key(self, monkeypatch, tmp_path):
        """Verify returns 400 when audit_signing_key not configured."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        monkeypatch.setattr(
            "aurora_lens.proxy.app._build_provider_adapters",
            lambda cfg: (MockAdapter(), MockAdapter()),
        )
        # Clear env var so apply_env_overrides does not supply a key for this test.
        monkeypatch.delenv("AURORA_LENS_AUDIT_SIGNING_KEY", raising=False)
        audit_file = tmp_path / "audit.jsonl"
        cfg = ProxyConfig.from_mapping({
            "upstream": {"provider": "openai", "api_key": "sk-test", "model": "gpt-4"},
            "governance": {"audit_log": str(audit_file), "audit_backend": "jsonl"},
        })
        from aurora_lens.proxy.app import create_app
        app = create_app(cfg)
        client = TestClient(app)

        r = client.get("/v1/audit/verify?n=10")
        assert r.status_code == 400
        assert "audit_signing_key" in r.json()["error"]["message"]

    def test_verify_cli_ok_on_valid_chain(self, tmp_path):
        """Verify CLI exits 0 when chain and HMAC valid."""
        from aurora_lens.govern.audit_io import append_audit_entry, CHAIN_GENESIS

        path = tmp_path / "audit.jsonl"
        key = b"test-key-32bytes!!!!!!!!!!!!!!!!"
        prev = CHAIN_GENESIS
        for i in range(3):
            entry = {"schema_version": 2, "trace_id": f"t{i}", "outcome": "PASS"}
            cid = append_audit_entry(path, entry, signing_key=key, prev_cid=prev)
            prev = cid or prev

        result = __import__("subprocess").run(
            [__import__("sys").executable, "-m", "aurora_lens.scripts.verify_audit", "--path", str(path), "--key", key.decode()],
            capture_output=True,
            text=True,
            cwd=str(tmp_path),
        )
        assert result.returncode == 0
        assert "OK" in result.stdout

    def test_verify_cli_replay_included_by_default(self, tmp_path):
        """Verify CLI runs replay verification; exits 0 when state_hash matches."""
        from aurora_lens.govern.audit_io import append_audit_entry, CHAIN_GENESIS
        from aurora_lens.govern.bridge import build_forensic_event
        from aurora_lens.govern.decision import GovernanceDecision, InterventionAction
        from aurora_lens.pef.state import PEFState

        path = tmp_path / "audit.jsonl"
        key = b"test-key-32bytes!!!!!!!!!!!!!!!!"
        pef = PEFState()
        pef_snap = pef.to_dict()
        decision = GovernanceDecision(action=InterventionAction.HARD_STOP, flags=[], rationale="test", policy="strict")
        fe = build_forensic_event(decision, pre_llm=False, pef_snapshot=pef_snap, trace_id="t1", timestamp="2026-02-24T12:00:00Z", audit_id="c1")
        prev = CHAIN_GENESIS
        for i in range(2):
            entry = {"schema_version": 2, "trace_id": f"t{i}", "outcome": "PASS"}
            cid = append_audit_entry(path, entry, signing_key=key, prev_cid=prev)
            prev = cid or prev
        entry = {"schema_version": 2, "trace_id": "t2", "outcome": "HARD_STOP", "forensic_event": fe, "pef_snapshot": pef_snap}
        cid = append_audit_entry(path, entry, signing_key=key, prev_cid=prev)
        prev = cid or prev

        result = __import__("subprocess").run(
            [__import__("sys").executable, "-m", "aurora_lens.scripts.verify_audit", "--path", str(path), "--key", key.decode()],
            capture_output=True,
            text=True,
            cwd=str(tmp_path),
        )
        assert result.returncode == 0
        assert "OK" in result.stdout
        assert "state_hash" in result.stdout or "replayed" in result.stdout

    def test_verify_cli_no_replay_skips_replay(self, tmp_path):
        """Verify CLI with --no-replay skips replay verification."""
        from aurora_lens.govern.audit_io import append_audit_entry, CHAIN_GENESIS

        path = tmp_path / "audit.jsonl"
        key = b"test-key-32bytes!!!!!!!!!!!!!!!!"
        prev = CHAIN_GENESIS
        for i in range(2):
            entry = {"schema_version": 2, "trace_id": f"t{i}", "outcome": "PASS"}
            cid = append_audit_entry(path, entry, signing_key=key, prev_cid=prev)
            prev = cid or prev

        result = __import__("subprocess").run(
            [__import__("sys").executable, "-m", "aurora_lens.scripts.verify_audit", "--path", str(path), "--key", key.decode(), "--no-replay"],
            capture_output=True,
            text=True,
            cwd=str(tmp_path),
        )
        assert result.returncode == 0
        assert "OK" in result.stdout

    def test_verify_cli_fail_on_chain_break(self, tmp_path):
        """Verify CLI exits 1 when chain broken."""
        from aurora_lens.govern.audit_io import append_audit_entry, CHAIN_GENESIS

        path = tmp_path / "audit.jsonl"
        key = b"test-key-32bytes!!!!!!!!!!!!!!!!"
        prev = CHAIN_GENESIS
        for i in range(2):
            entry = {"schema_version": 2, "trace_id": f"t{i}", "outcome": "PASS"}
            cid = append_audit_entry(path, entry, signing_key=key, prev_cid=prev)
            prev = cid or prev
        lines = path.read_text().splitlines()
        data = json.loads(lines[-1])
        data["outcome"] = "TAMPERED"
        path.write_text("\n".join(lines[:-1] + [json.dumps(data, separators=(",", ":"))]))

        result = __import__("subprocess").run(
            [__import__("sys").executable, "-m", "aurora_lens.scripts.verify_audit", "--path", str(path), "--key", key.decode()],
            capture_output=True,
            text=True,
            cwd=str(tmp_path),
        )
        assert result.returncode == 1
        assert "FAIL" in result.stderr

    def test_anchor_ledger_writes_signed_record(self, tmp_path):
        """Anchor script reads last cid, writes signed anchor record."""
        from aurora_lens.govern.audit_io import append_audit_entry, CHAIN_GENESIS

        path = tmp_path / "audit.jsonl"
        key = b"test-key-32bytes!!!!!!!!!!!!!!!!"
        prev = CHAIN_GENESIS
        entry = {"schema_version": 2, "trace_id": "t1", "outcome": "PASS"}
        append_audit_entry(path, entry, signing_key=key, prev_cid=prev)

        out_path = tmp_path / "anchors.jsonl"
        result = __import__("subprocess").run(
            [
                __import__("sys").executable,
                str(Path(__file__).resolve().parents[1] / "scripts" / "anchor_ledger.py"),
                "--path", str(path),
                "--out", str(out_path),
                "--key", key.decode(),
            ],
            capture_output=True,
            text=True,
            cwd=str(tmp_path),
        )
        assert result.returncode == 0
        lines = out_path.read_text().strip().splitlines()
        assert len(lines) == 1
        anchor = json.loads(lines[0])
        assert "anchor_cid" in anchor
        assert "anchor_time" in anchor
        assert "ledger_path" in anchor
        assert anchor["entry_count"] == 1
        assert "anchor_hmac" in anchor

    def test_multi_key_verification_spanning_rotation(self, tmp_path):
        """D4: Verify log spanning key rotation with --keys key1,key2."""
        from aurora_lens.govern.audit_io import append_audit_entry, verify_audit_entries, verify_chain, CHAIN_GENESIS

        path = tmp_path / "audit.jsonl"
        key1 = b"old-key-32bytes!!!!!!!!!!!!!!!!"
        key2 = b"new-key-32bytes!!!!!!!!!!!!!!!!!"
        prev = CHAIN_GENESIS
        for i in range(2):
            entry = {"schema_version": 2, "trace_id": f"t{i}", "outcome": "PASS"}
            cid = append_audit_entry(path, entry, signing_key=key1, prev_cid=prev)
            prev = cid or prev
        for i in range(2, 4):
            entry = {"schema_version": 2, "trace_id": f"t{i}", "outcome": "PASS"}
            cid = append_audit_entry(path, entry, signing_key=key2, prev_cid=prev)
            prev = cid or prev

        hmac_ok, n, _ = verify_audit_entries(path, 10, signing_keys=[key1, key2])
        assert hmac_ok, "HMAC failed with both keys"
        assert n == 4
        chain_ok, _, _, _ = verify_chain(path, 10, signing_keys=[key1, key2])
        assert chain_ok

    def test_checkpoint_entry_written_and_verified(self, tmp_path):
        """D3 Option 2: Checkpoint written every N entries, verifier accepts it."""
        from aurora_lens.govern.audit_io import append_audit_entry, append_checkpoint_entry, verify_chain, CHAIN_GENESIS

        path = tmp_path / "audit.jsonl"
        key = b"test-key-32bytes!!!!!!!!!!!!!!!!"
        prev = CHAIN_GENESIS
        for i in range(2):
            entry = {"schema_version": 2, "trace_id": f"t{i}", "outcome": "PASS"}
            cid = append_audit_entry(path, entry, signing_key=key, prev_cid=prev)
            prev = cid or prev
        new_cid = append_checkpoint_entry(path, prev, signing_key=key)
        assert new_cid is not None

        lines = path.read_text().strip().splitlines()
        assert len(lines) == 3
        checkpoint = json.loads(lines[-1])
        assert checkpoint.get("type") == "checkpoint"
        assert "checkpoint_cid" in checkpoint
        assert "checkpoint_sig" in checkpoint

        chain_ok, n, _, _ = verify_chain(path, 10, signing_key=key)
        assert chain_ok
        assert n == 3


    def test_verify_endpoint_multi_key(self, monkeypatch, tmp_path):
        """Verify endpoint tries all configured keys (spanning key rotation)."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        monkeypatch.setattr(
            "aurora_lens.proxy.app._build_provider_adapters",
            lambda cfg: (MockAdapter(), MockAdapter()),
        )
        # Clear env vars so apply_env_overrides uses the explicit test keys.
        monkeypatch.delenv("AURORA_LENS_AUDIT_SIGNING_KEY", raising=False)
        monkeypatch.delenv("AURORA_LENS_AUDIT_SIGNING_KEYS", raising=False)
        audit_file = tmp_path / "audit.jsonl"
        old_key = "old-signing-key"
        new_key = "new-signing-key"
        cfg = ProxyConfig.from_mapping({
            "upstream": {"provider": "openai", "api_key": "sk-test", "model": "gpt-4"},
            "governance": {
                "audit_log": str(audit_file),
                "audit_backend": "jsonl",
                "audit_signing_key": new_key,
                "audit_signing_keys": [old_key],
            },
        })
        assert cfg.governance.audit_signing_keys == (old_key,)

        from aurora_lens.proxy.app import create_app
        from aurora_lens.govern.audit_io import append_audit_entry, CHAIN_GENESIS
        app = create_app(cfg)
        client = TestClient(app)

        # Write two entries: first with old key, second with new key
        prev = CHAIN_GENESIS
        prev = append_audit_entry(
            audit_file, {"trace_id": "r1", "action": "PASS"},
            signing_key=old_key.encode(), prev_cid=prev,
        )
        append_audit_entry(
            audit_file, {"trace_id": "r2", "action": "PASS"},
            signing_key=new_key.encode(), prev_cid=prev,
        )

        r = client.get("/v1/audit/verify?n=10")
        assert r.status_code == 200
        data = r.json()
        assert data["verified"] is True, f"expected verified, got: {data}"
        assert data["hmac_verified"] is True
        assert data["chain_verified"] is True
        assert data["backend"] == "jsonl"

    def test_verify_endpoint_unanchored_slice(self, monkeypatch, tmp_path):
        """Tail-slice: verify last N of M>N entries → reason='unanchored_slice', chain_verified=True.

        Regression for the bug where verify_chain() reset prev_cid=CHAIN_GENESIS on every
        non-chained entry, causing genuine tail slices to be misreported as chain_break.
        The first chained entry in the window links to a predecessor outside the window —
        this is a normal, expected outcome of partial verification, not a chain failure.
        """
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        monkeypatch.setattr(
            "aurora_lens.proxy.app._build_provider_adapters",
            lambda cfg: (MockAdapter(), MockAdapter()),
        )
        monkeypatch.delenv("AURORA_LENS_AUDIT_SIGNING_KEY", raising=False)
        monkeypatch.delenv("AURORA_LENS_AUDIT_SIGNING_KEYS", raising=False)

        audit_file = tmp_path / "audit.jsonl"
        key = "test-key-32bytes!!!!!!!!!!!!!!!!"
        cfg = ProxyConfig.from_mapping({
            "upstream": {"provider": "openai", "api_key": "sk-test", "model": "gpt-4"},
            "governance": {
                "audit_log": str(audit_file),
                "audit_backend": "jsonl",
                "audit_signing_key": key,
            },
        })

        from aurora_lens.proxy.app import create_app
        from aurora_lens.govern.audit_io import append_audit_entry, CHAIN_GENESIS
        app = create_app(cfg)
        client = TestClient(app)

        # Write 10 chained entries; request only the last 5.
        # entry[0] of the 5-entry window has prev_cid pointing outside the window.
        prev = CHAIN_GENESIS
        for i in range(10):
            prev = append_audit_entry(
                audit_file,
                {"trace_id": f"t{i}", "action": "PASS"},
                signing_key=key.encode(),
                prev_cid=prev,
            )

        r = client.get("/v1/audit/verify?n=5")
        assert r.status_code == 200
        data = r.json()

        # Must NOT be classified as chain_break — tail slice is expected.
        assert data.get("reason") == "unanchored_slice", (
            f"Expected unanchored_slice, got: {data}"
        )
        # The slice is internally consistent.
        assert data.get("chain_verified") is True, (
            f"Expected chain_verified=True for internally consistent tail slice: {data}"
        )
        # HMAC valid — all entries signed with same key.
        assert data.get("hmac_verified") is True
        # verified=False — global anchoring cannot be proved from a tail slice.
        assert data.get("verified") is False
        assert data.get("backend") == "jsonl"
        # No first_break_index — there is no break, just an unanchored window.
        assert "first_break_index" not in data


class TestLensIntegration:
    """Lens-level integration tests (no proxy)."""

    @pytest.mark.asyncio
    async def test_end_to_end_with_mock(self):
        """Full pipeline: request → parse → lens → format → response."""
        adapter = MockAdapter()
        backend = MockBackend()
        bridge = BuiltinBridge()
        config = LensConfig(
            adapter=adapter,
            extraction_backend=backend,
            governance_bridge=bridge,
        )
        lens = Lens(config)

        # Simulate what the proxy does
        body = {
            "model": "gpt-4",
            "messages": [
                {"role": "user", "content": "Hello world"},
            ],
        }
        parsed = parse_chat_request(body)
        result = await lens.process(parsed.user_message)
        response_body = format_chat_response(result, request_model=parsed.model)

        assert response_body["choices"][0]["message"]["content"] == "Echo: Hello world"
        assert response_body["aurora"]["governance"] == "PASS"
        assert response_body["model"] == "gpt-4"

    @pytest.mark.asyncio
    async def test_stream_returns_sse_with_metadata(self):
        """stream=true returns SSE stream with content chunks and final aurora metadata."""
        adapter = MockAdapter()
        backend = MockBackend()
        bridge = BuiltinBridge()
        config = LensConfig(
            adapter=adapter,
            extraction_backend=backend,
            governance_bridge=bridge,
        )
        lens = Lens(config)

        chunks = []
        async for kind, payload in lens.process_stream("Hello"):
            if kind == "chunk":
                chunk_dict, content = payload
                chunks.append((chunk_dict, content))
            elif kind == "metadata":
                assert "governance" in payload
                assert payload["governance"] == "PASS"
                assert "turn" in payload
                return
        pytest.fail("Expected metadata event")

    @pytest.mark.asyncio
    async def test_stream_disconnect_closes_upstream_no_metadata(self):
        """When client disconnects mid-stream (post-governance): no metadata; upstream already completed.

        With full-buffer streaming governance, the upstream provider stream is fully consumed
        during private buffering before any chunk is released to the caller.  Breaking after
        the first released chunk therefore does NOT close the upstream early — it already
        completed.  The important invariant is that no metadata event is emitted after the break.
        """
        adapter = StreamMockAdapter(["a", "b", "c"])
        backend = MockBackend()
        bridge = BuiltinBridge()
        config = LensConfig(
            adapter=adapter,
            extraction_backend=backend,
            governance_bridge=bridge,
        )
        lens = Lens(config)

        metadata_count = 0
        chunk_count = 0
        async with aclosing(lens.process_stream("Hi")) as gen:
            async for kind, payload in gen:
                if kind == "metadata":
                    metadata_count += 1
                elif kind == "chunk":
                    chunk_count += 1
                if chunk_count >= 1:
                    break  # Simulate client disconnect after first chunk

        assert chunk_count >= 1
        assert metadata_count == 0
        # Upstream completed during private buffering before chunks were released;
        # closing the generator mid-delivery does not affect the upstream state.
        assert adapter.closed_early is False

    @pytest.mark.asyncio
    async def test_stream_truncation_sets_flags(self):
        """When accumulated stream exceeds max_stream_bytes, metadata includes stream_truncated and stream_dropped_chars."""
        # Create chunks that exceed the limit (default 512 KiB)
        chunk_size = 100 * 1024  # 100 KiB per chunk
        big_chunk = "x" * chunk_size
        chunks = [big_chunk] * 10  # 1 MiB total
        adapter = StreamMockAdapter(chunks)
        backend = MockBackend()
        bridge = BuiltinBridge()
        config = LensConfig(
            adapter=adapter,
            extraction_backend=backend,
            governance_bridge=bridge,
        )
        lens = Lens(config)

        metadata = None
        async with aclosing(lens.process_stream("Hi")) as gen:
            async for kind, payload in gen:
                if kind == "metadata":
                    metadata = payload
                    break

        assert metadata is not None
        assert metadata.get("stream_truncated") is True
        assert metadata.get("stream_dropped_chars", 0) >= 1

    @pytest.mark.asyncio
    async def test_stream_completed_logs_audit_with_stream_fields(self, tmp_path):
        """Stream completion writes audit entry with stream: true, stream_completed: true."""
        audit_file = tmp_path / "audit.jsonl"
        adapter = StreamMockAdapter(["a", "b", "c"])
        backend = MockBackend()
        bridge = BuiltinBridge(audit_path=str(audit_file))
        config = LensConfig(
            adapter=adapter,
            extraction_backend=backend,
            governance_bridge=bridge,
        )
        lens = Lens(config)

        async with aclosing(lens.process_stream("Hi")) as gen:
            async for kind, payload in gen:
                if kind == "metadata":
                    break

        lines = audit_file.read_text().strip().split("\n")
        assert len(lines) == 1
        entry = json.loads(lines[0])
        assert entry.get("stream") is True
        assert entry.get("stream_completed") is True
        assert "stream_abort_reason" not in entry

    @pytest.mark.asyncio
    async def test_stream_abort_logs_audit_with_abort_reason(self, tmp_path):
        """Provider abort during private buffering writes audit entry with stream_completed: false.

        With full-buffer streaming governance the upstream provider is consumed during a private
        buffering phase before governance runs.  The 'abort' scenario is therefore a provider-side
        interruption (CancelledError raised inside generate_stream) — not a post-governance
        client disconnect.  CancelledStreamAdapter simulates this by raising CancelledError
        after the first chunk, which triggers the finally-block abort path in process_stream().
        """
        audit_file = tmp_path / "audit.jsonl"
        # Raises CancelledError after yielding 1 chunk — provider abort during buffering
        adapter = CancelledStreamAdapter(["a", "b", "c"], cancel_after=1)
        backend = MockBackend()
        bridge = BuiltinBridge(audit_path=str(audit_file))
        config = LensConfig(
            adapter=adapter,
            extraction_backend=backend,
            governance_bridge=bridge,
        )
        lens = Lens(config)

        # CancelledError from provider propagates out of process_stream; suppress it here.
        try:
            async with aclosing(lens.process_stream("Hi")) as gen:
                async for kind, payload in gen:
                    pass
        except asyncio.CancelledError:
            pass

        lines = audit_file.read_text().strip().split("\n")
        assert len(lines) == 1
        entry = json.loads(lines[0])
        assert entry.get("stream") is True
        assert entry.get("stream_completed") is False
        assert entry.get("stream_abort_reason") == "provider_abort"

    @pytest.mark.asyncio
    async def test_stream_truncated_logs_audit_flags(self, tmp_path):
        """Stream truncation writes stream_truncated and stream_dropped_chars in audit."""
        audit_file = tmp_path / "audit.jsonl"
        chunk_size = 100 * 1024
        big_chunk = "x" * chunk_size
        adapter = StreamMockAdapter([big_chunk] * 10)
        backend = MockBackend()
        bridge = BuiltinBridge(audit_path=str(audit_file))
        config = LensConfig(
            adapter=adapter,
            extraction_backend=backend,
            governance_bridge=bridge,
        )
        lens = Lens(config)

        async with aclosing(lens.process_stream("Hi")) as gen:
            async for kind, payload in gen:
                if kind == "metadata":
                    break

        lines = audit_file.read_text().strip().split("\n")
        assert len(lines) == 1
        entry = json.loads(lines[0])
        assert entry.get("stream") is True
        assert entry.get("stream_completed") is True
        assert entry.get("stream_truncated") is True
        assert entry.get("stream_dropped_chars", 0) >= 1

    def test_stream_request_returns_event_stream(self, monkeypatch):
        """POST with stream=true returns text/event-stream and SSE data."""
        try:
            from starlette.testclient import TestClient
        except ImportError:
            pytest.skip("Starlette not installed")

        from aurora_lens.proxy.app import create_app

        monkeypatch.setattr(
            "aurora_lens.proxy.app._build_provider_adapters",
            lambda cfg: (MockAdapter(), MockAdapter()),
        )
        cfg = ProxyConfig.from_mapping({
            "upstream": {"provider": "openai", "api_key": "sk-test", "model": "gpt-4"},
            "governance": {"audit_log": None},
            "extraction": {"backend": "spacy"},
        })
        app = create_app(cfg)
        client = TestClient(app)

        r = client.post(
            "/v1/chat/completions",
            json={
                "model": "gpt-4",
                "messages": [{"role": "user", "content": "Hi"}],
                "stream": True,
            },
        )
        assert r.status_code == 200
        assert "text/event-stream" in r.headers.get("content-type", "")
        text = r.text
        assert "data: " in text
        assert "[DONE]" in text
        assert "aurora" in text
        assert "governance" in text


class TestProxyMain:
    """Tests for proxy __main__ entry point."""

    def test_main_calls_setup_logging(self, monkeypatch, tmp_path):
        """main() calls setup_logging() at startup before create_app."""
        config_yaml = tmp_path / "aurora-lens.yaml"
        config_yaml.write_text("""
upstream:
  provider: openai
  api_key: sk-test
  model: gpt-4
""")
        setup_called = []

        def _track_setup(*args, **kwargs):
            setup_called.append(True)

        def _no_run(*args, **kwargs):
            raise SystemExit(0)

        monkeypatch.setattr("aurora_lens.proxy.__main__.setup_logging", _track_setup)
        monkeypatch.setattr("uvicorn.run", _no_run)

        from aurora_lens.proxy.__main__ import main

        with pytest.raises(SystemExit):
            main(["-c", str(config_yaml)])

        assert len(setup_called) == 1

    def test_aurora_record_factory_injects_trace_and_session(self):
        """LogRecord factory injects trace_id and session_id from contextvars into every log record."""
        from aurora_lens.context import trace_id_var, session_id_var
        from aurora_lens.proxy.logging import _configure_record_factory

        _configure_record_factory()
        captured: list = []

        class CaptureHandler(logging.Handler):
            def emit(self, record):
                captured.append(record)

        logger = logging.getLogger("test.aurora.factory")
        logger.addHandler(CaptureHandler())
        logger.setLevel(logging.INFO)

        # Without context: trace_id and session_id are None (startup logs)
        logger.info("startup")
        assert len(captured) == 1
        assert captured[0].trace_id is None
        assert captured[0].session_id is None
        captured.clear()

        # With context: populated (request logs)
        token_t = trace_id_var.set("proxy:abc123")
        token_s = session_id_var.set("sess-xyz")
        try:
            logger.info("request")
            assert len(captured) == 1
            assert captured[0].trace_id == "proxy:abc123"
            assert captured[0].session_id == "sess-xyz"
        finally:
            trace_id_var.reset(token_t)
            session_id_var.reset(token_s)

    def test_context_reset_prevents_bleed(self):
        """Context must be reset after request; otherwise next request inherits previous values."""
        from aurora_lens.context import trace_id_var, session_id_var, get_trace_id, get_session_id

        # Simulate request 1: set context
        token_t = trace_id_var.set("proxy:req1")
        token_s = session_id_var.set("sess-1")
        assert get_trace_id() == "proxy:req1"
        assert get_session_id() == "sess-1"

        # Reset (as middleware/handler finally would)
        trace_id_var.reset(token_t)
        session_id_var.reset(token_s)

        # After reset: context is clear
        assert get_trace_id() is None
        assert get_session_id() is None

        # Simulate request 2: would get its own values, not req1's
        token_t2 = trace_id_var.set("proxy:req2")
        try:
            assert get_trace_id() == "proxy:req2"
        finally:
            trace_id_var.reset(token_t2)

    def test_json_formatter_always_emits_trace_id_session_id(self):
        """JsonFormatter always emits trace_id and session_id, even when null."""
        from aurora_lens.proxy.logging import JsonFormatter

        record = logging.LogRecord("test", logging.INFO, "", 0, "startup", (), None)
        record.trace_id = None
        record.session_id = None

        formatter = JsonFormatter()
        out = json.loads(formatter.format(record))
        assert "trace_id" in out
        assert "session_id" in out
        assert out["trace_id"] is None
        assert out["session_id"] is None
        assert out["timestamp"]
        assert out["level"] == "INFO"
        assert out["message"] == "startup"

    def test_governance_outcome_log_emits_required_fields(self):
        """governance_outcome log event contains all forensically required fields.

        Verifies that the JsonFormatter serialises a governance_outcome record
        with the full set of fields needed for Railway production diagnosis:
        outcome, flags, flags_count, blocked, revised, audit_id, policy,
        pathway_id, commitment_closed, stream, trace_id, session_id.
        """
        from aurora_lens.context import trace_id_var, session_id_var
        from aurora_lens.proxy.logging import JsonFormatter, _configure_record_factory

        _configure_record_factory()
        captured: list = []

        class CaptureHandler(logging.Handler):
            def emit(self, record):
                captured.append(record)

        logger = logging.getLogger("test.aurora.governance_outcome")
        handler = CaptureHandler()
        logger.addHandler(handler)
        logger.setLevel(logging.INFO)

        token_t = trace_id_var.set("proxy:test-trace-001")
        token_s = session_id_var.set("sess-gv-test")
        try:
            logger.info(
                "governance_outcome",
                extra={
                    "outcome": "HARD_STOP",
                    "flags": ["VIOLENT_CRIMINAL_INTENT"],
                    "flags_count": 1,
                    "blocked": True,
                    "revised": False,
                    "audit_id": "bafytest123",
                    "policy": "strict",
                    "pathway_id": "P_STOP_TERMINAL",
                    "commitment_closed": True,
                    "stream": False,
                },
            )
        finally:
            trace_id_var.reset(token_t)
            session_id_var.reset(token_s)
            logger.removeHandler(handler)

        assert len(captured) == 1
        rec = captured[0]

        # Verify the record carries all required fields
        assert rec.outcome == "HARD_STOP"
        assert rec.flags == ["VIOLENT_CRIMINAL_INTENT"]
        assert rec.flags_count == 1
        assert rec.blocked is True
        assert rec.revised is False
        assert rec.audit_id == "bafytest123"
        assert rec.policy == "strict"
        assert rec.pathway_id == "P_STOP_TERMINAL"
        assert rec.commitment_closed is True
        assert rec.stream is False
        assert rec.trace_id == "proxy:test-trace-001"
        assert rec.session_id == "sess-gv-test"

        # Verify JsonFormatter serialises all fields to the log line
        formatter = JsonFormatter()
        out = json.loads(formatter.format(rec))
        assert out["message"] == "governance_outcome"
        assert out["outcome"] == "HARD_STOP"
        assert out["flags"] == ["VIOLENT_CRIMINAL_INTENT"]
        assert out["flags_count"] == 1
        assert out["blocked"] is True
        assert out["audit_id"] == "bafytest123"
        assert out["policy"] == "strict"
        assert out["pathway_id"] == "P_STOP_TERMINAL"
        assert out["trace_id"] == "proxy:test-trace-001"
        assert out["session_id"] == "sess-gv-test"
