"""PostgreSQL Homebrew : dumps par base et globals."""

from __future__ import annotations

import json
import time
from dataclasses import asdict, dataclass
from pathlib import Path

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,
)
from osx_backup.modules.brew import parse_services

EXCLUDED_DBS = {"template0", "template1", "postgres"}
LIST_SQL = (
    "SELECT datname, pg_get_userbyid(datdba) FROM pg_database "
    "WHERE NOT datistemplate ORDER BY datname"
)
EXISTS_SQL = "SELECT 1 FROM pg_database WHERE datname = :'name'"
DEFAULT_FORMULA = "postgresql@18"
UNREACHABLE = "PostgreSQL injoignable : brew services start {formula}"
PG_DUMP_MISSING = "pg_dump absent : brew install postgresql@18"


@dataclass(frozen=True)
class Database:
    name: str
    owner: str


def parse_databases(text: str) -> list[Database]:
    dbs: list[Database] = []
    for line in text.splitlines():
        if "|" not in line:
            continue
        name, owner = line.split("|", 1)
        if name.strip() not in EXCLUDED_DBS:
            dbs.append(Database(name.strip(), owner.strip()))
    return dbs


def list_databases(ctx: Context) -> list[Database] | None:
    """Liste les bases utilisateur, ou None si le serveur est injoignable."""
    res = ctx.runner.run(
        ["psql", "-X", "-At", "-d", "postgres", "-c", LIST_SQL], check=False, readonly=True
    )
    return parse_databases(res.stdout) if res.ok else None


def postgres_formula(dest: Path) -> str:
    """Formule Homebrew à démarrer, déduite des services sauvegardés (Task brew)."""
    services_file = dest / "brew" / "services.json"
    if services_file.exists():
        for service in parse_services(services_file.read_text(encoding="utf-8")):
            if service.name.startswith("postgresql"):
                return service.name
    return DEFAULT_FORMULA


