from pathlib import Path

import pytest

from pentestgpt_agent.execution import ExecutionOutcome, ValidExecution
from pentestgpt_agent.memory import MemoryKernel, RunSpec, RunSpecMismatchError, RunStatus
from pentestgpt_agent.plan import (
    SupervisorDecision,
    TaskKind,
    TaskProposal,
    TaskStatus,
    compile_plan,
)


def test_run_is_recoverable_from_a_new_memory_kernel_instance(tmp_path: Path) -> None:
    database = tmp_path / "run-state.sqlite3"
    created = MemoryKernel(database).create_run(
        RunSpec(
            run_id="run-1",
            goal="Assess the authorized target.",
            allowed_targets=("http://127.0.0.1:8080",),
        )
    )

    recovered = MemoryKernel(database).snapshot("run-1")

    assert recovered == created
    assert recovered.revision == 0
    assert recovered.status is RunStatus.RUNNING
    assert recovered.tasks == ()


def test_open_run_reuses_only_an_identical_persisted_spec(tmp_path: Path) -> None:
    database = tmp_path / "run-state.sqlite3"
    spec = RunSpec(
        run_id="run-1",
        goal="Assess the authorized target.",
        allowed_targets=("http://127.0.0.1:8080",),
        max_attempts_per_task=2,
    )
    created = MemoryKernel(database).open_run(spec)

    reopened = MemoryKernel(database).open_run(spec)

    assert reopened == created
    with pytest.raises(RunSpecMismatchError, match="does not match"):
        MemoryKernel(database).open_run(
            RunSpec(
                run_id="run-1",
                goal="Assess a different target.",
                allowed_targets=spec.allowed_targets,
                max_attempts_per_task=spec.max_attempts_per_task,
            )
        )


@pytest.mark.parametrize(
    "spec, message",
    [
        (
            RunSpec("run-1", "x" * 4_001, ("http://127.0.0.1:8080",)),
            "goal must be",
        ),
        (
            RunSpec(
                "run-1",
                "Assess the target.",
                tuple(f"http://127.0.0.1:{8000 + index}" for index in range(17)),
            ),
            "at most 16",
        ),
    ],
)
def test_run_spec_memory_inputs_are_bounded(
    tmp_path: Path,
    spec: RunSpec,
    message: str,
) -> None:
    with pytest.raises(ValueError, match=message):
        MemoryKernel(tmp_path / "state.sqlite3").open_run(spec)


def test_progress_task_can_be_selected_again_and_completed(tmp_path: Path) -> None:
    memory = MemoryKernel(tmp_path / "state.sqlite3")
    initial = memory.create_run(
        RunSpec(
            run_id="run-1",
            goal="Assess the authorized target.",
            allowed_targets=("http://127.0.0.1:8080",),
        )
    )
    target = "http://127.0.0.1:8080"
    first_commit = memory.commit_plan(
        compile_plan(
            SupervisorDecision(
                base_revision=0,
                new_tasks=(
                    TaskProposal(
                        "discover",
                        TaskKind.DISCOVER,
                        target,
                        "Inspect HTTP.",
                        "HTTP is recorded.",
                    ),
                ),
                next_task_id="discover",
                finish=False,
                summary="Start discovery.",
            ),
            initial,
        )
    )
    assert first_commit.lease is not None
    partial = memory.commit_execution(
        ValidExecution(
            run_id="run-1",
            task_id="discover",
            attempt_id=first_commit.lease.attempt_id,
            lease_revision=first_commit.lease.revision,
            trace_episode_id="executor-1",
            outcome=ExecutionOutcome.PROGRESS,
            summary="Discovery needs one more request.",
            observation="The first response linked to a second path.",
            evidence_sequences=(1,),
        )
    )
    second_commit = memory.commit_plan(
        compile_plan(
            SupervisorDecision(
                base_revision=partial.revision,
                new_tasks=(),
                next_task_id="discover",
                finish=False,
                summary="Use the second bounded discovery attempt.",
            ),
            partial,
        )
    )
    assert second_commit.lease is not None

    completed = memory.commit_execution(
        ValidExecution(
            run_id="run-1",
            task_id="discover",
            attempt_id=second_commit.lease.attempt_id,
            lease_revision=second_commit.lease.revision,
            trace_episode_id="executor-2",
            outcome=ExecutionOutcome.DONE,
            summary="HTTP inspected.",
            observation="HTTP returned 200 OK.",
            evidence_sequences=(1,),
        )
    )

    assert {task.id: task.status for task in completed.tasks} == {
        "discover": TaskStatus.DONE,
    }


