#!/usr/bin/env python3
"""
docker-update  —  Docker Compose stack manager for sammons-server

Usage:
  docker-update          Update all stacks
  docker-update log      Browse past update logs (fzf or plain-text fallback)
  docker-update debug    Dry-run: show what would happen, no changes made
"""

from __future__ import annotations

import argparse
import json
import os
import shutil
import subprocess
import sys
import textwrap
from datetime import datetime
from pathlib import Path
from typing import Any

# ── optional rich ──────────────────────────────────────────────────────────
try:
    from rich.console import Console
    from rich.panel import Panel
    from rich.progress import (
        BarColumn,
        Progress,
        SpinnerColumn,
        TaskProgressColumn,
        TextColumn,
        TimeElapsedColumn,
    )
    from rich.table import Table
    from rich.markup import escape as rich_escape

    HAS_RICH = True
    console = Console()
except ImportError:
    HAS_RICH = False
    console = None  # type: ignore[assignment]

# ── constants ──────────────────────────────────────────────────────────────
LOG_DIR    = Path("/var/log/docker-update")
FZF_HELPER = "docker-update-fzf"

SUCCEEDED      = "succeeded"
FAILED_PULL    = "failed_pull"
FAILED_STOP    = "failed_stop"
FAILED_RESTART = "failed_restart"

OUTCOME_ORDER = [SUCCEEDED, FAILED_PULL, FAILED_STOP, FAILED_RESTART]

OUTCOME_LABELS: dict[str, str] = {
    SUCCEEDED:      "Succeeded",
    FAILED_PULL:    "Failed to pull (restarted)",
    FAILED_STOP:    "Failed to stop",
    FAILED_RESTART: "Failed to restart",
}

OUTCOME_ICONS: dict[str, str] = {
    SUCCEEDED:      "✓",
    FAILED_PULL:    "⚠",
    FAILED_STOP:    "✗",
    FAILED_RESTART: "✗",
}

OUTCOME_COLORS: dict[str, str] = {
    SUCCEEDED:      "green",
    FAILED_PULL:    "yellow",
    FAILED_STOP:    "red",
    FAILED_RESTART: "red",
}


# ── subprocess helpers ─────────────────────────────────────────────────────

def _run(cmd: list[str], cwd: str | None = None) -> subprocess.CompletedProcess[str]:
    return subprocess.run(
        cmd, cwd=cwd, capture_output=True, text=True
    )


def _get_compose_cmd() -> list[str]:
    """Detect docker compose v2 plugin or legacy docker-compose."""
    if _run(["docker", "compose", "version"]).returncode == 0:
        return ["docker", "compose"]
    if shutil.which("docker-compose"):
        return ["docker-compose"]
    sys.exit("error: neither 'docker compose' nor 'docker-compose' found in PATH")


def _is_git_repo(directory: str) -> bool:
    return _run(["git", "-C", directory, "rev-parse", "--is-inside-work-tree"]).returncode == 0


# ── project discovery ──────────────────────────────────────────────────────

def discover_projects(compose_cmd: list[str]) -> list[dict[str, Any]]:
    """Return sorted list of compose projects found on this host."""
    try:
        r = _run(compose_cmd + ["ls", "--all", "--format", "json"])
    except FileNotFoundError:
        sys.exit("error: 'docker' not found in PATH")

    if r.returncode != 0:
        sys.exit(f"error: docker compose ls failed:\n{r.stderr.strip()}")

    try:
        raw: list[dict[str, Any]] = json.loads(r.stdout or "[]")
    except json.JSONDecodeError:
        sys.exit(f"error: could not parse docker compose ls output:\n{r.stdout[:200]}")

    projects: list[dict[str, Any]] = []
    for item in raw:
        name   = item.get("Name", "").strip()
        config = item.get("ConfigFiles", "").strip()
        if not name or not config:
            continue
        workdir = str(Path(config.split(",")[0]).parent)
        projects.append({
            "name":    name,
            "workdir": workdir,
            "is_git":  _is_git_repo(workdir),
            "status":  item.get("Status", ""),
        })

    return sorted(projects, key=lambda p: p["name"].lower())


