"""
Tests for the runtime execution reporter — report generation, privacy
filter, batching, and retry queue.

Privacy is the paramount concern. These tests verify that NO user content
can leave the machine under any circumstances.
"""

from __future__ import annotations

import asyncio
import json
import os
import tempfile
import time
from typing import Any
from unittest.mock import AsyncMock, patch

import pytest

from runtime.reporter.privacy import (
    MAX_SAFE_STRING_LENGTH,
    PERMANENTLY_BLOCKED,
    SAFE_PLAN_FIELDS,
    SAFE_STEP_FIELDS,
    SanitizedReport,
    SanitizedStepReport,
    sanitize_report,
    to_wire_dict,
)
from runtime.reporter.metrics import (
    MAX_QUEUE_AGE_SECONDS,
    MAX_RETRY_QUEUE_ENTRIES,
    ExecutionReporter,
    PlanMetrics,
    ReportTransport,
    StepMetrics,
    _append_pending,
    _append_retry_queue,
    _clear_pending,
    _clear_retry_queue,
    _load_pending,
    _load_retry_queue,
    _plan_metrics_to_raw,
)


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


def _make_step_dict(**overrides: Any) -> dict[str, Any]:
    """Build a step report dict with sensible defaults."""
    base = {
        "step_id": "step_1",
        "elapsed_ms": 1234.5,
        "tokens_used": 500,
        "success": True,
        "error_type": None,
        "model_name": "llama3",
        "backend": "ollama",
        "task_type": "summarization",
    }
    base.update(overrides)
    return base


def _make_report_dict(**overrides: Any) -> dict[str, Any]:
    """Build a plan report dict with sensible defaults."""
    base = {
        "plan_id": "plan-abc-123",
        "total_elapsed_ms": 2500.0,
        "steps_completed": 2,
        "steps_failed": 0,
        "steps": [_make_step_dict(), _make_step_dict(step_id="step_2")],
        "reported_at": time.time(),
    }
    base.update(overrides)
    return base


class MockTransport(ReportTransport):
    """Test transport that records calls instead of making HTTP requests."""

    def __init__(self, succeed: bool = True) -> None:
        self.calls: list[list[dict[str, Any]]] = []
        self.succeed = succeed

    async def send(self, reports: list[dict[str, Any]]) -> bool:
        self.calls.append(reports)
        return self.succeed


# ══════════════════════════════════════════════════════════════════════
# Privacy Filter Tests
# ══════════════════════════════════════════════════════════════════════


