"""BranchStore behaviour (SPEC §7.1, §7.2, §7.3, §7.5).

End-to-end style: every test creates a fresh pygit2 repo and exercises
real reads/writes. Faster than they look (~1ms per init) and far
more useful than mocks for catching tree-manipulation bugs.
"""

import pygit2
import pytest

from attractor.checkpoint import (
    ATTRACTOR_EMAIL,
    ATTRACTOR_NAME,
    Author,
    BranchStore,
    BranchStoreError,
    CheckpointTrailers,
    NamespaceError,
)

# Canonical valid ref for the bulk of these tests. Using a constant
# keeps the test names readable and means an accidental copy-paste
# typo points at the right offender.
STATE_REF = "refs/heads/attractor/run/test-run/state"
WORKTREE_REF = "refs/heads/attractor/run/test-run/worktree"


# [unit->REQ-STORE-BRANCH-AS-FS]
class TestEnsureBranch:
    """`ensure_branch` is idempotent and creates an empty root commit."""

    def test_creates_branch_with_empty_root(
        self, store: BranchStore
    ) -> None:
        store.ensure_branch(STATE_REF)
        # Tip is the empty root.
        assert store.tip(STATE_REF) is not None
        # Empty tree means no entries.
        assert store.list_entries(STATE_REF) == []

    def test_idempotent(self, store: BranchStore) -> None:
        store.ensure_branch(STATE_REF)
        tip1 = store.tip(STATE_REF)
        store.ensure_branch(STATE_REF)
        tip2 = store.tip(STATE_REF)
        # Second call must NOT add a commit.
        assert tip1 == tip2

    def test_rejects_non_attractor_ref(
        self, store: BranchStore
    ) -> None:
        with pytest.raises(NamespaceError):
            store.ensure_branch("refs/heads/main")


# [unit->REQ-STORE-BRANCH-AS-FS]
# [unit->REQ-CHECKPOINT-DUAL-COMMIT]
class TestWriteEntry:
    """Single-entry writes produce one commit each and form linear history."""

    def test_single_write_round_trip(self, store: BranchStore) -> None:
        store.write_entry(STATE_REF, "events/0001.json", b"{}", "cp: a")
        assert store.read_entry(STATE_REF, "events/0001.json") == b"{}"

    def test_sequential_writes_form_history(
        self, store: BranchStore
    ) -> None:
        store.write_entry(STATE_REF, "a.txt", b"first", "cp: a")
        store.write_entry(STATE_REF, "b.txt", b"second", "cp: b")
        log = store.log(STATE_REF)
        # log() returns newest-first; we expect: tip ("b"), middle ("a"),
        # root ("checkpoint: initialise"). Both writes carry user
        # subjects so we can distinguish them.
        assert len(log) == 3
        assert log[0].message.startswith("cp: b")
        assert log[1].message.startswith("cp: a")
        assert log[2].message.startswith("checkpoint: initialise")

    def test_read_returns_last_written(
        self, store: BranchStore
    ) -> None:
        store.write_entry(STATE_REF, "a.txt", b"v1", "cp: v1")
        store.write_entry(STATE_REF, "a.txt", b"v2", "cp: v2")
        # Tip reflects the latest write.
        assert store.read_entry(STATE_REF, "a.txt") == b"v2"

    def test_write_creates_intermediate_directories(
        self, store: BranchStore
    ) -> None:
        store.write_entry(
            STATE_REF, "deep/nested/path/file.txt", b"hello", "cp: deep"
        )
        assert store.read_entry(
            STATE_REF, "deep/nested/path/file.txt"
        ) == b"hello"

    def test_write_to_non_attractor_ref_rejected(
        self, store: BranchStore
    ) -> None:
        with pytest.raises(NamespaceError):
            store.write_entry("refs/heads/main", "a", b"x", "cp")

    def test_write_returns_commit_oid(
        self, store: BranchStore, repo: pygit2.Repository
    ) -> None:
        oid_hex = store.write_entry(STATE_REF, "a.txt", b"x", "cp")
        # The returned OID must resolve to a Commit object in the repo.
        obj = repo[oid_hex]
        assert isinstance(obj, pygit2.Commit)