# ── update logic ───────────────────────────────────────────────────────────

def _container_logs(workdir: str) -> str:
    r = _run(["docker", "compose", "logs", "--tail=100", "--no-color"], cwd=workdir)
    return (r.stdout or r.stderr or "(no logs available)").strip()


def update_project_git(project: dict[str, Any], compose_cmd: list[str]) -> dict[str, Any]:
    workdir   = project["workdir"]
    log_lines: list[str] = []

    r = _run(["git", "-C", workdir, "pull"])
    log_lines.append(f"=== git pull (exit {r.returncode}) ===\n{r.stdout}{r.stderr}")
    pull_ok = r.returncode == 0

    r = _run(compose_cmd + ["up", "-d", "--build"], cwd=workdir)
    log_lines.append(
        f"=== up -d --build (exit {r.returncode}) ===\n{r.stdout}{r.stderr}"
    )

    if r.returncode != 0:
        log_lines.append(
            f"\nContainer logs ({project['name']}):\n{_container_logs(workdir)}"
        )
        outcome = FAILED_RESTART
    elif not pull_ok:
        outcome = FAILED_PULL
    else:
        outcome = SUCCEEDED

    return {**project, "outcome": outcome, "log_lines": log_lines}


def update_project_registry(project: dict[str, Any], compose_cmd: list[str]) -> dict[str, Any]:
    workdir   = project["workdir"]
    log_lines: list[str] = []

    r = _run(compose_cmd + ["down"], cwd=workdir)
    log_lines.append(f"=== down (exit {r.returncode}) ===\n{r.stdout}{r.stderr}")
    if r.returncode != 0:
        log_lines.append(f"\nStep 'stop' failed (exit {r.returncode}):")
        log_lines.append(r.stderr)
        return {**project, "outcome": FAILED_STOP, "log_lines": log_lines}

    r = _run(compose_cmd + ["pull"], cwd=workdir)
    log_lines.append(f"=== pull (exit {r.returncode}) ===\n{r.stdout}{r.stderr}")
    pull_ok = r.returncode == 0
    if not pull_ok:
        log_lines.append(f"\nStep 'pull' failed (exit {r.returncode}) — restarting with existing image:")
        log_lines.append(r.stderr)

    r = _run(compose_cmd + ["up", "-d"], cwd=workdir)
    log_lines.append(
        f"=== up -d (exit {r.returncode}) ===\n{r.stdout}{r.stderr}"
    )

    if r.returncode != 0:
        log_lines.append(
            f"\nContainer logs ({project['name']}):\n{_container_logs(workdir)}"
        )
        outcome = FAILED_RESTART
    elif not pull_ok:
        outcome = FAILED_PULL
    else:
        outcome = SUCCEEDED

    return {**project, "outcome": outcome, "log_lines": log_lines}


# ── rich runner ────────────────────────────────────────────────────────────

