import json
from pathlib import Path

import pytest
from typer.testing import CliRunner

from osx_backup import cli
from osx_backup.config import config_path
from osx_backup.context import Context
from osx_backup.modules import secrets as secrets_module
from osx_backup.modules.base import Inventory, Module, ModuleResult, ProgressReporter
from osx_backup.modules.dotfiles import DotfilesModule
from osx_backup.modules.secrets import SecretsModule
from osx_backup.runner import RecordingRunner
from osx_backup.ui import confirm as ui_confirm

runner = CliRunner()


class Failing(Module):
    name = "failing"
    description = "échoue toujours"
    order = 999

    def inventory(self, ctx: Context) -> Inventory:
        return Inventory(items=1)

    def backup(self, ctx: Context, progress: ProgressReporter) -> ModuleResult:
        return ModuleResult.failed("boom")

    def restore(self, ctx: Context, progress: ProgressReporter) -> ModuleResult:
        return ModuleResult.failed("boom")


class Crashing(Failing):
    name = "crashing"

    def backup(self, ctx: Context, progress: ProgressReporter) -> ModuleResult:
        raise RuntimeError("kaboom")


class Interrupting(Failing):
    name = "interrupting"
    order = 500

    def backup(self, ctx: Context, progress: ProgressReporter) -> ModuleResult:
        raise KeyboardInterrupt


class Flaky(Failing):
    name = "flaky"
    order = 400
    mode = "ok"

    def backup(self, ctx: Context, progress: ProgressReporter) -> ModuleResult:
        if Flaky.mode == "ok":
            return ModuleResult.ok("archivé", items=3, size_bytes=12)
        return ModuleResult.failed("cassé")


class Noisy(Failing):
    name = "noisy"
    order = 500

    def inventory(self, ctx: Context) -> Inventory:
        return Inventory(items=1, warnings=["absent : ~/quelque-chose"])

    def backup(self, ctx: Context, progress: ProgressReporter) -> ModuleResult:
        return ModuleResult.ok("rien à faire")


@pytest.fixture
def env(fake_home: Path, dest: Path, map_path: Path, monkeypatch: pytest.MonkeyPatch):
    monkeypatch.setenv("OSX_BACKUP_MAP", str(map_path))
    recording = RecordingRunner()
    monkeypatch.setattr(cli, "make_runner", lambda _log: recording)
    monkeypatch.setattr(cli, "list_modules", lambda: [DotfilesModule()])
    (fake_home / ".zshrc").write_text("zsh")
    return fake_home, dest, recording


def test_map_prints_inventory(env) -> None:
    result = runner.invoke(cli.app, ["map"])
    assert result.exit_code == 0, result.output
    assert "dotfiles" in result.output


def test_backup_all_yes(env) -> None:
    home, dest, _ = env
    result = runner.invoke(cli.app, ["backup", "--dest", str(dest), "--all", "--yes"])
    assert result.exit_code == 0, result.output
    assert (dest / "dotfiles" / ".zshrc").read_text() == "zsh"
    manifest = json.loads((dest / "manifest.json").read_text())
    assert manifest["modules"]["dotfiles"]["status"] == "ok"
    assert manifest["home"] == str(home)
    assert (dest / "README.md").exists()
    assert (dest / "osx-backup" / "pyproject.toml").exists()
    assert not (dest / "osx-backup" / ".venv").exists()
    assert list((dest / "logs").glob("*.log"))
    cfg = (home / ".config" / "osx-backup" / "config.toml").read_text()
    assert str(dest) in cfg and "dotfiles" in cfg


def test_backup_remembers_dest(env) -> None:
    home, dest, _ = env
    assert runner.invoke(cli.app, ["backup", "--dest", str(dest), "--all", "--yes"]).exit_code == 0
    result = runner.invoke(cli.app, ["backup", "--all", "--yes"])
    assert result.exit_code == 0, result.output


