"""Per-run worktree lifecycle tests (SPEC §6.7, §6.8).

These tests shell out to real `git worktree` commands because that's
the production execution path. They're slower than the checkpoint
suite (one subprocess per test) but each test is still well under a
second and they catch issues mocks would mask — argv ordering,
working-directory assumptions, porcelain output parsing.
"""

import subprocess
import uuid
from pathlib import Path

import pytest

from attractor.worktree import (
    InputCopy,
    WorktreeCommandError,
    WorktreeManager,
    WorktreeValidationError,
)

# Test access to module-private helpers is intentional — these
# helpers carry §6.7/§6.8 invariants worth pinning directly rather
# than only via the high-level public methods.
from attractor.worktree.manager import (
    RUN_ARGUMENTS_FILENAME,
    _full_ref,  # pyright: ignore[reportPrivateUsage]
    _short_branch,  # pyright: ignore[reportPrivateUsage]
    _validate_inputs,  # pyright: ignore[reportPrivateUsage]
    _validate_run_id,  # pyright: ignore[reportPrivateUsage]
)

# Two known-good v4 UUID strings keep the test names readable. We
# don't want randomness in test data — the same run_id always
# produces the same branch name, so failures are easy to inspect.
RUN_ID_A = "11111111-1111-4111-8111-111111111111"
RUN_ID_B = "22222222-2222-4222-8222-222222222222"


def _has_ref(repo_root: Path, ref: str) -> bool:
    """True if `ref` exists in the repo at `repo_root`.

    Wraps `git show-ref --verify --quiet <ref>` so the helper is
    cheap and uses the same git binary as the manager under test.
    """
    result = subprocess.run(
        ["git", "show-ref", "--verify", "--quiet", ref],
        cwd=repo_root,
        capture_output=True,
    )
    return result.returncode == 0


# [unit->REQ-EXEC-RUN-ISOLATION]
class TestRunIdValidation:
    """`_validate_run_id` rejects anything that isn't a v4 UUID string."""

    def test_valid_v4_uuid_passes(self) -> None:
        _validate_run_id(RUN_ID_A)

    def test_random_uuid_passes(self) -> None:
        _validate_run_id(str(uuid.uuid4()))

    def test_empty_string_rejected(self) -> None:
        with pytest.raises(WorktreeValidationError, match="canonical UUID"):
            _validate_run_id("")

    def test_garbage_rejected(self) -> None:
        with pytest.raises(WorktreeValidationError, match="canonical UUID"):
            _validate_run_id("not-a-uuid")

    def test_uuid_v1_rejected(self) -> None:
        # Even a valid UUID is rejected if it's not v4.
        v1 = str(uuid.uuid1())
        with pytest.raises(WorktreeValidationError):
            _validate_run_id(v1)

    def test_path_traversal_in_uuid_position_rejected(self) -> None:
        # Belt-and-braces: even if a malicious caller embedded `..` in
        # what they claim is a UUID, the regex rejects it.
        with pytest.raises(WorktreeValidationError):
            _validate_run_id("../../etc/passwd")


# [unit->REQ-EXEC-INPUT-COPY]
class TestInputValidation:
    """`_validate_inputs` blocks unsafe filenames before git is invoked."""

    def test_simple_filename_allowed(self) -> None:
        _validate_inputs([InputCopy(name="goal.md", source=Path("ignored"))])

    def test_path_separator_rejected(self) -> None:
        with pytest.raises(WorktreeValidationError, match="separator"):
            _validate_inputs([InputCopy(name="sub/foo", source=Path("x"))])

    def test_backslash_rejected(self) -> None:
        # Windows separator — even on POSIX hosts we reject because a
        # workflow may be copied to a Windows machine.
        with pytest.raises(WorktreeValidationError, match="separator"):
            _validate_inputs([InputCopy(name="sub\\foo", source=Path("x"))])

    def test_traversal_rejected(self) -> None:
        with pytest.raises(WorktreeValidationError, match=r"\.\."):
            _validate_inputs([InputCopy(name="..", source=Path("x"))])

    def test_dotfile_rejected(self) -> None:
        with pytest.raises(WorktreeValidationError, match="dotfile"):
            _validate_inputs([InputCopy(name=".env", source=Path("x"))])

    def test_empty_name_rejected(self) -> None:
        with pytest.raises(WorktreeValidationError, match="empty"):
            _validate_inputs([InputCopy(name="", source=Path("x"))])

    def test_duplicate_names_rejected(self) -> None:
        with pytest.raises(WorktreeValidationError, match="duplicate"):
            _validate_inputs(
                [
                    InputCopy(name="goal.md", source=Path("x")),
                    InputCopy(name="goal.md", source=Path("y")),
                ]
            )

    def test_reserved_arguments_filename_rejected(self) -> None:
        with pytest.raises(WorktreeValidationError, match="reserved"):
            _validate_inputs(
                [InputCopy(name=RUN_ARGUMENTS_FILENAME, source=Path("x"))]
            )