def _run_update_rich(projects: list[dict[str, Any]], compose_cmd: list[str]) -> list[dict[str, Any]]:
    results: list[dict[str, Any]] = []
    git_count = sum(1 for p in projects if p["is_git"])
    reg_count = len(projects) - git_count

    console.print(Panel.fit(
        f"[bold cyan]docker-update[/bold cyan]  "
        f"[dim]{len(projects)} project(s)  ·  {git_count} git  ·  {reg_count} registry[/dim]",
        border_style="cyan",
    ))
    console.print()

    with Progress(
        SpinnerColumn(),
        TextColumn("[progress.description]{task.description}"),
        BarColumn(bar_width=30),
        TaskProgressColumn(),
        TimeElapsedColumn(),
        console=console,
        transient=False,
    ) as progress:
        overall = progress.add_task("[bold white]Overall", total=len(projects))

        for project in projects:
            name    = project["name"]
            workdir = project["workdir"]
            is_git  = project["is_git"]
            mode_tag = "[magenta]git[/magenta]" if is_git else "[blue]reg[/blue]"
            n_steps  = 2 if is_git else 3

            task = progress.add_task(
                f"[cyan]{name}[/cyan] {mode_tag}",
                total=n_steps,
            )

            if is_git:
                step_cmds = [
                    ("git pull",   ["git", "-C", workdir, "pull"]),
                    ("up --build", compose_cmd + ["up", "-d", "--build"]),
                ]
            else:
                step_cmds = [
                    ("down", compose_cmd + ["down"]),
                    ("pull", compose_cmd + ["pull"]),
                    ("up",   compose_cmd + ["up", "-d"]),
                ]

            steps: dict[str, dict[str, Any]] = {}
            pull_failed = False
            outcome = SUCCEEDED

            for label, cmd in step_cmds:
                progress.update(
                    task,
                    description=f"[cyan]{name}[/cyan] {mode_tag}  [yellow]{label}[/yellow]",
                )
                r = subprocess.run(cmd, cwd=workdir, capture_output=True, text=True)
                rc, out, err = r.returncode, r.stdout, r.stderr
                steps[label] = {"rc": rc, "out": out, "err": err}
                progress.advance(task)

                if rc != 0:
                    if label in ("git pull", "pull"):
                        pull_failed = True
                    elif label == "down":
                        outcome = FAILED_STOP
                        break
                    elif label in ("up", "up --build"):
                        outcome = FAILED_RESTART
                        break

            if outcome == SUCCEEDED and pull_failed:
                outcome = FAILED_PULL

            color = OUTCOME_COLORS[outcome]
            progress.update(
                task,
                description=f"[{color}]{OUTCOME_ICONS[outcome]} {name}[/{color}] {mode_tag}",
            )

            # Build log_lines for the text log writer
            log_lines: list[str] = []
            for step_label, sd in steps.items():
                log_lines.append(
                    f"=== {step_label} (exit {sd['rc']}) ===\n{sd['out']}{sd['err']}"
                )
            if outcome != SUCCEEDED:
                log_lines.append(
                    f"\nContainer logs ({name}):\n{_container_logs(workdir)}"
                )

            results.append({**project, "outcome": outcome, "log_lines": log_lines})
            progress.advance(overall)

    return results


# ── plain runner ───────────────────────────────────────────────────────────

def _run_update_plain(projects: list[dict[str, Any]], compose_cmd: list[str]) -> list[dict[str, Any]]:
    total = len(projects)
    git_count = sum(1 for p in projects if p["is_git"])
    reg_count = total - git_count
    print(f"docker-update — {total} project(s)  ·  {git_count} git  ·  {reg_count} registry\n")

    results: list[dict[str, Any]] = []
    for i, project in enumerate(projects, 1):
        mode = "git" if project["is_git"] else "registry"
        print(f"[{i}/{total}] {project['name']} ({mode}) — updating…", flush=True)
        result = (
            update_project_git(project, compose_cmd)
            if project["is_git"]
            else update_project_registry(project, compose_cmd)
        )
        results.append(result)
        icon  = OUTCOME_ICONS[result["outcome"]]
        label = OUTCOME_LABELS[result["outcome"]]
        print(f"  {icon}  {result['name']}  ({label})")

    return results


# ── summary printers ───────────────────────────────────────────────────────

def _group_by_outcome(
    results: list[dict[str, Any]],
) -> dict[str, list[dict[str, Any]]]:
    groups: dict[str, list[dict[str, Any]]] = {k: [] for k in OUTCOME_ORDER}
    for r in results:
        groups[r["outcome"]].append(r)
    return groups


def _print_summary_rich(groups: dict[str, list[dict[str, Any]]]) -> None:
    console.print()
    parts: list[str] = []
    if groups[SUCCEEDED]:
        parts.append(f"[bold green]✓ {len(groups[SUCCEEDED])} succeeded[/bold green]")
    if groups[FAILED_PULL]:
        parts.append(f"[bold yellow]⚠ {len(groups[FAILED_PULL])} failed to pull[/bold yellow]")
    if groups[FAILED_STOP]:
        parts.append(f"[bold red]✗ {len(groups[FAILED_STOP])} failed to stop[/bold red]")
    if groups[FAILED_RESTART]:
        parts.append(f"[bold red]✗ {len(groups[FAILED_RESTART])} failed to restart[/bold red]")
    console.print("   ".join(parts))
    console.print()


