#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import argparse
import datetime
import json
import os
import pathlib
import shlex
import subprocess
import sys
from typing import List, Dict, Tuple, Optional, Set

PROJECT_ROOT    = pathlib.Path.cwd()
COMPOSE_BASE    = PROJECT_ROOT / "compose.yaml"
COMPOSE_DEV     = PROJECT_ROOT / "compose.dev.yaml"
COMPOSE_PROD    = PROJECT_ROOT / "compose.prod.yaml"
COMPOSE_NPM     = PROJECT_ROOT / "compose.npm.yaml"
COMPOSE_DBVR    = PROJECT_ROOT / "compose.dbeaver.yaml"

GLOBAL_DEFAULT_PROJECT = "suitecoffee"  # proyecto global (NPM/DBeaver)

# ---------- Shell utils ----------

def run(cmd: List[str], check=True, capture_output=True, text=True) -> subprocess.CompletedProcess:
    return subprocess.run(cmd, check=check, capture_output=capture_output, text=text)

def which(program: str) -> bool:
    from shutil import which as _which
    return _which(program) is not None

# ---------- Docker volume discovery ----------

def docker_volume_ls_json(filters: List[str]) -> List[Dict[str, str]]:
    """
    Devuelve objetos de 'docker volume ls' (formato json por entrada).
    Soporta filtros como '--filter label=...'.
    """
    cmd = ["docker", "volume", "ls", "--format", "{{json .}}"]
    for f in filters:
        cmd += ["--filter", f]
    try:
        cp = run(cmd)
    except subprocess.CalledProcessError:
        return []
    out = []
    for line in cp.stdout.splitlines():
        line = line.strip()
        if not line:
            continue
        try:
            out.append(json.loads(line))
        except json.JSONDecodeError:
            pass
    return out

def docker_volume_ls_names(filters: List[str]) -> List[str]:
    """Devuelve solo los nombres (Name) con filtros aplicados."""
    rows = docker_volume_ls_json(filters)
    names = []
    for v in rows:
        name = v.get("Name")
        if name:
            names.append(name)
    return names

def list_by_label_project(project: str) -> List[Dict[str, str]]:
    return docker_volume_ls_json([f"label=com.docker.compose.project={project}"])

def list_by_name_prefix(prefix: str) -> List[Dict[str, str]]:
    vols = docker_volume_ls_json([])
    keep = []
    for v in vols:
        name = v.get("Name")
        if not name:
            continue
        if name.startswith(prefix + "_") or name.startswith(prefix + "-") or name == prefix:
            keep.append(v)
    return keep

def normalize_project_name(p: str) -> str:
    return (p or "").replace(" ", "_")

# ---------- Compose config parsing ----------

def compose_config_json(files: List[pathlib.Path]) -> Optional[dict]:
    if not files or not all(p.exists() for p in files):
        return None
    cmd = ["docker", "compose"]
    for f in files:
        cmd += ["-f", str(f)]
    cmd += ["config", "--format", "json"]
    try:
        cp = run(cmd)
        return json.loads(cp.stdout or "{}")
    except Exception:
        return None

def extract_short_volume_names_from_config(cfg: dict) -> Set[str]:
    """
    Extrae short names de volúmenes usados en services[].volumes (type: volume)
    y las claves del toplevel 'volumes'.
    """
    names: Set[str] = set()
    if not cfg:
        return names

    # services[].volumes
    services = cfg.get("services") or {}
    for svc in services.values():
        vols = svc.get("volumes") or []
        for m in vols:
            # en JSON canonical, cada mount es un dict con 'type', 'source', 'target', ...
            if isinstance(m, dict) and m.get("type") == "volume":
                src = m.get("source")
                if isinstance(src, str) and src:
                    names.add(src)

    # top-level volumes (claves)
    top_vols = cfg.get("volumes") or {}
    if isinstance(top_vols, dict):
        for k in top_vols.keys():
            if isinstance(k, str) and k:
                names.add(k)

    return names

