#!/usr/bin/env python3
"""Run DyzineFrame stage/promote/rollback as topbox (ethergold-http is often root)."""
from __future__ import annotations

import os
import subprocess
import time
from pathlib import Path
from typing import Any, Dict, List, Optional

DF_ROOT = Path(os.environ.get("DYZINEFRAME_ROOT", "/home/topbox/DyzineFrame"))
DF_SYNC = DF_ROOT / "sync"
TOPBOX_USER = os.environ.get("DYZINEFRAME_USER", "topbox")
STAGE_TIMEOUT_SEC = int(os.environ.get("DF_DEPLOY_STAGE_TIMEOUT_SEC", "180"))
PROMOTE_TIMEOUT_SEC = int(os.environ.get("DF_DEPLOY_PROMOTE_TIMEOUT_SEC", "120"))


def _read_text(path: Path) -> str:
    try:
        return path.read_text(encoding="utf-8", errors="replace").strip()
    except OSError:
        return ""


def _stamp_tuple(stamp: str):
    parts = []
    for tok in (stamp or "").strip().split("."):
        if not tok.isdigit():
            return None
        parts.append(int(tok))
    if not parts:
        return None
    while len(parts) < 4:
        parts.append(0)
    return tuple(parts[:4])


def _stamp_is_older(left: str, right: str) -> bool:
    a, b = _stamp_tuple(left), _stamp_tuple(right)
    if a is None or b is None:
        return False
    return a < b


def _cmd_prefix() -> List[str]:
    try:
        if hasattr(os, "geteuid") and os.geteuid() == 0:
            runuser = "/usr/sbin/runuser"
            if not os.path.isfile(runuser):
                runuser = "runuser"
            return [runuser, "-u", TOPBOX_USER, "--"]
    except OSError:
        pass
    return []


def _first_script(*names: str) -> str:
    for name in names:
        if (DF_SYNC / name).is_file():
            return name
    return names[0]


def _run_df_script(
    script: str,
    extra_env: Optional[Dict[str, str]] = None,
    timeout: int = 120,
) -> Dict[str, Any]:
    path = DF_SYNC / script
    if not path.is_file():
        return {"ok": False, "error": f"missing {path}", "exit": 127}
    env = os.environ.copy()
    if extra_env:
        env.update(extra_env)
    env.setdefault("HOME", str(Path("/home") / TOPBOX_USER))
    cmd = _cmd_prefix() + ["/bin/bash", str(path)]
    started = time.monotonic()
    try:
        proc = subprocess.run(
            cmd,
            cwd=str(DF_SYNC),
            capture_output=True,
            text=True,
            timeout=timeout,
            check=False,
            env=env,
        )
    except subprocess.TimeoutExpired as exc:
        return {
            "ok": False,
            "error": f"timeout after {timeout}s",
            "cmd": " ".join(cmd),
            "stdout": (exc.stdout or "")[-2000:] if isinstance(exc.stdout, str) else "",
            "stderr": (exc.stderr or "")[-2000:] if isinstance(exc.stderr, str) else "",
        }
    except OSError as exc:
        return {"ok": False, "error": str(exc), "cmd": " ".join(cmd)}
    out = (proc.stdout or "")[-4000:]
    err = (proc.stderr or "")[-2000:]
    held = proc.returncode == 0 and "hold — staging VERSION" in out
    return {
        "ok": proc.returncode == 0 and not held,
        "exit": proc.returncode,
        "seconds": round(time.monotonic() - started, 1),
        "stdout": out,
        "stderr": err,
        "cmd": " ".join(cmd),
    }


def deploy_status() -> Dict[str, Any]:
    pending = DF_ROOT / "run" / "staging-pending"
    home = Path("/home") / TOPBOX_USER
    staging = Path(os.environ.get("DYZINEFRAME_STAGING", str(home / "DyzineFrame-Staging")))
    rollback = Path(
        os.environ.get("DYZINEFRAME_ROLLBACK_ROOT", str(home / "DyzineFrame-Rollback"))
    )
    rollback_empty = True
    if rollback.is_dir():
        try:
            rollback_empty = not any(rollback.iterdir())
        except OSError:
            rollback_empty = True
    hold = _read_text(DF_ROOT / "run" / "promote-hold")
    live_version = _read_text(DF_ROOT / "VERSION.txt")
    staging_version = _read_text(staging / "VERSION.txt")
    staging_older = _stamp_is_older(staging_version, live_version)
    pending_file = pending.is_file()
    return {
        "ok": True,
        "product": "dyzineframe",
        "df_root": str(DF_ROOT),
        "live_version": live_version,
        "staging_version": staging_version,
        "rollback_version": _read_text(rollback / "VERSION.txt"),
        "staging_pending": pending_file and not staging_older,
        "staging_older": staging_older,
        "staging_pending_text": _read_text(pending) if pending_file else "",
        "promote_hold": bool(hold),
        "promote_hold_text": hold,
        "last_promote": _read_text(DF_ROOT / "run" / "last-promote.txt"),
        "last_rollback": _read_text(DF_ROOT / "run" / "last-rollback.txt"),
        "last_rollback_directive": _read_text(DF_ROOT / "run" / "last-rollback-directive"),
        "rollback_available": not rollback_empty,
        "rollback_dir": str(rollback),
        "staging_dir": str(staging),
    }


