"""Real-Git rollback contracts for checkpoints containing uncaptured gitlinks."""
import os
import shutil
import subprocess

import pytest

from tools import checkpoint_manager as cm
from utils import rmtree_readonly


@pytest.fixture
def checkpoint(tmp_path, monkeypatch):
    monkeypatch.setattr(cm, "CHECKPOINT_BASE", tmp_path / "checkpoints")
    work = tmp_path / "workspace"
    work.mkdir()
    nested = work / "tool"
    nested.mkdir()
    env = {**os.environ, "GIT_CONFIG_GLOBAL": os.devnull,
           "GIT_CONFIG_NOSYSTEM": "1"}
    for args in (["init", "-q"], ["config", "user.name", "Test"],
                 ["config", "user.email", "test@example.invalid"]):
        subprocess.run(["git", *args], cwd=nested, env=env, check=True, capture_output=True)
    (nested / "main.py").write_text("committed\n")
    subprocess.run(["git", "add", "."], cwd=nested, env=env, check=True, capture_output=True)
    subprocess.run(["git", "commit", "-qm", "seed"], cwd=nested, env=env, check=True, capture_output=True)
    (nested / "main.py").write_text("user work not captured\n")
    (nested / "untracked.txt").write_text("not captured either\n")
    (work / "notes.txt").write_text("before\n")
    mgr = cm.CheckpointManager(enabled=True)
    assert mgr.ensure_checkpoint(str(work), "baseline")
    commit = mgr.list_checkpoints(str(work))[0]["hash"]
    ok, tree, _ = cm._run_git(["ls-tree", "-r", "-z", commit], cm._store_path(), str(work))
    assert ok and "160000 commit " in tree
    (work / "notes.txt").write_text("after\n")
    (nested / "main.py").write_text("agent overwrite\n")
    return mgr, work, commit


def durable_state(work):
    """Include refs, indexes, ledger and working files, not transient lock files."""
    roots = [work, cm._store_path()]
    return {(str(root), str(path.relative_to(root))): path.read_bytes() if path.is_file() else None
            for root in roots for path in root.rglob("*")}


@pytest.mark.parametrize("safe", [False, True])
@pytest.mark.parametrize("spec", [None, ".", "tool", "tool/", "tool/main.py", "*",
                                   "t*", ":(glob)*", ":(top,glob)**", ":(icase)TOOL"])
def test_refusal_is_nonmutating(checkpoint, safe, spec):
    mgr, work, commit = checkpoint
    before = durable_state(work)
    result = mgr.restore(str(work), commit, file_path=spec, safe=safe)
    assert result["success"] is False
    assert result["nested_repositories"] == ["tool"]
    assert "not captured" in result["error"]
    assert durable_state(work) == before


@pytest.mark.parametrize("safe", [False, True])
@pytest.mark.parametrize("spec", ["notes.txt", "*.txt", ":(glob)*.txt", ":(exclude)tool",
                                   ":(literal)notes.txt"])
def test_unrelated_selection_still_restores(checkpoint, safe, spec):
    mgr, work, commit = checkpoint
    result = mgr.restore(str(work), commit, file_path=spec, safe=safe)
    assert result["success"] is True, result
    assert (work / "notes.txt").read_text() == "before\n"
    assert (work / "tool" / "main.py").read_text() == "agent overwrite\n"


@pytest.mark.parametrize("name", ["space name.txt", "unicode-雪.txt",
                                  pytest.param("tab\tline\n.txt", marks=pytest.mark.platforms("posix")),
                                  # Raw bytes stay bytes until the test runs: decoding at
                                  # collection fails on Windows, and macOS rejects them.
                                  pytest.param(b"bad-\xff.txt", marks=pytest.mark.platforms("linux"))])
def test_unusual_filename_does_not_break_tree_inspection(checkpoint, name):
    mgr, work, _ = checkpoint
    name = os.fsdecode(name)
    target = work / name
    target.write_text("original\n")
    mgr.new_turn()
    assert mgr.ensure_checkpoint(str(work), "unusual filename")
    commit = mgr.list_checkpoints(str(work))[0]["hash"]
    target.write_text("changed\n")
    result = mgr.restore(str(work), commit, file_path=name)
    assert result["success"] is True, result
    assert target.read_text() == "original\n"