# [unit->REQ-STORE-BRANCH-AS-FS]
class TestWriteEntries:
    """Batch writes land all paths in one commit."""

    def test_batch_write_creates_single_commit(
        self, store: BranchStore
    ) -> None:
        store.ensure_branch(STATE_REF)
        before = len(store.log(STATE_REF))
        store.write_entries(
            STATE_REF,
            {"a.txt": b"1", "b.txt": b"2", "sub/c.txt": b"3"},
            "cp: batch",
        )
        after = len(store.log(STATE_REF))
        assert after == before + 1

    def test_batch_write_all_paths_readable(
        self, store: BranchStore
    ) -> None:
        store.write_entries(
            STATE_REF,
            {"a.txt": b"1", "sub/b.txt": b"2"},
            "cp: batch",
        )
        assert store.read_entry(STATE_REF, "a.txt") == b"1"
        assert store.read_entry(STATE_REF, "sub/b.txt") == b"2"

    def test_empty_batch_rejected(self, store: BranchStore) -> None:
        with pytest.raises(BranchStoreError, match="no entries"):
            store.write_entries(STATE_REF, {}, "cp: empty")

    def test_path_conflict_rejected(self, store: BranchStore) -> None:
        # "a" as a blob conflicts with "a/b" requiring a tree.
        with pytest.raises(BranchStoreError, match="conflict"):
            store.write_entries(
                STATE_REF, {"a": b"x", "a/b": b"y"}, "cp"
            )

    def test_path_traversal_rejected(self, store: BranchStore) -> None:
        with pytest.raises(BranchStoreError, match="traversal"):
            store.write_entry(STATE_REF, "../escape.txt", b"x", "cp")

    def test_absolute_path_rejected(self, store: BranchStore) -> None:
        with pytest.raises(BranchStoreError, match="starts with"):
            store.write_entry(STATE_REF, "/etc/passwd", b"x", "cp")

    def test_trailing_slash_rejected(self, store: BranchStore) -> None:
        with pytest.raises(BranchStoreError, match="ends with"):
            store.write_entry(STATE_REF, "dir/", b"x", "cp")


# [unit->REQ-STORE-BRANCH-AS-FS]
class TestReadAndList:
    """Reads and listings honour the storage model."""

    def test_read_missing_entry_returns_none(
        self, store: BranchStore
    ) -> None:
        store.ensure_branch(STATE_REF)
        assert store.read_entry(STATE_REF, "nope.txt") is None

    def test_read_missing_branch_returns_none(
        self, store: BranchStore
    ) -> None:
        # No ensure_branch — store has never written anything.
        assert store.read_entry(STATE_REF, "x") is None

    def test_list_returns_sorted_paths(
        self, store: BranchStore
    ) -> None:
        store.write_entries(
            STATE_REF,
            {"b.txt": b"b", "a.txt": b"a", "sub/c.txt": b"c"},
            "cp: many",
        )
        assert store.list_entries(STATE_REF) == [
            "a.txt",
            "b.txt",
            "sub/c.txt",
        ]

    def test_list_with_prefix_filters(self, store: BranchStore) -> None:
        store.write_entries(
            STATE_REF,
            {"a.txt": b"a", "sub/b.txt": b"b", "sub/c.txt": b"c"},
            "cp",
        )
        assert store.list_entries(STATE_REF, prefix="sub/") == [
            "sub/b.txt",
            "sub/c.txt",
        ]

    def test_tip_of_missing_branch_is_none(
        self, store: BranchStore
    ) -> None:
        assert store.tip(STATE_REF) is None

    def test_list_path_remains_blob_after_overwrite(
        self, store: BranchStore
    ) -> None:
        # Overwrite a file twice and confirm it still shows up once.
        store.write_entry(STATE_REF, "a.txt", b"1", "cp")
        store.write_entry(STATE_REF, "a.txt", b"2", "cp")
        assert store.list_entries(STATE_REF) == ["a.txt"]


