from pathlib import Path

import pytest

from osx_backup.runner import (
    CommandError,
    CommandResult,
    DryRunRunner,
    RealRunner,
    RecordingRunner,
    UnexpectedCommand,
)


def test_real_runner_runs_and_captures() -> None:
    logs: list[str] = []
    r = RealRunner(log=logs.append)
    res = r.run(["echo", "hello"])
    assert res.ok and res.stdout.strip() == "hello"
    assert logs[0] == "$ echo hello"


def test_real_runner_raises_on_failure() -> None:
    r = RealRunner()
    with pytest.raises(CommandError):
        r.run(["false"])
    assert r.run(["false"], check=False).returncode == 1


def test_real_runner_stream_splits_on_cr_and_lf() -> None:
    r = RealRunner()
    lines: list[str] = []
    res = r.stream(["printf", "a\\rb\\nc"], lines.append)
    assert lines == ["a", "b", "c"]
    assert res.ok


def test_real_runner_which() -> None:
    assert RealRunner().which("sh") is not None
    assert RealRunner().which("definitely-not-a-binary-xyz") is None


def test_recording_runner_matches_longest_prefix() -> None:
    r = RecordingRunner()
    r.add(("brew", "list"), stdout="generic")
    r.add(("brew", "list", "--cask"), stdout="casks")
    assert r.run(["brew", "list", "--cask"]).stdout == "casks"
    assert r.run(["brew", "list", "--formula"]).stdout == "generic"
    assert r.calls == [["brew", "list", "--cask"], ["brew", "list", "--formula"]]


def test_recording_runner_readonly_not_in_mutations() -> None:
    r = RecordingRunner()
    r.run(["docker", "info"], readonly=True)
    r.run(["docker", "stop", "x"])
    assert r.mutations == [["docker", "stop", "x"]]
    assert r.has_call("docker", "info")
    assert not r.has_call("docker", "start")


def test_recording_runner_strict_raises() -> None:
    r = RecordingRunner(strict=True)
    with pytest.raises(UnexpectedCommand):
        r.run(["unknown"])


def test_recording_runner_failure_and_check() -> None:
    r = RecordingRunner()
    r.add(("pg_isready",), returncode=2)
    with pytest.raises(CommandError):
        r.run(["pg_isready"])
    assert r.run(["pg_isready"], check=False).returncode == 2


def test_recording_runner_stream_feeds_lines() -> None:
    r = RecordingRunner()
    r.add(("rsync",), stdout="l1\nl2")
    got: list[str] = []
    r.stream(["rsync", "-a"], got.append)
    assert got == ["l1", "l2"]


def test_recording_runner_which() -> None:
    assert RecordingRunner().which("brew") == "/opt/homebrew/bin/brew"
    r = RecordingRunner(which_map={"brew": None})
    assert r.which("brew") is None
    assert r.which("docker") is None


def test_dry_run_runner_delegates_readonly_only() -> None:
    real = RecordingRunner()
    real.add(("docker", "info"), stdout="ok")
    logs: list[str] = []
    d = DryRunRunner(real, logs.append)
    assert d.run(["docker", "info"], readonly=True).stdout == "ok"
    res = d.run(["docker", "stop", "x"])
    assert res == CommandResult(0)
    assert d.planned == [["docker", "stop", "x"]]
    assert real.calls == [["docker", "info"]]
    assert logs == ["[dry-run] $ docker stop x"]
    d.stream(["rsync"], lambda _line: None)
    assert d.planned[-1] == ["rsync"]


def test_real_runner_stream_survives_large_stderr() -> None:
    r = RealRunner()
    lines: list[str] = []
    res = r.stream(
        ["sh", "-c", "head -c 200000 /dev/zero | tr '\\0' x >&2; echo done"], lines.append
    )
    assert lines == ["done"]
    assert res.stderr == "x" * 200000
    assert res.ok


def test_real_runner_stream_closes_pipes_when_callback_raises() -> None:
    r = RealRunner()

    def boom(_line: str) -> None:
        raise ValueError("boom")

    with pytest.raises(ValueError):
        r.stream(["printf", "a\\nb\\n"], boom)


def test_recording_runner_last_added_wins_on_equal_prefix() -> None:
    r = RecordingRunner()
    r.add(("brew", "list"), stdout="first")
    r.add(("brew", "list"), stdout="second")
    assert r.run(["brew", "list"]).stdout == "second"


def test_real_runner_run_to_file_writes_stdout(tmp_path: Path) -> None:
    p = tmp_path / "out.bin"
    res = RealRunner().run_to_file(["sh", "-c", "printf 'abc'"], p)
    assert res.ok and res.stdout == ""
    assert p.read_bytes() == b"abc"
    assert p.stat().st_size == 3


def test_real_runner_run_to_file_reports_stderr_and_code(tmp_path: Path) -> None:
    p = tmp_path / "out.bin"
    res = RealRunner().run_to_file(["sh", "-c", "printf 'boom' >&2; exit 3"], p)
    assert res.returncode == 3 and res.stderr == "boom"
    assert p.read_bytes() == b""


def test_real_runner_run_from_file_feeds_stdin(tmp_path: Path) -> None:
    p = tmp_path / "in.bin"
    p.write_bytes(b"abc")
    res = RealRunner().run_from_file(["cat"], p)
    assert res.ok and res.stdout == "abc"


def test_recording_runner_run_to_file_writes_prepared_stdout(tmp_path: Path) -> None:
    r = RecordingRunner()
    r.add(("docker", "run"), stdout="archive")
    p = tmp_path / "vol.tar.gz.tmp"
    res = r.run_to_file(["docker", "run", "--rm", "alpine"], p)
    assert res.ok
    assert p.read_bytes() == b"archive"
    assert r.calls == [["docker", "run", "--rm", "alpine"]]
    assert r.mutations == [["docker", "run", "--rm", "alpine"]]
    assert r.files == [(["docker", "run", "--rm", "alpine"], p)]


def test_recording_runner_run_from_file_records_without_reading(tmp_path: Path) -> None:
    r = RecordingRunner()
    r.add(("docker", "run"), returncode=1, stderr="tar: short read")
    p = tmp_path / "vol.tar.gz"
    res = r.run_from_file(["docker", "run", "-i", "alpine"], p)
    assert res.returncode == 1 and res.stderr == "tar: short read"
    assert r.mutations == [["docker", "run", "-i", "alpine"]]
    assert r.files == [(["docker", "run", "-i", "alpine"], p)]


def test_dry_run_runner_file_helpers_plan_without_writing(tmp_path: Path) -> None:
    real = RecordingRunner()
    logs: list[str] = []
    d = DryRunRunner(real, logs.append)
    p = tmp_path / "vol.tar.gz.tmp"
    assert d.run_to_file(["docker", "run", "x"], p) == CommandResult(0)
    assert d.run_from_file(["docker", "run", "-i", "x"], p) == CommandResult(0)
    assert not p.exists()
    assert real.calls == []
    assert d.planned == [["docker", "run", "x"], ["docker", "run", "-i", "x"]]