def docker_compose_name_from(files: List[pathlib.Path]) -> Optional[str]:
    cfg = compose_config_json(files)
    if cfg and isinstance(cfg, dict):
        name = cfg.get("name")
        if name:
            return name
    return None

def read_compose_project_from_env(env_path: pathlib.Path) -> Optional[str]:
    try:
        if env_path.exists():
            for line in env_path.read_text(encoding="utf-8").splitlines():
                line = line.strip()
                if not line or line.startswith("#"):
                    continue
                if line.startswith("COMPOSE_PROJECT_NAME="):
                    return line.split("=", 1)[1].strip()
    except Exception:
        pass
    return None

def base_folder_slug() -> str:
    return PROJECT_ROOT.name.lower().replace(" ", "_")

def candidates_for_env(env: str) -> List[str]:
    cand: List[str] = []
    if env == "development":
        n1 = docker_compose_name_from([COMPOSE_BASE, COMPOSE_DEV])
        n2 = read_compose_project_from_env(PROJECT_ROOT / ".env.development")
        n3 = f"{base_folder_slug()}_dev"
        n4 = f"{base_folder_slug()}-dev"
        cand.extend([n1, n2, n3, n4, base_folder_slug()])
    elif env == "production":
        n1 = docker_compose_name_from([COMPOSE_BASE, COMPOSE_PROD])
        n2 = read_compose_project_from_env(PROJECT_ROOT / ".env.production")
        n3 = f"{base_folder_slug()}_prod"
        n4 = f"{base_folder_slug()}-prod"
        cand.extend([n1, n2, n3, n4, base_folder_slug()])
    # dedup preservando orden
    seen = set(); ordered = []
    for x in cand:
        if x and x not in seen:
            seen.add(x); ordered.append(x)
    return ordered

def candidates_for_global() -> List[str]:
    cand: List[str] = []
    # nombres desde compose globales
    if COMPOSE_NPM.exists():
        n = docker_compose_name_from([COMPOSE_NPM])
        if n: cand.append(n)
    if COMPOSE_DBVR.exists():
        n = docker_compose_name_from([COMPOSE_DBVR])
        if n and n not in cand: cand.append(n)
    # fallback esperados
    if GLOBAL_DEFAULT_PROJECT not in cand: cand.append(GLOBAL_DEFAULT_PROJECT)
    bf = base_folder_slug()
    if bf not in cand: cand.append(bf)
    return cand

# ---------- Nueva detección por grupo: COMPOSE + labels ----------

def detect_group_volumes_with_compose(filesets: List[List[pathlib.Path]],
                                      project_candidates: List[str]) -> Tuple[Optional[str], str, List[str]]:
    """
    filesets: lista de listas de archivos compose a considerar (dev=[base,dev], prod=[base,prod],
              global=[[npm], [dbeaver]] -> dos sets para unir shortnames).
    Devuelve (project_seleccionado, metodo, [nombres_de_volumen]).
    """
    # 1) Unir shortnames de todos los filesets
    shortnames: Set[str] = set()
    for files in filesets:
        cfg = compose_config_json(files)
        shortnames |= extract_short_volume_names_from_config(cfg)

    # 2) Si hay shortnames, probar a buscar por (project,label.volume)
    if shortnames:
        for proj in project_candidates:
            # Buscar cada shortname con ambos labels
            found: List[str] = []
            for sn in sorted(shortnames):
                names = docker_volume_ls_names([
                    f"label=com.docker.compose.project={proj}",
                    f"label=com.docker.compose.volume={sn}"
                ])
                if names:
                    found.extend(names)
            # dedup preservando orden
            if found:
                seen = set(); ordered = []
                for n in found:
                    if n not in seen:
                        seen.add(n); ordered.append(n)
                return proj, f"compose+labels:{proj}", ordered

    # 3) Fallback: probar cualquier volumen del proyecto (label) o por prefijo
    for proj in project_candidates:
        method, rows = discover_volumes_for_project(proj)
        if rows:
            return proj, f"fallback:{method}", [r.get("Name") for r in rows if r.get("Name")]

    # 4) Nada
    first = project_candidates[0] if project_candidates else None
    return first, "none", []

