"""Tests for the vost CLI — core commands (init, destroy, rm, write, log, sync, diff, undo, redo, reflog)."""

import os
import signal
import subprocess
import sys
import time
import pytest
from click.testing import CliRunner

from vost.cli import main


# ---------------------------------------------------------------------------
# TestInit
# ---------------------------------------------------------------------------

class TestInit:
    def test_creates_repo(self, runner, repo_path):
        result = runner.invoke(main, ["init", "--repo", repo_path])
        assert result.exit_code == 0
        result = runner.invoke(main, ["branch", "--repo", repo_path, "list"])
        assert "main" in result.output

    def test_creates_repo_with_custom_branch(self, runner, repo_path):
        result = runner.invoke(main, ["init", "--repo", repo_path, "--branch", "trunk"])
        assert result.exit_code == 0
        result = runner.invoke(main, ["branch", "--repo", repo_path, "list"])
        assert "trunk" in result.output

    def test_already_exists_error(self, runner, initialized_repo):
        result = runner.invoke(main, ["init", "--repo", initialized_repo])
        assert result.exit_code != 0
        assert "already exists" in result.output


# ---------------------------------------------------------------------------
# TestDestroy
# ---------------------------------------------------------------------------

class TestDestroy:
    def test_destroy_empty(self, runner, initialized_repo):
        result = runner.invoke(main, ["destroy", "--repo", initialized_repo])
        assert result.exit_code == 0
        assert not os.path.exists(initialized_repo)

    def test_destroy_nonempty_requires_force(self, runner, repo_with_files):
        result = runner.invoke(main, ["destroy", "--repo", repo_with_files])
        assert result.exit_code != 0
        assert "not empty" in result.output.lower()
        assert os.path.exists(repo_with_files)

    def test_destroy_nonempty_with_force(self, runner, repo_with_files):
        result = runner.invoke(main, ["destroy", "--repo", repo_with_files, "-f"])
        assert result.exit_code == 0
        assert not os.path.exists(repo_with_files)

    def test_destroy_missing_repo(self, runner, tmp_path):
        bad_path = str(tmp_path / "nope.git")
        result = runner.invoke(main, ["destroy", "--repo", bad_path])
        assert result.exit_code != 0
        assert "not found" in result.output.lower()


# ---------------------------------------------------------------------------
# TestGc
# ---------------------------------------------------------------------------

class TestGc:
    def test_gc_succeeds(self, runner, repo_with_files):
        result = runner.invoke(main, ["gc", "--repo", repo_with_files])
        assert result.exit_code == 0

    def test_gc_missing_repo(self, runner, tmp_path):
        bad_path = str(tmp_path / "nope.git")
        result = runner.invoke(main, ["gc", "--repo", bad_path])
        assert result.exit_code != 0
        assert "not found" in result.output.lower()

    def test_gc_help(self, runner):
        result = runner.invoke(main, ["gc", "--help"])
        assert result.exit_code == 0
        assert "pack loose objects" in result.output.lower()


# ---------------------------------------------------------------------------
# TestRm
# ---------------------------------------------------------------------------

class TestRm:
    def test_removes_file(self, runner, repo_with_files):
        result = runner.invoke(main, ["rm", "--repo", repo_with_files, ":hello.txt"])
        assert result.exit_code == 0, result.output

        result = runner.invoke(main, ["ls", "--repo", repo_with_files])
        assert "hello.txt" not in result.output

    def test_missing_file_error(self, runner, repo_with_files):
        result = runner.invoke(main, ["rm", "--repo", repo_with_files, ":nonexistent.txt"])
        assert result.exit_code != 0
        assert "not found" in result.output.lower()

    def test_directory_rejected(self, runner, repo_with_files):
        result = runner.invoke(main, ["rm", "--repo", repo_with_files, ":data"])
        assert result.exit_code != 0
        assert "directory" in result.output.lower()
        assert "-R" in result.output

    def test_without_colon(self, runner, repo_with_files):
        result = runner.invoke(main, ["rm", "--repo", repo_with_files, "hello.txt"])
        assert result.exit_code == 0, result.output
        result = runner.invoke(main, ["ls", "--repo", repo_with_files])
        assert "hello.txt" not in result.output

    def test_custom_message(self, runner, repo_with_files):
        result = runner.invoke(main, ["rm", "--repo", repo_with_files, ":hello.txt", "-m", "bye bye"])
        assert result.exit_code == 0, result.output

        result = runner.invoke(main, ["log", "--repo", repo_with_files])
        assert "bye bye" in result.output

    def test_rm_glob(self, runner, repo_with_files):
        # Add a second txt file first
        from pathlib import Path
        extra = Path(repo_with_files).parent / "extra.txt"
        extra.write_text("extra")
        result = runner.invoke(main, ["cp", "--repo", repo_with_files, str(extra), ":"])
        assert result.exit_code == 0, result.output

        result = runner.invoke(main, ["rm", "--repo", repo_with_files, ":*.txt"])
        assert result.exit_code == 0, result.output

        result = runner.invoke(main, ["ls", "--repo", repo_with_files])
        assert "hello.txt" not in result.output
        assert "extra.txt" not in result.output

    def test_rm_recursive(self, runner, repo_with_files):
        result = runner.invoke(main, ["rm", "-R", "--repo", repo_with_files, ":data"])
        assert result.exit_code == 0, result.output

        result = runner.invoke(main, ["ls", "-R", "--repo", repo_with_files])
        assert "data.bin" not in result.output

    def test_rm_dry_run(self, runner, repo_with_files):
        result = runner.invoke(main, ["rm", "-n", "--repo", repo_with_files, ":hello.txt"])
        assert result.exit_code == 0, result.output
        assert "- :hello.txt" in result.output

        # File still exists
        result = runner.invoke(main, ["ls", "--repo", repo_with_files])
        assert "hello.txt" in result.output

    def test_rm_multiple_paths(self, runner, repo_with_files):
        result = runner.invoke(main, ["rm", "-R", "--repo", repo_with_files, ":hello.txt", ":data"])
        assert result.exit_code == 0, result.output

        result = runner.invoke(main, ["ls", "-R", "--repo", repo_with_files])
        assert "hello.txt" not in result.output
        assert "data.bin" not in result.output