class TestPrivacyFilter:
    """Tests for runtime.reporter.privacy — the SINGLE chokepoint."""

    def test_sanitize_passes_safe_fields(self) -> None:
        """Known-safe fields survive sanitization unchanged."""
        report = _make_report_dict()
        result = sanitize_report(report)

        assert result.plan_id == "plan-abc-123"
        assert result.total_elapsed_ms == 2500.0
        assert result.steps_completed == 2
        assert result.steps_failed == 0
        assert len(result.steps) == 2
        assert result.steps[0].step_id == "step_1"
        assert result.steps[0].elapsed_ms == 1234.5
        assert result.steps[0].tokens_used == 500
        assert result.steps[0].success is True
        assert result.steps[0].model_name == "llama3"
        assert result.steps[0].backend == "ollama"

    def test_sanitize_strips_unknown_plan_fields(self) -> None:
        """Any field not in SAFE_PLAN_FIELDS is silently dropped."""
        report = _make_report_dict(
            user_document="This is my secret document",
            custom_field="should be gone",
        )
        result = sanitize_report(report)

        wire = to_wire_dict(result)
        assert "user_document" not in wire
        assert "custom_field" not in wire

    def test_sanitize_strips_unknown_step_fields(self) -> None:
        """Any step field not in SAFE_STEP_FIELDS is silently dropped."""
        step = _make_step_dict(
            prompt="Analyze this document...",
            output="The analysis shows...",
            api_key="sk-abc123",
        )
        report = _make_report_dict(steps=[step])
        result = sanitize_report(report)

        wire = to_wire_dict(result)
        step_wire = wire["steps"][0]
        assert "prompt" not in step_wire
        assert "output" not in step_wire
        assert "api_key" not in step_wire

    def test_permanently_blocked_fields_never_pass(self) -> None:
        """Fields in PERMANENTLY_BLOCKED are stripped even if whitelisted."""
        for blocked_field in PERMANENTLY_BLOCKED:
            report = _make_report_dict(**{blocked_field: "sensitive data"})
            result = sanitize_report(report)
            wire = to_wire_dict(result)
            assert blocked_field not in wire or wire.get(blocked_field) in (
                None,
                "",
                0,
                0.0,
                False,
                [],
            ), f"Permanently blocked field '{blocked_field}' leaked through"

    def test_long_strings_redacted(self) -> None:
        """Strings longer than MAX_SAFE_STRING_LENGTH are replaced with <redacted>."""
        long_string = "x" * (MAX_SAFE_STRING_LENGTH + 1)
        step = _make_step_dict(model_name=long_string)
        report = _make_report_dict(steps=[step])
        result = sanitize_report(report)

        assert result.steps[0].model_name == "<redacted>"

    def test_short_strings_pass(self) -> None:
        """Strings at or below the length limit pass through."""
        ok_string = "x" * MAX_SAFE_STRING_LENGTH
        step = _make_step_dict(model_name=ok_string)
        report = _make_report_dict(steps=[step])
        result = sanitize_report(report)

        assert result.steps[0].model_name == ok_string

    def test_prompt_content_never_in_output(self) -> None:
        """Even if someone stuffs a prompt into a whitelisted field, the
        length check catches it."""
        # Simulate a prompt accidentally stored in model_name
        fake_prompt = "Please analyze the following financial report: " + "x" * 200
        step = _make_step_dict(model_name=fake_prompt)
        report = _make_report_dict(steps=[step])
        result = sanitize_report(report)

        wire = to_wire_dict(result)
        serialized = json.dumps(wire)
        assert "financial report" not in serialized
        assert fake_prompt not in serialized

    def test_sanitize_does_not_mutate_input(self) -> None:
        """The privacy filter deep-copies — caller's data is untouched."""
        report = _make_report_dict(extra_field="secret")
        original = json.dumps(report, sort_keys=True)
        sanitize_report(report)
        after = json.dumps(report, sort_keys=True)
        assert original == after

    def test_empty_report(self) -> None:
        """An empty report produces a valid SanitizedReport with defaults."""
        result = sanitize_report({})
        assert isinstance(result, SanitizedReport)
        assert result.plan_id == ""
        assert result.steps == []

    def test_error_type_passes_when_short(self) -> None:
        """Error type strings (like 'timeout', 'rate_limit') pass through."""
        step = _make_step_dict(
            success=False, error_type="timeout"
        )
        report = _make_report_dict(steps=[step])
        result = sanitize_report(report)

        assert result.steps[0].error_type == "timeout"
        assert result.steps[0].success is False

    def test_to_wire_dict_only_contains_safe_keys(self) -> None:
        """The wire dict produced by to_wire_dict contains only safe fields."""
        report = _make_report_dict()
        result = sanitize_report(report)
        wire = to_wire_dict(result)

        # Plan-level keys
        plan_keys = {k for k in wire.keys() if k != "steps"}
        assert plan_keys.issubset(SAFE_PLAN_FIELDS)

        # Step-level keys
        for step in wire["steps"]:
            step_keys = set(step.keys())
            assert step_keys.issubset(SAFE_STEP_FIELDS)

    def test_nested_content_in_step_blocked(self) -> None:
        """A 'context' field containing nested dicts is blocked."""
        step = _make_step_dict(
            context={"user_input": "My secret data", "history": ["msg1", "msg2"]}
        )
        report = _make_report_dict(steps=[step])
        result = sanitize_report(report)

        wire = to_wire_dict(result)
        serialized = json.dumps(wire)
        assert "secret data" not in serialized
        assert "msg1" not in serialized


