import hashlib
import json
from collections.abc import AsyncIterator
from pathlib import Path

import pytest
from unified_agent import (
    AgentEvent,
    AssistantText,
    CommandOutputDelta,
    CommandRun,
    CommandStarted,
    FileChanged,
    RawEvent,
    Reasoning,
    RunOptions,
    SandboxPolicy,
    SessionStarted,
    SQLiteRunMonitor,
    Task,
    ToolCall,
    ToolProgress,
    ToolResult,
    TurnCompleted,
    UnifiedAgent,
    UnifiedUsage,
)

from pentestgpt_agent.trace import (
    AgentRole,
    EpisodeInput,
    EpisodeRunner,
    EpisodeTrace,
    TraceStore,
    failure_detail,
    has_action_receipts,
)


class SuccessfulBackend:
    name = "scripted"

    async def stream(self, prompt: str, opts: RunOptions) -> AsyncIterator[AgentEvent]:
        yield AssistantText(text="completed")
        yield TurnCompleted(
            success=True,
            final_text="completed",
            structured_output={"decision": "finish"},
            usage=UnifiedUsage(input_tokens=12, output_tokens=3),
            session_id="provider-session",
        )


class CrashingBackend:
    name = "scripted"

    def __init__(self, traces: TraceStore) -> None:
        self.traces = traces

    async def stream(self, prompt: str, opts: RunOptions) -> AsyncIterator[AgentEvent]:
        trace = self.traces.load("run-1", "episode-crash")
        assert trace.input["rendered_prompt"] == "Choose the next task."
        raise RuntimeError("provider crashed")
        yield  # pragma: no cover - makes this an async generator


class SensitiveBackend:
    name = "scripted"

    async def stream(self, prompt: str, opts: RunOptions) -> AsyncIterator[AgentEvent]:
        yield Reasoning(text="PRIVATE-REASONING-MARKER")
        yield RawEvent(
            backend="scripted",
            kind="native-secret",
            data={"secret": "PRIVATE-RAW-MARKER"},
        )
        yield TurnCompleted(success=True, final_text="safe")


class OperationalBackend:
    name = "scripted"

    async def stream(self, prompt: str, opts: RunOptions) -> AsyncIterator[AgentEvent]:
        yield SessionStarted(session_id="session-1")
        yield ToolCall(name="HttpGet", input={"url": "http://target/"}, call_id="call-1")
        yield ToolResult(call_id="call-1", output="200 OK", is_error=False)
        yield CommandRun(command="curl http://target/", exit_code=0, output="200 OK")
        yield FileChanged(path="notes.txt", kind="add")
        yield AssistantText(text="observed target")
        yield TurnCompleted(
            success=True,
            final_text="observed target",
            structured_output={"outcome": "done"},
            usage=UnifiedUsage(input_tokens=21, output_tokens=5),
            cost_usd=0.02,
            session_id="session-1",
            duration_ms=123,
        )


class ObservableOperationalBackend:
    name = "scripted"

    async def stream(self, prompt: str, opts: RunOptions) -> AsyncIterator[AgentEvent]:
        yield SessionStarted(session_id="session-observable")
        yield CommandStarted(
            command="curl http://target/",
            call_id="command-1",
            cwd="/workspace",
            process_id="process-1",
        )
        yield CommandOutputDelta(call_id="command-1", delta="HTTP/1.1 200 OK\n")
        yield CommandRun(
            command="curl http://target/",
            exit_code=0,
            output="HTTP/1.1 200 OK\n",
            call_id="command-1",
            process_id="process-1",
            duration_ms=25,
        )
        yield ToolCall(name="HttpGet", input={"url": "http://target/"}, call_id="tool-1")
        yield ToolProgress(call_id="tool-1", message="received response headers")
        yield ToolResult(call_id="tool-1", output="200 OK", is_error=False)
        yield TurnCompleted(success=True, final_text="observed target")


class MissingTerminalBackend:
    name = "scripted"

    async def stream(self, prompt: str, opts: RunOptions) -> AsyncIterator[AgentEvent]:
        yield AssistantText(text="partial response")


@pytest.mark.asyncio
async def test_successful_episode_is_recoverable_from_its_trace(tmp_path: Path) -> None:
    agent = UnifiedAgent(
        SuccessfulBackend(),
        workspace=tmp_path / "workspace",
        instructions="You are the Supervisor.",
    )
    traces = TraceStore(tmp_path / "runs")
    runner = EpisodeRunner(agent, traces)

    result = await runner.run(
        EpisodeInput(
            run_id="run-1",
            episode_id="episode-1",
            role=AgentRole.SUPERVISOR,
            state_revision=0,
            task=Task("Choose the next task."),
            output_schema={"type": "object"},
            max_turns=2,
        )
    )

    trace = traces.load("run-1", "episode-1")

    assert result.success is True
    assert trace.input["rendered_prompt"] == "Choose the next task."
    assert trace.input["instructions"] == "You are the Supervisor."
    assert [event["type"] for event in trace.events] == ["assistant_text", "turn_completed"]
    assert trace.output["structured_output"] == {"decision": "finish"}
    assert trace.output["usage"] == {
        "cached_input_tokens": 0,
        "input_tokens": 12,
        "output_tokens": 3,
        "reasoning_output_tokens": 0,
    }