def test_backup_dry_run_writes_nothing(env) -> None:
    home, dest, _ = env
    result = runner.invoke(cli.app, ["backup", "--dest", str(dest), "--all", "--dry-run"])
    assert result.exit_code == 0, result.output
    assert not (dest / "manifest.json").exists()
    assert not (dest / "dotfiles").exists()
    assert not config_path(home).exists()


def test_backup_ctrl_c_at_confirmation_exits_130(env, monkeypatch: pytest.MonkeyPatch) -> None:
    _, dest, _ = env
    monkeypatch.setattr(cli, "confirm", lambda message: ui_confirm(message, lambda _m: None))
    result = runner.invoke(cli.app, ["backup", "--dest", str(dest), "--all"])
    assert result.exit_code == 130, result.output
    assert not (dest / "manifest.json").exists()
    assert not (dest / "dotfiles").exists()


def test_backup_failure_exit_code(env, monkeypatch: pytest.MonkeyPatch) -> None:
    _, dest, _ = env
    monkeypatch.setattr(cli, "list_modules", lambda: [DotfilesModule(), Failing(), Crashing()])
    result = runner.invoke(cli.app, ["backup", "--dest", str(dest), "--all", "--yes"])
    assert result.exit_code == 1
    manifest = json.loads((dest / "manifest.json").read_text())
    assert manifest["modules"]["failing"]["status"] == "failed"
    assert "kaboom" in manifest["modules"]["crashing"]["message"]
    assert manifest["modules"]["dotfiles"]["status"] == "ok"


def test_backup_interrupt_exits_130(env, monkeypatch: pytest.MonkeyPatch) -> None:
    _, dest, _ = env
    monkeypatch.setattr(cli, "list_modules", lambda: [DotfilesModule(), Interrupting(), Failing()])
    result = runner.invoke(cli.app, ["backup", "--dest", str(dest), "--all", "--yes"])
    assert result.exit_code == 130, result.output
    manifest = json.loads((dest / "manifest.json").read_text())
    assert manifest["modules"]["dotfiles"]["status"] == "ok"
    assert "interrompu" in manifest["modules"]["interrupting"]["message"]
    assert "failing" not in manifest["modules"]


def test_backup_modules_option_validates(env) -> None:
    _, dest, _ = env
    result = runner.invoke(cli.app, ["backup", "--dest", str(dest), "--modules", "nope", "--yes"])
    assert result.exit_code != 0 and "nope" in result.output


def test_backup_rejects_home_as_dest(env) -> None:
    home, _, _ = env
    result = runner.invoke(cli.app, ["backup", "--dest", str(home), "--all", "--yes"])
    assert result.exit_code != 0 and "HOME" in result.output


def test_backup_rejects_missing_dest(env, tmp_path: Path) -> None:
    result = runner.invoke(
        cli.app, ["backup", "--dest", str(tmp_path / "nulle-part"), "--all", "--yes"]
    )
    assert result.exit_code != 0 and "nulle-part" in result.output


def test_backup_requires_dest_when_unknown(env) -> None:
    result = runner.invoke(cli.app, ["backup", "--all", "--yes"])
    assert result.exit_code != 0 and "--dest" in result.output


def test_backup_hides_inventory_warnings_unless_verbose(env, monkeypatch: pytest.MonkeyPatch):
    _, dest, _ = env
    monkeypatch.setattr(cli, "list_modules", lambda: [Noisy()])
    quiet = runner.invoke(cli.app, ["backup", "--dest", str(dest), "--all", "--yes"])
    assert quiet.exit_code == 0, quiet.output
    assert "quelque-chose" not in quiet.output
    loud = runner.invoke(cli.app, ["map", "--dest", str(dest)])
    assert loud.exit_code == 0, loud.output
    assert "quelque-chose" in loud.output