# ══════════════════════════════════════════════════════════════════════
# StepMetrics / PlanMetrics Tests
# ══════════════════════════════════════════════════════════════════════


class TestMetricsDataModels:
    """Tests for the metrics data models."""

    def test_step_metrics_creation(self) -> None:
        """StepMetrics stores execution metadata."""
        step = StepMetrics(
            step_id="step_1",
            elapsed_ms=1500.0,
            tokens_used=800,
            success=True,
            model_name="claude-sonnet",
            backend="claude",
            task_type="analysis",
        )
        assert step.step_id == "step_1"
        assert step.elapsed_ms == 1500.0
        assert step.error_type is None

    def test_step_metrics_failure(self) -> None:
        """Failed step captures the error type."""
        step = StepMetrics(
            step_id="step_2",
            elapsed_ms=30000.0,
            tokens_used=0,
            success=False,
            error_type="timeout",
            model_name="gpt-4o",
            backend="gpt",
        )
        assert step.success is False
        assert step.error_type == "timeout"

    def test_plan_metrics_from_raw(self) -> None:
        """_plan_metrics_to_raw produces a sanitizable dict."""
        steps = [
            StepMetrics("s1", 100.0, 50, True, model_name="llama3", backend="ollama"),
            StepMetrics("s2", 200.0, 100, False, error_type="rate_limit",
                        model_name="claude", backend="claude"),
        ]
        metrics = PlanMetrics(
            plan_id="plan-xyz",
            total_elapsed_ms=300.0,
            steps_completed=1,
            steps_failed=1,
            step_metrics=steps,
        )
        raw = _plan_metrics_to_raw(metrics)

        assert raw["plan_id"] == "plan-xyz"
        assert raw["total_elapsed_ms"] == 300.0
        assert len(raw["steps"]) == 2
        assert raw["steps"][1]["error_type"] == "rate_limit"

        # Verify it can pass through the privacy filter
        sanitized = sanitize_report(raw)
        assert sanitized.plan_id == "plan-xyz"
        assert sanitized.steps[1].error_type == "rate_limit"


# ══════════════════════════════════════════════════════════════════════
# Report Batching and Sending Tests
# ══════════════════════════════════════════════════════════════════════


