import json
from pathlib import Path

import pytest

from osx_backup.context import Context
from osx_backup.modules.base import NullProgress
from osx_backup.modules.docker import (
    DockerModule,
    compose_hints,
    is_anonymous,
    parse_labels,
    rewrite_home,
)
from osx_backup.runner import DryRunRunner, RecordingRunner

ANON = "3ac321aa9d5ba236d2298d75c388995551da05dfb50bc4057fa8124a2ab3a92d"
PS = "\n".join(
    [
        json.dumps(
            {
                "Names": "plane-app-api-1",
                "Image": "plane:1",
                "State": "running",
                "Labels": "com.docker.compose.project=plane-app,com.docker.compose.project.working_dir=/Users/old/Github/plane",
            }
        ),
        json.dumps(
            {
                "Names": "plane-app-db-1",
                "Image": "postgres:15",
                "State": "running",
                "Labels": "com.docker.compose.project=plane-app,com.docker.compose.project.working_dir=/Users/old/Github/plane",
            }
        ),
        json.dumps({"Names": "adhoc", "Image": "alpine", "State": "exited", "Labels": ""}),
    ]
)
VOLUMES = "\n".join([json.dumps({"Name": "plane_pgdata"}), json.dumps({"Name": ANON})])
IMAGES = json.dumps({"Repository": "postgres", "Tag": "15"})


def _runner(docker_ok: bool = True) -> RecordingRunner:
    r = RecordingRunner()
    r.add(("docker", "info"), returncode=0 if docker_ok else 1)
    r.add(("docker", "ps", "-a", "--format"), stdout=PS)
    r.add(("docker", "volume", "ls"), stdout=VOLUMES)
    r.add(("docker", "images"), stdout=IMAGES)
    r.add(
        ("docker", "ps", "-a", "--filter", "volume=plane_pgdata"),
        stdout="plane-app-db-1\trunning\nadhoc\texited\n",
    )
    # La réponse préparée de `docker run` tient lieu de contenu d'archive (run_to_file).
    r.add(("docker", "run"), stdout="fake-archive")
    return r


def _export_cmd(volume: str) -> list[str]:
    return [
        "docker",
        "run",
        "--rm",
        "-v",
        f"{volume}:/from:ro",
        "alpine",
        "tar",
        "czf",
        "-",
        "-C",
        "/from",
        ".",
    ]


def test_helpers() -> None:
    assert is_anonymous(ANON) and not is_anonymous("plane_pgdata")
    assert parse_labels("a=1,b=x=y") == {"a": "1", "b": "x=y"}
    assert parse_labels("") == {}
    assert (
        rewrite_home("/Users/old/Github/p", Path("/Users/old"), Path("/Users/new"))
        == "/Users/new/Github/p"
    )
    assert rewrite_home("/opt/x", Path("/Users/old"), Path("/Users/new")) == "/opt/x"


def test_compose_hints_dedup_and_rewrite() -> None:
    inv = {
        "containers": [
            {
                "name": "a",
                "labels": {
                    "com.docker.compose.project": "p",
                    "com.docker.compose.project.working_dir": "/Users/old/Github/p",
                },
            },
            {
                "name": "b",
                "labels": {
                    "com.docker.compose.project": "p",
                    "com.docker.compose.project.working_dir": "/Users/old/Github/p",
                },
            },
            {"name": "c", "labels": {}},
        ]
    }
    assert compose_hints(inv, Path("/Users/old"), Path("/Users/new")) == [
        "cd /Users/new/Github/p && docker compose up -d   # p"
    ]


def test_inventory(ctx: Context) -> None:
    ctx.runner = _runner()
    inv = DockerModule().inventory(ctx)
    assert inv.available and inv.items == 1
    assert inv.details["containers"] == 3
    assert inv.warnings == [
        "1 volume(s) anonyme(s) ignoré(s) (--include-anonymous pour les inclure)"
    ]


def test_inventory_unavailable(ctx: Context) -> None:
    ctx.runner = _runner(docker_ok=False)
    inv = DockerModule().inventory(ctx)
    assert not inv.available and "colima start" in inv.reason