@pytest.mark.parametrize("safe", [False, True])
@pytest.mark.parametrize("spec", [None, "*", "tool/main.py"])
def test_deleted_nested_repository_is_not_reported_restored(checkpoint, safe, spec):
    mgr, work, commit = checkpoint
    # Git for Windows makes loose objects read-only; plain rmtree fails there.
    rmtree_readonly(work / "tool")
    assert not (work / "tool").exists()
    before = durable_state(work)
    result = mgr.restore(str(work), commit, file_path=spec, safe=safe)
    assert result["success"] is False
    assert result["nested_repositories"] == ["tool"]
    assert not (work / "tool").exists()
    assert durable_state(work) == before


@pytest.mark.parametrize("notes", ["before\n", "after\n"])
def test_empty_selection_refuses_without_mutation(checkpoint, tmp_path, notes):
    # Checkout with an empty pathspec file switches HEAD instead of restoring
    # nothing. "after" is the dirty-file control, which Git itself protects.
    mgr, work, commit = checkpoint
    (work / "notes.txt").write_text(notes)
    (work / "tool").rename(tmp_path / "moved-tool")
    before = durable_state(work)
    result = mgr.restore(str(work), commit, file_path=":(exclude)*")
    assert result["success"] is False, result
    assert "matched no files" in result["error"]
    assert not (work / "tool").exists()
    assert durable_state(work) == before


@pytest.mark.parametrize("name", ["nested space", "nested-雪", "vendor/tool",
                                  pytest.param("nested\tline\n", marks=pytest.mark.platforms("posix")),
                                  pytest.param(b"nested-\xff", marks=pytest.mark.platforms("linux"))])
def test_gitlink_name_roundtrips_in_refusal(checkpoint, name):
    mgr, work, _ = checkpoint
    name = os.fsdecode(name)
    (work / name).parent.mkdir(parents=True, exist_ok=True)
    (work / "tool").rename(work / name)
    mgr.new_turn()
    assert mgr.ensure_checkpoint(str(work), "renamed nested repository")
    commit = mgr.list_checkpoints(str(work))[0]["hash"]
    before = durable_state(work)
    result = mgr.restore(str(work), commit, file_path="*")
    assert result["success"] is False
    assert result["nested_repositories"] == [name]
    assert durable_state(work) == before


def test_checkpoint_listing_discloses_uncaptured_nested_repository(checkpoint, tmp_path):
    mgr, work, _ = checkpoint
    entry = mgr.list_checkpoints(str(work))[0]
    assert entry["reason"] == "baseline [nested git repos not captured: tool]"
    assert "[nested git repos not captured: tool]" in cm.format_checkpoint_list([entry], str(work))

    plain = tmp_path / "plain"
    plain.mkdir()
    (plain / "a.txt").write_text("a\n")
    assert mgr.ensure_checkpoint(str(plain), "no nested repo")
    assert mgr.list_checkpoints(str(plain))[0]["reason"] == "no nested repo"


def test_multiline_reason_keeps_disclosure_in_listing(checkpoint):
    mgr, work, _ = checkpoint
    (work / "notes.txt").write_text("changed again\n")
    mgr.new_turn()
    assert mgr.ensure_checkpoint(str(work), "before terminal: rm -rf build\n\nprintf done")
    entry = mgr.list_checkpoints(str(work))[0]
    assert entry["reason"] == (
        "before terminal: rm -rf build printf done [nested git repos not captured: tool]")
    assert "[nested git repos not captured: tool]" in cm.format_checkpoint_list([entry], str(work))


def test_checkpoint_disclosure_caps_listed_repositories(checkpoint):
    assert cm._uncaptured_note(["a", "b", "c", "d", "e"]) == " [nested git repos not captured: a, b, c (+2)]"


@pytest.fixture
def deep_checkpoint(checkpoint):
    mgr, work, _ = checkpoint
    (work / "packages").mkdir()
    (work / "tool").rename(work / "packages" / "nested")
    (work / "notes.txt").write_text("before\n")
    mgr.new_turn()
    assert mgr.ensure_checkpoint(str(work), "deeper nested repository")
    commit = mgr.list_checkpoints(str(work))[0]["hash"]
    (work / "notes.txt").write_text("after\n")
    return mgr, work, commit


@pytest.mark.parametrize("spec", [
    "packages", "packages/", "packages/nested", "packages/nested/", "packages/nested/main.py",
    "packages/./nested", "notes/../packages/nested",
    pytest.param("packages\\nested", marks=pytest.mark.platforms("windows")),
    pytest.param("packages\\nested\\main.py", marks=pytest.mark.platforms("windows")),
])
def test_deeper_gitlink_scope_refuses_in_any_spelling(deep_checkpoint, spec):
    mgr, work, commit = deep_checkpoint
    before = durable_state(work)
    result = mgr.restore(str(work), commit, file_path=spec)
    assert result["success"] is False
    assert result["nested_repositories"] == ["packages/nested"]
    assert durable_state(work) == before


