#!/usr/bin/env python3
"""Devolver la máquina a como estaba antes de una actualización.

Por qué no vale `snapper rollback`
----------------------------------
Esa orden cambia el **subvolumen por defecto** del sistema de ficheros. Aquí no
lo mira nadie: la línea de arranque dice `rootflags=subvol=@` y el fstab lo
repite, así que el kernel monta `@` por su nombre pase lo que pase. `snapper
rollback` en esta máquina no da error y tampoco hace nada, que es la peor de las
dos posibilidades. La documentación lo prometía; esto lo cumple.

Qué hace en su lugar
--------------------
Cambia el CONTENIDO de `@`. Hace una copia escribible de la instantánea, aparta
la raíz actual con un nombre fechado y pone la copia en su sitio. Al reiniciar,
`subvol=@` monta lo de antes. La raíz rota no se borra: se queda ahí, con su
fecha, por si hacía falta algo de dentro.

Lo que NO devuelve
------------------
`/boot` es una partición aparte —ext4—, así que el kernel y su initrd no están
dentro de las instantáneas y no vuelven atrás. Si lo que se rompió fue el
kernel, esto no lo arregla; para eso está el menú de arranque, que conserva el
anterior. `/home` y el swap tampoco: viven en sus propios subvolúmenes y nadie
quiere que una actualización rota se lleve por delante el trabajo del día.

Y una advertencia que hay que decir en voz alta: entre el cambio y el reinicio,
el sistema sigue corriendo sobre la raíz vieja. Lo que se escriba en ese rato se
queda ahí y se pierde al reiniciar.
"""

from __future__ import annotations

import argparse
import os
import re
import subprocess
import sys
import tempfile
import time
from pathlib import Path

RAIZ = "@"
INSTANTANEAS = ".snapshots"


class Fallo(Exception):
    """Algo que impide seguir, dicho para una persona."""


def correr(*orden: str, entrada: str | None = None) -> str:
    hecho = subprocess.run(  # noqa: S603 - órdenes fijas, sin shell
        list(orden), capture_output=True, text=True, input=entrada, check=False
    )
    if hecho.returncode != 0:
        raise Fallo(f"«{' '.join(orden)}» ha fallado: {hecho.stderr.strip() or hecho.stdout.strip()}")
    return hecho.stdout


def sistema_de_ficheros(camino: str) -> tuple[str, str]:
    """El dispositivo btrfs que sostiene ese camino, y el subvolumen montado."""

    salida = correr("findmnt", "-no", "SOURCE,FSTYPE,OPTIONS", "--target", camino)
    partes = salida.split(None, 2)
    if len(partes) < 3:
        raise Fallo(f"no he podido averiguar qué sistema de ficheros hay en {camino}")
    origen, tipo, opciones = partes[0], partes[1], partes[2]
    if tipo != "btrfs":
        raise Fallo(f"{camino} no está en btrfs, sino en {tipo}: aquí no hay instantáneas")
    # findmnt da el dispositivo con el subvolumen pegado: /dev/nvme0n1p2[/@]
    dispositivo = origen.split("[")[0]
    subvolumen = ""
    encontrado = re.search(r"subvol=/?([^,\s]+)", opciones)
    if encontrado:
        subvolumen = encontrado.group(1)
    return dispositivo, subvolumen


#: `@/.snapshots/140/snapshot`: la forma que tiene el subvolumen montado cuando
#: se ha arrancado desde el menú de emergencia, en vez del `@` de siempre.
DESDE_UNA_INSTANTANEA = re.compile(
    rf"^{re.escape(RAIZ)}/{re.escape(INSTANTANEAS)}/(\d+)/snapshot/?$"
)


def es_subvolumen(camino: Path) -> bool:
    if not camino.is_dir():
        return False
    try:
        correr("btrfs", "subvolume", "show", str(camino))
    except Fallo:
        return False
    return True