def _print_summary_plain(groups: dict[str, list[dict[str, Any]]]) -> None:
    print()
    print("=" * 60)
    for outcome in OUTCOME_ORDER:
        items = groups[outcome]
        if items:
            icon  = OUTCOME_ICONS[outcome]
            label = OUTCOME_LABELS[outcome]
            print(f"{icon} {label}: {len(items)}")
            for r in items:
                print(f"    - {r['name']}")
    print("=" * 60)


# ── log writers ────────────────────────────────────────────────────────────

def _write_text_log(
    run_id: str,
    results: list[dict[str, Any]],
    path: Path,
) -> None:
    groups = _group_by_outcome(results)
    lines: list[str] = [
        f"docker-update run: {run_id}",
        "=" * 60,
        "",
    ]
    for outcome in OUTCOME_ORDER:
        items = groups[outcome]
        if items:
            icon  = OUTCOME_ICONS[outcome]
            label = OUTCOME_LABELS[outcome]
            lines.append(f"{icon} {label}: {len(items)}")
            for r in items:
                lines.append(f"    - {r['name']}")

    failures = [r for r in results if r["outcome"] != SUCCEEDED]
    if failures:
        lines += ["", "=" * 60, "FAILURE DETAILS", "=" * 60]
        for r in failures:
            sep = "─" * 60
            lines += [
                "",
                sep,
                f"  {r['name']}  [{OUTCOME_LABELS[r['outcome']]}]",
                sep,
                "",
            ]
            for chunk in r.get("log_lines", []):
                lines.append(chunk)

    path.write_text("\n".join(lines) + "\n", encoding="utf-8")


def _write_json_log(
    run_id: str,
    results: list[dict[str, Any]],
    path: Path,
) -> None:
    data = {
        "run_id": run_id,
        "projects": [
            {
                "name":    r["name"],
                "workdir": r["workdir"],
                "is_git":  r["is_git"],
                "outcome": r["outcome"],
            }
            for r in results
        ],
    }
    path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")


# ── cmd_update ─────────────────────────────────────────────────────────────

def cmd_update() -> None:
    LOG_DIR.mkdir(parents=True, exist_ok=True)
    run_id    = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
    log_path  = LOG_DIR / f"docker-update-{run_id}.log"
    json_path = LOG_DIR / f"docker-update-{run_id}.json"

    compose_cmd = _get_compose_cmd()
    projects    = discover_projects(compose_cmd)
    if not projects:
        print("No Docker Compose projects found.")
        return

    results = (
        _run_update_rich(projects, compose_cmd)
        if HAS_RICH
        else _run_update_plain(projects, compose_cmd)
    )

    groups = _group_by_outcome(results)
    _print_summary_rich(groups) if HAS_RICH else _print_summary_plain(groups)

    _write_text_log(run_id, results, log_path)
    _write_json_log(run_id, results, json_path)

    print(f"Log saved to: {log_path}")
    print()
    print("To browse logs:  docker-update log")


# ── cmd_log ────────────────────────────────────────────────────────────────

def cmd_log() -> None:
    if not LOG_DIR.exists() or not any(LOG_DIR.glob("*.log")):
        print(f"No log files found in {LOG_DIR}")
        return

    fzf_bin    = shutil.which("fzf")
    helper_bin = shutil.which(FZF_HELPER)

    missing: list[str] = []
    if not fzf_bin:
        missing.append("fzf")
    if not helper_bin:
        missing.append(FZF_HELPER)

    if missing:
        print(f"Warning: {', '.join(missing)} not found — falling back to plain-text log viewer.")
        print()
        _cmd_log_plain()
        return

    os.execvp(helper_bin, [helper_bin, "log", str(LOG_DIR)])


def _cmd_log_plain() -> None:
    logs = sorted(LOG_DIR.glob("*.log"), reverse=True)
    if not logs:
        print("No logs found.")
        return

    print("Available logs (newest first):")
    print()
    for i, log in enumerate(logs, 1):
        print(f"  {i:3d}.  {log.name}")

    print()
    try:
        raw = input("Enter number to view (or Enter to exit): ").strip()
    except (EOFError, KeyboardInterrupt):
        print()
        return

    if not raw:
        return

    try:
        selected = logs[int(raw) - 1]
        print()
        print("─" * 60)
        print(selected.read_text(encoding="utf-8"))
    except (ValueError, IndexError):
        print("Invalid selection.")


