"""Commandes map / backup / restore."""

from __future__ import annotations

import json
import os
import shutil
import socket
import sys
import tempfile
import time
import traceback
from collections.abc import Callable, Sequence
from dataclasses import replace
from datetime import datetime
from pathlib import Path

import questionary
import typer
from rich.console import Console

import osx_backup.modules  # noqa: F401 - remplit le registre des modules
from osx_backup import __version__
from osx_backup.config import UserConfig, load_user_config, save_user_config
from osx_backup.context import Context
from osx_backup.fsutil import format_size, remove_path
from osx_backup.manifest import Manifest, build_manifest, read_manifest, write_manifest
from osx_backup.modules.base import Inventory, Module, ModuleResult, ProgressReporter, get_modules
from osx_backup.modules.secrets import get_passphrase
from osx_backup.runner import CommandRunner, DryRunRunner, RealRunner
from osx_backup.ui import (
    RichProgress,
    checklist,
    confirm,
    inventory_table,
    plan_table,
    summary_table,
)

app = typer.Typer(
    help="Sauvegarde et réinstallation d'un Mac de développeur.",
    no_args_is_help=True,
    add_completion=False,
)
console = Console()

#: Ce qui n'a pas sa place dans la copie de l'outil sur le disque de sauvegarde.
#: `.git` est volontairement conservé : il permet de mettre l'outil à jour depuis le disque.
TOOL_IGNORES = (
    ".venv",
    "__pycache__",
    ".pytest_cache",
    ".ruff_cache",
    ".superpowers",
    ".DS_Store",
)


class Interrupted(Exception):
    """Ctrl-C pendant l'exécution : porte les résultats partiels jusqu'à l'appelant."""

    def __init__(self, results: dict[str, ModuleResult]) -> None:
        super().__init__("interrompu")
        self.results = results


def make_runner(log: Callable[[str], None]) -> CommandRunner:
    return RealRunner(log)


def list_modules() -> list[Module]:
    return get_modules()


def build_context(
    dest: Path,
    action: str,
    *,
    dry_run: bool = False,
    yes: bool = False,
    verbose: bool = False,
    force: bool = False,
    no_stop: bool = False,
    include_anonymous: bool = False,
    passphrase: str | None = None,
    with_log: bool = True,
) -> Context:
    ctx = Context(
        home=Path.home(),
        dest=dest,
        runner=RealRunner(),
        console=console,
        dry_run=dry_run,
        yes=yes,
        # Un dry-run n'a d'intérêt que s'il montre ce qu'il ferait.
        verbose=verbose or dry_run,
        force=force,
        docker_no_stop=no_stop,
        docker_include_anonymous=include_anonymous,
        passphrase=passphrase,
    )
    if with_log and not dry_run:
        # Un disque de sauvegarde monté en lecture seule doit donner un avertissement,
        # pas un traceback à la première ligne de log.
        if os.access(dest, os.W_OK):
            ctx.log_path = dest / "logs" / f"{ctx.timestamp}-{action}.log"
        else:
            ctx.warn(f"{dest} n'est pas inscriptible : pas de fichier de log")
    real = make_runner(ctx.log)
    ctx.runner = DryRunRunner(real, ctx.log) if dry_run else real
    return ctx


def resolve_dest(option: Path | None, home: Path, flag: str) -> Path:
    if option is not None:
        return option.expanduser().resolve()
    cfg = load_user_config(home)
    if cfg.dest:
        return Path(cfg.dest)
    if not sys.stdin.isatty():
        raise typer.BadParameter(f"aucune destination connue : passer {flag}")
    answer = questionary.path("Dossier de sauvegarde :").ask()
    if not answer:
        raise typer.Abort()
    return Path(answer).expanduser().resolve()


def _is_same_dir(a: Path, b: Path) -> bool:
    """Vrai si les deux chemins désignent le même dossier, alias compris."""
    try:
        return os.path.samefile(a, b)
    except OSError:
        return False


def _check_symlinks(dest: Path, force: bool) -> None:
    """Refuse un disque qui ne sait pas faire de lien symbolique (exFAT, FAT32).

    rsync `-a` y sort en code 23, `copy_tree` en OSError et l'outil embarqué n'est pas
    copiable : mieux vaut le dire avant la sauvegarde qu'au milieu.
    """
    probe = dest / f".osx-backup-symlink-probe-{os.getpid()}"
    try:
        os.symlink("cible", probe)
    except OSError as exc:
        if force:
            return
        raise typer.BadParameter(
            f"disque sans liens symboliques : formater en APFS ({exc}) — --force pour ignorer"
        ) from exc
    probe.unlink(missing_ok=True)