@pytest.mark.parametrize("spec", ["notes.txt", "packages/../notes.txt"])
def test_deeper_gitlink_leaves_unrelated_recovery_available(deep_checkpoint, spec):
    mgr, work, commit = deep_checkpoint
    result = mgr.restore(str(work), commit, file_path=spec)
    assert result["success"] is True, result
    assert (work / "notes.txt").read_text() == "before\n"
    assert (work / "packages" / "nested" / "main.py").read_text() == "agent overwrite\n"


@pytest.mark.parametrize("spec", [":(literal)tool/main.py", ":(glob)tool/*.py", ":(invalid)tool"])
def test_unresolvable_selection_is_nonmutating(checkpoint, spec):
    mgr, work, commit = checkpoint
    before = durable_state(work)
    result = mgr.restore(str(work), commit, file_path=spec)
    assert result["success"] is False
    assert durable_state(work) == before


def test_attribute_pathspec_restores_only_the_inspected_selection(checkpoint, tmp_path):
    # The pre-rollback snapshot restages the index that attribute pathspecs
    # read .gitattributes from, so checkout must not re-evaluate the spec.
    mgr, work, _ = checkpoint
    attrs = work / ".gitattributes"
    attrs.write_text("tool restore\nnotes.txt restore\n")
    (work / "notes.txt").write_text("before\n")
    mgr.new_turn()
    assert mgr.ensure_checkpoint(str(work), "attributes")
    commit = mgr.list_checkpoints(str(work))[0]["hash"]
    (work / "tool").rename(tmp_path / "moved-tool")
    attrs.unlink()
    (work / "notes.txt").write_text("after\n")
    result = mgr.restore(str(work), commit, file_path=":(attr:!restore)*")
    assert result["success"] is True, result
    assert attrs.read_text() == "tool restore\nnotes.txt restore\n"
    assert (work / "notes.txt").read_text() == "after\n"
    assert not (work / "tool").exists()


@pytest.mark.parametrize("command", ["read-tree", "ls-files"])
def test_inspection_failure_leaves_files_and_history_untouched(checkpoint, monkeypatch, command):
    mgr, work, commit = checkpoint
    run_git = cm._run_git

    def fail_inspection(args, *a, **kw):
        if args[0] == command:
            return False, "", "inspection unavailable"
        return run_git(args, *a, **kw)

    monkeypatch.setattr(cm, "_run_git", fail_inspection)
    before = durable_state(work)
    result = mgr.restore(str(work), commit, file_path="*")
    assert result["success"] is False
    assert "inspection unavailable" in result["error"]
    assert durable_state(work) == before
    assert not list(cm._store_path().glob("restore-inspect-*"))


@pytest.mark.platforms("posix")  # symlink creation needs privileges on Windows
@pytest.mark.parametrize("safe", [False, True])
def test_captured_file_replaced_by_symlink_into_nested_repo_restores(checkpoint, safe):
    mgr, work, commit = checkpoint
    notes = work / "notes.txt"
    notes.unlink()
    notes.symlink_to(work / "tool" / "main.py")
    result = mgr.restore(str(work), commit, file_path="notes.txt", safe=safe)
    assert result["success"] is True, result
    assert not notes.is_symlink()
    assert notes.read_text() == "before\n"
    assert (work / "tool" / "main.py").read_text() == "agent overwrite\n"


@pytest.mark.platforms("posix")  # symlink creation needs privileges on Windows
@pytest.mark.parametrize("spec", [None, "tool", "tool/main.py"])
def test_gitlink_replaced_by_unrelated_symlink_still_refuses(checkpoint, tmp_path, spec):
    mgr, work, commit = checkpoint
    (work / "tool").rename(tmp_path / "moved-tool")
    other = work / "other"
    other.mkdir()
    (other / "main.py").write_text("unrelated\n")
    (work / "tool").symlink_to(other, target_is_directory=True)
    before = durable_state(work)
    result = mgr.restore(str(work), commit, file_path=spec)
    assert result["success"] is False
    assert result["nested_repositories"] == ["tool"]
    assert (work / "tool").is_symlink()
    assert durable_state(work) == before