# ---------------------------------------------------------------------------
# TestWrite
# ---------------------------------------------------------------------------

class TestWrite:
    def test_write_from_stdin(self, runner, initialized_repo):
        result = runner.invoke(main, ["write", "--repo", initialized_repo, "file.txt"], input=b"hello world\n")
        assert result.exit_code == 0, result.output

        result = runner.invoke(main, ["cat", "--repo", initialized_repo, "file.txt"])
        assert result.exit_code == 0
        assert result.output == "hello world\n"

    def test_write_overwrites_existing(self, runner, repo_with_files):
        result = runner.invoke(main, ["write", "--repo", repo_with_files, "hello.txt"], input=b"new content\n")
        assert result.exit_code == 0, result.output

        result = runner.invoke(main, ["cat", "--repo", repo_with_files, "hello.txt"])
        assert result.exit_code == 0
        assert result.output == "new content\n"

    def test_write_custom_message(self, runner, initialized_repo):
        result = runner.invoke(main, ["write", "--repo", initialized_repo, "file.txt", "-m", "my msg"], input=b"data")
        assert result.exit_code == 0, result.output

        result = runner.invoke(main, ["log", "--repo", initialized_repo])
        assert "my msg" in result.output

    def test_write_colon_prefix_optional(self, runner, initialized_repo):
        result = runner.invoke(main, ["write", "--repo", initialized_repo, ":file.txt"], input=b"abc")
        assert result.exit_code == 0, result.output

        result = runner.invoke(main, ["cat", "--repo", initialized_repo, "file.txt"])
        assert result.exit_code == 0
        assert result.output == "abc"

    def test_write_passthrough_echoes_to_stdout(self, runner, initialized_repo):
        result = runner.invoke(
            main,
            ["write", "--repo", initialized_repo, "log.txt", "--passthrough"],
            input=b"pipeline data\n",
        )
        assert result.exit_code == 0, result.output
        # stdout should contain the piped-through data
        assert "pipeline data\n" in result.output

        # File should also be in the repo
        result = runner.invoke(main, ["cat", "--repo", initialized_repo, "log.txt"])
        assert result.exit_code == 0
        assert result.output == "pipeline data\n"

    def test_write_passthrough_short_flag(self, runner, initialized_repo):
        result = runner.invoke(
            main,
            ["write", "--repo", initialized_repo, "log.txt", "-p"],
            input=b"short flag\n",
        )
        assert result.exit_code == 0, result.output
        assert "short flag\n" in result.output

    def test_write_without_passthrough_no_stdout(self, runner, initialized_repo):
        result = runner.invoke(
            main,
            ["write", "--repo", initialized_repo, "quiet.txt"],
            input=b"silent data\n",
        )
        assert result.exit_code == 0, result.output
        # Without passthrough, the input data should NOT appear in stdout
        assert "silent data" not in result.output


# ---------------------------------------------------------------------------
# TestLog
# ---------------------------------------------------------------------------

