"""Rathole client - binary download, config generation, process management."""

from __future__ import annotations

import platform
import signal
import subprocess
import tempfile
import time
import zipfile
from pathlib import Path
from typing import IO

import httpx

from .config import RATHOLE_DIR, clear_pid, read_pid, write_pid
from .exceptions import BeaconSystemError
from .system import get_architecture, get_os

# Rathole release info

RATHOLE_VERSION = "v0.5.0"
RATHOLE_BASE_URL = (
    "https://github.com/rathole-org/rathole/releases/download/{version}/"
    "rathole-{suffix}.zip"
)

RATHOLE_BUNDLED_PATH = Path("/usr/local/libexec/ethiack/rathole")

# Maps (arch, os) → full target triple used in release asset filenames.
# aarch64/arm Linux builds use musl (static); x86_64 Linux uses gnu.
_SUFFIX_MAP: dict[tuple[str, str], str] = {
    ("x86_64", "linux"):   "x86_64-unknown-linux-gnu",
    ("aarch64", "linux"):  "aarch64-unknown-linux-musl",
    ("arm", "linux"):      "armv7-unknown-linux-musleabihf",
    ("x86_64", "darwin"):  "x86_64-apple-darwin",
}


def _binary_name(arch: str, os_name: str) -> str:
    suffix = _SUFFIX_MAP.get((arch, os_name))
    if suffix is None:
        raise BeaconSystemError(
            f"Unsupported platform: {arch}/{os_name}. "
            "Download rathole manually from https://github.com/rathole-org/rathole/releases"
        )
    return f"rathole-{suffix}.zip"


def get_rathole_path() -> Path:
    return RATHOLE_DIR / "rathole"


# Download

def ensure_rathole(console_print: "Callable[[str], None] | None" = None) -> Path:  # noqa: F821
    """Return path to rathole binary, downloading it if necessary.

    Checks for a pre-bundled binary first (e.g. Docker image), then the
    local state directory, and finally downloads from GitHub if neither exists.
    """
    if RATHOLE_BUNDLED_PATH.exists():
        return RATHOLE_BUNDLED_PATH

    path = get_rathole_path()
    if path.exists():
        return path

    arch = get_architecture()
    os_name = get_os()
    zip_name = _binary_name(arch, os_name)
    suffix = _SUFFIX_MAP[(arch, os_name)]
    url = RATHOLE_BASE_URL.format(version=RATHOLE_VERSION, suffix=suffix)

    if console_print:
        console_print(f"Downloading rathole {RATHOLE_VERSION} for {arch}/{os_name}…")

    _download_rathole(url, zip_name, path)
    path.chmod(0o755)
    return path


def _download_rathole(url: str, zip_name: str, dest: Path) -> None:
    """Download rathole zip, extract binary to dest."""
    RATHOLE_DIR.mkdir(parents=True, exist_ok=True)
    zip_path = RATHOLE_DIR / zip_name

    try:
        with httpx.stream("GET", url, follow_redirects=True, timeout=60) as resp:
            if resp.status_code != 200:
                raise BeaconSystemError(
                    f"Failed to download rathole: HTTP {resp.status_code} from {url}"
                )
            total = int(resp.headers.get("content-length", 0))
            downloaded = 0
            with open(zip_path, "wb") as f:
                for chunk in resp.iter_bytes(chunk_size=8192):
                    f.write(chunk)
                    downloaded += len(chunk)
    except httpx.RequestError as exc:
        raise BeaconSystemError(f"Download failed: {exc}") from exc

    try:
        with zipfile.ZipFile(zip_path) as zf:
            names = zf.namelist()
            # find the binary (no extension, or named 'rathole')
            binary = next(
                (n for n in names if n == "rathole" or (not n.endswith("/") and "." not in n.split("/")[-1])),
                None,
            )
            if not binary:
                raise BeaconSystemError(f"Could not find rathole binary in {zip_name}")
            with zf.open(binary) as src, open(dest, "wb") as dst:
                dst.write(src.read())
    finally:
        zip_path.unlink(missing_ok=True)


# Config generation