# [unit->REQ-STORE-REF-NAMESPACE]
class TestBranchNameHelpers:
    """Helpers must produce the SHORT name git CLI expects."""

    def test_full_ref_matches_namespace(self) -> None:
        full = _full_ref(RUN_ID_A)
        assert full == f"refs/heads/attractor/run/{RUN_ID_A}/worktree"

    def test_short_branch_strips_refs_heads(self) -> None:
        full = _full_ref(RUN_ID_A)
        short = _short_branch(full)
        # This is what gets passed to `git worktree add -b <short>`.
        # The Rust prototype had a bug where the FULL ref was passed,
        # producing `refs/heads/refs/heads/...` — this test guards
        # against that regression.
        assert short == f"attractor/run/{RUN_ID_A}/worktree"
        assert not short.startswith("refs/")


# [unit->REQ-EXEC-RUN-ISOLATION]
class TestCreate:
    """End-to-end: `create` materialises directory + branch."""

    def test_create_makes_directory(self, seeded_repo: Path) -> None:
        mgr = WorktreeManager(seeded_repo)
        wt = mgr.create(RUN_ID_A, "main", inputs=[])
        assert wt.path.exists()
        assert wt.path.is_dir()

    def test_create_makes_branch_at_full_ref(
        self, seeded_repo: Path
    ) -> None:
        mgr = WorktreeManager(seeded_repo)
        wt = mgr.create(RUN_ID_A, "main", inputs=[])
        # The branch must exist at the FULL ref — proving the SHORT
        # vs FULL distinction is honoured.
        assert _has_ref(seeded_repo, wt.branch)
        assert wt.branch == f"refs/heads/attractor/run/{RUN_ID_A}/worktree"

    def test_create_uses_run_id_subdir(self, seeded_repo: Path) -> None:
        mgr = WorktreeManager(seeded_repo)
        wt = mgr.create(RUN_ID_A, "main", inputs=[])
        # Path is .attractor/worktrees/<run-id>/
        assert wt.path.name == RUN_ID_A
        assert wt.path.parent.name == "worktrees"
        assert wt.path.parent.parent.name == ".attractor"

    def test_invalid_run_id_rejected_before_git(
        self, seeded_repo: Path
    ) -> None:
        mgr = WorktreeManager(seeded_repo)
        with pytest.raises(WorktreeValidationError):
            mgr.create("not-a-uuid", "main", inputs=[])
        # No stray directory or branch left behind.
        assert not (seeded_repo / ".attractor").exists()

    def test_duplicate_run_id_rejected(self, seeded_repo: Path) -> None:
        mgr = WorktreeManager(seeded_repo)
        mgr.create(RUN_ID_A, "main", inputs=[])
        with pytest.raises(WorktreeValidationError, match="already exists"):
            mgr.create(RUN_ID_A, "main", inputs=[])

    def test_bad_base_ref_raises_command_error(
        self, seeded_repo: Path
    ) -> None:
        mgr = WorktreeManager(seeded_repo)
        with pytest.raises(WorktreeCommandError):
            mgr.create(RUN_ID_A, "no-such-ref", inputs=[])


# [unit->REQ-EXEC-INPUT-COPY]
class TestInputCopy:
    """`--input` files land in the worktree root."""

    def test_input_copy_lands_at_worktree_root(
        self, seeded_repo: Path, tmp_path: Path
    ) -> None:
        # Stage a host file outside the repo so the copy is a real
        # cross-tree operation.
        source = tmp_path / "goal.md"
        source.write_text("Implement the thing.\n")

        mgr = WorktreeManager(seeded_repo)
        wt = mgr.create(
            RUN_ID_A,
            "main",
            inputs=[InputCopy(name="goal.md", source=source)],
        )

        # Content survives the copy.
        copied = wt.path / "goal.md"
        assert copied.exists()
        assert copied.read_text() == "Implement the thing.\n"

    def test_multiple_inputs_supported(
        self, seeded_repo: Path, tmp_path: Path
    ) -> None:
        goal = tmp_path / "goal.md"
        goal.write_text("g")
        issue = tmp_path / "issue.json"
        issue.write_text("{}")

        mgr = WorktreeManager(seeded_repo)
        wt = mgr.create(
            RUN_ID_A,
            "main",
            inputs=[
                InputCopy(name="goal.md", source=goal),
                InputCopy(name="issue.json", source=issue),
            ],
        )

        assert (wt.path / "goal.md").read_text() == "g"
        assert (wt.path / "issue.json").read_text() == "{}"

    def test_missing_source_raises_validation_error(
        self, seeded_repo: Path, tmp_path: Path
    ) -> None:
        mgr = WorktreeManager(seeded_repo)
        # Note: this throws AFTER git worktree add succeeds. The
        # worktree directory will exist; the caller is expected to
        # handle the cleanup. We still surface a clear error.
        with pytest.raises(WorktreeValidationError, match="does not exist"):
            mgr.create(
                RUN_ID_A,
                "main",
                inputs=[
                    InputCopy(name="goal.md", source=tmp_path / "missing")
                ],
            )

    def test_input_name_rejected_before_git(
        self, seeded_repo: Path, tmp_path: Path
    ) -> None:
        source = tmp_path / "goal.md"
        source.write_text("g")
        mgr = WorktreeManager(seeded_repo)
        with pytest.raises(WorktreeValidationError, match="separator"):
            mgr.create(
                RUN_ID_A,
                "main",
                inputs=[InputCopy(name="../goal.md", source=source)],
            )
        # Bad-input validation runs BEFORE `git worktree add`, so the
        # directory should NOT exist.
        assert not (
            seeded_repo / ".attractor" / "worktrees" / RUN_ID_A
        ).exists()