class TestLog:
    def test_all_commits(self, runner, repo_with_files):
        result = runner.invoke(main, ["log", "--repo", repo_with_files])
        assert result.exit_code == 0
        lines = result.output.strip().split("\n")
        # At least: init + write hello.txt + write data tree
        assert len(lines) >= 3

    def test_path_filter(self, runner, repo_with_files):
        result = runner.invoke(main, ["log", "--repo", repo_with_files, "--path", "hello.txt"])
        assert result.exit_code == 0
        lines = result.output.strip().split("\n")
        assert len(lines) >= 1

    def test_at_without_colon(self, runner, repo_with_files):
        """--path should work without a leading ':'."""
        result = runner.invoke(main, ["log", "--repo", repo_with_files, "--path", "hello.txt"])
        assert result.exit_code == 0
        lines = result.output.strip().split("\n")
        assert len(lines) >= 1

    def test_nonexistent_path_empty(self, runner, repo_with_files):
        result = runner.invoke(main, ["log", "--repo", repo_with_files, "--path", "nonexistent.txt"])
        assert result.exit_code == 0
        assert result.output.strip() == ""

    def test_match_exact(self, runner, initialized_repo, tmp_path):
        f = tmp_path / "a.txt"
        f.write_text("a")
        runner.invoke(main, ["cp", "--repo", initialized_repo, str(f), ":a.txt", "-m", "deploy v1"])
        f.write_text("b")
        runner.invoke(main, ["cp", "--repo", initialized_repo, str(f), ":a.txt", "-m", "fix bug"])
        result = runner.invoke(main, ["log", "--repo", initialized_repo, "--match", "deploy v1"])
        assert result.exit_code == 0
        lines = result.output.strip().split("\n")
        assert len(lines) == 1
        assert "deploy v1" in lines[0]

    def test_match_wildcard(self, runner, initialized_repo, tmp_path):
        f = tmp_path / "a.txt"
        f.write_text("a")
        runner.invoke(main, ["cp", "--repo", initialized_repo, str(f), ":a.txt", "-m", "deploy v1"])
        f.write_text("b")
        runner.invoke(main, ["cp", "--repo", initialized_repo, str(f), ":a.txt", "-m", "deploy v2"])
        f.write_text("c")
        runner.invoke(main, ["cp", "--repo", initialized_repo, str(f), ":a.txt", "-m", "fix bug"])
        result = runner.invoke(main, ["log", "--repo", initialized_repo, "--match", "deploy*"])
        assert result.exit_code == 0
        lines = result.output.strip().split("\n")
        assert len(lines) == 2
        assert all("deploy" in line for line in lines)

    def test_match_no_results(self, runner, repo_with_files):
        result = runner.invoke(main, ["log", "--repo", repo_with_files, "--match", "zzz-no-match*"])
        assert result.exit_code == 0
        assert result.output.strip() == ""

    def test_match_and_at(self, runner, initialized_repo, tmp_path):
        f1 = tmp_path / "a.txt"
        f2 = tmp_path / "b.txt"
        f1.write_text("a")
        f2.write_text("b")
        runner.invoke(main, ["cp", "--repo", initialized_repo, str(f1), ":a.txt", "-m", "deploy a"])
        runner.invoke(main, ["cp", "--repo", initialized_repo, str(f2), ":b.txt", "-m", "deploy b"])
        f1.write_text("a2")
        runner.invoke(main, ["cp", "--repo", initialized_repo, str(f1), ":a.txt", "-m", "fix a"])
        result = runner.invoke(main, ["log", "--repo", initialized_repo, "--path", "a.txt", "--match", "deploy*"])
        assert result.exit_code == 0
        lines = result.output.strip().split("\n")
        assert len(lines) == 1
        assert "deploy a" in lines[0]

    def test_json_format(self, runner, repo_with_files):
        import json
        result = runner.invoke(main, ["log", "--repo", repo_with_files, "--format", "json"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert isinstance(data, list)
        assert len(data) >= 3
        entry = data[0]
        assert "hash" in entry
        assert "message" in entry
        assert "time" in entry
        assert "author_name" in entry
        assert "author_email" in entry
        assert len(entry["hash"]) == 40

    def test_jsonl_format(self, runner, repo_with_files):
        import json
        result = runner.invoke(main, ["log", "--repo", repo_with_files, "--format", "jsonl"])
        assert result.exit_code == 0
        lines = result.output.strip().split("\n")
        assert len(lines) >= 3
        for line in lines:
            entry = json.loads(line)
            assert "hash" in entry
            assert "message" in entry

    def test_back(self, runner, repo_with_files):
        # repo_with_files has: init + write hello.txt + write data/
        # --back 1 should start log from the "write hello.txt" commit
        result = runner.invoke(main, [
            "log", "--repo", repo_with_files, "--back", "1"
        ])
        assert result.exit_code == 0
        lines = result.output.strip().split("\n")
        # Should have 2 commits (hello.txt write + init), not 3
        assert len(lines) == 2


# ---------------------------------------------------------------------------
# TestErrorPaths
# ---------------------------------------------------------------------------

class TestErrorPaths:
    def test_missing_repo(self, runner, tmp_path):
        bad_path = str(tmp_path / "nope.git")
        result = runner.invoke(main, ["ls", "--repo", bad_path])
        assert result.exit_code != 0
        assert "not found" in result.output.lower()

    def test_missing_branch(self, runner, initialized_repo):
        result = runner.invoke(main, ["ls", "--repo", initialized_repo, "-b", "nope"])
        assert result.exit_code != 0
        assert "not found" in result.output.lower()

    def test_missing_repo_error(self, runner):
        """Running a command with no --repo and no VOST_REPO should fail."""
        result = runner.invoke(main, ["ls"])
        assert result.exit_code != 0
        assert "VOST_REPO" in result.output

    def test_env_var_fallback(self, runner, initialized_repo):
        """VOST_REPO env var should work as fallback for --repo."""
        result = runner.invoke(main, ["ls"], env={"VOST_REPO": initialized_repo})
        assert result.exit_code == 0

    def test_repo_flag_overrides_env_var(self, runner, initialized_repo):
        """Explicit --repo flag must win over VOST_REPO env var."""
        result = runner.invoke(
            main, ["--repo", initialized_repo, "ls"],
            env={"VOST_REPO": "/nonexistent/bogus.git"},
        )
        assert result.exit_code == 0


# ---------------------------------------------------------------------------
# TestSync
# ---------------------------------------------------------------------------

class TestSync:
    def test_sync_1arg_disk_to_repo(self, runner, initialized_repo, tmp_path):
        """sync ./dir syncs local dir to repo root."""
        src = tmp_path / "src"
        src.mkdir()
        (src / "a.txt").write_text("aaa")
        (src / "b.txt").write_text("bbb")
        result = runner.invoke(main, [
            "sync", "--repo", initialized_repo, str(src),
        ])
        assert result.exit_code == 0, result.output
        r = runner.invoke(main, ["ls", "--repo", initialized_repo])
        assert "a.txt" in r.output
        assert "b.txt" in r.output

    def test_sync_2arg_disk_to_repo(self, runner, initialized_repo, tmp_path):
        """sync ./dir :dest syncs to a repo sub-path."""
        src = tmp_path / "src"
        src.mkdir()
        (src / "x.txt").write_text("xxx")
        result = runner.invoke(main, [
            "sync", "--repo", initialized_repo, str(src), ":data",
        ])
        assert result.exit_code == 0, result.output
        r = runner.invoke(main, ["ls", "--repo", initialized_repo, ":data"])
        assert "x.txt" in r.output

    def test_sync_2arg_repo_to_disk(self, runner, repo_with_files, tmp_path):
        """sync :data ./out syncs repo path to disk."""
        dest = tmp_path / "out"
        result = runner.invoke(main, [
            "sync", "--repo", repo_with_files, ":data", str(dest),
        ])
        assert result.exit_code == 0, result.output
        assert (dest / "data.bin").exists()

    def test_sync_deletes_extra_files(self, runner, repo_with_files, tmp_path):
        """Sync deletes files in dest not present in source."""
        # First sync repo data to disk
        dest = tmp_path / "out"
        dest.mkdir()
        (dest / "extra.txt").write_text("extra")
        (dest / "data.bin").write_bytes(b"\x00\x01\x02")
        result = runner.invoke(main, [
            "sync", "--repo", repo_with_files, ":data", str(dest),
        ])
        assert result.exit_code == 0, result.output
        assert (dest / "data.bin").exists()
        assert not (dest / "extra.txt").exists()

    def test_sync_dry_run(self, runner, initialized_repo, tmp_path):
        """-n shows plan without writing."""
        src = tmp_path / "src"
        src.mkdir()
        (src / "new.txt").write_text("new")
        result = runner.invoke(main, [
            "sync", "--repo", initialized_repo, "-n", str(src),
        ])
        assert result.exit_code == 0, result.output
        assert "+" in result.output  # add action
        # Verify nothing was actually written
        r = runner.invoke(main, ["ls", "--repo", initialized_repo])
        assert "new.txt" not in r.output

    def test_sync_both_local_error(self, runner, initialized_repo, tmp_path):
        """sync a b (no colon on either) errors."""
        result = runner.invoke(main, [
            "sync", "--repo", initialized_repo, "a", "b",
        ])
        assert result.exit_code != 0
        assert "Neither argument is a repo path" in result.output

    def test_sync_repo_to_repo(self, runner, repo_with_files):
        """sync :src :dest now works (repo→repo sync)."""
        # First create a second branch
        runner.invoke(main, ["branch", "--repo", repo_with_files, "set", "dev"])
        # Sync from main to dev (repo→repo)
        result = runner.invoke(main, [
            "sync", "--repo", repo_with_files, ":", "dev:", "-n",
        ])
        # Should not error about "Both arguments are repo paths"
        assert "Both arguments are repo paths" not in result.output

    def test_sync_1arg_repo_error(self, runner, initialized_repo):
        """sync :path errors (1-arg must be local)."""
        result = runner.invoke(main, [
            "sync", "--repo", initialized_repo, ":path",
        ])
        assert result.exit_code != 0
        assert "must be a local path" in result.output

    def test_sync_ignore_errors(self, runner, repo_with_files, tmp_path):
        """--ignore-errors works."""
        src = tmp_path / "src"
        src.mkdir()
        (src / "good.txt").write_text("good")
        # Create an unreadable file
        bad = src / "bad.txt"
        bad.write_text("bad")
        os.chmod(str(bad), 0o000)
        result = runner.invoke(main, [
            "sync", "--repo", repo_with_files, str(src), ":dest",
            "--ignore-errors",
        ])
        # Restore permissions for cleanup
        os.chmod(str(bad), 0o644)
        # good.txt should have been written
        r = runner.invoke(main, ["ls", "--repo", repo_with_files, ":dest"])
        assert "good.txt" in r.output

    def test_sync_custom_message(self, runner, initialized_repo, tmp_path):
        """-m sets commit message."""
        src = tmp_path / "src"
        src.mkdir()
        (src / "f.txt").write_text("data")
        result = runner.invoke(main, [
            "sync", "--repo", initialized_repo,
            "-m", "custom sync message", str(src),
        ])
        assert result.exit_code == 0, result.output
        r = runner.invoke(main, ["log", "--repo", initialized_repo])
        assert "custom sync message" in r.output

    def test_sync_watch_dry_run_incompatible(self, runner, initialized_repo, tmp_path):
        """--watch and --dry-run are incompatible."""
        src = tmp_path / "src"
        src.mkdir()
        result = runner.invoke(main, [
            "sync", "--repo", initialized_repo, "--watch", "-n", str(src),
        ])
        assert result.exit_code != 0
        assert "--watch and --dry-run are incompatible" in result.output

    def test_sync_watch_from_repo_incompatible(self, runner, repo_with_files, tmp_path):
        """--watch with repo->disk direction errors."""
        dest = tmp_path / "out"
        result = runner.invoke(main, [
            "sync", "--repo", repo_with_files, "--watch", ":data", str(dest),
        ])
        assert result.exit_code != 0
        assert "--watch only supports disk" in result.output

    def test_sync_watch_debounce_too_low(self, runner, initialized_repo, tmp_path):
        """--debounce below 100 errors."""
        src = tmp_path / "src"
        src.mkdir()
        result = runner.invoke(main, [
            "sync", "--repo", initialized_repo, "--watch", "--debounce", "50", str(src),
        ])
        assert result.exit_code != 0
        assert "--debounce must be at least 100" in result.output


class TestChecksumMode:
    """Tests for the mtime-based (default) vs checksum change detection."""

    def test_sync_skips_unchanged_files_default_mode(self, runner, initialized_repo, tmp_path):
        """Default mode: sync dir to repo, touch nothing, sync again → no updates."""
        src = tmp_path / "src"
        src.mkdir()
        (src / "a.txt").write_text("aaa")
        (src / "b.txt").write_text("bbb")

        # Initial sync
        r = runner.invoke(main, ["sync", "--repo", initialized_repo, str(src)])
        assert r.exit_code == 0, r.output

        # Second sync with no changes — dry-run should show nothing
        r = runner.invoke(main, ["sync", "--repo", initialized_repo, "-n", str(src)])
        assert r.exit_code == 0, r.output
        assert "~" not in r.output  # no updates
        assert "+" not in r.output  # no adds
        assert "-" not in r.output  # no deletes

    def test_sync_detects_new_mtime(self, runner, initialized_repo, tmp_path):
        """Rewriting a file (new mtime) is detected in default mode."""
        src = tmp_path / "src"
        src.mkdir()
        (src / "a.txt").write_text("original")

        r = runner.invoke(main, ["sync", "--repo", initialized_repo, str(src)])
        assert r.exit_code == 0, r.output

        # Rewrite the file — mtime must land in a later second
        time.sleep(1.1)
        (src / "a.txt").write_text("changed")

        r = runner.invoke(main, ["sync", "--repo", initialized_repo, "-n", str(src)])
        assert r.exit_code == 0, r.output
        assert "~" in r.output  # update detected

    def test_cp_delete_skips_unchanged_default_mode(self, runner, initialized_repo, tmp_path):
        """cp --delete: unchanged files are skipped in default (mtime) mode."""
        src = tmp_path / "src"
        src.mkdir()
        (src / "x.txt").write_text("xxx")

        r = runner.invoke(main, [
            "cp", "--repo", initialized_repo, "--delete",
            str(src) + "/", ":",
        ])
        assert r.exit_code == 0, r.output

        # Dry-run again with no changes
        r = runner.invoke(main, [
            "cp", "--repo", initialized_repo, "--delete", "-n",
            str(src) + "/", ":",
        ])
        assert r.exit_code == 0, r.output
        assert "~" not in r.output

    def test_checksum_detects_backdated_change(self, runner, initialized_repo, tmp_path):
        """--checksum catches content change even when mtime is backdated."""
        src = tmp_path / "src"
        src.mkdir()
        (src / "a.txt").write_text("original")

        r = runner.invoke(main, ["sync", "--repo", initialized_repo, str(src)])
        assert r.exit_code == 0, r.output

        # Change content but backdate the mtime to before the commit
        (src / "a.txt").write_text("sneaky change")
        old_time = 1000000000.0  # Sep 2001
        os.utime(src / "a.txt", (old_time, old_time))

        # Default mode (mtime): misses the change
        r = runner.invoke(main, [
            "sync", "--repo", initialized_repo, "-n", str(src),
        ])
        assert r.exit_code == 0, r.output
        assert "~" not in r.output  # mtime mode skips it

        # --checksum mode: catches it
        r = runner.invoke(main, [
            "sync", "--repo", initialized_repo, "-n", "-c", str(src),
        ])
        assert r.exit_code == 0, r.output
        assert "~" in r.output  # checksum mode catches it

    def test_checksum_dry_run_matches_real_run(self, runner, initialized_repo, tmp_path):
        """--checksum dry-run and real-run agree on what changes."""
        src = tmp_path / "src"
        src.mkdir()
        (src / "a.txt").write_text("aaa")
        (src / "b.txt").write_text("bbb")

        r = runner.invoke(main, ["sync", "--repo", initialized_repo, str(src)])
        assert r.exit_code == 0, r.output

        # Modify one file — mtime must land in a later second
        time.sleep(1.1)
        (src / "a.txt").write_text("AAA")

        # Dry-run with --checksum
        r = runner.invoke(main, [
            "sync", "--repo", initialized_repo, "-n", "-c", str(src),
        ])
        assert r.exit_code == 0, r.output
        assert ":a.txt" in r.output
        assert "b.txt" not in r.output

        # Real run with --checksum
        r = runner.invoke(main, [
            "sync", "--repo", initialized_repo, "-c", str(src),
        ])
        assert r.exit_code == 0, r.output

        # Verify: now a dry-run shows no changes
        r = runner.invoke(main, [
            "sync", "--repo", initialized_repo, "-n", "-c", str(src),
        ])
        assert r.exit_code == 0, r.output
        assert "~" not in r.output
        assert "+" not in r.output

    def test_default_mode_skips_old_mtime_file(self, runner, initialized_repo, tmp_path):
        """Document the tradeoff: a file with old mtime is skipped in default mode."""
        src = tmp_path / "src"
        src.mkdir()
        (src / "a.txt").write_text("original")

        r = runner.invoke(main, ["sync", "--repo", initialized_repo, str(src)])
        assert r.exit_code == 0, r.output

        # Write new content but backdate mtime
        (src / "a.txt").write_text("different content")
        old_time = 946684800.0  # Jan 1 2000
        os.utime(src / "a.txt", (old_time, old_time))

        # Default mode skips it (file appears unchanged)
        r = runner.invoke(main, [
            "sync", "--repo", initialized_repo, str(src),
        ])
        assert r.exit_code == 0, r.output

        # Verify the repo still has the original content
        r = runner.invoke(main, ["cat", "--repo", initialized_repo, "a.txt"])
        assert r.output == "original"

    def test_round_trip_preserves_mtime(self, runner, initialized_repo, tmp_path):
        """After repo→disk, files get commit mtime so disk→repo skips them."""
        src = tmp_path / "src"
        src.mkdir()
        (src / "a.txt").write_text("aaa")
        (src / "b.txt").write_text("bbb")

        # Sync disk → repo
        r = runner.invoke(main, ["sync", "--repo", initialized_repo, str(src)])
        assert r.exit_code == 0, r.output

        # Sync repo → disk (different directory)
        dest = tmp_path / "dest"
        r = runner.invoke(main, [
            "sync", "--repo", initialized_repo, ":", str(dest),
        ])
        assert r.exit_code == 0, r.output
        assert (dest / "a.txt").read_text() == "aaa"

        # Now sync that dest back to repo — should detect no changes
        r = runner.invoke(main, [
            "sync", "--repo", initialized_repo, "-n", str(dest),
        ])
        assert r.exit_code == 0, r.output
        assert "~" not in r.output  # no updates
        assert "+" not in r.output  # no adds


# ---------------------------------------------------------------------------
# TestDiff
# ---------------------------------------------------------------------------

class TestDiff:
    def test_diff_no_changes(self, runner, repo_with_files):
        """diff --back 0 compares HEAD to itself — no output."""
        r = runner.invoke(main, ["diff", "--repo", repo_with_files, "--back", "0"])
        assert r.exit_code == 0, r.output
        assert r.output == ""

    def test_diff_added_file(self, runner, repo_with_files, tmp_path):
        """New file shows as A (added)."""
        new = tmp_path / "new.txt"
        new.write_text("new content")
        r = runner.invoke(main, ["cp", "--repo", repo_with_files, str(new), ":new.txt"])
        assert r.exit_code == 0, r.output

        r = runner.invoke(main, ["diff", "--repo", repo_with_files, "--back", "1"])
        assert r.exit_code == 0, r.output
        assert "A  new.txt" in r.output

    def test_diff_modified_file(self, runner, repo_with_files, tmp_path):
        """Modified file shows as M."""
        updated = tmp_path / "hello.txt"
        updated.write_text("changed content")
        r = runner.invoke(main, ["cp", "--repo", repo_with_files, str(updated), ":hello.txt"])
        assert r.exit_code == 0, r.output

        r = runner.invoke(main, ["diff", "--repo", repo_with_files, "--back", "1"])
        assert r.exit_code == 0, r.output
        assert "M  hello.txt" in r.output

    def test_diff_deleted_file(self, runner, repo_with_files):
        """Deleted file shows as D."""
        r = runner.invoke(main, ["rm", "--repo", repo_with_files, ":hello.txt"])
        assert r.exit_code == 0, r.output

        r = runner.invoke(main, ["diff", "--repo", repo_with_files, "--back", "1"])
        assert r.exit_code == 0, r.output
        assert "D  hello.txt" in r.output

    def test_diff_mixed(self, runner, repo_with_files, tmp_path):
        """Add + modify + delete in one commit shows all three prefixes."""
        from vost import GitStore
        store = GitStore.open(repo_with_files, create=False)
        fs = store.branches["main"]
        fs = fs.write("hello.txt", b"changed")
        fs = fs.write("added.txt", b"new")
        fs = fs.remove("data/data.bin")

        r = runner.invoke(main, ["diff", "--repo", repo_with_files, "--back", "3"])
        assert r.exit_code == 0, r.output
        assert "A  added.txt" in r.output
        assert "M  hello.txt" in r.output
        assert "D  data/data.bin" in r.output

    def test_diff_reverse(self, runner, repo_with_files, tmp_path):
        """--reverse swaps A and D."""
        new = tmp_path / "new.txt"
        new.write_text("new content")
        r = runner.invoke(main, ["cp", "--repo", repo_with_files, str(new), ":new.txt"])
        assert r.exit_code == 0, r.output

        r = runner.invoke(main, ["diff", "--repo", repo_with_files, "--back", "1", "--reverse"])
        assert r.exit_code == 0, r.output
        assert "D  new.txt" in r.output
        assert "A" not in r.output or "A  new.txt" not in r.output


# ---------------------------------------------------------------------------
# TestUndo (CLI)
# ---------------------------------------------------------------------------

class TestUndo:
    def test_basic_undo(self, runner, initialized_repo, tmp_path):
        """Undo one commit via CLI."""
        f = tmp_path / "a.txt"
        f.write_text("v1")
        runner.invoke(main, ["cp", "--repo", initialized_repo, str(f), ":a.txt", "-m", "add a"])
        f.write_text("v2")
        runner.invoke(main, ["cp", "--repo", initialized_repo, str(f), ":a.txt", "-m", "update a"])

        r = runner.invoke(main, ["undo", "--repo", initialized_repo])
        assert r.exit_code == 0, r.output
        assert "Branch now at:" in r.output

        # Branch should now be at v1
        r = runner.invoke(main, ["cat", "--repo", initialized_repo, ":a.txt"])
        assert r.output == "v1"

    def test_multi_step_undo(self, runner, initialized_repo, tmp_path):
        """Undo multiple commits at once."""
        f = tmp_path / "a.txt"
        for i in range(4):
            f.write_text(f"v{i}")
            runner.invoke(main, ["cp", "--repo", initialized_repo, str(f), ":a.txt", "-m", f"v{i}"])

        r = runner.invoke(main, ["undo", "--repo", initialized_repo, "3"])
        assert r.exit_code == 0, r.output
        assert "Branch now at:" in r.output

        r = runner.invoke(main, ["cat", "--repo", initialized_repo, ":a.txt"])
        assert r.output == "v0"

    def test_undo_past_root_error(self, runner, initialized_repo):
        """Undoing past the root commit errors out."""
        r = runner.invoke(main, ["undo", "--repo", initialized_repo, "5"])
        assert r.exit_code != 0
        assert "Cannot undo" in r.output or "too short" in r.output.lower()

    def test_undo_nonexistent_branch(self, runner, initialized_repo):
        """Undo on a non-existent branch errors out."""
        r = runner.invoke(main, ["undo", "--repo", initialized_repo, "-b", "nope"])
        assert r.exit_code != 0
        assert "not found" in r.output.lower()

    def test_undo_custom_branch(self, runner, initialized_repo, tmp_path):
        """Undo on a specific branch via -b."""
        runner.invoke(main, ["branch", "--repo", initialized_repo, "set", "dev"])
        f = tmp_path / "a.txt"
        f.write_text("dev-data")
        runner.invoke(main, ["cp", "--repo", initialized_repo, "-b", "dev", str(f), ":a.txt"])

        r = runner.invoke(main, ["undo", "--repo", initialized_repo, "-b", "dev"])
        assert r.exit_code == 0, r.output
        assert "Branch now at:" in r.output


# ---------------------------------------------------------------------------
# TestRedo (CLI)
# ---------------------------------------------------------------------------

class TestRedo:
    def test_undo_then_redo(self, runner, initialized_repo, tmp_path):
        """Undo then redo restores the original state."""
        f = tmp_path / "a.txt"
        f.write_text("v1")
        runner.invoke(main, ["cp", "--repo", initialized_repo, str(f), ":a.txt", "-m", "add a"])
        f.write_text("v2")
        runner.invoke(main, ["cp", "--repo", initialized_repo, str(f), ":a.txt", "-m", "update a"])

        # Undo
        r = runner.invoke(main, ["undo", "--repo", initialized_repo])
        assert r.exit_code == 0, r.output
        r = runner.invoke(main, ["cat", "--repo", initialized_repo, ":a.txt"])
        assert r.output == "v1"

        # Redo
        r = runner.invoke(main, ["redo", "--repo", initialized_repo])
        assert r.exit_code == 0, r.output
        assert "Branch now at:" in r.output
        r = runner.invoke(main, ["cat", "--repo", initialized_repo, ":a.txt"])
        assert r.output == "v2"

    def test_redo_past_available_steps_error(self, runner, initialized_repo, tmp_path):
        """Redo more steps than available should fail."""
        f = tmp_path / "a.txt"
        f.write_text("data")
        runner.invoke(main, ["cp", "--repo", initialized_repo, str(f), ":a.txt"])

        # There are only a few reflog entries, so redo 100 should fail
        r = runner.invoke(main, ["redo", "--repo", initialized_repo, "100"])
        assert r.exit_code != 0
        assert "Cannot redo" in r.output or "step" in r.output.lower()

    def test_redo_nonexistent_branch(self, runner, initialized_repo):
        """Redo on a non-existent branch errors out."""
        r = runner.invoke(main, ["redo", "--repo", initialized_repo, "-b", "nope"])
        assert r.exit_code != 0
        assert "not found" in r.output.lower()


# ---------------------------------------------------------------------------
# TestReflogCLI
# ---------------------------------------------------------------------------

class TestReflogCLI:
    def test_reflog_text(self, runner, initialized_repo, tmp_path):
        """Default text format shows reflog entries."""
        f = tmp_path / "a.txt"
        f.write_text("data")
        runner.invoke(main, ["cp", "--repo", initialized_repo, str(f), ":a.txt"])

        r = runner.invoke(main, ["reflog", "--repo", initialized_repo])
        assert r.exit_code == 0, r.output
        assert "Reflog for branch" in r.output

    def test_reflog_json(self, runner, initialized_repo, tmp_path):
        """--format json outputs valid JSON array."""
        import json

        f = tmp_path / "a.txt"
        f.write_text("data")
        runner.invoke(main, ["cp", "--repo", initialized_repo, str(f), ":a.txt"])

        r = runner.invoke(main, ["reflog", "--repo", initialized_repo, "--format", "json"])
        assert r.exit_code == 0, r.output
        data = json.loads(r.output)
        assert isinstance(data, list)
        assert len(data) >= 1
        assert "new_sha" in data[0]
        assert "message" in data[0]

    def test_reflog_jsonl(self, runner, initialized_repo, tmp_path):
        """--format jsonl outputs one JSON object per line."""
        import json

        f = tmp_path / "a.txt"
        f.write_text("data")
        runner.invoke(main, ["cp", "--repo", initialized_repo, str(f), ":a.txt"])

        r = runner.invoke(main, ["reflog", "--repo", initialized_repo, "--format", "jsonl"])
        assert r.exit_code == 0, r.output
        lines = r.output.strip().split("\n")
        assert len(lines) >= 1
        for line in lines:
            entry = json.loads(line)
            assert "new_sha" in entry

    def test_reflog_limit(self, runner, initialized_repo, tmp_path):
        """--limit N shows at most N entries."""
        f = tmp_path / "a.txt"
        for i in range(5):
            f.write_text(f"v{i}")
            runner.invoke(main, ["cp", "--repo", initialized_repo, str(f), ":a.txt", "-m", f"v{i}"])

        r = runner.invoke(main, ["reflog", "--repo", initialized_repo, "--format", "json", "-n", "2"])
        assert r.exit_code == 0, r.output
        import json
        data = json.loads(r.output)
        assert len(data) == 2

    def test_reflog_nonexistent_branch(self, runner, initialized_repo):
        """Reflog on a non-existent branch errors out."""
        r = runner.invoke(main, ["reflog", "--repo", initialized_repo, "-b", "nope"])
        assert r.exit_code != 0
        assert "not found" in r.output.lower()


# ---------------------------------------------------------------------------
# TestSnapshotFilterCombined (CLI)
# ---------------------------------------------------------------------------

class TestSnapshotFilterCombined:
    def test_path_and_back(self, runner, initialized_repo, tmp_path):
        """--path + --back together: back from the latest commit touching that path."""
        f = tmp_path / "a.txt"
        f.write_text("v1")
        runner.invoke(main, ["cp", "--repo", initialized_repo, str(f), ":a.txt", "-m", "add a"])
        f.write_text("v2")
        runner.invoke(main, ["cp", "--repo", initialized_repo, str(f), ":a.txt", "-m", "update a"])
        f.write_text("v3")
        runner.invoke(main, ["cp", "--repo", initialized_repo, str(f), ":a.txt", "-m", "update a again"])

        # --back 1 from tip gets "update a" commit
        r = runner.invoke(main, ["log", "--repo", initialized_repo, "--back", "1"])
        assert r.exit_code == 0, r.output
        lines = r.output.strip().split("\n")
        assert "update a again" not in r.output

    def test_before_and_match(self, runner, initialized_repo, tmp_path):
        """--before + --match together: only commits matching pattern before cutoff."""
        import time
        f = tmp_path / "a.txt"
        f.write_text("v1")
        runner.invoke(main, ["cp", "--repo", initialized_repo, str(f), ":a.txt", "-m", "deploy v1"])
        time.sleep(1.1)
        f.write_text("v2")
        runner.invoke(main, ["cp", "--repo", initialized_repo, str(f), ":a.txt", "-m", "deploy v2"])

        # Get timestamp between the two commits
        from vost import GitStore
        store = GitStore.open(initialized_repo, create=False)
        fs = store.branches["main"]
        entries = list(fs.log())
        # entries[0] = deploy v2 (newest), entries[1] = deploy v1
        cutoff = entries[0].time  # include both

        r = runner.invoke(main, [
            "log", "--repo", initialized_repo,
            "--before", cutoff.isoformat(),
            "--match", "deploy*"
        ])
        assert r.exit_code == 0, r.output
        lines = [l for l in r.output.strip().split("\n") if l.strip()]
        assert len(lines) == 2
        assert all("deploy" in line for line in lines)

    def test_back_and_match(self, runner, initialized_repo, tmp_path):
        """--back + --match: start from ancestor, filter by message."""
        f = tmp_path / "a.txt"
        f.write_text("v1")
        runner.invoke(main, ["cp", "--repo", initialized_repo, str(f), ":a.txt", "-m", "deploy v1"])
        f.write_text("v2")
        runner.invoke(main, ["cp", "--repo", initialized_repo, str(f), ":a.txt", "-m", "fix bug"])
        f.write_text("v3")
        runner.invoke(main, ["cp", "--repo", initialized_repo, str(f), ":a.txt", "-m", "deploy v3"])

        # --back 1 starts from "fix bug" commit, --match "deploy*" should only match "deploy v1"
        r = runner.invoke(main, [
            "log", "--repo", initialized_repo,
            "--back", "1", "--match", "deploy*"
        ])
        assert r.exit_code == 0, r.output
        lines = [l for l in r.output.strip().split("\n") if l.strip()]
        assert len(lines) == 1
        assert "deploy v1" in lines[0]


# ---------------------------------------------------------------------------
# TestSIGPIPE
# ---------------------------------------------------------------------------

@pytest.mark.skipif(
    not hasattr(signal, "SIGPIPE"),
    reason="SIGPIPE not available on this platform",
)
class TestSIGPIPE:
    def test_broken_pipe_no_stderr_noise(self, tmp_path):
        """vost cat piped to a process that closes early should not print exceptions."""
        from vost import GitStore

        repo_path = str(tmp_path / "test.git")
        store = GitStore.open(repo_path)
        fs = store.branches["main"]
        # Write enough data that the pipe buffer matters
        fs.write("big.txt", b"x" * 100_000)

        # Run: vost cat big.txt | head -c 1
        # head closes the pipe after 1 byte; vost should exit silently.
        import shutil
        vost_bin = shutil.which("vost")
        assert vost_bin is not None, "vost CLI not found on PATH"
        cat_proc = subprocess.Popen(
            [vost_bin, "--repo", repo_path, "cat", "big.txt"],
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
        )
        head_proc = subprocess.Popen(
            ["head", "-c", "1"],
            stdin=cat_proc.stdout,
            stdout=subprocess.PIPE,
        )
        cat_proc.stdout.close()  # allow SIGPIPE to reach cat_proc
        head_proc.communicate()
        _, stderr = cat_proc.communicate()

        assert b"Exception" not in stderr
        assert b"BrokenPipe" not in stderr

