"""Exécution de commandes externes, injectable pour les tests et le dry-run."""

from __future__ import annotations

import shutil
import subprocess
import threading
from abc import ABC, abstractmethod
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from pathlib import Path


@dataclass(frozen=True)
class CommandResult:
    returncode: int
    stdout: str = ""
    stderr: str = ""

    @property
    def ok(self) -> bool:
        return self.returncode == 0


class CommandError(RuntimeError):
    def __init__(self, cmd: Sequence[str], result: CommandResult) -> None:
        self.cmd = list(cmd)
        self.result = result
        super().__init__(
            f"Commande échouée ({result.returncode}) : {' '.join(cmd)}\n{result.stderr.strip()}"
        )


class UnexpectedCommand(AssertionError):
    pass


class CommandRunner(ABC):
    @abstractmethod
    def run(
        self,
        cmd: Sequence[str],
        *,
        check: bool = True,
        capture: bool = True,
        env: dict[str, str] | None = None,
        cwd: Path | None = None,
        input: str | None = None,
        readonly: bool = False,
    ) -> CommandResult:
        """Exécute `cmd` et renvoie son résultat.

        `env`, s'il est fourni, *remplace* tout l'environnement du process enfant
        (il ne complète pas `os.environ`) : passer `os.environ | {...}` pour l'étendre.
        """

    @abstractmethod
    def run_to_file(
        self, cmd: Sequence[str], path: Path, *, env: dict[str, str] | None = None
    ) -> CommandResult:
        """Écrit la sortie binaire de `cmd` dans `path` ; ne lève jamais.

        Nécessaire pour les flux volumineux (archives) : `run` capture stdout en mémoire
        et le décode en texte, ce qui corromprait des octets arbitraires.
        Le `CommandResult` renvoyé a un `stdout` vide (il est dans le fichier).
        """

    @abstractmethod
    def run_from_file(
        self, cmd: Sequence[str], path: Path, *, env: dict[str, str] | None = None
    ) -> CommandResult:
        """Alimente stdin de `cmd` avec le contenu binaire de `path` ; ne lève jamais."""

    @abstractmethod
    def stream(
        self,
        cmd: Sequence[str],
        on_line: Callable[[str], None],
        *,
        env: dict[str, str] | None = None,
        cwd: Path | None = None,
    ) -> CommandResult:
        """Exécute `cmd` en appelant `on_line` pour chaque ligne de stdout.

        Les lignes sont coupées sur `\r` comme sur `\n` (barres de progression).
        `env` a la même sémantique de remplacement complet que dans `run`.
        """

    @abstractmethod
    def which(self, name: str) -> str | None: ...


class RealRunner(CommandRunner):
    def __init__(self, log: Callable[[str], None] | None = None) -> None:
        self._log = log or (lambda _s: None)

    def run(
        self,
        cmd: Sequence[str],
        *,
        check: bool = True,
        capture: bool = True,
        env: dict[str, str] | None = None,
        cwd: Path | None = None,
        input: str | None = None,
        readonly: bool = False,
    ) -> CommandResult:
        self._log("$ " + " ".join(cmd))
        proc = subprocess.run(
            list(cmd), capture_output=capture, text=True, env=env, cwd=cwd, input=input
        )
        result = CommandResult(proc.returncode, proc.stdout or "", proc.stderr or "")
        if result.stdout:
            self._log(result.stdout.rstrip())
        if result.stderr:
            self._log(result.stderr.rstrip())
        if check and not result.ok:
            raise CommandError(cmd, result)
        return result

    def run_to_file(
        self, cmd: Sequence[str], path: Path, *, env: dict[str, str] | None = None
    ) -> CommandResult:
        self._log("$ " + " ".join(cmd) + f" > {path}")
        with path.open("wb") as fh:
            proc = subprocess.run(list(cmd), stdout=fh, stderr=subprocess.PIPE, text=True, env=env)
        stderr = proc.stderr or ""
        if stderr:
            self._log(stderr.rstrip())
        return CommandResult(proc.returncode, "", stderr)

    def run_from_file(
        self, cmd: Sequence[str], path: Path, *, env: dict[str, str] | None = None
    ) -> CommandResult:
        self._log("$ " + " ".join(cmd) + f" < {path}")
        with path.open("rb") as fh:
            proc = subprocess.run(list(cmd), stdin=fh, capture_output=True, text=True, env=env)
        result = CommandResult(proc.returncode, proc.stdout or "", proc.stderr or "")
        if result.stderr:
            self._log(result.stderr.rstrip())
        return result

    def stream(
        self,
        cmd: Sequence[str],
        on_line: Callable[[str], None],
        *,
        env: dict[str, str] | None = None,
        cwd: Path | None = None,
    ) -> CommandResult:
        self._log("$ " + " ".join(cmd))
        buf = bytearray()
        lines: list[str] = []
        err_chunks: list[bytes] = []

        def flush() -> None:
            if buf:
                line = buf.decode(errors="replace")
                lines.append(line)
                on_line(line)
                buf.clear()

        with subprocess.Popen(
            list(cmd), stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, cwd=cwd
        ) as proc:
            assert proc.stdout is not None and proc.stderr is not None
            stderr_pipe = proc.stderr

            def drain_stderr() -> None:
                err_chunks.append(stderr_pipe.read())

            # stderr est drainé en parallèle : sinon un enfant bavard sature le buffer
            # du pipe (64 Ko sur macOS) et se bloque pendant qu'on ne lit que stdout.
            reader = threading.Thread(target=drain_stderr)
            reader.start()
            try:
                while True:
                    ch = proc.stdout.read(1)
                    if not ch:
                        break
                    if ch in (b"\r", b"\n"):
                        flush()
                    else:
                        buf.extend(ch)
                flush()
            except BaseException:
                # `on_line` a levé : tuer l'enfant pour débloquer la lecture de stderr.
                proc.kill()
                raise
            finally:
                reader.join()
            rc = proc.wait()

        stderr = b"".join(err_chunks).decode(errors="replace")
        if stderr:
            self._log(stderr.rstrip())
        return CommandResult(rc, "\n".join(lines), stderr)

    def which(self, name: str) -> str | None:
        return shutil.which(name)