# ── cmd_debug ──────────────────────────────────────────────────────────────

def cmd_debug() -> None:
    """Dry-run: discover projects and show what would happen, no changes."""
    compose_cmd = _get_compose_cmd()
    projects    = discover_projects(compose_cmd)
    if not projects:
        print("No Docker Compose projects found.")
        return

    if HAS_RICH:
        _debug_rich(projects)
    else:
        _debug_plain(projects)

    print()
    try:
        answer = input("Show example log in fzf? [y/N] ").strip().lower()
    except (EOFError, KeyboardInterrupt):
        print()
        return

    if answer != "y":
        return

    fzf_bin    = shutil.which("fzf")
    helper_bin = shutil.which(FZF_HELPER)

    missing = []
    if not fzf_bin:
        missing.append("fzf")
    if not helper_bin:
        missing.append(FZF_HELPER)

    if missing:
        print(f"Cannot open fzf demo: {', '.join(missing)} not found.")
        return

    import random
    import tempfile

    # Assign fake outcomes weighted towards success
    outcome_pool = (
        [SUCCEEDED] * 6
        + [FAILED_PULL] * 2
        + [FAILED_STOP]
        + [FAILED_RESTART]
    )
    fake_projects = [
        {
            "name":    p["name"],
            "workdir": p["workdir"],
            "is_git":  p["is_git"],
            "outcome": random.choice(outcome_pool),
        }
        for p in projects
    ]

    fake_data = {
        "run_id":  "debug-" + datetime.now().strftime("%Y-%m-%d_%H-%M-%S"),
        "debug":   True,
        "projects": fake_projects,
    }

    tmp = tempfile.NamedTemporaryFile(
        mode="w", suffix=".json", prefix="du-debug-", delete=False
    )
    json.dump(fake_data, tmp, indent=2)
    tmp.flush()
    tmp.close()

    os.execvp(helper_bin, [helper_bin, "debug", tmp.name])


def _debug_rich(projects: list[dict[str, Any]]) -> None:
    console.print()
    console.rule("[bold cyan]Debug Mode — no changes will be made")
    console.print()

    tbl = Table(show_header=True, header_style="bold")
    tbl.add_column("Project",     style="cyan",    no_wrap=True)
    tbl.add_column("Workdir",     style="dim")
    tbl.add_column("Type")
    tbl.add_column("Update steps")

    for p in projects:
        ptype  = "[blue]git[/blue]" if p["is_git"] else "[magenta]registry[/magenta]"
        steps  = "git pull → up -d --build" if p["is_git"] else "down → pull → up -d"
        tbl.add_row(p["name"], p["workdir"], ptype, steps)

    console.print(tbl)
    console.print()
    console.print(f"  [dim]{len(projects)} project(s) would be updated[/dim]")


def _debug_plain(projects: list[dict[str, Any]]) -> None:
    print()
    print("Debug Mode — no changes will be made")
    print("=" * 60)
    for p in projects:
        ptype = "git" if p["is_git"] else "registry"
        steps = "git pull → up -d --build" if p["is_git"] else "down → pull → up -d"
        print(f"  {p['name']}")
        print(f"    workdir : {p['workdir']}")
        print(f"    type    : {ptype}")
        print(f"    steps   : {steps}")
        print()
    print(f"{len(projects)} project(s) would be updated")


# ── entrypoint ─────────────────────────────────────────────────────────────

def main() -> None:
    parser = argparse.ArgumentParser(
        prog="docker-update",
        description="Docker Compose stack manager",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=textwrap.dedent("""\
            commands:
              (none)   update all stacks
              log      browse past update logs
              debug    dry-run — show what would run without making changes
        """),
    )
    parser.add_argument(
        "command",
        nargs="?",
        choices=["log", "debug"],
        default=None,
    )
    args = parser.parse_args()

    if args.command == "log":
        cmd_log()
    elif args.command == "debug":
        cmd_debug()
    else:
        cmd_update()


if __name__ == "__main__":
    main()