class TestExecutionReporter:
    """Tests for the batched reporter."""

    @pytest.fixture
    def transport(self) -> MockTransport:
        return MockTransport(succeed=True)

    @pytest.fixture
    def reporter(self, transport: MockTransport) -> ExecutionReporter:
        return ExecutionReporter(transport=transport, batch_interval=60.0)

    @pytest.mark.asyncio
    async def test_report_execution_sends_immediately(
        self, reporter: ExecutionReporter, transport: MockTransport
    ) -> None:
        """report_execution() triggers an immediate flush."""
        steps = [
            StepMetrics("s1", 100.0, 50, True, model_name="llama3", backend="ollama"),
        ]
        result = await reporter.report_execution("plan-001", steps)

        assert result is True
        assert len(transport.calls) == 1
        assert len(transport.calls[0]) == 1
        assert transport.calls[0][0]["plan_id"] == "plan-001"

    @pytest.mark.asyncio
    async def test_report_contains_no_user_content(
        self, reporter: ExecutionReporter, transport: MockTransport
    ) -> None:
        """The report sent to the transport contains ONLY safe fields."""
        steps = [
            StepMetrics("s1", 100.0, 50, True, model_name="llama3", backend="ollama"),
        ]
        await reporter.report_execution("plan-002", steps)

        sent = transport.calls[0][0]
        serialized = json.dumps(sent)

        # No content-bearing fields
        for blocked in PERMANENTLY_BLOCKED:
            assert blocked not in sent, f"Blocked field '{blocked}' found in report"

    @pytest.mark.asyncio
    async def test_report_aggregates_step_metrics(
        self, reporter: ExecutionReporter, transport: MockTransport
    ) -> None:
        """Total elapsed, steps_completed, steps_failed are correctly aggregated."""
        steps = [
            StepMetrics("s1", 100.0, 50, True, model_name="a", backend="b"),
            StepMetrics("s2", 200.0, 80, True, model_name="a", backend="b"),
            StepMetrics("s3", 300.0, 0, False, error_type="timeout",
                        model_name="c", backend="d"),
        ]
        await reporter.report_execution("plan-003", steps)

        sent = transport.calls[0][0]
        assert sent["total_elapsed_ms"] == 600.0
        assert sent["steps_completed"] == 2
        assert sent["steps_failed"] == 1

    @pytest.mark.asyncio
    async def test_failed_send_queues_locally(self, tmp_path: Any) -> None:
        """When transport fails, reports are saved to disk for retry."""
        transport = MockTransport(succeed=False)
        reporter = ExecutionReporter(transport=transport, batch_interval=60.0)

        # Patch the pending file path to use tmp_path
        pending_file = str(tmp_path / "pending_reports.jsonl")
        with patch("runtime.reporter.metrics.PENDING_REPORTS_FILE", pending_file), \
             patch("runtime.reporter.metrics.PENDING_REPORTS_DIR", str(tmp_path)):
            steps = [
                StepMetrics("s1", 100.0, 50, True, model_name="a", backend="b"),
            ]
            result = await reporter.report_execution("plan-fail", steps)

            assert result is False  # Cloud unreachable
            assert os.path.exists(pending_file)

            # Verify the pending file contains the report
            with open(pending_file, "r") as f:
                lines = [l.strip() for l in f if l.strip()]
            assert len(lines) == 1
            saved = json.loads(lines[0])
            assert saved["plan_id"] == "plan-fail"

    @pytest.mark.asyncio
    async def test_retry_drains_pending_on_success(self, tmp_path: Any) -> None:
        """On successful reconnection, pending reports are sent and file is cleared."""
        # First: create a pending report
        pending_file = str(tmp_path / "pending_reports.jsonl")
        pending_dir = str(tmp_path)
        old_report = {"plan_id": "old-plan", "total_elapsed_ms": 50.0,
                      "steps_completed": 1, "steps_failed": 0, "steps": [],
                      "reported_at": time.time() - 3600}

        with patch("runtime.reporter.metrics.PENDING_REPORTS_FILE", pending_file), \
             patch("runtime.reporter.metrics.PENDING_REPORTS_DIR", pending_dir):
            _append_pending([old_report])
            assert os.path.exists(pending_file)

            # Now create a reporter with a working transport
            transport = MockTransport(succeed=True)
            reporter = ExecutionReporter(transport=transport, batch_interval=60.0)

            steps = [
                StepMetrics("s1", 200.0, 100, True, model_name="x", backend="y"),
            ]
            await reporter.report_execution("new-plan", steps)

            # Both old and new reports should have been sent
            assert len(transport.calls) == 1
            batch = transport.calls[0]
            plan_ids = {r["plan_id"] for r in batch}
            assert "old-plan" in plan_ids
            assert "new-plan" in plan_ids

            # Pending file should be cleared
            assert not os.path.exists(pending_file)

    @pytest.mark.asyncio
    async def test_start_stop_lifecycle(
        self, reporter: ExecutionReporter
    ) -> None:
        """Reporter can be started and stopped cleanly."""
        await reporter.start()
        assert reporter._running is True
        assert reporter._flush_task is not None

        await reporter.stop()
        assert reporter._running is False
        assert reporter._flush_task is None

    @pytest.mark.asyncio
    async def test_multiple_reports_batched(
        self, reporter: ExecutionReporter, transport: MockTransport
    ) -> None:
        """Multiple report_execution calls each trigger their own flush."""
        steps1 = [StepMetrics("s1", 100.0, 50, True, model_name="a", backend="b")]
        steps2 = [StepMetrics("s1", 200.0, 80, True, model_name="c", backend="d")]

        await reporter.report_execution("plan-a", steps1)
        await reporter.report_execution("plan-b", steps2)

        # Each call triggers its own flush
        assert len(transport.calls) == 2

    @pytest.mark.asyncio
    async def test_queue_age_tracking(
        self, reporter: ExecutionReporter
    ) -> None:
        """_oldest_queued_at is set when first report queued, cleared on flush."""
        assert reporter._oldest_queued_at is None

        steps = [StepMetrics("s1", 100.0, 50, True, model_name="a", backend="b")]
        await reporter.report_execution("plan-age", steps)

        # After immediate flush, oldest_queued_at should be reset
        assert reporter._oldest_queued_at is None

    @pytest.mark.asyncio
    async def test_time_based_flush_triggers_on_aged_queue(self) -> None:
        """If reports sit in the queue for MAX_QUEUE_AGE_SECONDS, flush is triggered."""
        transport = MockTransport(succeed=True)
        # Use a very long batch_interval so only the age-based flush fires
        reporter = ExecutionReporter(transport=transport, batch_interval=3600.0)

        # Manually queue a report and backdate _oldest_queued_at
        raw = {
            "plan_id": "plan-aged",
            "total_elapsed_ms": 100.0,
            "steps_completed": 1,
            "steps_failed": 0,
            "steps": [],
            "reported_at": time.time(),
        }
        sanitized = sanitize_report(raw)
        reporter._queue.append(sanitized)
        reporter._oldest_queued_at = time.time() - (MAX_QUEUE_AGE_SECONDS + 1)

        # Start the reporter — the periodic flush should detect the aged queue
        # and flush immediately
        await reporter.start()
        # Give the event loop a moment for the flush task to run
        await asyncio.sleep(0.1)
        await reporter.stop()

        assert len(transport.calls) >= 1
        plan_ids = {r["plan_id"] for batch in transport.calls for r in batch}
        assert "plan-aged" in plan_ids

    @pytest.mark.asyncio
    async def test_time_based_flush_reduces_sleep_for_young_queue(self) -> None:
        """When queue has reports but hasn't aged past the limit, sleep is shortened."""
        transport = MockTransport(succeed=True)
        reporter = ExecutionReporter(transport=transport, batch_interval=3600.0)

        # Queue a report that's 55 seconds old (5 seconds from limit)
        raw = {
            "plan_id": "plan-young",
            "total_elapsed_ms": 100.0,
            "steps_completed": 1,
            "steps_failed": 0,
            "steps": [],
            "reported_at": time.time(),
        }
        sanitized = sanitize_report(raw)
        reporter._queue.append(sanitized)
        reporter._oldest_queued_at = time.time() - (MAX_QUEUE_AGE_SECONDS - 5)

        # The periodic flush should wait ~5s, not 3600s. We can't easily test
        # the exact sleep time, but we can verify the queue is still there
        # immediately after start (it hasn't flushed yet).
        await reporter.start()
        # Give a tiny moment — not enough for the 5s sleep to complete
        await asyncio.sleep(0.05)

        # Queue should still have the report (hasn't aged past limit yet)
        # Note: might or might not have flushed depending on timing
        await reporter.stop()

        # After stop, the final flush drains everything
        plan_ids = {r["plan_id"] for batch in transport.calls for r in batch}
        assert "plan-young" in plan_ids