def _check_homonyms(dest: Path, module_names: Sequence[str], force: bool) -> None:
    """Refuse un premier run sur un dossier qui porte déjà les noms de la sauvegarde.

    Sur un système de fichiers insensible à la casse, `<dest>/Documents` de l'utilisateur
    et le dossier du module `documents` sont le même dossier : le miroir `rsync --delete`
    le viderait. On ne pose la question qu'au premier run (pas de `manifest.json`).
    """
    if force or (dest / "manifest.json").exists():
        return
    reserved = {n.casefold() for n in module_names} | {"osx-backup", "secrets.tar.age"}
    for entry in sorted(dest.iterdir()):
        if entry.name.casefold() in reserved:
            raise typer.BadParameter(
                f"{dest} contient déjà « {entry.name} », homonyme d'un élément de sauvegarde, "
                "et n'est pas une sauvegarde osx-backup : destination probablement erronée "
                "(--force pour ignorer)"
            )


def validate_dest(
    dest: Path,
    home: Path,
    *,
    force: bool = False,
    module_names: Sequence[str] = (),
) -> None:
    if not dest.is_dir():
        raise typer.BadParameter(f"{dest} n'existe pas ou n'est pas un dossier")
    # `home.is_relative_to(dest)` couvre aussi `dest == home` : sauvegarder $HOME dans
    # $HOME (ou dans un de ses parents) écrirait la sauvegarde dans ce qu'elle lit.
    # La comparaison lexicale ne suffit pas : sur macOS `/System/Volumes/Data/Users/x`
    # est le même dossier que `/Users/x` (firmlink) et survit à `Path.resolve()`.
    # On compare donc aussi par identité (device + inode) avec $HOME et chacun de ses parents.
    if home.is_relative_to(dest) or any(_is_same_dir(dest, p) for p in (home, *home.parents)):
        raise typer.BadParameter("la destination ne peut pas être $HOME ni un de ses parents")
    if not os.access(dest, os.W_OK):
        raise typer.BadParameter(f"{dest} n'est pas inscriptible")
    _check_symlinks(dest, force)
    _check_homonyms(dest, module_names, force)


def check_disk_space(dest: Path, needed: int, force: bool) -> None:
    free = shutil.disk_usage(dest).free
    if needed > free and not force:
        raise typer.BadParameter(
            f"espace insuffisant : {format_size(needed)} nécessaires, "
            f"{format_size(free)} libres (--force pour ignorer)"
        )


def inventory_all(ctx: Context, modules: Sequence[Module]) -> dict[str, Inventory]:
    inventories: dict[str, Inventory] = {}
    with console.status("Inventaire en cours…"):
        for module in modules:
            try:
                inventories[module.name] = module.inventory(ctx)
            except Exception as exc:  # noqa: BLE001 - un module cassé ne bloque pas la carte
                ctx.log(traceback.format_exc())
                inventories[module.name] = Inventory(available=False, reason=f"erreur : {exc}")
    return inventories


def restore_inventories(manifest: Manifest, modules: Sequence[Module]) -> dict[str, Inventory]:
    """Ce que la sauvegarde contient, vu comme un inventaire (pas de lecture disque)."""
    inventories: dict[str, Inventory] = {}
    for module in modules:
        entry = manifest.modules.get(module.name)
        if entry and entry.get("status") == "ok":
            inventories[module.name] = Inventory(
                items=int(entry.get("items", 0)), size_bytes=int(entry.get("size_bytes", 0))
            )
        else:
            inventories[module.name] = Inventory(available=False, reason="absent de la sauvegarde")
    return inventories


def displayed(inventories: dict[str, Inventory], verbose: bool) -> dict[str, Inventory]:
    """Inventaires pour l'affichage : les ⚠ des chemins optionnels absents noient la table."""
    if verbose:
        return inventories
    return {name: replace(inv, warnings=[]) for name, inv in inventories.items()}


def select_modules(
    ctx: Context,
    modules: Sequence[Module],
    inventories: dict[str, Inventory],
    modules_opt: str | None,
    all_flag: bool,
) -> list[str]:
    available = [m.name for m in modules if inventories[m.name].available]
    if modules_opt:
        wanted = [n.strip() for n in modules_opt.split(",") if n.strip()]
        known = {m.name for m in modules}
        for name in wanted:
            if name not in known:
                raise typer.BadParameter(f"module inconnu : {name}")
            if name not in available:
                raise typer.BadParameter(f"module {name} indisponible : {inventories[name].reason}")
        return [m.name for m in modules if m.name in wanted]
    if all_flag or ctx.yes:
        return available
    last = set(load_user_config(ctx.home).last_selection) & set(available)
    return checklist(modules, inventories, last or None)