def discover_volumes_for_project(project_raw: str) -> Tuple[str, List[Dict[str, str]]]:
    """
    Método previo de respaldo: por label de proyecto y prefijo (para CLI y fallback).
    """
    project_norm  = normalize_project_name(project_raw)
    project_lower = project_norm.lower()

    vols = list_by_label_project(project_norm)
    if vols:
        return f"label:{project_norm}", vols

    vols2 = list_by_label_project(project_lower)
    if vols2:
        return f"label:{project_lower}", vols2

    by_name = list_by_name_prefix(project_norm)
    if by_name:
        return f"name-prefix:{project_norm}", by_name

    by_name2 = list_by_name_prefix(project_lower)
    if by_name2:
        return f"name-prefix:{project_lower}", by_name2

    return "none", []

# ---------- Backup helpers ----------

def ensure_alpine_image():
    try:
        run(["docker", "image", "inspect", "alpine:latest"])
    except subprocess.CalledProcessError:
        print("Pulling alpine:latest ...")
        run(["docker", "pull", "alpine:latest"], check=True, capture_output=False)

def build_archive_name(project: str, volume_name: str, ts: str) -> str:
    """
    Construye el nombre del .tar.gz evitando duplicar el prefijo del proyecto.
    - Si volume_name ya empieza con '<project>_' o '<project>-', se usa tal cual.
    - Si no, se antepone '<project>_'.
    Resultado: <project>_<shortname>-<ts>.tar.gz
    """
    proj_token = project.lower().replace(" ", "_")
    v_lower = volume_name.lower()
    if v_lower.startswith(proj_token + "_") or v_lower.startswith(proj_token + "-"):
        base = volume_name
    else:
        base = f"{proj_token}_{volume_name}"
    return f"{base}-{ts}.tar.gz"

def backup_volume(volume_name: str, out_dir: pathlib.Path, archive_name: str, dry_run: bool = False) -> int:
    out_dir.mkdir(parents=True, exist_ok=True)
    out_dir_abs = out_dir.resolve()
    out_path = out_dir_abs / archive_name
    docker_cmd = [
        "docker", "run", "--rm",
        "-v", f"{volume_name}:/volume:ro",
        "-v", f"{str(out_dir_abs)}:/backup",
        # "--user", f"{os.getuid()}:{os.getgid()}",
        "alpine:latest",
        "sh", "-lc",
        f"tar czf /backup/{shlex.quote(out_path.name)} -C /volume ."
    ]
    if dry_run:
        print("[DRY RUN] Would run:", " ".join(shlex.quote(c) for c in docker_cmd))
        return 0
    cp = subprocess.run(docker_cmd)
    return cp.returncode

def backup_explicit(volume_names: List[str], ts: str, output_dir: Optional[str], dry_run: bool, prefix_project: Optional[str]) -> int:
    """
    Respalda exactamente los volúmenes indicados.
    - Directorio por defecto: ./docker-volumes-<ts>
    - Nombre de archivo: build_archive_name(prefix_project, volume_name, ts)
    """
    out_dir = pathlib.Path(output_dir) if output_dir else (PROJECT_ROOT / f"docker-volumes-{ts}")
    if not dry_run:
        ensure_alpine_image()

    failures = []
    for vname in volume_names:
        if not vname:
            continue
        archive = build_archive_name(prefix_project or "", vname, ts)
        print(f"Backing up volume: {vname} -> {archive}")
        rc = backup_volume(vname, out_dir, archive, dry_run=dry_run)
        if rc != 0:
            print(f"  ERROR: backup failed for volume '{vname}' (exit code {rc})", file=sys.stderr)
            failures.append(vname)
    if failures:
        print("\nCompleted with errors. Failed volumes:", ", ".join(failures))
        return 1
    else:
        print("\nAll done. Archives written to:", str(out_dir.resolve()))
        return 0