def instantaneas(raiz: Path) -> list[dict[str, str]]:
    """Lo que hay guardado, leído del disco y no de la base de snapper."""

    carpeta = raiz / INSTANTANEAS
    if not carpeta.is_dir():
        return []
    encontradas = []
    for hija in sorted(carpeta.iterdir(), key=lambda c: int(c.name) if c.name.isdigit() else -1):
        if not hija.name.isdigit() or not (hija / "snapshot").is_dir():
            continue
        encontradas.append(
            {
                "numero": hija.name,
                "cuando": descripcion(hija / "info.xml", "date"),
                "que": descripcion(hija / "info.xml", "description"),
            }
        )
    return encontradas


def descripcion(info: Path, etiqueta: str) -> str:
    if not info.is_file():
        return ""
    texto = info.read_text(encoding="utf-8", errors="replace")
    encontrado = re.search(rf"<{etiqueta}>(.*?)</{etiqueta}>", texto, re.DOTALL)
    return encontrado.group(1).strip() if encontrado else ""


def listar(raiz: Path) -> int:
    guardadas = instantaneas(raiz)
    if not guardadas:
        print("No hay ninguna instantánea guardada.")
        return 1
    print("Instantáneas guardadas:\n")
    for una in guardadas:
        print(f"  {una['numero']:>4}  {una['cuando']}  {una['que']}")
    print("\nPara volver a una:  filmos-volver-atras <número>")
    print("Y después, reiniciar.")
    return 0


def _nombres_libres(raiz: Path, sello: str) -> tuple[Path, Path]:
    """Un par de nombres que no pise nada de lo que ya hay."""

    for intento in range(100):
        sufijo = sello if intento == 0 else f"{sello}-{intento}"
        nueva = raiz / f"{RAIZ}-nueva-{sufijo}"
        apartada = raiz / f"{RAIZ}-rota-{sufijo}"
        if not nueva.exists() and not apartada.exists():
            return nueva, apartada
    raise Fallo("hay demasiadas raíces apartadas con esta fecha: limpia alguna antes")


def volver(raiz: Path, numero: str, sello: str) -> Path:
    """El cambio, con su deshacer si algo se tuerce a mitad."""

    # Las instantáneas cuelgan de la raíz, no de la cima del sistema de
    # ficheros: `@/.snapshots/N/snapshot`.
    origen = raiz / RAIZ / INSTANTANEAS / numero / "snapshot"
    if not es_subvolumen(origen):
        raise Fallo(f"la instantánea {numero} no está o no es una instantánea de verdad")

    actual = raiz / RAIZ
    if not es_subvolumen(actual):
        raise Fallo(f"no encuentro el subvolumen {RAIZ}: esta máquina no tiene el reparto de filmos")

    # El sello tiene resolución de segundo, así que dos vueltas atrás seguidas
    # chocarían de nombre: la segunda fallaría al apartar la raíz sobre una
    # carpeta que ya existe. No pierde nada -deshace lo hecho y avisa- pero se
    # niega a trabajar sin motivo, y esta herramienta se usa cuando ya no queda
    # otra. Se busca un nombre libre.
    nueva, apartada = _nombres_libres(raiz, sello)
    correr("btrfs", "subvolume", "snapshot", str(origen), str(nueva))

    hecho: list[tuple[Path, Path]] = []
    try:
        actual.rename(apartada)
        hecho.append((apartada, actual))
        nueva.rename(actual)
        hecho.append((actual, nueva))

        # Las instantáneas viven DENTRO de la raíz, así que hay que traérselas:
        # si se quedan en la vieja, al reiniciar no habría a dónde volver.
        heredadas = actual / INSTANTANEAS
        if heredadas.is_dir():
            if any(heredadas.iterdir()):
                raise Fallo(
                    f"{heredadas} no está vacía: no piso instantáneas ajenas"
                )
            heredadas.rmdir()
        (apartada / INSTANTANEAS).rename(heredadas)
    except Exception:
        for destino, origen_de_vuelta in reversed(hecho):
            with_suppress_rename(destino, origen_de_vuelta)
        raise
    return apartada