# [unit->REQ-EXEC-RUN-ARGUMENTS]
class TestRunArguments:
    """Bound workflow arguments are materialized in the worktree root."""

    def test_run_arguments_written_as_json(self, seeded_repo: Path) -> None:
        mgr = WorktreeManager(seeded_repo)
        wt = mgr.create(
            RUN_ID_A,
            "main",
            inputs=[],
            run_arguments={
                "issue_url": "https://github.com/owner/repo/issues/123"
            },
        )

        args_file = wt.path / RUN_ARGUMENTS_FILENAME
        assert args_file.read_text(encoding="utf-8") == (
            '{\n'
            '  "issue_url": "https://github.com/owner/repo/issues/123"\n'
            '}\n'
        )


# [unit->REQ-EXEC-RUN-ISOLATION]
class TestRemoveAndList:
    """`remove` deletes the directory but keeps the branch (§6.7)."""

    def test_remove_deletes_directory(self, seeded_repo: Path) -> None:
        mgr = WorktreeManager(seeded_repo)
        wt = mgr.create(RUN_ID_A, "main", inputs=[])
        assert wt.path.exists()
        mgr.remove(wt)
        assert not wt.path.exists()

    def test_remove_keeps_branch_ref(self, seeded_repo: Path) -> None:
        # The whole point of §6.7's "directory removed, branch retained"
        # rule — the worktree branch is the durable artifact.
        mgr = WorktreeManager(seeded_repo)
        wt = mgr.create(RUN_ID_A, "main", inputs=[])
        mgr.remove(wt)
        assert _has_ref(seeded_repo, wt.branch)

    def test_list_enumerates_active_worktrees(
        self, seeded_repo: Path
    ) -> None:
        mgr = WorktreeManager(seeded_repo)
        wt_a = mgr.create(RUN_ID_A, "main", inputs=[])
        wt_b = mgr.create(RUN_ID_B, "main", inputs=[])

        listed = mgr.list()
        run_ids = {w.run_id for w in listed}
        assert wt_a.run_id in run_ids
        assert wt_b.run_id in run_ids

    def test_list_returns_full_refs(self, seeded_repo: Path) -> None:
        mgr = WorktreeManager(seeded_repo)
        wt = mgr.create(RUN_ID_A, "main", inputs=[])
        listed = mgr.list()
        match = [w for w in listed if w.run_id == wt.run_id]
        assert match
        assert match[0].branch == wt.branch
        assert match[0].branch.startswith("refs/heads/attractor/run/")

    def test_list_excludes_user_worktrees(
        self, seeded_repo: Path
    ) -> None:
        # Create a non-attractor worktree alongside an attractor one.
        # Only the attractor one should appear.
        mgr = WorktreeManager(seeded_repo)
        # Create an attractor worktree.
        attractor_wt = mgr.create(RUN_ID_A, "main", inputs=[])
        # Create a sibling user worktree directly via git.
        user_wt_path = seeded_repo.parent / "user-wt"
        subprocess.run(
            [
                "git",
                "-C",
                str(seeded_repo),
                "worktree",
                "add",
                "-b",
                "user/work",
                str(user_wt_path),
                "main",
            ],
            check=True,
            capture_output=True,
        )

        listed = mgr.list()
        run_ids = [w.run_id for w in listed]
        # Attractor worktree present, user worktree filtered out.
        assert attractor_wt.run_id in run_ids
        assert all(
            "user" not in w.branch and w.branch.startswith(
                "refs/heads/attractor/run/"
            )
            for w in listed
        )


# [unit->REQ-EXEC-RUN-ISOLATION]
class TestRepoRootValidation:
    """`WorktreeManager(repo_root)` rejects nonexistent paths up front."""

    def test_missing_repo_root_rejected(self, tmp_path: Path) -> None:
        with pytest.raises(WorktreeValidationError, match="does not exist"):
            WorktreeManager(tmp_path / "nope")