# ══════════════════════════════════════════════════════════════════════
# Retry Queue Persistence Tests
# ══════════════════════════════════════════════════════════════════════


class TestRetryQueue:
    """Tests for the local retry queue (pending_reports.jsonl)."""

    def test_append_and_load(self, tmp_path: Any) -> None:
        """Reports can be appended and loaded back."""
        pending_file = str(tmp_path / "pending_reports.jsonl")
        with patch("runtime.reporter.metrics.PENDING_REPORTS_FILE", pending_file), \
             patch("runtime.reporter.metrics.PENDING_REPORTS_DIR", str(tmp_path)):
            reports = [
                {"plan_id": "p1", "total_elapsed_ms": 100.0},
                {"plan_id": "p2", "total_elapsed_ms": 200.0},
            ]
            _append_pending(reports)
            loaded = _load_pending()

            assert len(loaded) == 2
            assert loaded[0]["plan_id"] == "p1"
            assert loaded[1]["plan_id"] == "p2"

    def test_clear_removes_file(self, tmp_path: Any) -> None:
        """_clear_pending removes the pending file."""
        pending_file = str(tmp_path / "pending_reports.jsonl")
        with patch("runtime.reporter.metrics.PENDING_REPORTS_FILE", pending_file), \
             patch("runtime.reporter.metrics.PENDING_REPORTS_DIR", str(tmp_path)):
            _append_pending([{"plan_id": "p1"}])
            assert os.path.exists(pending_file)

            _clear_pending()
            assert not os.path.exists(pending_file)

    def test_load_empty_returns_empty(self, tmp_path: Any) -> None:
        """Loading from a non-existent file returns an empty list."""
        pending_file = str(tmp_path / "nonexistent.jsonl")
        with patch("runtime.reporter.metrics.PENDING_REPORTS_FILE", pending_file):
            loaded = _load_pending()
            assert loaded == []

    def test_load_handles_malformed_lines(self, tmp_path: Any) -> None:
        """Malformed lines are skipped, valid lines are loaded."""
        pending_file = str(tmp_path / "pending_reports.jsonl")
        with open(pending_file, "w") as f:
            f.write('{"plan_id": "good"}\n')
            f.write("not json at all\n")
            f.write('{"plan_id": "also_good"}\n')

        with patch("runtime.reporter.metrics.PENDING_REPORTS_FILE", pending_file):
            loaded = _load_pending()
            assert len(loaded) == 2
            assert loaded[0]["plan_id"] == "good"
            assert loaded[1]["plan_id"] == "also_good"