# [unit->REQ-STORE-BRANCH-AS-FS]
class TestDelete:
    """`delete_entry` removes paths and rejects missing entries."""

    def test_delete_removes_entry(self, store: BranchStore) -> None:
        store.write_entry(STATE_REF, "a.txt", b"x", "cp")
        store.delete_entry(STATE_REF, "a.txt", "cp: rm")
        assert store.read_entry(STATE_REF, "a.txt") is None
        assert "a.txt" not in store.list_entries(STATE_REF)

    def test_delete_keeps_siblings(self, store: BranchStore) -> None:
        store.write_entries(
            STATE_REF, {"a.txt": b"a", "b.txt": b"b"}, "cp"
        )
        store.delete_entry(STATE_REF, "a.txt", "cp: rm a")
        assert store.read_entry(STATE_REF, "b.txt") == b"b"

    def test_delete_missing_entry_raises(
        self, store: BranchStore
    ) -> None:
        store.ensure_branch(STATE_REF)
        with pytest.raises(BranchStoreError, match="not present"):
            store.delete_entry(STATE_REF, "nope.txt", "cp: rm")

    def test_delete_nested_entry(self, store: BranchStore) -> None:
        store.write_entry(STATE_REF, "sub/a.txt", b"x", "cp")
        store.delete_entry(STATE_REF, "sub/a.txt", "cp: rm")
        assert store.read_entry(STATE_REF, "sub/a.txt") is None


# [unit->REQ-STORE-TRAILERS]
class TestTrailerRoundTrip:
    """Trailers survive the write→log round trip via the real commit object."""

    def test_render_then_parse_via_log(self, store: BranchStore) -> None:
        original = CheckpointTrailers(
            run_id="r1",
            stage="plan",
            node="approve",
            outcome="SUCCESS",
            duration_ms=42,
        )
        store.write_entry(
            STATE_REF, "a.txt", b"x", "cp: a", trailers=original
        )
        log = store.log(STATE_REF)
        # log[0] is the tip — the write we just made.
        parsed = log[0].trailers
        assert parsed == original

    def test_no_trailers_round_trip(self, store: BranchStore) -> None:
        store.write_entry(STATE_REF, "a.txt", b"x", "cp: a")
        log = store.log(STATE_REF)
        # No trailers passed → all fields None.
        assert log[0].trailers == CheckpointTrailers()


# [unit->REQ-STORE-AUTHOR-ID]
class TestAuthorIdentityOnCommits:
    """Author/committer headers reflect §7.3 case A vs case B."""

    def test_engine_only_author_header(
        self, repo: pygit2.Repository
    ) -> None:
        store = BranchStore(repo, Author())
        store.write_entry(STATE_REF, "a.txt", b"x", "cp: a")
        log = store.log(STATE_REF)
        assert log[0].author_name == ATTRACTOR_NAME
        assert log[0].author_email == ATTRACTOR_EMAIL
        # No trailer — solo-engine commits keep a single author line.
        assert "Co-Authored-By" not in log[0].message

    def test_configured_user_author_header(
        self, repo: pygit2.Repository
    ) -> None:
        store = BranchStore(
            repo,
            Author(user_name="Jane", user_email="jane@example.com"),
        )
        store.write_entry(STATE_REF, "a.txt", b"x", "cp: a")
        log = store.log(STATE_REF)
        # Case B: user in header, Attractor in trailer.
        assert log[0].author_name == "Jane"
        assert log[0].author_email == "jane@example.com"
        assert "Co-Authored-By:" in log[0].message
        assert ATTRACTOR_NAME in log[0].message