def promote_now(confirm_hold: bool = False) -> Dict[str, Any]:
    env = {"FORCE": "1", "PROMOTE_RESTART": "1"}
    if confirm_hold:
        env["CONFIRM_PROMOTE_HOLD"] = "1"
    promote = _run_df_script(
        _first_script("promote_staging.sh", "promote-staging.sh"),
        extra_env=env,
        timeout=PROMOTE_TIMEOUT_SEC,
    )
    return {
        "ok": bool(promote.get("ok")),
        "step": "promote" if promote.get("ok") else "promote_failed",
        "promote": promote,
        "status": deploy_status(),
    }


def check_for_updates() -> Dict[str, Any]:
    """rclone SGVI → Staging only. Does not promote or restart."""
    stage = _run_df_script(
        _first_script("stage_from_dropbox.sh", "stage-from-dropbox.sh"),
        timeout=STAGE_TIMEOUT_SEC,
    )
    status = deploy_status()
    pending = bool(status.get("staging_pending"))
    available = bool(stage.get("ok")) and pending
    return {
        "ok": bool(stage.get("ok")),
        "step": "check",
        "update_available": available,
        "stage": stage,
        "status": status,
    }


def update_now() -> Dict[str, Any]:
    """rclone stage then promote + restart (operator override of hourly / 03:00)."""
    stage = _run_df_script(
        _first_script("stage_from_dropbox.sh", "stage-from-dropbox.sh"),
        timeout=STAGE_TIMEOUT_SEC,
    )
    if not stage.get("ok"):
        return {"ok": False, "step": "stage", "result": stage, "status": deploy_status()}
    promote = _run_df_script(
        _first_script("promote_staging.sh", "promote-staging.sh"),
        extra_env={"FORCE": "1", "PROMOTE_RESTART": "1"},
        timeout=PROMOTE_TIMEOUT_SEC,
    )
    return {
        "ok": bool(promote.get("ok")),
        "step": "promote" if promote.get("ok") else "promote_failed",
        "stage": stage,
        "promote": promote,
        "status": deploy_status(),
    }


def rollback_now() -> Dict[str, Any]:
    result = _run_df_script(
        "rollback_live.sh",
        extra_env={"RESTART": "1"},
        timeout=PROMOTE_TIMEOUT_SEC,
    )
    return {
        "ok": bool(result.get("ok")),
        "step": "rollback",
        "result": result,
        "status": deploy_status(),
    }


def _chown_topbox(path: Path) -> None:
    try:
        import pwd

        pw = pwd.getpwnam(TOPBOX_USER)
        os.chown(path, pw.pw_uid, pw.pw_gid)
    except (OSError, KeyError, ImportError):
        pass
    try:
        os.chmod(path, 0o664)
    except OSError:
        pass


def _python_has_cmd(needle: str) -> bool:
    """True if a python process cmdline contains needle. Never pkill -f."""
    if os.name == "nt":
        return False
    seen = False
    for proc_name in ("python3", "python"):
        try:
            proc = subprocess.run(
                ["ps", "-C", proc_name, "-o", "pid=,args="],
                capture_output=True,
                text=True,
                timeout=5,
                check=False,
            )
        except (OSError, subprocess.TimeoutExpired):
            continue
        for line in (proc.stdout or "").splitlines():
            parts = line.strip().split(None, 1)
            if len(parts) < 2:
                continue
            args = " " + parts[1] + " "
            if f" {needle}" in args or f"/{needle}" in args:
                seen = True
                break
        if seen:
            break
    return seen