def test_new_task_with_completed_dependencies_can_be_leased_immediately(
    tmp_path: Path,
) -> None:
    memory = MemoryKernel(tmp_path / "state.sqlite3")
    initial = memory.create_run(
        RunSpec(
            run_id="run-1",
            goal="Assess the authorized target.",
            allowed_targets=("http://127.0.0.1:8080",),
        )
    )
    target = "http://127.0.0.1:8080"
    first_commit = memory.commit_plan(
        compile_plan(
            SupervisorDecision(
                base_revision=0,
                new_tasks=(
                    TaskProposal(
                        "discover",
                        TaskKind.DISCOVER,
                        target,
                        "Inspect HTTP.",
                        "HTTP is recorded.",
                    ),
                ),
                next_task_id="discover",
                finish=False,
                summary="Discover the target.",
            ),
            initial,
        )
    )
    assert first_commit.lease is not None
    after_discovery = memory.commit_execution(
        ValidExecution(
            run_id="run-1",
            task_id="discover",
            attempt_id=first_commit.lease.attempt_id,
            lease_revision=first_commit.lease.revision,
            trace_episode_id="executor-1",
            outcome=ExecutionOutcome.DONE,
            summary="HTTP inspected.",
            observation="HTTP returned 200 OK.",
            evidence_sequences=(1,),
        )
    )
    next_plan = compile_plan(
        SupervisorDecision(
            base_revision=after_discovery.revision,
            new_tasks=(
                TaskProposal(
                    "test-input",
                    TaskKind.TEST,
                    target,
                    "Test the discovered input.",
                    "The behavior is confirmed.",
                    depends_on=("discover",),
                ),
            ),
            next_task_id="test-input",
            finish=False,
            summary="Test the discovered input.",
        ),
        after_discovery,
    )

    second_commit = memory.commit_plan(next_plan)
    recovered = memory.snapshot("run-1")

    assert second_commit.lease is not None
    assert second_commit.lease.task_id == "test-input"
    assert {task.id: task.status for task in recovered.tasks}["test-input"] is TaskStatus.ACTIVE


def test_blocked_execution_does_not_reenter_the_dependency_ready_queue(
    tmp_path: Path,
) -> None:
    memory = MemoryKernel(tmp_path / "state.sqlite3")
    initial = memory.create_run(
        RunSpec(
            run_id="run-1",
            goal="Assess the authorized target.",
            allowed_targets=("http://127.0.0.1:8080",),
        )
    )
    target = "http://127.0.0.1:8080"
    plan_commit = memory.commit_plan(
        compile_plan(
            SupervisorDecision(
                base_revision=0,
                new_tasks=(
                    TaskProposal(
                        "exploit",
                        TaskKind.TEST,
                        target,
                        "Exploit the tested input.",
                        "The hypothesis is resolved.",
                    ),
                ),
                next_task_id="exploit",
                finish=False,
                summary="Try the exploit.",
            ),
            initial,
        )
    )
    assert plan_commit.lease is not None

    after_attempt = memory.commit_execution(
        ValidExecution(
            run_id="run-1",
            task_id="exploit",
            attempt_id=plan_commit.lease.attempt_id,
            lease_revision=plan_commit.lease.revision,
            trace_episode_id="executor-1",
            outcome=ExecutionOutcome.BLOCKED,
            summary="The prerequisite hypothesis was disproved.",
            observation="The candidate behavior is a hard-coded decoy.",
            evidence_sequences=(1,),
        )
    )

    assert after_attempt.tasks[0].status is TaskStatus.FAILED


def test_progress_cannot_reopen_a_task_past_its_attempt_budget(tmp_path: Path) -> None:
    memory = MemoryKernel(tmp_path / "state.sqlite3")
    initial = memory.create_run(
        RunSpec(
            run_id="run-1",
            goal="Assess the authorized target.",
            allowed_targets=("http://127.0.0.1:8080",),
            max_attempts_per_task=2,
        )
    )
    first = memory.commit_plan(
        compile_plan(
            SupervisorDecision(
                base_revision=0,
                new_tasks=(
                    TaskProposal(
                        "enumerate",
                        TaskKind.ENUMERATE,
                        "http://127.0.0.1:8080",
                        "Enumerate the named surface.",
                        "The surface is recorded.",
                    ),
                ),
                next_task_id="enumerate",
                finish=False,
                summary="Begin enumeration.",
            ),
            initial,
        )
    )
    assert first.lease is not None
    after_first = memory.commit_execution(
        ValidExecution(
            run_id="run-1",
            task_id="enumerate",
            attempt_id=first.lease.attempt_id,
            lease_revision=first.lease.revision,
            trace_episode_id="executor-1",
            outcome=ExecutionOutcome.PROGRESS,
            summary="One bounded request remains.",
            observation="First response",
            evidence_sequences=(1,),
        )
    )
    second = memory.commit_plan(
        compile_plan(
            SupervisorDecision(
                base_revision=after_first.revision,
                new_tasks=(),
                next_task_id="enumerate",
                finish=False,
                summary="Use the final attempt.",
            ),
            after_first,
        )
    )
    assert second.lease is not None

    exhausted = memory.commit_execution(
        ValidExecution(
            run_id="run-1",
            task_id="enumerate",
            attempt_id=second.lease.attempt_id,
            lease_revision=second.lease.revision,
            trace_episode_id="executor-2",
            outcome=ExecutionOutcome.PROGRESS,
            summary="The proposed completion evidence could not be resolved.",
            observation=None,
            evidence_sequences=(),
            evidence_unresolved=True,
        )
    )

    assert exhausted.status is RunStatus.RUNNING
    assert exhausted.tasks[0].status is TaskStatus.FAILED
    assert [attempt.status.value for attempt in exhausted.attempts] == ["progress", "progress"]
    assert exhausted.transitions[-1].detail["attempt_limit_reached"] is True
    assert exhausted.transitions[-1].detail["evidence_unresolved"] is True
