"""Engine-side prune coverage. SPEC §14.1.

Covers `Engine.prune_run`, `Engine.list_all_run_ids`,
`Engine.auto_prune_completed`, and the duration parser.
CLI-surface tests live under `tests/test_cli/test_prune.py`.
"""

from __future__ import annotations

from datetime import timedelta
from pathlib import Path

import pygit2
import pytest

from attractor.engine import (
    Engine,
    PruneRefusal,
    PruneResult,
    RunStatus,
    UnknownRun,
    parse_duration,
)
from attractor.workflow import parse, validate


# [unit->REQ-CLI-PRUNE-SELECTORS]
class TestParseDuration:
    """The CLI shares this parser; cover the small grammar comprehensively."""

    @pytest.mark.parametrize(
        ("text", "expected"),
        [
            ("60s", timedelta(seconds=60)),
            ("0s", timedelta(0)),
            ("30m", timedelta(minutes=30)),
            ("12h", timedelta(hours=12)),
            ("5d", timedelta(days=5)),
            ("100d", timedelta(days=100)),
        ],
    )
    def test_parses_known_units(self, text: str, expected: timedelta) -> None:
        assert parse_duration(text) == expected

    @pytest.mark.parametrize("bad", ["", "5", "x", "5x", "5.5d", "-1d", "d", "5 d"])
    def test_rejects_malformed(self, bad: str) -> None:
        with pytest.raises(ValueError):
            parse_duration(bad)


# [unit->REQ-CLI-PRUNE-SELECTORS]
class TestListAllRunIds:
    """`list_all_run_ids` enumerates every state branch, even if the
    matching worktree dir is gone (COMPLETED runs)."""

    def test_empty_repo_returns_empty(self, seeded_repo: Path) -> None:
        assert Engine(seeded_repo).list_all_run_ids() == []

    @pytest.mark.asyncio
    async def test_lists_a_completed_run(
        self, seeded_repo: Path, tool_only_workflow: str
    ) -> None:
        engine = Engine(seeded_repo)
        graph = validate(parse(tool_only_workflow))
        await engine.run(graph)
        ids = engine.list_all_run_ids()
        assert len(ids) == 1, ids
        # Round-trip through show() to prove the id is valid.
        summary = engine.show(ids[0])
        assert summary.status == RunStatus.COMPLETED


# [unit->REQ-CLI-PRUNE-SELECTORS]
# [unit->REQ-CLI-PRUNE-SAFETY]
class TestPruneRun:
    """End-to-end: run a tool-only workflow, prune it, assert refs gone."""

    @pytest.mark.asyncio
    async def test_prunes_completed_run_removes_refs(
        self, seeded_repo: Path, tool_only_workflow: str
    ) -> None:
        engine = Engine(seeded_repo)
        graph = validate(parse(tool_only_workflow))
        await engine.run(graph)
        run_id = engine.list_all_run_ids()[0]

        result = engine.prune_run(run_id)

        assert isinstance(result, PruneResult)
        assert result.run_id == run_id
        assert result.status_at_prune == "completed"
        assert result.removed_state_ref is True
        assert result.removed_worktree_ref is True

        # Refs are gone — list_all_run_ids no longer sees this id.
        assert engine.list_all_run_ids() == []
        # show() raises since the state branch is gone.
        with pytest.raises(UnknownRun):
            engine.show(run_id)

    @pytest.mark.asyncio
    async def test_unknown_run_id_raises(self, seeded_repo: Path) -> None:
        engine = Engine(seeded_repo)
        with pytest.raises(UnknownRun):
            engine.prune_run("not-a-real-uuid")

    @pytest.mark.asyncio
    async def test_refuses_paused_without_force(
        self, seeded_repo: Path, human_gate_workflow: str
    ) -> None:
        """A paused run is awaiting `respond` — prune by default would
        silently drop the gate context. Require --force to override.
        """
        engine = Engine(seeded_repo)
        graph = validate(parse(human_gate_workflow))
        status = await engine.run(graph)
        assert status == RunStatus.PAUSED
        run_id = engine.list_all_run_ids()[0]

        with pytest.raises(PruneRefusal, match="paused"):
            engine.prune_run(run_id)

        # State branch is intact after the refusal.
        assert engine.show(run_id).status == RunStatus.PAUSED

    @pytest.mark.asyncio
    async def test_force_prunes_paused_run(
        self, seeded_repo: Path, human_gate_workflow: str
    ) -> None:
        engine = Engine(seeded_repo)
        graph = validate(parse(human_gate_workflow))
        await engine.run(graph)
        run_id = engine.list_all_run_ids()[0]

        result = engine.prune_run(run_id, force=True)
        assert result.removed_state_ref is True
        assert engine.list_all_run_ids() == []

    @pytest.mark.asyncio
    async def test_refuses_dirty_worktree_without_force(
        self,
        seeded_repo: Path,
        human_gate_workflow: str,
    ) -> None:
        """Run pauses → leaves worktree dir on disk → user adds an
        untracked file → prune refuses without --force.
        """
        engine = Engine(seeded_repo)
        graph = validate(parse(human_gate_workflow))
        await engine.run(graph)
        run_id = engine.list_all_run_ids()[0]
        wt_path = next(
            wt.worktree_path for wt in engine.list() if wt.run_id == run_id
        )
        # Drop an untracked file into the worktree.
        (wt_path / "untracked-edit.txt").write_text("manual edit\n")

        # Paused refusal fires first; force past that. With force, the
        # dirty-worktree path is exercised inside `prune_run` but force
        # also overrides it.
        result = engine.prune_run(run_id, force=True)
        assert result.removed_state_ref is True