def backup_group(project_name: str, ts: str, output_dir: Optional[str] = None,
                 dry_run: bool = False, excludes: Optional[List[str]] = None) -> int:
    """
    Fallback legacy (label/prefix). Mantiene coherencia con nombres y directorio por defecto.
    """
    method, rows = discover_volumes_for_project(project_name)

    print_header(f"Proyecto '{project_name}': {len(rows)} volumen(es) detectado(s) (método: {method})")
    for v in rows:
        print(" -", v.get("Name"))

    if not rows:
        warn("No hay volúmenes para respaldar.")
        return 0

    vols = [v.get("Name") for v in rows if v.get("Name")]
    if excludes:
        excl = set(excludes)
        vols = [n for n in vols if n not in excl]
        if not vols:
            warn("Tras aplicar exclusiones, no quedó nada por respaldar.")
            return 0

    out_dir = pathlib.Path(output_dir) if output_dir else (PROJECT_ROOT / f"docker-volumes-{ts}")
    if not dry_run:
        ensure_alpine_image()

    failures = []
    for vname in vols:
        archive = build_archive_name(project_name, vname, ts)
        print(f"Backing up volume: {vname} -> {archive}")
        rc = backup_volume(vname, out_dir, archive, dry_run=dry_run)
        if rc != 0:
            print(f"  ERROR: backup failed for volume '{vname}' (exit code {rc})", file=sys.stderr)
            failures.append(vname)

    if failures:
        print("\nCompleted with errors. Failed volumes:", ", ".join(failures))
        return 1
    else:
        print("\nAll done. Archives written to:", str(out_dir.resolve()))
        return 0

# ---------- UI helpers ----------

def yes_no(prompt: str, default="n") -> bool:
    default = default.lower()
    hint = "[Y/n]" if default == "y" else "[y/N]"
    while True:
        resp = input(f"{prompt} {hint} ").strip().lower()
        if not resp:
            return default == "y"
        if resp in ("y","yes","s","si","sí"):
            return True
        if resp in ("n","no"):
            return False
        print("Respuesta no reconocida. Por favor, responde con 'y' o 'n'.")

def print_header(title: str):
    print("\n" + "=" * 60)
    print(title)
    print("=" * 60 + "\n")

def info(msg): print(f"• {msg}")
def ok(msg):   print(f"✓ {msg}")
def warn(msg): print(f"! {msg}")
def fail(msg):
    print(f"✗ {msg}")
    sys.exit(1)

# ---------- Menú interactivo ----------