# ══════════════════════════════════════════════════════════════════════
# Metrics Retry Queue (metrics_retry.jsonl) Tests
# ══════════════════════════════════════════════════════════════════════


class TestMetricsRetryQueue:
    """Tests for the metrics_retry.jsonl persistence layer."""

    def test_append_and_load(self, tmp_path: Any) -> None:
        """Failed reports are written with metadata and can be loaded back."""
        retry_file = str(tmp_path / "metrics_retry.jsonl")
        with patch("runtime.reporter.metrics.RETRY_QUEUE_FILE", retry_file), \
             patch("runtime.reporter.metrics.PENDING_REPORTS_DIR", str(tmp_path)):
            reports = [
                {"plan_id": "r1", "total_elapsed_ms": 100.0},
                {"plan_id": "r2", "total_elapsed_ms": 200.0},
            ]
            written = _append_retry_queue(reports)
            assert written == 2

            loaded = _load_retry_queue()
            assert len(loaded) == 2
            assert loaded[0]["plan_id"] == "r1"
            assert loaded[1]["plan_id"] == "r2"

    def test_entries_have_failure_metadata(self, tmp_path: Any) -> None:
        """Each entry in the retry file includes a failed_at timestamp."""
        retry_file = str(tmp_path / "metrics_retry.jsonl")
        with patch("runtime.reporter.metrics.RETRY_QUEUE_FILE", retry_file), \
             patch("runtime.reporter.metrics.PENDING_REPORTS_DIR", str(tmp_path)):
            _append_retry_queue([{"plan_id": "meta-test"}])

            # Read raw to verify envelope
            with open(retry_file, "r") as f:
                entry = json.loads(f.readline())
            assert "failed_at" in entry
            assert "report" in entry
            assert entry["report"]["plan_id"] == "meta-test"
            assert isinstance(entry["failed_at"], float)

    def test_clear_removes_file(self, tmp_path: Any) -> None:
        """_clear_retry_queue removes the retry file."""
        retry_file = str(tmp_path / "metrics_retry.jsonl")
        with patch("runtime.reporter.metrics.RETRY_QUEUE_FILE", retry_file), \
             patch("runtime.reporter.metrics.PENDING_REPORTS_DIR", str(tmp_path)):
            _append_retry_queue([{"plan_id": "r1"}])
            assert os.path.exists(retry_file)

            _clear_retry_queue()
            assert not os.path.exists(retry_file)

    def test_load_empty_returns_empty(self, tmp_path: Any) -> None:
        """Loading from a non-existent retry file returns an empty list."""
        retry_file = str(tmp_path / "nonexistent_retry.jsonl")
        with patch("runtime.reporter.metrics.RETRY_QUEUE_FILE", retry_file):
            loaded = _load_retry_queue()
            assert loaded == []

    def test_load_handles_malformed_lines(self, tmp_path: Any) -> None:
        """Malformed lines in retry file are skipped."""
        retry_file = str(tmp_path / "metrics_retry.jsonl")
        with open(retry_file, "w") as f:
            f.write(json.dumps({"failed_at": 1.0, "report": {"plan_id": "ok"}}) + "\n")
            f.write("garbage\n")
            f.write(json.dumps({"failed_at": 2.0, "report": {"plan_id": "ok2"}}) + "\n")

        with patch("runtime.reporter.metrics.RETRY_QUEUE_FILE", retry_file):
            loaded = _load_retry_queue()
            assert len(loaded) == 2
            assert loaded[0]["plan_id"] == "ok"
            assert loaded[1]["plan_id"] == "ok2"

    def test_load_handles_bare_report_format(self, tmp_path: Any) -> None:
        """Bare report dicts (without envelope) are loaded as-is."""
        retry_file = str(tmp_path / "metrics_retry.jsonl")
        with open(retry_file, "w") as f:
            f.write(json.dumps({"plan_id": "bare"}) + "\n")

        with patch("runtime.reporter.metrics.RETRY_QUEUE_FILE", retry_file):
            loaded = _load_retry_queue()
            assert len(loaded) == 1
            assert loaded[0]["plan_id"] == "bare"

    def test_cap_trims_oldest(self, tmp_path: Any) -> None:
        """Retry queue is capped to MAX_RETRY_QUEUE_ENTRIES, dropping oldest."""
        retry_file = str(tmp_path / "metrics_retry.jsonl")
        with patch("runtime.reporter.metrics.RETRY_QUEUE_FILE", retry_file), \
             patch("runtime.reporter.metrics.PENDING_REPORTS_DIR", str(tmp_path)), \
             patch("runtime.reporter.metrics.MAX_RETRY_QUEUE_ENTRIES", 3):
            reports = [{"plan_id": f"p{i}"} for i in range(5)]
            _append_retry_queue(reports)

            loaded = _load_retry_queue()
            assert len(loaded) == 3
            # Oldest 2 dropped, newest 3 kept
            assert loaded[0]["plan_id"] == "p2"
            assert loaded[2]["plan_id"] == "p4"

    @pytest.mark.asyncio
    async def test_failed_flush_writes_retry_queue(self, tmp_path: Any) -> None:
        """When transport fails, reports are persisted to metrics_retry.jsonl."""
        transport = MockTransport(succeed=False)
        reporter = ExecutionReporter(transport=transport, batch_interval=60.0)

        retry_file = str(tmp_path / "metrics_retry.jsonl")
        pending_file = str(tmp_path / "pending_reports.jsonl")
        with patch("runtime.reporter.metrics.RETRY_QUEUE_FILE", retry_file), \
             patch("runtime.reporter.metrics.PENDING_REPORTS_FILE", pending_file), \
             patch("runtime.reporter.metrics.PENDING_REPORTS_DIR", str(tmp_path)):
            steps = [
                StepMetrics("s1", 100.0, 50, True, model_name="a", backend="b"),
            ]
            await reporter.report_execution("plan-retry", steps)

            assert os.path.exists(retry_file)
            loaded = _load_retry_queue()
            assert len(loaded) == 1
            assert loaded[0]["plan_id"] == "plan-retry"

    @pytest.mark.asyncio
    async def test_successful_flush_clears_retry_queue(self, tmp_path: Any) -> None:
        """On successful send, the retry queue file is cleaned up."""
        retry_file = str(tmp_path / "metrics_retry.jsonl")
        pending_file = str(tmp_path / "pending_reports.jsonl")

        # Pre-populate retry queue
        with patch("runtime.reporter.metrics.RETRY_QUEUE_FILE", retry_file), \
             patch("runtime.reporter.metrics.PENDING_REPORTS_FILE", pending_file), \
             patch("runtime.reporter.metrics.PENDING_REPORTS_DIR", str(tmp_path)):
            _append_retry_queue([{"plan_id": "old-retry", "total_elapsed_ms": 50.0,
                                  "steps_completed": 1, "steps_failed": 0, "steps": [],
                                  "reported_at": time.time() - 3600}])
            assert os.path.exists(retry_file)

            transport = MockTransport(succeed=True)
            reporter = ExecutionReporter(transport=transport, batch_interval=60.0)

            steps = [
                StepMetrics("s1", 200.0, 100, True, model_name="x", backend="y"),
            ]
            await reporter.report_execution("new-plan", steps)

            # Retry queue should be cleared after successful send
            assert not os.path.exists(retry_file)

            # Both old retry and new reports should have been sent
            batch = transport.calls[0]
            plan_ids = {r["plan_id"] for r in batch}
            assert "old-retry" in plan_ids
            assert "new-plan" in plan_ids