# [unit->REQ-CLI-AUTOPRUNE]
class TestAutoPruneCompleted:
    """Auto-prune walks completed runs and removes those older than
    the retention window. Exception-tolerant by design."""

    @pytest.mark.asyncio
    async def test_does_not_prune_recent_completed(
        self, seeded_repo: Path, tool_only_workflow: str
    ) -> None:
        engine = Engine(seeded_repo)
        graph = validate(parse(tool_only_workflow))
        await engine.run(graph)
        # Just-completed run is well within any reasonable retention.
        removed = engine.auto_prune_completed(timedelta(days=5))
        assert removed == 0
        assert len(engine.list_all_run_ids()) == 1

    @pytest.mark.asyncio
    async def test_prunes_when_retention_is_zero(
        self, seeded_repo: Path, tool_only_workflow: str
    ) -> None:
        """retention=0 means "anything completed before now" — exercises
        the prune path without time-travel.
        """
        engine = Engine(seeded_repo)
        graph = validate(parse(tool_only_workflow))
        await engine.run(graph)
        removed = engine.auto_prune_completed(timedelta(0))
        assert removed == 1
        assert engine.list_all_run_ids() == []

    @pytest.mark.asyncio
    async def test_does_not_touch_paused_or_incomplete(
        self,
        seeded_repo: Path,
        human_gate_workflow: str,
        goal_gate_unmet_workflow: str,
    ) -> None:
        """SPEC §14.1: auto-prune is COMPLETED-only by design."""
        engine = Engine(seeded_repo)
        await engine.run(validate(parse(human_gate_workflow)))     # → PAUSED
        await engine.run(validate(parse(goal_gate_unmet_workflow))) # → INCOMPLETE

        before = set(engine.list_all_run_ids())
        assert len(before) == 2

        removed = engine.auto_prune_completed(timedelta(0))
        assert removed == 0
        assert set(engine.list_all_run_ids()) == before


# [unit->REQ-EXEC-PARALLEL-WORKTREE]
class TestPruneSweepsBranchRefs:
    """Regression: `prune_run` deletes branch refs created by `create_branch`.

    The existing prefix-sweep in `prune_run` covers any ref under
    `refs/heads/attractor/run/<id>/` — including branch sub-refs added
    by the parallel-worktree primitives. This test pins that invariant
    without modifying `engine.py`.
    """

    @pytest.mark.asyncio
    async def test_prune_removes_branch_ref(
        self, seeded_repo: Path, tool_only_workflow: str
    ) -> None:
        # 1. Run a workflow to completion so the run-id exists and the
        #    state branch is present (prune_run calls show() first).
        engine = Engine(seeded_repo)
        graph = validate(parse(tool_only_workflow))
        await engine.run(graph)
        run_id = engine.list_all_run_ids()[0]

        # 2. Manually create a branch ref under this run's namespace,
        #    simulating what create_branch would do.
        repo = pygit2.Repository(str(seeded_repo))
        main_oid = repo.references["refs/heads/main"].target
        branch_ref_name = (
            f"refs/heads/attractor/run/{run_id}/branch/side"
        )
        repo.references.create(branch_ref_name, main_oid)

        # 3. Verify the ref exists before pruning.
        assert branch_ref_name in list(repo.references)

        # 4. Prune the run.
        engine.prune_run(run_id)

        # 5. Assert the branch ref was swept along with state/worktree.
        repo2 = pygit2.Repository(str(seeded_repo))
        assert branch_ref_name not in list(repo2.references)


# [unit->REQ-CLI-PRUNE-SAFETY]
def test_repo_with_corrupt_run_ref_is_skipped_by_list(
    seeded_repo: Path,
) -> None:
    """A bare attractor/run/<id>/state branch with no journal entries
    is corrupt; list_all_run_ids still returns it (the ref exists), but
    auto_prune handles the show() failure gracefully.
    """
    engine = Engine(seeded_repo)
    repo = pygit2.Repository(str(seeded_repo))
    main_oid = repo.references["refs/heads/main"].target
    repo.references.create(
        "refs/heads/attractor/run/corrupt-id/state", main_oid
    )

    ids = engine.list_all_run_ids()
    assert "corrupt-id" in ids

    # auto_prune swallows the exception from show() on the corrupt id.
    removed = engine.auto_prune_completed(timedelta(0))
    assert removed == 0  # nothing pruned, no exception raised
