#!/usr/bin/env python3
"""Outbound-only Linux node agent for Cang'er's home server."""

from __future__ import annotations

import argparse
import json
import os
import platform
import shutil
import socket
import ssl
import subprocess
import sys
import tempfile
import threading
import time
from queue import Empty, Queue
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen

AGENT_VERSION = "0.1.0"
MAX_OUTPUT_CHARS = 240_000


def read_config(path: str) -> dict[str, Any]:
    with open(path, encoding="utf-8") as handle:
        value = json.load(handle)
    if not isinstance(value, dict):
        raise ValueError("config must be a JSON object")
    return value


def write_config(path: str, value: dict[str, Any]) -> None:
    target = Path(path)
    target.parent.mkdir(parents=True, exist_ok=True)
    fd, temp_name = tempfile.mkstemp(prefix=".canger-node-", dir=target.parent)
    try:
        os.fchmod(fd, 0o600)
        with os.fdopen(fd, "w", encoding="utf-8") as handle:
            json.dump(value, handle, ensure_ascii=False, indent=2)
            handle.write("\n")
        os.replace(temp_name, target)
    finally:
        if os.path.exists(temp_name):
            os.unlink(temp_name)


def ssl_context(config: dict[str, Any]) -> ssl.SSLContext:
    context = ssl.create_default_context()
    if config.get("verify_tls", True) is False:
        if not str(config.get("server_url", "")).startswith(
            ("http://127.0.0.1", "http://localhost")
        ):
            raise ValueError("TLS verification may only be disabled for localhost")
        context.check_hostname = False
        context.verify_mode = ssl.CERT_NONE
    return context


def api(
    config: dict[str, Any],
    method: str,
    path: str,
    body: dict[str, Any] | None = None,
    token: str | None = None,
) -> dict[str, Any]:
    base = str(config["server_url"]).rstrip("/")
    if not base.startswith(("https://", "http://127.0.0.1", "http://localhost")):
        raise ValueError("server_url must use HTTPS (HTTP is only allowed on localhost)")
    data = None if body is None else json.dumps(body).encode("utf-8")
    headers = {"Accept": "application/json"}
    if data is not None:
        headers["Content-Type"] = "application/json"
    if token:
        headers["Authorization"] = f"Bearer {token}"
    request = Request(base + path, data=data, headers=headers, method=method)
    try:
        with urlopen(request, timeout=30, context=ssl_context(config)) as response:
            value = json.load(response)
    except HTTPError as exc:
        detail = exc.read().decode("utf-8", errors="replace")
        raise RuntimeError(f"server returned HTTP {exc.code}: {detail}") from exc
    if not isinstance(value, dict):
        raise RuntimeError("server returned a non-object response")
    return value


def allowed_path(config: dict[str, Any], raw: str) -> Path:
    candidate = Path(raw).expanduser().resolve()
    roots = [Path(item).expanduser().resolve() for item in config["allowed_roots"]]
    if not any(candidate == root or root in candidate.parents for root in roots):
        raise PermissionError(f"path is outside allowed_roots: {candidate}")
    return candidate


def run_process(
    argv: list[str],
    cwd: Path | None = None,
    timeout: int = 120,
    event_cb: Any = None,
) -> dict[str, Any]:
    started = time.monotonic()
    process = subprocess.Popen(
        argv,
        cwd=str(cwd) if cwd else None,
        stdin=subprocess.DEVNULL,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        text=True,
        bufsize=1,
        shell=False,
        env={
            "HOME": os.environ.get("HOME", "/root"),
            "LANG": os.environ.get("LANG", "C.UTF-8"),
            "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
        },
    )
    lines: Queue[str | None] = Queue()

    def reader() -> None:
        assert process.stdout is not None
        for line in process.stdout:
            lines.put(line)
        lines.put(None)

    threading.Thread(target=reader, daemon=True).start()
    output_parts: list[str] = []
    output_size = 0
    batch: list[str] = []
    last_emit = time.monotonic()
    ended = False
    try:
        while not ended:
            if time.monotonic() - started > timeout:
                process.terminate()
                try:
                    process.wait(timeout=5)
                except subprocess.TimeoutExpired:
                    process.kill()
                    process.wait()
                raise subprocess.TimeoutExpired(argv, timeout)
            try:
                line = lines.get(timeout=0.5)
                if line is None:
                    ended = True
                else:
                    if output_size < MAX_OUTPUT_CHARS:
                        remaining = MAX_OUTPUT_CHARS - output_size
                        kept = line[:remaining]
                        output_parts.append(kept)
                        output_size += len(kept)
                    batch.append(line)
            except Empty:
                pass
            if batch and (
                sum(map(len, batch)) >= 8192
                or time.monotonic() - last_emit >= 1.0
                or ended
            ):
                if event_cb:
                    event_cb(
                        "output",
                        {
                            "text": "".join(batch)[:16_000],
                            "stream": "combined",
                        },
                    )
                batch.clear()
                last_emit = time.monotonic()
        return_code = process.wait()
        output = "".join(output_parts)
        return {
            "ok": return_code == 0,
            "exit_code": return_code,
            "duration_ms": round((time.monotonic() - started) * 1000),
            "output": output,
            "output_truncated": output_size >= MAX_OUTPUT_CHARS,
        }
    except subprocess.TimeoutExpired as exc:
        output = "".join(output_parts)
        return {
            "ok": False,
            "exit_code": None,
            "duration_ms": round((time.monotonic() - started) * 1000),
            "output": output,
            "output_truncated": output_size >= MAX_OUTPUT_CHARS,
            "error": "timeout",
        }