def test_backup_stops_exports_restarts(ctx: Context) -> None:
    r = _runner()
    ctx.runner = r
    res = DockerModule().backup(ctx, NullProgress())
    assert res.status == "ok", res.message
    voldir = ctx.dest / "docker" / "volumes"
    assert r.mutations == [
        ["docker", "stop", "-t", "30", "plane-app-db-1"],
        _export_cmd("plane_pgdata"),
        ["docker", "start", "plane-app-db-1"],
    ]
    # L'archive arrive par stdout : aucun chemin hôte n'est bind-monté dans la VM Colima.
    assert not any(str(ctx.dest) in arg for cmd in r.mutations for arg in cmd)
    assert r.files == [(_export_cmd("plane_pgdata"), voldir / "plane_pgdata.tar.gz.tmp")]
    archive = voldir / "plane_pgdata.tar.gz"
    assert archive.stat().st_size > 0
    assert not (voldir / "plane_pgdata.tar.gz.tmp").exists()
    inv = json.loads((ctx.dest / "docker" / "inventory.json").read_text())
    assert inv["home"] == str(ctx.home)
    assert inv["containers"][0]["labels"]["com.docker.compose.project"] == "plane-app"
    assert inv["images"] == ["postgres:15"]
    assert ANON in inv["volumes"]


def test_backup_restarts_even_on_failure(ctx: Context) -> None:
    r = _runner()
    r.add(("docker", "run"), returncode=1, stderr="tar failed")
    ctx.runner = r
    res = DockerModule().backup(ctx, NullProgress())
    assert res.status == "failed"
    assert ["docker", "start", "plane-app-db-1"] in r.mutations
    assert not (ctx.dest / "docker" / "volumes" / "plane_pgdata.tar.gz").exists()
    assert not (ctx.dest / "docker" / "volumes" / "plane_pgdata.tar.gz.tmp").exists()
    assert any("tar failed" in w for w in res.warnings)


def test_backup_empty_archive_is_not_kept(ctx: Context) -> None:
    """rc 0 mais rien sur stdout : l'archive vide ne doit pas passer pour un succès."""
    r = _runner()
    r.add(("docker", "run"), stdout="")
    ctx.runner = r
    res = DockerModule().backup(ctx, NullProgress())
    assert res.status == "failed"
    assert any("vide" in w for w in res.warnings)
    voldir = ctx.dest / "docker" / "volumes"
    assert not (voldir / "plane_pgdata.tar.gz").exists()
    assert not (voldir / "plane_pgdata.tar.gz.tmp").exists()


def test_backup_restarts_when_export_raises(ctx: Context) -> None:
    """Une exception inattendue pendant l'export ne doit pas laisser un container arrêté."""
    r = _runner()
    ctx.runner = r

    def exploding_run_to_file(cmd: list[str], path: Path, **kw: object) -> object:
        raise RuntimeError("boum")

    r.run_to_file = exploding_run_to_file  # type: ignore[method-assign]
    with pytest.raises(RuntimeError):
        DockerModule().backup(ctx, NullProgress())
    assert ["docker", "start", "plane-app-db-1"] in r.mutations


def test_backup_warns_when_restart_fails(ctx: Context) -> None:
    r = _runner()
    r.add(("docker", "start"), returncode=1, stderr="no such container")
    ctx.runner = r
    res = DockerModule().backup(ctx, NullProgress())
    assert res.status == "ok"
    assert any("redémarrage de plane-app-db-1" in w for w in res.warnings)


def test_backup_stops_each_container_once_for_several_volumes(ctx: Context) -> None:
    r = _runner()
    r.add(
        ("docker", "volume", "ls"),
        stdout="\n".join([json.dumps({"Name": "v1"}), json.dumps({"Name": "v2"})]),
    )
    for volume in ("v1", "v2"):
        r.add(
            ("docker", "ps", "-a", "--filter", f"volume={volume}"),
            stdout="plane-app-db-1\trunning\n",
        )
    ctx.runner = r
    res = DockerModule().backup(ctx, NullProgress())
    assert res.status == "ok" and res.items == 2
    assert r.mutations == [
        ["docker", "stop", "-t", "30", "plane-app-db-1"],
        _export_cmd("v1"),
        _export_cmd("v2"),
        ["docker", "start", "plane-app-db-1"],
    ]


def test_backup_no_stop_warns(ctx: Context) -> None:
    r = _runner()
    ctx.runner = r
    ctx.docker_no_stop = True
    res = DockerModule().backup(ctx, NullProgress())
    assert res.status == "ok"
    assert not r.has_call("docker", "stop")
    assert any("à chaud" in w for w in res.warnings)


def test_backup_include_anonymous(ctx: Context) -> None:
    r = _runner()
    r.add(("docker", "ps", "-a", "--filter", f"volume={ANON}"), stdout="")
    ctx.runner = r
    ctx.docker_include_anonymous = True
    res = DockerModule().backup(ctx, NullProgress())
    assert res.status == "ok" and res.items == 2