def test_restore_roundtrip(env, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    home, dest, _ = env
    assert runner.invoke(cli.app, ["backup", "--dest", str(dest), "--all", "--yes"]).exit_code == 0
    home2 = tmp_path / "home2"
    home2.mkdir()
    (home2 / ".zshrc").write_text("old")
    monkeypatch.setenv("HOME", str(home2))
    result = runner.invoke(cli.app, ["restore", "--from", str(dest), "--all", "--yes"])
    assert result.exit_code == 0, result.output
    assert (home2 / ".zshrc").read_text() == "zsh"
    aside = list((home2 / ".osx-backup-pre-restore").glob("*/.zshrc"))
    assert len(aside) == 1 and aside[0].read_text() == "old"
    assert list((dest / "manifests").glob("*-restore.json"))
    assert json.loads((dest / "manifest.json").read_text())["action"] == "backup"


def test_restore_requires_manifest(env, tmp_path: Path) -> None:
    empty = tmp_path / "empty"
    empty.mkdir()
    result = runner.invoke(cli.app, ["restore", "--from", str(empty), "--all", "--yes"])
    assert result.exit_code != 0 and "manifest" in result.output


def test_restore_rejects_corrupt_manifest(env, tmp_path: Path) -> None:
    broken = tmp_path / "broken"
    broken.mkdir()
    (broken / "manifest.json").write_text("{pas du json")
    result = runner.invoke(cli.app, ["restore", "--from", str(broken), "--all", "--yes"])
    assert result.exit_code != 0 and "manifest" in result.output


def test_restore_inventories_from_manifest(env) -> None:
    home, dest, recording = env
    assert runner.invoke(cli.app, ["backup", "--dest", str(dest), "--all", "--yes"]).exit_code == 0
    from osx_backup.manifest import read_manifest

    manifest = read_manifest(dest)
    assert manifest is not None
    inventories = cli.restore_inventories(manifest, [DotfilesModule(), Failing()])
    assert inventories["dotfiles"].available and inventories["dotfiles"].items == 1
    assert not inventories["failing"].available


def test_check_disk_space(dest: Path) -> None:
    import typer

    cli.check_disk_space(dest, 1, force=False)
    with pytest.raises(typer.BadParameter):
        cli.check_disk_space(dest, 10**18, force=False)
    cli.check_disk_space(dest, 10**18, force=True)


def test_validate_dest(fake_home: Path, dest: Path) -> None:
    import typer

    cli.validate_dest(dest, fake_home)
    with pytest.raises(typer.BadParameter):
        cli.validate_dest(fake_home, fake_home)
    with pytest.raises(typer.BadParameter):
        cli.validate_dest(fake_home.parent, fake_home)
    with pytest.raises(typer.BadParameter):
        cli.validate_dest(dest / "absent", fake_home)


def test_validate_dest_rejects_an_alias_of_home(fake_home: Path, tmp_path: Path) -> None:
    """Un firmlink macOS (/System/Volumes/Data/Users/x) désigne $HOME sans lui ressembler."""
    import typer

    alias = tmp_path / "alias-home"
    alias.symlink_to(fake_home, target_is_directory=True)
    with pytest.raises(typer.BadParameter):
        cli.validate_dest(alias, fake_home)

    alias_parent = tmp_path / "alias-parent"
    alias_parent.symlink_to(fake_home.parent, target_is_directory=True)
    with pytest.raises(typer.BadParameter):
        cli.validate_dest(alias_parent, fake_home)


def test_render_readme_lists_bootstrap_steps(dest: Path) -> None:
    text = cli.render_readme(dest, {"dotfiles": ModuleResult.ok("ok", items=1)})
    assert "xcode-select --install" in text
    assert "Homebrew/install" in text
    assert "brew install uv rsync" in text
    assert f'cd "{dest / "osx-backup"}" && uv run osx-backup restore --from "{dest}"' in text
    assert "dotfiles" in text


def test_copy_tool_survives_a_copy_error(
    fake_home: Path, dest: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    import shutil

    from osx_backup.context import Context as Ctx

    ctx = Ctx(home=fake_home, dest=dest, runner=RecordingRunner())

    def boom(*_a, **_kw):
        raise OSError("exFAT ne sait pas faire de lien symbolique")

    monkeypatch.setattr(shutil, "copytree", boom)
    cli.copy_tool(ctx)  # ne doit pas lever


def test_copy_tool_skips_a_dest_inside_the_repo(fake_home: Path) -> None:
    repo = Path(cli.__file__).resolve().parents[2]
    ctx = Context(home=fake_home, dest=repo / "sauvegarde", runner=RecordingRunner())
    cli.copy_tool(ctx)
    assert not (repo / "sauvegarde").exists()


def test_copy_tool_replaces_a_previous_tool_copy(fake_home: Path, dest: Path) -> None:
    previous = dest / "osx-backup"
    previous.mkdir()
    (previous / "pyproject.toml").write_text('[project]\nname = "osx-backup"\n')
    (previous / "obsolete.py").write_text("vieux")
    cli.copy_tool(Context(home=fake_home, dest=dest, runner=RecordingRunner()))
    assert (previous / "pyproject.toml").exists()
    assert not (previous / "obsolete.py").exists()


def test_copy_tool_refuses_to_erase_a_foreign_directory(fake_home: Path, dest: Path) -> None:
    foreign = dest / "osx-backup"
    foreign.mkdir()
    (foreign / "photos.jpg").write_text("précieux")
    cli.copy_tool(Context(home=fake_home, dest=dest, runner=RecordingRunner()))
    assert (foreign / "photos.jpg").read_text() == "précieux"
    assert not (foreign / "pyproject.toml").exists()


def test_restore_rejects_a_non_object_manifest(env, tmp_path: Path) -> None:
    weird = tmp_path / "weird"
    weird.mkdir()
    (weird / "manifest.json").write_text("[1, 2, 3]")
    result = runner.invoke(cli.app, ["restore", "--from", str(weird), "--all", "--yes"])
    assert result.exit_code != 0 and "manifest" in result.output


def test_validate_dest_refuses_a_homonym_directory(fake_home: Path, dest: Path) -> None:
    """Un `Documents/` déjà présent sur le disque serait vidé par le miroir rsync --delete."""
    import typer

    (dest / "Documents").mkdir()
    with pytest.raises(typer.BadParameter, match="Documents"):
        cli.validate_dest(dest, fake_home, module_names=["documents"])
    # --force passe outre, et un dest qui est déjà une sauvegarde n'est plus questionné.
    cli.validate_dest(dest, fake_home, module_names=["documents"], force=True)
    (dest / "manifest.json").write_text("{}")
    cli.validate_dest(dest, fake_home, module_names=["documents"])


def test_validate_dest_refuses_a_disk_without_symlinks(
    fake_home: Path, dest: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    import os

    import typer

    def boom(*_a: object, **_kw: object) -> None:
        raise OSError(45, "Operation not supported")

    monkeypatch.setattr(os, "symlink", boom)
    with pytest.raises(typer.BadParameter, match="liens symboliques"):
        cli.validate_dest(dest, fake_home)
    cli.validate_dest(dest, fake_home, force=True)
    assert list(dest.iterdir()) == []


def test_backup_refuses_a_dest_with_a_homonym_directory(env) -> None:
    _, dest, _ = env
    (dest / "Dotfiles").mkdir()
    result = runner.invoke(cli.app, ["backup", "--dest", str(dest), "--all", "--yes"])
    assert result.exit_code != 0
    assert "Dotfiles" in result.output


def test_render_readme_mentions_the_mount_name_and_uv_env(dest: Path) -> None:
    text = cli.render_readme(dest, {})
    assert "/Volumes/" in text
    assert "UV_PROJECT_ENVIRONMENT" in text


def test_passphrase_is_asked_before_the_progress_display(
    env, monkeypatch: pytest.MonkeyPatch
) -> None:
    """Le spinner rich redessine l'écran par-dessus le prompt getpass : demander avant."""
    _, dest, _ = env
    monkeypatch.setattr(cli, "list_modules", lambda: [SecretsModule()])
    events: list[object] = []

    def fake_passphrase(ctx: Context, confirm: bool) -> str:
        events.append(("passphrase", confirm))
        ctx.passphrase = "pw"
        return "pw"

    class RecordingProgress(cli.RichProgress):
        def __enter__(self):
            events.append("progress")
            return super().__enter__()

    monkeypatch.setattr(cli, "get_passphrase", fake_passphrase)
    monkeypatch.setattr(cli, "RichProgress", RecordingProgress)
    result = runner.invoke(cli.app, ["backup", "--dest", str(dest), "--all", "--yes"])
    assert result.exit_code == 0, result.output
    assert events == [("passphrase", True), "progress"]


def test_dry_run_never_asks_for_the_passphrase(env, monkeypatch: pytest.MonkeyPatch) -> None:
    home, dest, _ = env
    (home / ".ssh").mkdir()
    (home / ".ssh" / "id_ed25519").write_text("PRIVATE")
    monkeypatch.setattr(cli, "list_modules", lambda: [SecretsModule()])

    def boom(*_a: object, **_kw: object) -> str:
        raise AssertionError("aucune passphrase ne doit être demandée en dry-run")

    monkeypatch.setattr(cli, "get_passphrase", boom)
    monkeypatch.setattr(secrets_module, "get_passphrase", boom)
    result = runner.invoke(cli.app, ["backup", "--dest", str(dest), "--all", "--dry-run"])
    assert result.exit_code == 0, result.output
    assert not (dest / "secrets.tar.age").exists()


def test_manifest_keeps_entries_of_modules_not_rerun(env, monkeypatch: pytest.MonkeyPatch) -> None:
    """Un backup partiel ne doit pas effacer du manifeste ce qui reste sur le disque."""
    _, dest, _ = env
    monkeypatch.setattr(cli, "list_modules", lambda: [DotfilesModule(), Flaky()])
    monkeypatch.setattr(Flaky, "mode", "ok")
    first = runner.invoke(cli.app, ["backup", "--dest", str(dest), "--modules", "flaky", "--yes"])
    assert first.exit_code == 0, first.output
    second = runner.invoke(
        cli.app, ["backup", "--dest", str(dest), "--modules", "dotfiles", "--yes"]
    )
    assert second.exit_code == 0, second.output
    modules = json.loads((dest / "manifest.json").read_text())["modules"]
    assert modules["flaky"]["status"] == "ok" and modules["flaky"]["items"] == 3
    assert modules["dotfiles"]["status"] == "ok"
    assert modules["flaky"]["created_at"] and modules["dotfiles"]["created_at"]

    # Un nouveau run du même module remplace bien son entrée, statut compris.
    monkeypatch.setattr(Flaky, "mode", "failed")
    third = runner.invoke(cli.app, ["backup", "--dest", str(dest), "--modules", "flaky", "--yes"])
    assert third.exit_code == 1
    modules = json.loads((dest / "manifest.json").read_text())["modules"]
    assert modules["flaky"]["status"] == "failed"
    assert modules["dotfiles"]["status"] == "ok"


def test_build_context_without_a_writable_source_has_no_log(
    dest: Path, fake_home: Path, monkeypatch: pytest.MonkeyPatch, capsys
) -> None:
    """Restauration depuis un disque en lecture seule : un warning, pas un traceback."""
    import os

    monkeypatch.setattr(os, "access", lambda *_a, **_kw: False)
    ctx = cli.build_context(dest, "restore")
    assert ctx.log_path is None
    ctx.log("rien ne doit être écrit")
    assert not (dest / "logs").exists()


def test_backup_dry_run_with_the_real_registry(
    fake_home: Path, dest: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """Le vrai registre de modules doit traverser un dry-run complet sans exception."""
    monkeypatch.setattr(cli, "make_runner", lambda _log: RecordingRunner())
    real_build = cli.build_context

    def build(*args: object, **kwargs: object) -> Context:
        ctx = real_build(*args, **kwargs)
        ctx.applications_dir = tmp_path / "Applications"
        return ctx

    monkeypatch.setattr(cli, "build_context", build)
    result = runner.invoke(cli.app, ["backup", "--dest", str(dest), "--all", "--dry-run", "--yes"])
    assert result.exit_code == 0, result.output
    assert "Aucun module sélectionné" not in result.output
    assert not (dest / "manifest.json").exists()