def write_client_config(
    beacon_id: str,
    server: str,
    control_port: int,
    token: str,
    local_port: int,
) -> Path:
    """Write a rathole client TOML config and return its path."""
    RATHOLE_DIR.mkdir(parents=True, exist_ok=True)
    config_path = RATHOLE_DIR / f"client-{beacon_id}.toml"
    service_name = f"beacon{beacon_id}"
    content = f"""[client]
remote_addr = "{server}:{control_port}"

[client.services.{service_name}]
token = "{token}"
local_addr = "127.0.0.1:{local_port}"
type = "udp"
"""
    config_path.write_text(content)
    return config_path


# Process management

def start(
    beacon_id: str,
    server: str,
    control_port: int,
    token: str,
    local_port: int,
) -> int:
    """Start rathole client as a background process. Returns PID."""
    if is_running(beacon_id):
        raise BeaconSystemError(
            f"Rathole is already running for beacon {beacon_id[:8]}."
        )

    binary = ensure_rathole()
    config_path = write_client_config(beacon_id, server, control_port, token, local_port)

    log_path = RATHOLE_DIR / f"rathole-{beacon_id[:8]}.log"
    log_file: IO[bytes] = open(log_path, "ab")

    proc = subprocess.Popen(
        [str(binary), "--client", str(config_path)],
        stdout=log_file,
        stderr=log_file,
        start_new_session=True,  # detach from current session
    )

    # Give it a moment to fail fast if the config is wrong
    time.sleep(1)
    if proc.poll() is not None:
        log_file.close()
        raise BeaconSystemError(
            f"Rathole exited immediately (rc={proc.returncode}). "
            f"Check logs at {log_path}"
        )

    write_pid(beacon_id, proc.pid)
    log_file.close()
    return proc.pid


def stop(beacon_id: str) -> bool:
    """Stop the rathole process for the given beacon. Returns True if stopped."""
    pid = read_pid(beacon_id)
    if pid is None:
        return False

    try:
        import psutil
        proc = psutil.Process(pid)
        proc.terminate()
        try:
            proc.wait(timeout=5)
        except psutil.TimeoutExpired:
            proc.kill()
        clear_pid(beacon_id)
        return True
    except (ImportError, ProcessLookupError):
        # psutil not available - fall back to os.kill
        import os
        try:
            os.kill(pid, signal.SIGTERM)
            time.sleep(2)
            try:
                os.kill(pid, signal.SIGKILL)
            except ProcessLookupError:
                pass
        except ProcessLookupError:
            pass
        clear_pid(beacon_id)
        return True
    except Exception:
        clear_pid(beacon_id)
        return False


def is_connected(beacon_id: str, window_seconds: int = 120) -> bool:
    """Return True if rathole is running and has not logged errors recently.

    Reads the tail of the rathole log file and checks for ERROR lines within
    the last ``window_seconds``. Returns False if errors are found (tunnel is
    retrying/broken) or if the process is not running.
    """
    if not is_running(beacon_id):
        return False

    import datetime

    log_path = RATHOLE_DIR / f"rathole-{beacon_id[:8]}.log"
    if not log_path.exists():
        return True  # no log yet - process just started, give it the benefit of the doubt

    now = datetime.datetime.now(tz=datetime.timezone.utc)
    cutoff = now - datetime.timedelta(seconds=window_seconds)

    try:
        # Read last 8 KB to avoid loading huge logs
        with open(log_path, "rb") as fh:
            fh.seek(0, 2)
            fh.seek(max(0, fh.tell() - 8192))
            tail = fh.read().decode("utf-8", errors="ignore")
    except OSError:
        return True

    for line in tail.splitlines():
        if " ERROR " not in line:
            continue
        try:
            ts_str = line.split(None, 1)[0].rstrip("Z")
            ts = datetime.datetime.fromisoformat(ts_str).replace(
                tzinfo=datetime.timezone.utc
            )
            if ts >= cutoff:
                return False
        except (ValueError, IndexError):
            continue

    return True


def is_running(beacon_id: str) -> bool:
    """Return True if the rathole process for this beacon is alive."""
    pid = read_pid(beacon_id)
    if pid is None:
        return False
    try:
        import psutil
        return psutil.pid_exists(pid) and psutil.Process(pid).status() != psutil.STATUS_ZOMBIE
    except ImportError:
        import os
        try:
            os.kill(pid, 0)
            return True
        except (ProcessLookupError, PermissionError):
            return False