@pytest.mark.asyncio
async def test_provider_crash_leaves_a_complete_failure_trace(tmp_path: Path) -> None:
    traces = TraceStore(tmp_path / "runs")
    agent = UnifiedAgent(CrashingBackend(traces), workspace=tmp_path / "workspace")
    runner = EpisodeRunner(agent, traces)

    result = await runner.run(
        EpisodeInput(
            run_id="run-1",
            episode_id="episode-crash",
            role=AgentRole.SUPERVISOR,
            state_revision=0,
            task="Choose the next task.",
        )
    )

    trace = traces.load("run-1", "episode-crash")

    assert result.success is False
    assert trace.events == ()
    assert trace.output["error"] == "RuntimeError: provider crashed"


@pytest.mark.asyncio
async def test_reasoning_and_raw_provider_payloads_are_never_persisted(tmp_path: Path) -> None:
    traces = TraceStore(tmp_path / "runs")
    agent = UnifiedAgent(SensitiveBackend(), workspace=tmp_path / "workspace")

    result = await EpisodeRunner(agent, traces).run(
        EpisodeInput(
            run_id="run-1",
            episode_id="episode-sensitive",
            role=AgentRole.SUPERVISOR,
            state_revision=0,
            task="Choose the next task.",
        )
    )

    trace = traces.load("run-1", "episode-sensitive")
    persisted = "\n".join(
        path.read_text(encoding="utf-8")
        for path in (tmp_path / "runs").rglob("*")
        if path.is_file()
    )

    assert "PRIVATE-REASONING-MARKER" not in persisted
    assert "PRIVATE-RAW-MARKER" not in persisted
    assert [event["type"] for event in trace.events] == [
        "reasoning",
        "raw_event",
        "turn_completed",
    ]
    assert all(not isinstance(event, (Reasoning, RawEvent)) for event in result.events)


@pytest.mark.asyncio
async def test_operational_events_and_terminal_result_share_one_ordered_trace(
    tmp_path: Path,
) -> None:
    traces = TraceStore(tmp_path / "runs")
    agent = UnifiedAgent(OperationalBackend(), workspace=tmp_path / "workspace")

    result = await EpisodeRunner(agent, traces).run(
        EpisodeInput(
            run_id="run-1",
            episode_id="episode-operational",
            role=AgentRole.EXECUTOR,
            state_revision=4,
            task="Inspect the target.",
            max_turns=3,
        )
    )

    trace = traces.load("run-1", "episode-operational")

    assert [event["sequence"] for event in trace.events] == list(range(1, 8))
    assert [event["type"] for event in trace.events] == [
        "session_started",
        "tool_call",
        "tool_result",
        "command_run",
        "file_changed",
        "assistant_text",
        "turn_completed",
    ]
    assert trace.output is not None
    assert trace.output["success"] == result.success is True
    assert trace.output["structured_output"] == result.structured_output == {"outcome": "done"}
    assert trace.output["session_id"] == result.session_id == "session-1"
    assert trace.output["cost_usd"] == result.cost_usd == 0.02


@pytest.mark.asyncio
async def test_episode_trace_and_wrapper_monitor_accept_live_operational_events(
    tmp_path: Path,
) -> None:
    """The PentestGPT trace and wrapper inspector must coexist for one episode."""
    traces = TraceStore(tmp_path / "runs")
    monitor = SQLiteRunMonitor(tmp_path / "monitor.sqlite3")
    agent = UnifiedAgent(
        ObservableOperationalBackend(),
        workspace=tmp_path / "workspace",
        monitor=monitor,
    )
    episode = EpisodeInput(
        run_id="pentest-run-1",
        episode_id="executor-attempt-1",
        role=AgentRole.EXECUTOR,
        state_revision=4,
        task="Inspect the authorized target.",
        task_id="task-1",
        attempt_id="attempt-1",
    )

    result = await EpisodeRunner(agent, traces).run(episode)

    trace = traces.load(episode.run_id, episode.episode_id)
    monitored = monitor.get_run("pentest-run-1--executor-attempt-1")
    assert result.success is True
    assert [event["type"] for event in trace.events] == [
        "session_started",
        "command_started",
        "command_output_delta",
        "command_run",
        "tool_call",
        "tool_progress",
        "tool_result",
        "turn_completed",
    ]
    assert trace.events[3]["call_id"] == "command-1"
    assert has_action_receipts(trace) is True
    assert monitored is not None
    assert monitored.status == "succeeded"
    assert monitored.metadata == {
        "attempt_id": "attempt-1",
        "episode_id": "executor-attempt-1",
        "pentest_run_id": "pentest-run-1",
        "project": "PentestGPT",
        "role": "executor",
        "state_revision": 4,
        "task_id": "task-1",
    }
    assert [event.event_type for event in monitor.list_events(monitored.run_id)] == [
        "session_started",
        "command_started",
        "command_output_delta",
        "command_run",
        "tool_call",
        "tool_progress",
        "tool_result",
        "turn_completed",
    ]