def with_suppress_rename(destino: Path, origen: Path) -> None:
    try:
        destino.rename(origen)
    except OSError:
        pass


def main(argumentos: list[str]) -> int:
    analizador = argparse.ArgumentParser(
        prog="filmos-volver-atras",
        description="Devuelve la raíz del sistema a una instantánea anterior.",
    )
    analizador.add_argument("numero", nargs="?", help="la instantánea a la que volver")
    analizador.add_argument(
        "--desde",
        default="/",
        help="operar sobre el btrfs que sostiene este camino (por defecto, la raíz)",
    )
    opciones = analizador.parse_args(argumentos)

    if os.geteuid() != 0:
        print("Esto cambia la raíz del sistema: hace falta sudo.", file=sys.stderr)
        return 1

    try:
        dispositivo, montado = sistema_de_ficheros(opciones.desde)
    except Fallo as fallo:
        print(str(fallo), file=sys.stderr)
        return 1

    # Arrancados desde el menú de emergencia, `/` NO cuelga de `@` sino de la
    # instantánea. Es exactamente la situación para la que existe esta
    # herramienta: negarse aquí la deja inútil justo el día que hace falta, que
    # era el fallo. El objetivo sigue siendo `@`, así que el trabajo no cambia.
    rescate = DESDE_UNA_INSTANTANEA.match(montado)
    if montado not in (RAIZ, "") and not rescate:
        print(
            f"{opciones.desde} está montado desde «{montado}», no desde «{RAIZ}»: "
            "esta máquina no tiene el reparto de filmos y no voy a adivinar.",
            file=sys.stderr,
        )
        return 1
    if rescate:
        print(
            f"Estás arrancado desde la instantánea {rescate.group(1)}, en solo lectura. "
            f"Se cambia «{RAIZ}», que es lo que arranca normalmente."
        )
        print()

    # Arrancados desde una instantánea la raíz es de SOLO LECTURA, así que hace
    # falta un sitio escribible donde montar. `/run` es tmpfs siempre y en todo
    # arranque; `/tmp` normalmente también, pero «normalmente» no vale cuando
    # esto es lo último que queda.
    donde_montar = "/run" if os.path.isdir("/run") and os.access("/run", os.W_OK) else None

    with tempfile.TemporaryDirectory(prefix="filmos-raiz-", dir=donde_montar) as temporal:
        raiz = Path(temporal)
        try:
            # El subvolumen 5 es la raíz del sistema de ficheros: desde ahí se
            # ven `@` y las instantáneas como lo que son, carpetas hermanas.
            correr("mount", "-o", "subvolid=5", dispositivo, str(raiz))
        except Fallo as fallo:
            print(str(fallo), file=sys.stderr)
            return 1
        try:
            if not opciones.numero:
                return listar(raiz / RAIZ)
            sello = time.strftime("%Y%m%d-%H%M%S")
            try:
                apartada = volver(raiz, opciones.numero, sello)
            except Fallo as fallo:
                print(str(fallo), file=sys.stderr)
                return 1
            print(f"Hecho. La raíz de antes se queda en «{apartada.name}».")
            print()
            if rescate:
                # Aquí no hay nada que perder: se está corriendo sobre una
                # instantánea de solo lectura, no sobre la raíz vieja.
                print("Reinicia y arrancará ya la raíz buena. Esta vez, en la")
                print("entrada normal del menú, no en la de instantáneas.")
            else:
                print("Reinicia cuanto antes: hasta que lo hagas el sistema sigue")
                print("corriendo sobre la raíz vieja, y lo que escribas ahora se pierde.")
            print()
            print("`/boot`, `/home` y el swap no han cambiado: viven aparte.")
            return 0
        finally:
            subprocess.run(["umount", str(raiz)], capture_output=True, check=False)  # noqa: S603


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))