def test_backup_dry_run_plans_without_touching_docker(ctx: Context) -> None:
    r = _runner()
    dry = DryRunRunner(r, ctx.log)
    ctx.runner = dry
    ctx.dry_run = True
    res = DockerModule().backup(ctx, NullProgress())
    assert res.status == "ok"
    assert not r.has_call("docker", "stop") and not r.has_call("docker", "run")
    assert not (ctx.dest / "docker" / "volumes").exists()
    assert dry.planned == [
        ["docker", "stop", "-t", "30", "plane-app-db-1"],
        _export_cmd("plane_pgdata"),
        ["docker", "start", "plane-app-db-1"],
    ]


def test_restore_creates_volumes_and_prints_hints(ctx: Context) -> None:
    voldir = ctx.dest / "docker" / "volumes"
    voldir.mkdir(parents=True)
    (voldir / "plane_pgdata.tar.gz").write_bytes(b"x")
    (ctx.dest / "docker" / "inventory.json").write_text(
        json.dumps(
            {
                "home": "/Users/old",
                "containers": [
                    {
                        "name": "a",
                        "labels": {
                            "com.docker.compose.project": "plane-app",
                            "com.docker.compose.project.working_dir": "/Users/old/Github/plane",
                        },
                    }
                ],
                "images": [],
                "volumes": ["plane_pgdata"],
            }
        )
    )
    r = RecordingRunner()
    r.add(("docker", "info"), returncode=1)
    ctx.runner = r
    res = DockerModule().restore(ctx, NullProgress())
    assert res.status == "failed" and "injoignable" in res.message
    assert ["colima", "start"] in r.mutations

    r2 = RecordingRunner()
    r2.add(("docker", "info"), returncode=0)
    ctx.runner = r2
    res2 = DockerModule().restore(ctx, NullProgress())
    assert res2.status == "ok", res2.message
    import_cmd = [
        "docker",
        "run",
        "--rm",
        "-i",
        "-v",
        "plane_pgdata:/to",
        "alpine",
        "tar",
        "xzf",
        "-",
        "-C",
        "/to",
    ]
    assert r2.mutations == [["docker", "volume", "create", "plane_pgdata"], import_cmd]
    # L'archive arrive par stdin : aucun chemin du disque externe n'est bind-monté.
    assert r2.files == [(import_cmd, voldir / "plane_pgdata.tar.gz")]
    assert not any(str(ctx.dest) in arg for cmd in r2.mutations for arg in cmd)
    assert f"cd {ctx.home}/Github/plane && docker compose up -d" in res2.message


def test_restore_warns_on_failed_import(ctx: Context) -> None:
    voldir = ctx.dest / "docker" / "volumes"
    voldir.mkdir(parents=True)
    (voldir / "plane_pgdata.tar.gz").write_bytes(b"x")
    (ctx.dest / "docker" / "inventory.json").write_text(json.dumps({"home": "/Users/old"}))
    r = RecordingRunner()
    r.add(("docker", "info"), returncode=0)
    r.add(("docker", "run"), returncode=2, stderr="tar: short read")
    ctx.runner = r
    res = DockerModule().restore(ctx, NullProgress())
    assert res.status == "failed"
    assert any("tar: short read" in w for w in res.warnings)


def test_restore_without_backup_skipped(ctx: Context) -> None:
    assert DockerModule().restore(ctx, NullProgress()).status == "skipped"


def test_backup_restarts_containers_stopped_before_an_interruption(ctx: Context) -> None:
    """Ctrl-C pendant la phase d'arrêt : ce qui est déjà stoppé doit quand même redémarrer."""
    r = _runner()
    r.add(
        ("docker", "ps", "-a", "--filter", "volume=plane_pgdata"),
        stdout="plane-app-db-1\trunning\nplane-app-api-1\trunning\n",
    )
    ctx.runner = r
    real_run = r.run

    def interrupting_run(cmd: list[str], **kw: object):
        if cmd[:2] == ["docker", "stop"] and cmd[-1] == "plane-app-api-1":
            raise KeyboardInterrupt
        return real_run(cmd, **kw)

    r.run = interrupting_run  # type: ignore[method-assign]
    with pytest.raises(KeyboardInterrupt):
        DockerModule().backup(ctx, NullProgress())
    assert ["docker", "start", "plane-app-db-1"] in r.mutations