def run_modules(
    ctx: Context, action: str, names: Sequence[str], progress: ProgressReporter
) -> dict[str, ModuleResult]:
    """Exécute les modules ; un échec n'arrête pas les suivants, un Ctrl-C si."""
    by_name = {m.name: m for m in list_modules()}
    results: dict[str, ModuleResult] = {}
    overall = progress.task(f"{action} ({len(names)} modules)", total=len(names))
    for name in names:
        module = by_name[name]
        start = time.monotonic()
        try:
            if action == "backup":
                result = module.backup(ctx, progress)
            else:
                result = module.restore(ctx, progress)
        except KeyboardInterrupt:
            results[name] = ModuleResult.failed(
                "interrompu (Ctrl-C)", duration_s=round(time.monotonic() - start, 2)
            )
            ctx.log(f"{name}: interrompu")
            overall.finish()
            raise Interrupted(results) from None
        except Exception as exc:  # noqa: BLE001 - un module qui casse ne casse pas le run
            ctx.log(traceback.format_exc())
            result = ModuleResult.failed(f"{type(exc).__name__}: {exc}")
        result.duration_s = round(time.monotonic() - start, 2)
        results[name] = result
        ctx.log(f"{name}: {result.status} - {result.message}")
        overall.advance()
    overall.finish()
    return results


def _is_tool_copy(path: Path) -> bool:
    """Vrai si `path` est une copie antérieure de l'outil, donc effaçable sans danger."""
    manifest = path / "pyproject.toml"
    try:
        return 'name = "osx-backup"' in manifest.read_text(encoding="utf-8")
    except (OSError, UnicodeDecodeError):
        return False


def copy_tool(ctx: Context) -> None:
    """Copie l'outil lui-même sur le disque : la restauration doit être autonome."""
    repo = Path(__file__).resolve().parents[2]
    target = ctx.dest / "osx-backup"
    if not (repo / "pyproject.toml").exists():
        ctx.warn(f"source de l'outil introuvable ({repo}), copie ignorée")
        return
    if target == repo or target.is_relative_to(repo):
        ctx.warn("destination située dans le dépôt de l'outil, copie ignorée")
        return
    if ctx.dry_run:
        ctx.log(f"[dry-run] copie de l'outil {repo} -> {target}")
        return
    if target.exists() or target.is_symlink():
        # Copie neuve à chaque fois : réécrire par-dessus échoue sur les objets git en
        # lecture seule et sur les liens symboliques déjà présents. Mais on n'efface que
        # ce qu'on a écrit : un `osx-backup/` qui n'est pas une copie de l'outil appartient
        # à l'utilisateur.
        if not _is_tool_copy(target):
            ctx.warn(f"{target} existe et n'est pas une copie de l'outil, copie ignorée")
            return
        remove_path(target)
    try:
        shutil.copytree(
            repo,
            target,
            ignore=shutil.ignore_patterns(*TOOL_IGNORES),
            symlinks=True,
        )
    except (OSError, shutil.Error) as exc:
        # Un disque exFAT refuse les liens symboliques : la sauvegarde des données reste
        # valable, seul le confort « outil embarqué » est perdu.
        ctx.warn(f"copie de l'outil incomplète : {exc}")


def render_readme(dest: Path, results: dict[str, ModuleResult]) -> str:
    lines = [
        f"# Sauvegarde osx-backup — {socket.gethostname()}",
        "",
        f"Créée le {datetime.now():%Y-%m-%d %H:%M} avec osx-backup {__version__}.",
        "",
        "## Restaurer sur un Mac vierge",
        "",
        "1. Outils de ligne de commande Xcode : `xcode-select --install`",
        '2. Homebrew : `/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"`',
        "3. `brew install uv rsync`",
        f'4. `cd "{dest / "osx-backup"}" && uv run osx-backup restore --from "{dest}"`',
        "",
        "L'existant sur la machine est déplacé dans `~/.osx-backup-pre-restore/<horodatage>/` avant chaque écrasement.",
        "L'archive `secrets.tar.age` demande la passphrase choisie à la sauvegarde.",
        "",
        "Le disque peut se monter sous un autre nom sur le nouveau Mac (`/Volumes/<disque>`,"
        " `/Volumes/<disque> 1`…) : vérifier le chemin réel avant de lancer la commande.",
        "Si le disque ne supporte pas `uv run` (ou pour garder le `.venv` sur le Mac),"
        " placer l'environnement ailleurs : `UV_PROJECT_ENVIRONMENT=~/.osx-backup-venv uv run osx-backup restore …`.",
        "",
        "## Contenu",
        "",
        "| Module | Statut | Items | Taille |",
        "|---|---|---|---|",
    ]
    lines += [
        f"| {name} | {r.status} | {r.items} | {format_size(r.size_bytes)} |"
        for name, r in results.items()
    ]
    return "\n".join(lines) + "\n"