class RecordingRunner(CommandRunner):
    """Runner de test : enregistre les commandes et renvoie des réponses préparées."""

    def __init__(
        self, *, strict: bool = False, which_map: dict[str, str | None] | None = None
    ) -> None:
        self.calls: list[list[str]] = []
        self.mutations: list[list[str]] = []
        self.files: list[tuple[list[str], Path]] = []
        self.strict = strict
        self._which = which_map
        self._responses: list[tuple[tuple[str, ...], CommandResult]] = []

    def add(
        self,
        prefix: str | Sequence[str],
        stdout: str = "",
        returncode: int = 0,
        stderr: str = "",
    ) -> None:
        key = (prefix,) if isinstance(prefix, str) else tuple(prefix)
        self._responses.append((key, CommandResult(returncode, stdout, stderr)))

    def _match(self, cmd: Sequence[str]) -> CommandResult | None:
        best: tuple[int, CommandResult] | None = None
        for prefix, result in self._responses:
            n = len(prefix)
            if tuple(cmd[:n]) == prefix and (best is None or n >= best[0]):
                best = (n, result)
        return best[1] if best else None

    def _record(self, cmd: Sequence[str], readonly: bool) -> CommandResult:
        self.calls.append(list(cmd))
        if not readonly:
            self.mutations.append(list(cmd))
        result = self._match(cmd)
        if result is None:
            if self.strict:
                raise UnexpectedCommand(f"Commande non préparée : {' '.join(cmd)}")
            result = CommandResult(0)
        return result

    def run(
        self,
        cmd: Sequence[str],
        *,
        check: bool = True,
        capture: bool = True,
        env: dict[str, str] | None = None,
        cwd: Path | None = None,
        input: str | None = None,
        readonly: bool = False,
    ) -> CommandResult:
        result = self._record(cmd, readonly)
        if check and not result.ok:
            raise CommandError(cmd, result)
        return result

    def run_to_file(
        self, cmd: Sequence[str], path: Path, *, env: dict[str, str] | None = None
    ) -> CommandResult:
        result = self._record(cmd, readonly=False)
        self.files.append((list(cmd), path))
        # La réponse préparée tient lieu de contenu d'archive : les tests peuvent vérifier
        # que le fichier existe et n'est pas vide.
        path.write_bytes(result.stdout.encode())
        return CommandResult(result.returncode, "", result.stderr)

    def run_from_file(
        self, cmd: Sequence[str], path: Path, *, env: dict[str, str] | None = None
    ) -> CommandResult:
        result = self._record(cmd, readonly=False)
        self.files.append((list(cmd), path))
        return result

    def stream(
        self,
        cmd: Sequence[str],
        on_line: Callable[[str], None],
        *,
        env: dict[str, str] | None = None,
        cwd: Path | None = None,
    ) -> CommandResult:
        result = self._record(cmd, readonly=False)
        for line in result.stdout.splitlines():
            on_line(line)
        return result

    def which(self, name: str) -> str | None:
        if self._which is None:
            return f"/opt/homebrew/bin/{name}"
        return self._which.get(name)

    def has_call(self, *prefix: str) -> bool:
        return any(tuple(c[: len(prefix)]) == prefix for c in self.calls)


class DryRunRunner(CommandRunner):
    """Exécute les lectures (readonly=True) sur le vrai runner, journalise le reste."""

    def __init__(self, real: CommandRunner, log: Callable[[str], None]) -> None:
        self.real = real
        self._log = log
        self.planned: list[list[str]] = []

    def run(
        self,
        cmd: Sequence[str],
        *,
        check: bool = True,
        capture: bool = True,
        env: dict[str, str] | None = None,
        cwd: Path | None = None,
        input: str | None = None,
        readonly: bool = False,
    ) -> CommandResult:
        if readonly:
            return self.real.run(
                cmd, check=check, capture=capture, env=env, cwd=cwd, input=input, readonly=True
            )
        self.planned.append(list(cmd))
        self._log("[dry-run] $ " + " ".join(cmd))
        return CommandResult(0)

    def run_to_file(
        self, cmd: Sequence[str], path: Path, *, env: dict[str, str] | None = None
    ) -> CommandResult:
        self.planned.append(list(cmd))
        self._log(f"[dry-run] $ {' '.join(cmd)} > {path}")
        return CommandResult(0)

    def run_from_file(
        self, cmd: Sequence[str], path: Path, *, env: dict[str, str] | None = None
    ) -> CommandResult:
        self.planned.append(list(cmd))
        self._log(f"[dry-run] $ {' '.join(cmd)} < {path}")
        return CommandResult(0)

    def stream(
        self,
        cmd: Sequence[str],
        on_line: Callable[[str], None],
        *,
        env: dict[str, str] | None = None,
        cwd: Path | None = None,
    ) -> CommandResult:
        self.planned.append(list(cmd))
        self._log("[dry-run] $ " + " ".join(cmd))
        return CommandResult(0)

    def which(self, name: str) -> str | None:
        return self.real.which(name)