@register
class PostgresModule(Module):
    name = "postgres"
    description = "PostgreSQL (Homebrew) : rôles et dump de chaque base"
    order = 100
    ready_attempts: int = 10
    ready_delay: float = 1.0

    def inventory(self, ctx: Context) -> Inventory:
        if ctx.runner.which("pg_dump") is None:
            return Inventory(available=False, reason=PG_DUMP_MISSING)
        dbs = list_databases(ctx)
        if dbs is None:
            return Inventory(
                available=False, reason=UNREACHABLE.format(formula=postgres_formula(ctx.dest))
            )
        return Inventory(items=len(dbs), details={"databases": [d.name for d in dbs]})

    def _clear_stale_metadata(self, ctx: Context) -> None:
        """Efface globals.sql et databases.json d'une sauvegarde précédente.

        Sans cela, une sauvegarde qui échoue laisserait la restauration se fier aux
        métadonnées d'un run antérieur, sans rapport avec les dumps présents.
        """
        if ctx.dry_run:
            return
        out = ctx.dest / self.name
        for filename in ("globals.sql", "databases.json"):
            remove_path(out / filename)

    def backup(self, ctx: Context, progress: ProgressReporter) -> ModuleResult:
        self._clear_stale_metadata(ctx)
        dbs = list_databases(ctx)
        if dbs is None:
            return ModuleResult.failed(UNREACHABLE.format(formula=postgres_formula(ctx.dest)))
        out = ctx.module_dir(self.name)
        globals_res = ctx.runner.run(["pg_dumpall", "--globals-only"], check=False, readonly=True)
        if not globals_res.ok:
            # Sans les rôles, les dumps ne seraient pas restaurables : c'est un échec.
            return ModuleResult.failed(
                f"pg_dumpall --globals-only a échoué (code {globals_res.returncode})"
                f"{stderr_detail(ctx, 'pg_dumpall --globals-only', globals_res)}"
            )
        ctx.write_text(out / "globals.sql", globals_res.stdout)
        warnings: list[str] = []
        dumped: list[Database] = []
        task = progress.task("pg_dump", total=len(dbs))
        for db in dbs:
            # Une base cassée ne doit pas empêcher l'export des suivantes.
            res = ctx.runner.run(
                ["pg_dump", "-Fc", "-f", str(out / f"{db.name}.dump"), db.name], check=False
            )
            if res.ok:
                dumped.append(db)
            else:
                warnings.append(
                    f"{db.name} : pg_dump a échoué{stderr_detail(ctx, f'pg_dump {db.name}', res)}"
                )
            task.advance()
        task.finish()
        if dbs and not dumped:
            return ModuleResult.failed("aucune base n'a pu être exportée", warnings=warnings)
        # Écrit après la boucle : databases.json ne liste que des bases dont le dump existe.
        ctx.write_json(out / "databases.json", [asdict(d) for d in dumped])
        return ModuleResult.ok(
            f"{len(dumped)}/{len(dbs)} base(s) exportée(s)", items=len(dumped), warnings=warnings
        )

    def _is_ready(self, ctx: Context) -> bool:
        return ctx.runner.run(["pg_isready"], check=False, readonly=True).ok

    def _ensure_running(self, ctx: Context) -> bool:
        if self._is_ready(ctx):
            return True
        formula = postgres_formula(ctx.dest)
        ctx.runner.run(["brew", "services", "start", formula], check=False)
        if ctx.dry_run:
            # En dry-run le service n'a pas été démarré : attendre serait du temps perdu.
            ctx.log(f"[dry-run] service {formula} non démarré : attente de disponibilité ignorée")
            return False
        for _ in range(self.ready_attempts):
            if self.ready_delay:
                time.sleep(self.ready_delay)
            if self._is_ready(ctx):
                return True
        return False

    def _exists(self, ctx: Context, name: str) -> bool:
        # Le nom passe par une variable psql (:'name') : aucun échappement à faire nous-mêmes.
        # La requête arrive sur stdin : psql n'interpole pas les variables dans un `-c`.
        res = ctx.runner.run(
            ["psql", "-v", f"name={name}", "-X", "-At", "-d", "postgres"],
            input=EXISTS_SQL,
            check=False,
            readonly=True,
        )
        return res.stdout.strip() == "1"

    def restore(self, ctx: Context, progress: ProgressReporter) -> ModuleResult:
        pgdir = ctx.dest / self.name
        dbs_file = pgdir / "databases.json"
        if not dbs_file.exists():
            return ModuleResult.skipped("pas de dumps Postgres dans la sauvegarde")
        if not self._ensure_running(ctx):
            return ModuleResult.failed(UNREACHABLE.format(formula=postgres_formula(ctx.dest)))
        dbs = [Database(**d) for d in json.loads(dbs_file.read_text(encoding="utf-8"))]
        warnings: list[str] = []
        globals_sql = pgdir / "globals.sql"
        if globals_sql.exists():
            # ON_ERROR_STOP=0 : les rôles déjà présents sur la nouvelle machine sont normaux.
            res = ctx.runner.run(
                ["psql", "-X", "-d", "postgres", "-v", "ON_ERROR_STOP=0", "-f", str(globals_sql)],
                check=False,
            )
            if not res.ok:
                warnings.append(
                    f"psql globals.sql a échoué (code {res.returncode})"
                    f"{stderr_detail(ctx, 'psql globals.sql', res)}"
                )
        restored = 0
        task = progress.task("pg_restore", total=len(dbs))
        for db in dbs:
            if self._restore_db(ctx, pgdir, db, warnings):
                restored += 1
            task.advance()
        task.finish()
        if dbs and not restored:
            return ModuleResult.failed("aucune base n'a pu être restaurée", warnings=warnings)
        return ModuleResult.ok(
            f"{restored}/{len(dbs)} base(s) restaurée(s)", items=restored, warnings=warnings
        )

    def _restore_db(self, ctx: Context, pgdir: Path, db: Database, warnings: list[str]) -> bool:
        """Restaure une base ; une erreur est signalée sans interrompre les suivantes."""
        dump = pgdir / f"{db.name}.dump"
        if not dump.exists():
            warnings.append(f"{db.name} : dump absent")
            return False
        if not self._exists(ctx, db.name):
            res = ctx.runner.run(["createdb", "-O", db.owner, db.name], check=False)
            if not res.ok:
                warnings.append(
                    f"{db.name} : createdb a échoué{stderr_detail(ctx, f'createdb {db.name}', res)}"
                )
                return False
        res = ctx.runner.run(
            ["pg_restore", "--no-owner", f"--role={db.owner}", "-d", db.name, str(dump)],
            check=False,
        )
        if not res.ok:
            # pg_restore sort en erreur dès qu'une seule erreur a été ignorée (privilèges
            # d'extension, COMMENT ON EXTENSION, seconde passe…) : les données sont là.
            warnings.append(
                f"{db.name} : pg_restore a signalé des erreurs (données probablement restaurées)"
                f"{stderr_detail(ctx, f'pg_restore {db.name}', res)}"
            )
        return True