def _previous(ctx: Context) -> Manifest | None:
    """Manifeste déjà présent sur la destination, ou None s'il est absent ou illisible."""
    try:
        return read_manifest(ctx.dest)
    except (OSError, json.JSONDecodeError, KeyError, ValueError, TypeError, AttributeError) as exc:
        ctx.warn(f"manifeste précédent illisible, non repris : {exc}")
        return None


def prefetch_passphrase(ctx: Context, names: Sequence[str], action: str) -> None:
    """Demande la passphrase avant la barre de progression.

    `getpass` écrit son prompt sur le terminal ; le live display de rich le redessine
    par-dessus et l'utilisateur tape à l'aveugle. `get_passphrase` met le résultat en
    cache dans `ctx`, le module secrets ne redemandera rien. Un échec ici n'arrête pas le
    run : le module refera la demande et échouera proprement, lui seul (spec §8).
    """
    if "secrets" not in names or ctx.dry_run:
        return
    try:
        get_passphrase(ctx, confirm=(action == "backup"))
    except (RuntimeError, ValueError) as exc:
        ctx.log(f"passphrase non obtenue avant la progression : {exc}")


def _finish(ctx: Context, results: dict[str, ModuleResult], interrupted: bool) -> None:
    if results:
        console.print(summary_table(results))
    if ctx.log_path:
        console.print(f"Log : {ctx.log_path}")
    if interrupted:
        raise typer.Exit(130)
    if any(r.status == "failed" for r in results.values()):
        raise typer.Exit(1)


@app.command("map")
def map_cmd(
    dest: Path | None = typer.Option(None, "--dest", help="Destination (pour estimer rsync)."),
    verbose: bool = typer.Option(False, "--verbose", "-v"),
) -> None:
    """Affiche la carte de ce qui serait sauvegardé, sans rien écrire."""
    home = Path.home()
    cfg = load_user_config(home)
    target = dest or (
        Path(cfg.dest) if cfg.dest else Path(tempfile.gettempdir()) / "osx-backup-map"
    )
    ctx = build_context(target, "map", verbose=verbose, with_log=False)
    modules = list_modules()
    inventories = inventory_all(ctx, modules)
    console.print(inventory_table(modules, inventories))
    total = sum(i.size_bytes for i in inventories.values() if i.available)
    console.print(f"Taille estimée totale : {format_size(total)}")


@app.command()
def backup(
    dest: Path | None = typer.Option(None, "--dest", help="Dossier de destination."),
    modules: str | None = typer.Option(None, "--modules", help="Liste séparée par des virgules."),
    all_modules: bool = typer.Option(False, "--all", help="Tous les modules disponibles."),
    yes: bool = typer.Option(False, "--yes", "-y", help="Sans questions."),
    dry_run: bool = typer.Option(False, "--dry-run", help="Afficher le plan sans écrire."),
    verbose: bool = typer.Option(False, "--verbose", "-v"),
    force: bool = typer.Option(False, "--force", help="Ignorer le contrôle d'espace disque."),
    no_stop: bool = typer.Option(
        False, "--no-stop", help="Docker : ne pas stopper les containers."
    ),
    include_anonymous: bool = typer.Option(
        False, "--include-anonymous", help="Docker : volumes anonymes aussi."
    ),
    passphrase: str | None = typer.Option(
        None, "--passphrase", help="Passphrase de l'archive chiffrée (sinon demandée)."
    ),
) -> None:
    """Sauvegarde la machine vers un dossier (disque externe)."""
    home = Path.home()
    target = resolve_dest(dest, home, "--dest")
    mods = list_modules()
    validate_dest(target, home, force=force, module_names=[m.name for m in mods])
    ctx = build_context(
        target,
        "backup",
        dry_run=dry_run,
        yes=yes,
        verbose=verbose,
        force=force,
        no_stop=no_stop,
        include_anonymous=include_anonymous,
        passphrase=passphrase,
    )
    inventories = inventory_all(ctx, mods)
    console.print(inventory_table(mods, displayed(inventories, ctx.verbose)))
    names: list[str] = []
    results: dict[str, ModuleResult] = {}
    interrupted = False
    try:
        names = select_modules(ctx, mods, inventories, modules, all_modules)
        if not names:
            console.print("Aucun module sélectionné.")
            raise typer.Exit(0)
        check_disk_space(target, sum(inventories[n].size_bytes for n in names), force)
        console.print(plan_table("backup", names, inventories, target))
        if dry_run:
            console.print("[bold]Mode dry-run : les actions sont listées, rien n'est écrit.[/]")
        elif not yes and not confirm("Lancer la sauvegarde ?"):
            raise typer.Exit(1)
        prefetch_passphrase(ctx, names, "backup")
        with RichProgress(console) as progress:
            results = run_modules(ctx, "backup", names, progress)
    except Interrupted as exc:
        interrupted, results = True, exc.results
    except KeyboardInterrupt:
        interrupted = True
    if interrupted:
        console.print("[yellow]Interrompu (Ctrl-C).[/]")
    if not dry_run and results:
        # Le manifeste partiel est écrit même après un Ctrl-C : il dit ce qui est sur le disque.
        write_manifest(target, build_manifest(ctx, "backup", results, previous=_previous(ctx)))
        if not interrupted:
            copy_tool(ctx)
            ctx.write_text(target / "README.md", render_readme(target, results))
            save_user_config(home, UserConfig(dest=str(target), last_selection=list(names)))
    _finish(ctx, results, interrupted)


