"""Docker (Colima) : inventaire et export des volumes nommés."""

from __future__ import annotations

import json
import re
from pathlib import Path
from typing import Any

from osx_backup.context import Context
from osx_backup.fsutil import remove_path
from osx_backup.modules.base import (
    Inventory,
    Module,
    ModuleResult,
    ProgressReporter,
    register,
    stderr_detail,
)

ANON_RE = re.compile(r"^[0-9a-f]{64}$")
LABEL_PROJECT = "com.docker.compose.project"
LABEL_WORKDIR = "com.docker.compose.project.working_dir"
UNREACHABLE = "Docker injoignable : colima start"
STOP_TIMEOUT = "30"


def is_anonymous(name: str) -> bool:
    """Un volume anonyme porte pour nom son id : 64 caractères hexadécimaux."""
    return bool(ANON_RE.match(name))


def parse_json_lines(text: str) -> list[dict[str, Any]]:
    out: list[dict[str, Any]] = []
    for raw in text.splitlines():
        line = raw.strip()
        if line:
            out.append(json.loads(line))
    return out


def parse_labels(text: str) -> dict[str, str]:
    """Découpe le champ `Labels` de `docker ps` (`k=v,k=v`, la valeur peut contenir `=`)."""
    labels: dict[str, str] = {}
    for part in text.split(","):
        if "=" in part:
            key, value = part.split("=", 1)
            labels[key.strip()] = value
    return labels


def rewrite_home(path: str, old_home: Path | None, new_home: Path) -> str:
    """Remplace l'ancien $HOME par le nouveau ; laisse les chemins hors $HOME intacts."""
    if old_home and path.startswith(str(old_home) + "/"):
        return str(new_home) + path[len(str(old_home)) :]
    return path


def compose_hints(inventory: dict[str, Any], old_home: Path | None, new_home: Path) -> list[str]:
    """Une ligne `docker compose up -d` par projet compose, dédupliquée."""
    seen: dict[str, str] = {}
    for container in inventory.get("containers", []):
        labels = container.get("labels", {})
        project, workdir = labels.get(LABEL_PROJECT), labels.get(LABEL_WORKDIR)
        if project and workdir and project not in seen:
            seen[project] = rewrite_home(workdir, old_home, new_home)
    return [f"cd {wd} && docker compose up -d   # {project}" for project, wd in seen.items()]


def _docker_ok(ctx: Context) -> bool:
    return ctx.runner.run(["docker", "info"], check=False, readonly=True).ok


def _containers(ctx: Context) -> list[dict[str, Any]]:
    res = ctx.runner.run(["docker", "ps", "-a", "--format", "{{json .}}"], readonly=True)
    return [
        {
            "name": c.get("Names", ""),
            "image": c.get("Image", ""),
            "state": c.get("State", ""),
            "labels": parse_labels(c.get("Labels", "") or ""),
        }
        for c in parse_json_lines(res.stdout)
    ]


def _volumes(ctx: Context) -> list[str]:
    res = ctx.runner.run(["docker", "volume", "ls", "--format", "{{json .}}"], readonly=True)
    return [v["Name"] for v in parse_json_lines(res.stdout)]


def _images(ctx: Context) -> list[str]:
    res = ctx.runner.run(["docker", "images", "--format", "{{json .}}"], readonly=True)
    return [f"{i.get('Repository')}:{i.get('Tag')}" for i in parse_json_lines(res.stdout)]


def _volume_users(ctx: Context, volume: str) -> list[tuple[str, str]]:
    """Containers montant `volume`, sous forme de couples (nom, état)."""
    res = ctx.runner.run(
        [
            "docker",
            "ps",
            "-a",
            "--filter",
            f"volume={volume}",
            "--format",
            "{{.Names}}\t{{.State}}",
        ],
        readonly=True,
    )
    users: list[tuple[str, str]] = []
    for line in res.stdout.splitlines():
        if "\t" in line:
            name, state = line.split("\t", 1)
            users.append((name.strip(), state.strip()))
    return users


def _running_users(ctx: Context, volume: str) -> list[str]:
    return [name for name, state in _volume_users(ctx, volume) if state == "running"]