def interactive_menu():
    if not which("docker"):
        fail("ERROR: 'docker' no está en el PATH.")
    try:
        run(["docker", "version"], check=True, capture_output=True)
    except subprocess.CalledProcessError:
        fail("ERROR: No se puede hablar con el daemon de Docker. ¿Está corriendo? ¿Tu usuario está en el grupo 'docker'?")

    # DEV
    dev_candidates  = candidates_for_env("development") if COMPOSE_BASE.exists() and COMPOSE_DEV.exists() else []
    dev_proj, dev_method, dev_names = detect_group_volumes_with_compose(
        filesets=[[COMPOSE_BASE, COMPOSE_DEV]] if dev_candidates else [],
        project_candidates=dev_candidates
    )

    # PROD
    prod_candidates = candidates_for_env("production") if COMPOSE_BASE.exists() and COMPOSE_PROD.exists() else []
    prod_proj, prod_method, prod_names = detect_group_volumes_with_compose(
        filesets=[[COMPOSE_BASE, COMPOSE_PROD]] if prod_candidates else [],
        project_candidates=prod_candidates
    )

    # GLOBAL = NPM + DBEAVER (unir shortnames de ambos)
    global_candidates = candidates_for_global()
    global_filesets = []
    if COMPOSE_NPM.exists():
        global_filesets.append([COMPOSE_NPM])
    if COMPOSE_DBVR.exists():
        global_filesets.append([COMPOSE_DBVR])
    glob_proj, glob_method, glob_names = detect_group_volumes_with_compose(
        filesets=global_filesets,
        project_candidates=global_candidates
    )

    # Resumen
    print_header("Resumen de volúmenes detectados")
    if dev_proj:
        info(f"DESARROLLO ({dev_proj}): {len(dev_names)} volumen(es) (método: {dev_method})")
    else:
        info("DESARROLLO: archivos compose no encontrados.")
    if prod_proj:
        info(f"PRODUCCIÓN ({prod_proj}): {len(prod_names)} volumen(es) (método: {prod_method})")
    else:
        info("PRODUCCIÓN: archivos compose no encontrados.")
    if glob_proj:
        info(f"GLOBALES ({glob_proj}): {len(glob_names)} volumen(es) (método: {glob_method})")
    else:
        info("GLOBALES: no se detectaron archivos compose globales.")
    print()

    # Menú
    options = {}
    key = 1
    if dev_proj:
        print(f"  {key}) Respaldar volúmenes de DESARROLLO ({dev_proj})")
        options[str(key)] = ("backup_explicit", dev_proj, dev_names); key += 1
    if prod_proj:
        print(f"  {key}) Respaldar volúmenes de PRODUCCIÓN ({prod_proj})")
        options[str(key)] = ("backup_explicit", prod_proj, prod_names); key += 1
    if glob_proj:
        print(f"  {key}) Respaldar volúmenes GLOBALES ({glob_proj})")
        options[str(key)] = ("backup_explicit", glob_proj, glob_names); key += 1

    # TODOS: unión deduplicada por nombre (respalda 1 vez cada volumen)
    groups = []
    if dev_proj:  groups.append( (dev_proj, dev_names) )
    if prod_proj: groups.append( (prod_proj, prod_names) )
    if glob_proj: groups.append( (glob_proj, glob_names) )

    if len(groups) >= 2:
        print(f"  {key}) Respaldar TODOS los grupos detectados")
        options[str(key)] = ("backup_all_explicit", groups); key += 1

    print(f"  {key}) Salir")
    exit_key = str(key)

    ts = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")

    while True:
        choice = input("> ").strip()
        if choice == exit_key:
            ok("Saliendo.")
            sys.exit(0)

        if choice not in options:
            print("Opción inválida.")
            continue

        action = options[choice][0]
        dry  = yes_no("¿Dry-run (no escribir archivos)?", default="n")
        outd = input(f"Directorio de salida (vacío = ./docker-volumes-{ts}): ").strip() or None
        excl_input = input("Excluir volúmenes (nombres separados por coma, vacío = ninguno): ").strip()
        excludes = set(e.strip() for e in excl_input.split(",") if e.strip()) if excl_input else set()

        if action == "backup_explicit":
            _, proj, names = options[choice]
            names = [n for n in names if n not in excludes]
            if not names:
                warn("No hay volúmenes para respaldar.")
                sys.exit(0)
            rc = backup_explicit(names, ts, output_dir=outd, dry_run=dry, prefix_project=proj)
            sys.exit(rc)

        elif action == "backup_all_explicit":
            _, groups_payload = options[choice]
            vol_to_proj: Dict[str, str] = {}
            for proj, names in groups_payload:
                for n in names:
                    if n not in excludes and n not in vol_to_proj:
                        vol_to_proj[n] = proj
            if not vol_to_proj:
                warn("No hay volúmenes para respaldar.")
                sys.exit(0)
            if not dry:
                ensure_alpine_image()
            out_dir = pathlib.Path(outd) if outd else (PROJECT_ROOT / f"docker-volumes-{ts}")
            failures = []
            for vname, proj in vol_to_proj.items():
                archive = build_archive_name(proj, vname, ts)
                print(f"Backing up volume: {vname} -> {archive}")
                rc = backup_volume(vname, out_dir, archive, dry_run=dry)
                if rc != 0:
                    print(f"  ERROR: backup failed for volume '{vname}' (exit code {rc})", file=sys.stderr)
                    failures.append(vname)
            if failures:
                print("\nCompleted with errors. Failed volumes:", ", ".join(failures))
                sys.exit(1)
            else:
                print("\nAll done. Archives written to:", str(out_dir.resolve()))
                sys.exit(0)