@app.command()
def restore(
    source: Path | None = typer.Option(None, "--from", help="Dossier de sauvegarde."),
    modules: str | None = typer.Option(None, "--modules", help="Liste séparée par des virgules."),
    all_modules: bool = typer.Option(False, "--all", help="Tous les modules présents."),
    yes: bool = typer.Option(False, "--yes", "-y", help="Sans questions."),
    dry_run: bool = typer.Option(False, "--dry-run", help="Afficher le plan sans écrire."),
    verbose: bool = typer.Option(False, "--verbose", "-v"),
    passphrase: str | None = typer.Option(
        None, "--passphrase", help="Passphrase de l'archive chiffrée (sinon demandée)."
    ),
) -> None:
    """Restaure une sauvegarde sur cette machine."""
    home = Path.home()
    source_dir = resolve_dest(source, home, "--from")
    if not source_dir.is_dir():
        raise typer.BadParameter(f"{source_dir} n'existe pas")
    try:
        manifest = read_manifest(source_dir)
    # JSON valide mais qui n'est pas un objet manifeste : `from_dict` lève TypeError
    # (indexer une liste) ou AttributeError (pas de `.get`).
    except (json.JSONDecodeError, KeyError, ValueError, TypeError, AttributeError) as exc:
        raise typer.BadParameter(f"manifest.json illisible dans {source_dir} : {exc}") from exc
    if manifest is None:
        raise typer.BadParameter(
            f"pas de manifest.json dans {source_dir} : ce n'est pas une sauvegarde osx-backup"
        )
    ctx = build_context(
        source_dir, "restore", dry_run=dry_run, yes=yes, verbose=verbose, passphrase=passphrase
    )
    ctx.old_home = Path(manifest.home) if manifest.home else None
    mods = list_modules()
    inventories = restore_inventories(manifest, mods)
    console.print(
        f"Sauvegarde de [bold]{manifest.hostname}[/] du {manifest.created_at} "
        f"(home : {manifest.home})"
    )
    console.print(inventory_table(mods, inventories))
    names: list[str] = []
    results: dict[str, ModuleResult] = {}
    interrupted = False
    try:
        names = select_modules(ctx, mods, inventories, modules, all_modules)
        if not names:
            console.print("Aucun module sélectionné.")
            raise typer.Exit(0)
        console.print(plan_table("restore", names, inventories, source_dir))
        console.print(f"L'existant sera déplacé dans {ctx.pre_restore_dir}")
        if dry_run:
            console.print("[bold]Mode dry-run : les actions sont listées, rien n'est écrit.[/]")
        elif not yes and not confirm("Restaurer sur cette machine ?"):
            raise typer.Exit(1)
        prefetch_passphrase(ctx, names, "restore")
        with RichProgress(console) as progress:
            results = run_modules(ctx, "restore", names, progress)
    except Interrupted as exc:
        interrupted, results = True, exc.results
    except KeyboardInterrupt:
        interrupted = True
    if interrupted:
        console.print("[yellow]Interrompu (Ctrl-C).[/]")
    if not dry_run and results:
        # `latest=False` : une restauration ne réécrit pas le manifeste de la sauvegarde.
        write_manifest(source_dir, build_manifest(ctx, "restore", results), latest=False)
        if not interrupted:
            save_user_config(home, UserConfig(dest=str(source_dir), last_selection=list(names)))
    _finish(ctx, results, interrupted)


if __name__ == "__main__":
    app()