@register
class DockerModule(Module):
    name = "docker"
    description = "Docker/Colima : inventaire des containers et export des volumes nommés"
    order = 110

    def _selected_volumes(self, ctx: Context, names: list[str]) -> list[str]:
        return [n for n in names if ctx.docker_include_anonymous or not is_anonymous(n)]

    def inventory(self, ctx: Context) -> Inventory:
        if not _docker_ok(ctx):
            return Inventory(available=False, reason=UNREACHABLE)
        containers = _containers(ctx)
        all_volumes = _volumes(ctx)
        selected = self._selected_volumes(ctx, all_volumes)
        warnings: list[str] = []
        skipped = len(all_volumes) - len(selected)
        if skipped:
            warnings.append(
                f"{skipped} volume(s) anonyme(s) ignoré(s) (--include-anonymous pour les inclure)"
            )
        return Inventory(
            items=len(selected),
            warnings=warnings,
            details={"containers": len(containers), "volumes": selected},
        )

    def _export_volume(self, ctx: Context, volume: str, voldir: Path, warnings: list[str]) -> bool:
        """Exporte un volume par un flux stdout, vers `<voldir>/<volume>.tar.gz`.

        L'archive transite par stdout et non par un bind mount `-v <voldir>:/to` : Colima
        ne partage que `$HOME` et `/tmp/colima` avec la VM, un montage de `/Volumes/<disque>`
        y serait créé à vide et le tar finirait dans la VM sans rien écrire sur le disque.
        """
        tmp = voldir / f"{volume}.tar.gz.tmp"
        res = ctx.runner.run_to_file(
            [
                "docker",
                "run",
                "--rm",
                "-v",
                f"{volume}:/from:ro",
                "alpine",
                "tar",
                "czf",
                "-",
                "-C",
                "/from",
                ".",
            ],
            tmp,
        )
        if ctx.dry_run:
            return res.ok
        if not res.ok:
            warnings.append(
                f"{volume} : export échoué{stderr_detail(ctx, f'export {volume}', res)}"
            )
            remove_path(tmp)
            return False
        # Vérification côté hôte : un tar qui sort 0 sans rien écrire sur le disque de
        # destination ne doit pas passer pour une sauvegarde.
        if not tmp.exists() or tmp.stat().st_size == 0:
            warnings.append(
                f"{volume} : archive vide, export ignoré{stderr_detail(ctx, f'export {volume}', res)}"
            )
            remove_path(tmp)
            return False
        tmp.replace(voldir / f"{volume}.tar.gz")
        return True

    def _stop_containers(
        self, ctx: Context, names: list[str], warnings: list[str], stopped: list[str]
    ) -> list[str]:
        """Arrête `names` et note dans `stopped` (fourni par l'appelant) ce qui l'a été.

        La liste est remplie au fur et à mesure plutôt que rendue à la fin : un Ctrl-C au
        milieu de la boucle laisserait sinon l'appelant croire qu'aucun container n'a été
        arrêté, et son `finally` n'en redémarrerait aucun.
        """
        for name in names:
            res = ctx.runner.run(["docker", "stop", "-t", STOP_TIMEOUT, name], check=False)
            if res.ok:
                stopped.append(name)
            else:
                warnings.append(
                    f"arrêt de {name} échoué, export à chaud"
                    f"{stderr_detail(ctx, f'docker stop {name}', res)}"
                )
        return stopped

    def _start_containers(self, ctx: Context, names: list[str], warnings: list[str]) -> None:
        for name in names:
            res = ctx.runner.run(["docker", "start", name], check=False)
            if not res.ok:
                warnings.append(
                    f"redémarrage de {name} échoué{stderr_detail(ctx, f'docker start {name}', res)}"
                )

    def backup(self, ctx: Context, progress: ProgressReporter) -> ModuleResult:
        if not _docker_ok(ctx):
            return ModuleResult.failed(UNREACHABLE)
        out = ctx.module_dir(self.name)
        all_volumes = _volumes(ctx)
        inventory = {
            "home": str(ctx.home),
            "containers": _containers(ctx),
            "images": _images(ctx),
            "volumes": all_volumes,
        }
        ctx.write_json(out / "inventory.json", inventory)
        voldir = out / "volumes"
        if not ctx.dry_run:
            voldir.mkdir(exist_ok=True)
        volumes = self._selected_volumes(ctx, all_volumes)
        warnings: list[str] = []
        exported = 0
        # Les containers sont arrêtés une seule fois pour tous les volumes qu'ils montent :
        # une base compose partagée par plusieurs volumes ne redémarre pas N fois.
        running_by_volume = {v: _running_users(ctx, v) for v in volumes}
        to_stop: list[str] = []
        for volume, names in running_by_volume.items():
            if not names:
                continue
            if ctx.docker_no_stop:
                warnings.append(f"{volume} : export à chaud ({', '.join(names)} en cours)")
            else:
                to_stop.extend(n for n in names if n not in to_stop)
        stopped: list[str] = []
        try:
            self._stop_containers(ctx, to_stop, warnings, stopped)
            task = progress.task("volumes docker", total=len(volumes))
            for volume in volumes:
                # Un volume en échec ne doit pas interrompre l'export des suivants.
                if self._export_volume(ctx, volume, voldir, warnings):
                    exported += 1
                task.advance()
            task.finish()
        finally:
            # `finally` : même une erreur inattendue ne doit pas laisser les containers
            # de l'utilisateur arrêtés.
            self._start_containers(ctx, stopped, warnings)
        if volumes and not exported:
            return ModuleResult.failed("aucun volume n'a pu être exporté", warnings=warnings)
        return ModuleResult.ok(
            f"{exported}/{len(volumes)} volume(s) exporté(s)", items=exported, warnings=warnings
        )

    def _restore_volume(
        self, ctx: Context, archive: Path, volume: str, warnings: list[str]
    ) -> bool:
        """Recrée le volume et le remplit depuis `archive`, poussée sur stdin.

        Comme à l'export, l'archive ne peut pas être bind-montée : le disque externe
        n'est pas partagé avec la VM Colima.
        """
        create = ctx.runner.run(["docker", "volume", "create", volume], check=False)
        if not create.ok:
            warnings.append(
                f"{volume} : création échouée{stderr_detail(ctx, f'volume create {volume}', create)}"
            )
            return False
        res = ctx.runner.run_from_file(
            [
                "docker",
                "run",
                "--rm",
                "-i",
                "-v",
                f"{volume}:/to",
                "alpine",
                "tar",
                "xzf",
                "-",
                "-C",
                "/to",
            ],
            archive,
        )
        if not res.ok:
            warnings.append(
                f"{volume} : restauration échouée{stderr_detail(ctx, f'restore {volume}', res)}"
            )
            return False
        return True

    def restore(self, ctx: Context, progress: ProgressReporter) -> ModuleResult:
        docker_dir = ctx.dest / self.name
        inventory_file = docker_dir / "inventory.json"
        if not inventory_file.exists():
            return ModuleResult.skipped("pas d'inventaire Docker dans la sauvegarde")
        if not _docker_ok(ctx):
            ctx.runner.run(["colima", "start"], check=False)
            if not _docker_ok(ctx):
                return ModuleResult.failed(UNREACHABLE)
        voldir = docker_dir / "volumes"
        archives = sorted(voldir.glob("*.tar.gz")) if voldir.is_dir() else []
        warnings: list[str] = []
        restored = 0
        task = progress.task("volumes docker", total=len(archives))
        for archive in archives:
            volume = archive.name.removesuffix(".tar.gz")
            # Un volume en échec ne doit pas interrompre la restauration des suivants.
            if self._restore_volume(ctx, archive, volume, warnings):
                restored += 1
            task.advance()
        task.finish()
        if archives and not restored:
            return ModuleResult.failed("aucun volume n'a pu être restauré", warnings=warnings)
        inventory = json.loads(inventory_file.read_text(encoding="utf-8"))
        old_home = ctx.old_home or (Path(inventory["home"]) if inventory.get("home") else None)
        hints = compose_hints(inventory, old_home, ctx.home)
        message = f"{restored}/{len(archives)} volume(s) restauré(s)"
        if hints:
            # Les projets compose ne sont pas relancés automatiquement : leurs images et
            # leurs .env peuvent manquer sur la nouvelle machine.
            message += ". Projets compose à relancer manuellement :\n  " + "\n  ".join(hints)
            ctx.console.print("[bold]Projets compose à relancer :[/]")
            for hint in hints:
                ctx.console.print("  " + hint, markup=False, highlight=False)
        return ModuleResult.ok(message, items=restored, warnings=warnings)