def restart_now() -> Dict[str, Any]:
    """Supervisor Restart: write run/supervisor.cmd = restart; clear hold.

    Does not kill dyzineframe_supervisor.py (OTA HTTP has no DISPLAY).
    Does not sudo reboot. Does not SSH-start DyzineFrame.
    """
    run_dir = DF_ROOT / "run"
    cmd_path = run_dir / "supervisor.cmd"
    hold_path = run_dir / "supervisor.hold"
    try:
        run_dir.mkdir(parents=True, exist_ok=True)
    except OSError as exc:
        return {"ok": False, "error": f"cannot create {run_dir}: {exc}", "step": "restart"}
    try:
        hold_path.unlink(missing_ok=True)
    except OSError:
        pass
    try:
        cmd_path.write_text("restart\n", encoding="utf-8")
        _chown_topbox(cmd_path)
    except OSError as exc:
        return {"ok": False, "error": f"cannot write {cmd_path}: {exc}", "step": "restart"}
    supervisor = _python_has_cmd("dyzineframe_supervisor.py")
    return {
        "ok": True,
        "step": "supervisor_cmd_restart",
        "supervisor_running": supervisor,
        "cmd_path": str(cmd_path),
        "hint": (
            "Supervisor will Stop then Start DyzineFrame.py (same as HDMI Restart)."
            if supervisor
            else "Wrote supervisor.cmd restart — no dyzineframe_supervisor.py seen. HDMI Supervisor must already be running."
        ),
        "status": deploy_status(),
    }


def progressives_csv_path() -> Path:
    explicit = os.environ.get("ETHERGOLD_PROGRESSIVES_CSV", "").strip()
    if explicit:
        return Path(explicit)
    root = os.environ.get("ETHERGOLD_ROOT", "/home/topbox/EtherGold")
    return Path(root) / "progressives.csv"


def progressives_csv_ready(path: Path | None = None) -> bool:
    target = path or progressives_csv_path()
    try:
        return target.stat().st_size > 0
    except OSError:
        return False


def restart_ethergold_collector() -> Dict[str, Any]:
    """Bounce ethergold-collector (listen supervisor). Leave uartd. Never reboot.

    KillMode=process: systemctl restart does not SIGKILL pog422_uartd.
    """
    if os.name == "nt":
        return {"ok": True, "step": "collector_skip_windows", "skipped": True}
    try:
        cat = subprocess.run(
            ["systemctl", "cat", "ethergold-collector.service"],
            capture_output=True,
            check=False,
            timeout=10,
        )
    except (OSError, subprocess.TimeoutExpired) as exc:
        return {"ok": False, "step": "collector_restart", "error": str(exc)}
    if cat.returncode != 0:
        return {
            "ok": True,
            "step": "collector_not_installed",
            "skipped": True,
            "hint": "ethergold-collector.service not installed — skip listen restart",
        }
    try:
        euid = os.geteuid()
    except AttributeError:
        euid = 1
    cmd = ["systemctl", "restart", "ethergold-collector"]
    if euid != 0:
        cmd = ["sudo", "-n"] + cmd
    try:
        proc = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=30,
            check=False,
        )
    except (OSError, subprocess.TimeoutExpired) as exc:
        return {"ok": False, "step": "collector_restart", "error": str(exc), "cmd": cmd}
    if proc.returncode != 0:
        return {
            "ok": False,
            "step": "collector_restart",
            "error": (proc.stderr or proc.stdout or "systemctl restart failed")[-500:],
            "cmd": cmd,
            "exit": proc.returncode,
        }
    return {"ok": True, "step": "collector_restart", "cmd": cmd}


def recover_stack_now(*, wait_csv_s: float = 15.0) -> Dict[str, Any]:
    """Lucky Street order: listen first (rewrite CSV), then DyzineFrame HDMI.

    Never sudo reboot. Never kill dyzineframe_supervisor.py. Never usbreset 0002.
    """
    steps: List[Dict[str, Any]] = []
    listen = restart_ethergold_collector()
    steps.append(listen)
    csv_path = progressives_csv_path()
    deadline = time.monotonic() + max(0.0, float(wait_csv_s))
    csv_ok = progressives_csv_ready(csv_path)
    while (not csv_ok) and time.monotonic() < deadline:
        time.sleep(0.5)
        csv_ok = progressives_csv_ready(csv_path)
    steps.append(
        {
            "ok": csv_ok,
            "step": "wait_progressives_csv",
            "path": str(csv_path),
            "ready": csv_ok,
        }
    )
    df = restart_now()
    steps.append(df)
    ok = bool(listen.get("ok")) and bool(df.get("ok"))
    hint = (
        "Listen/collector bounced, then DyzineFrame supervisor.cmd restart. "
        "No Pi reboot. HDMI supervisor stays up."
    )
    if not csv_ok:
        hint += (
            " progressives.csv was still missing after the wait — "
            "HDMI may still banner until listen writes it."
        )
    return {
        "ok": ok,
        "step": "recover_stack",
        "hint": hint,
        "csv_ready": csv_ok,
        "steps": steps,
        "status": deploy_status(),
    }