@pytest.mark.asyncio
async def test_missing_terminal_event_is_an_explicit_episode_failure(tmp_path: Path) -> None:
    traces = TraceStore(tmp_path / "runs")
    agent = UnifiedAgent(MissingTerminalBackend(), workspace=tmp_path / "workspace")

    result = await EpisodeRunner(agent, traces).run(
        EpisodeInput(
            run_id="run-1",
            episode_id="episode-incomplete",
            role=AgentRole.SUPERVISOR,
            state_revision=0,
            task="Choose the next task.",
        )
    )

    trace = traces.load("run-1", "episode-incomplete")

    assert result.success is False
    assert result.error == "episode stream ended without TurnCompleted"
    assert trace.output is not None
    assert trace.output["error"] == result.error


@pytest.mark.asyncio
async def test_trace_load_preserves_events_before_a_torn_jsonl_tail(tmp_path: Path) -> None:
    traces = TraceStore(tmp_path / "runs")
    agent = UnifiedAgent(SuccessfulBackend(), workspace=tmp_path / "workspace")
    await EpisodeRunner(agent, traces).run(
        EpisodeInput(
            run_id="run-1",
            episode_id="episode-torn-tail",
            role=AgentRole.SUPERVISOR,
            state_revision=0,
            task="Choose the next task.",
        )
    )
    events_path = tmp_path / "runs" / "run-1" / "traces" / "episode-torn-tail" / "events.jsonl"
    with events_path.open("a", encoding="utf-8") as stream:
        stream.write('{"sequence":3,"type":')

    trace = traces.load("run-1", "episode-torn-tail")

    assert [event["type"] for event in trace.events] == ["assistant_text", "turn_completed"]
    assert trace.truncated_tail is True


@pytest.mark.asyncio
async def test_trace_records_the_provider_configuration_that_shaped_the_episode(
    tmp_path: Path,
) -> None:
    traces = TraceStore(tmp_path / "runs")
    agent = UnifiedAgent(
        SuccessfulBackend(),
        workspace=tmp_path / "workspace",
        model="model-1",
        sandbox=SandboxPolicy.READ_ONLY,
        instructions="You are the Supervisor.",
        effort="low",
    )

    await EpisodeRunner(agent, traces).run(
        EpisodeInput(
            run_id="run-1",
            episode_id="episode-config",
            role=AgentRole.SUPERVISOR,
            state_revision=3,
            task="Choose the next task.",
        )
    )

    trace = traces.load("run-1", "episode-config")

    assert trace.input["provider"] == {
        "backend": "scripted",
        "effort": "low",
        "model": "model-1",
        "sandbox": "read_only",
        "unified_agent_version": "0.3.0",
    }
    assert trace.input["signatures"] == {
        "instructions_sha256": hashlib.sha256(b"You are the Supervisor.").hexdigest(),
        "output_schema_sha256": hashlib.sha256(b"null").hexdigest(),
        "prompt_sha256": hashlib.sha256(b"Choose the next task.").hexdigest(),
        "provider_sha256": hashlib.sha256(
            json.dumps(trace.input["provider"], sort_keys=True, separators=(",", ":")).encode()
        ).hexdigest(),
    }


def test_claude_safety_rejection_has_a_typed_informative_failure() -> None:
    rejection = (
        "API Error: Opus 4.8's safeguards flagged this message for a cybersecurity topic. "
        "Request ID: req_safety"
    )
    trace = EpisodeTrace(
        input={},
        events=(
            {
                "type": "raw_event",
                "backend": "claude",
                "kind": "assistant_error:invalid_request",
                "data_omitted": True,
            },
            {"type": "assistant_text", "text": rejection},
            {
                "type": "turn_completed",
                "success": False,
                "stop_reason": "success",
                "error": "success",
                "final_text": rejection,
            },
        ),
        output={"success": False, "error": "success", "text": rejection},
        truncated_tail=False,
    )

    kind, message = failure_detail(trace, "provider failed")

    assert kind == "provider_safety_block"
    assert "safeguards flagged this message" in message
    assert "req_safety" in message


def test_existing_provider_stop_reasons_remain_typed() -> None:
    trace = EpisodeTrace(
        input={},
        events=(
            {
                "type": "raw_event",
                "backend": "claude",
                "kind": "assistant_error:invalid_request",
            },
            {
                "type": "assistant_text",
                "text": "An earlier request was safeguards flagged for a cybersecurity topic.",
            },
            {
                "type": "turn_completed",
                "success": False,
                "stop_reason": "error_max_turns",
                "error": "Reached maximum number of turns (2)",
            },
        ),
        output={"success": False, "error": "later transport error"},
        truncated_tail=False,
    )

    assert failure_detail(trace, "provider failed") == (
        "max_turns",
        "Reached maximum number of turns (2)",
    )