# ---------- CLI legacy (se mantiene) ----------

def detect_project_name(args_project: Optional[str]) -> str:
    if args_project:
        return args_project
    env_name = os.environ.get("COMPOSE_PROJECT_NAME")
    if env_name:
        return env_name
    return PROJECT_ROOT.name.replace(" ", "_")

def cli_main():
    parser = argparse.ArgumentParser(description="Export (compress) every Docker volume of a Docker Compose project.")
    parser.add_argument("-p", "--project", help="Compose project or prefix (see --discovery).")
    parser.add_argument("-o", "--output", help="Output directory (default: ./docker-volumes-<timestamp>).")
    parser.add_argument("--exclude", nargs="*", default=[], help="Volume names to exclude (space-separated).")
    parser.add_argument("--dry-run", action="store_true", help="Show what would be done without doing it.")
    parser.add_argument("--timestamp", default=datetime.datetime.now().strftime("%Y%m%d-%H%M%S"),
                        help="Timestamp to embed into filenames (default: current time).")
    parser.add_argument("--discovery", choices=["auto","label","name"], default="auto",
                        help="How to discover volumes: 'label' (strict), 'name' (prefix), or 'auto' (default).")
    parser.add_argument("--list-only", action="store_true", help="Only list volumes that would be backed up and exit.")
    parser.add_argument("--menu", action="store_true", help="Launch interactive menu instead of CLI behavior.")
    args = parser.parse_args()

    if args.menu or not args.project:
        interactive_menu()
        return

    if not which("docker"):
        print("ERROR: 'docker' not on PATH.", file=sys.stderr)
        sys.exit(2)

    project_raw = detect_project_name(args.project)
    project_norm = normalize_project_name(project_raw)
    project_lower = project_norm.lower()
    ts = args.timestamp
    out_dir = pathlib.Path(args.output) if args.output else (PROJECT_ROOT / f"docker-volumes-{ts}")

    try:
        run(["docker", "version"], check=True, capture_output=True)
    except subprocess.CalledProcessError:
        print("ERROR: Docker daemon not reachable.", file=sys.stderr)
        sys.exit(2)

    # Descubrimiento legacy por label/prefijo (se mantiene para compatibilidad)
    selected = []
    method_used = None
    vols = list_by_label_project(project_norm)
    if vols:
        selected = vols; method_used = f"label:{project_norm}"
    else:
        vols2 = list_by_label_project(project_lower)
        if vols2:
            selected = vols2; method_used = f"label:{project_lower}"
    if not selected:
        by_name = list_by_name_prefix(project_norm)
        if by_name:
            selected = by_name; method_used = f"name-prefix:{project_norm}"
        else:
            by_name2 = list_by_name_prefix(project_lower)
            if by_name2:
                selected = by_name2; method_used = f"name-prefix:{project_lower}"

    if not selected:
        print(f"No volumes found for project/prefix '{project_raw}'.")
        sys.exit(0)

    exclude_set = set(args.exclude or [])
    names = [v.get("Name") for v in selected if v.get("Name") not in exclude_set]

    print(f"Discovery method: {method_used}")
    print(f"Volumes discovered: {len(names)}")
    for n in names:
        print(" -", n)

    if args.list_only:
        return

    if not args.dry_run:
        ensure_alpine_image()

    failures = []
    for vname in names:
        archive = build_archive_name(project_lower, vname, ts)
        print(f"Backing up volume: {vname} -> {archive}")
        rc = backup_volume(vname, out_dir, archive, dry_run=args.dry_run)
        if rc != 0:
            print(f"  ERROR: backup failed for volume '{vname}' (exit code {rc})", file=sys.stderr)
            failures.append(vname)

    if failures:
        print("\nCompleted with errors. Failed volumes:", ", ".join(failures))
        sys.exit(1)
    else:
        print("\nAll done. Archives written to:", str(out_dir.resolve()))

# ---------- Entry point ----------

if __name__ == "__main__":
    if len(sys.argv) == 1:
        interactive_menu()
    else:
        cli_main()