# ══════════════════════════════════════════════════════════════════════
# End-to-End Privacy Audit
# ══════════════════════════════════════════════════════════════════════


class TestPrivacyAudit:
    """End-to-end audit: simulate a full execution with realistic data
    and verify NOTHING sensitive appears in the outbound report."""

    @pytest.mark.asyncio
    async def test_full_pipeline_privacy(self) -> None:
        """Realistic execution with prompts and outputs — nothing leaks."""
        transport = MockTransport(succeed=True)
        reporter = ExecutionReporter(transport=transport, batch_interval=60.0)

        # These StepMetrics objects are what the plan executor would create.
        # They contain only metric fields — no content.
        steps = [
            StepMetrics(
                step_id="step_1",
                elapsed_ms=3200.0,
                tokens_used=1500,
                success=True,
                model_name="claude-sonnet-4",
                backend="claude",
                task_type="analysis",
            ),
            StepMetrics(
                step_id="step_2",
                elapsed_ms=1800.0,
                tokens_used=800,
                success=True,
                model_name="gemini-2.5-flash",
                backend="gemini",
                task_type="fact_check",
            ),
            StepMetrics(
                step_id="step_3",
                elapsed_ms=500.0,
                tokens_used=300,
                success=False,
                error_type="rate_limit",
                model_name="llama3",
                backend="ollama",
                task_type="summarization",
            ),
        ]

        await reporter.report_execution("plan-realistic", steps)

        # Get the full serialized output
        sent = transport.calls[0][0]
        serialized = json.dumps(sent)

        # Verify NO content-bearing strings
        dangerous_patterns = [
            "analyze", "document", "report", "secret",
            "password", "sk-", "key", "please", "summarize",
        ]
        for pattern in dangerous_patterns:
            # Allow "reported_at" but not bare "report" in content values
            # We only check non-key positions
            for step in sent.get("steps", []):
                for v in step.values():
                    if isinstance(v, str):
                        assert pattern not in v.lower() or v in (
                            "rate_limit", "analysis", "fact_check", "summarization",
                        ), f"Pattern '{pattern}' found in step value '{v}'"

        # Verify structure
        assert sent["plan_id"] == "plan-realistic"
        assert sent["steps_completed"] == 2
        assert sent["steps_failed"] == 1
        assert sent["total_elapsed_ms"] == 5500.0