def execute(
    config: dict[str, Any], task: dict[str, Any], event_cb: Any = None
) -> dict[str, Any]:
    action = task["action"]
    args = task.get("args") or {}
    enabled = set(config.get("enabled_actions", []))
    if action not in enabled:
        raise PermissionError(f"action is disabled on this node: {action}")

    if action == "system.status":
        commands = [
            ["hostname"],
            ["uname", "-a"],
            ["uptime"],
            ["df", "-h", "/"],
        ]
        results = [
            run_process(command, timeout=30, event_cb=event_cb) for command in commands
        ]
        return {
            "ok": all(item["ok"] for item in results),
            "hostname": socket.gethostname(),
            "platform": platform.platform(),
            "commands": results,
        }

    if action == "repo.status":
        repo = allowed_path(config, args["repo"])
        return run_process(
            [
                "git",
                "-C",
                str(repo),
                "status",
                "--short",
                "--branch",
            ],
            timeout=60,
            event_cb=event_cb,
        )

    if action == "repo.fetch":
        repo = allowed_path(config, args["repo"])
        return run_process(
            ["git", "-C", str(repo), "fetch", "--prune", "origin"],
            timeout=300,
            event_cb=event_cb,
        )

    if action == "service.logs":
        return run_process(
            [
                "journalctl",
                "--no-pager",
                "-u",
                args["service"],
                "-n",
                str(args.get("lines", 200)),
            ],
            timeout=60,
            event_cb=event_cb,
        )

    if action == "command.run":
        argv = args["argv"]
        executable = argv[0]
        if "/" in executable or executable not in set(
            config.get("executable_allowlist", [])
        ):
            raise PermissionError(f"executable is not allowlisted: {executable}")
        if shutil.which(executable) is None:
            raise FileNotFoundError(f"executable is not installed: {executable}")
        cwd = allowed_path(config, args["cwd"])
        return run_process(
            argv,
            cwd=cwd,
            timeout=int(args.get("timeout_seconds", 300)),
            event_cb=event_cb,
        )

    raise ValueError(f"unsupported action: {action}")


def pair(config_path: str, code: str) -> None:
    config = read_config(config_path)
    if config.get("node_id") or config.get("node_token"):
        raise RuntimeError("node is already paired; remove its identity explicitly first")
    response = api(
        config,
        "POST",
        "/v1/pairings/claim",
        {
            "code": code,
            "node_name": config["node_name"],
            "agent_version": AGENT_VERSION,
            "policy": {
                "allowed_roots": config["allowed_roots"],
                "enabled_actions": config["enabled_actions"],
            },
        },
    )
    config["node_id"] = response["node_id"]
    config["node_token"] = response["node_token"]
    write_config(config_path, config)
    print(f"paired node {config['node_name']} as {config['node_id']}")


def run(config_path: str, once: bool = False) -> None:
    backoff = 2
    while True:
        config = read_config(config_path)
        node_id = config.get("node_id")
        node_token = config.get("node_token")
        if not node_id or not node_token:
            raise RuntimeError("node is not paired")
        try:
            api(
                config,
                "POST",
                f"/v1/nodes/{node_id}/heartbeat",
                {
                    "agent_version": AGENT_VERSION,
                    "policy": {
                        "allowed_roots": config["allowed_roots"],
                        "enabled_actions": config["enabled_actions"],
                    },
                },
                node_token,
            )
            response = api(
                config,
                "GET",
                f"/v1/nodes/{node_id}/tasks/next",
                token=node_token,
            )
            task = response.get("task")
            if task is not None:
                def emit(kind: str, payload: dict[str, Any]) -> None:
                    try:
                        api(
                            config,
                            "POST",
                            f"/v1/tasks/{task['id']}/events",
                            {"kind": kind, "payload": payload},
                            node_token,
                        )
                    except Exception as exc:
                        print(f"event upload failed: {exc}", file=sys.stderr)

                emit(
                    "started",
                    {
                        "action": task["action"],
                        "hostname": socket.gethostname(),
                        "agent_version": AGENT_VERSION,
                    },
                )
                try:
                    result = execute(config, task, emit)
                except Exception as exc:
                    result = {
                        "ok": False,
                        "error": type(exc).__name__,
                        "message": str(exc),
                    }
                emit(
                    "finished",
                    {
                        "ok": bool(result.get("ok")),
                        "exit_code": result.get("exit_code"),
                        "error": result.get("error"),
                    },
                )
                api(
                    config,
                    "POST",
                    f"/v1/tasks/{task['id']}/result",
                    result,
                    node_token,
                )
            backoff = 2
            if once:
                return
            time.sleep(float(config.get("poll_seconds", 5)))
        except (OSError, URLError, RuntimeError) as exc:
            print(f"connection error: {exc}; retrying in {backoff}s", file=sys.stderr)
            if once:
                raise
            time.sleep(backoff)
            backoff = min(backoff * 2, 120)


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "--config", default="/etc/canger-node/config.json", help="node config path"
    )
    sub = parser.add_subparsers(dest="command", required=True)
    pair_parser = sub.add_parser("pair")
    pair_parser.add_argument("--code", required=True)
    run_parser = sub.add_parser("run")
    run_parser.add_argument("--once", action="store_true")
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    if args.command == "pair":
        pair(args.config, args.code)
    else:
        run(args.config, args.once)


if __name__ == "__main__":
    main()
