from __future__ import annotations

import argparse
import asyncio
import ctypes
import getpass
import json
import locale
import os
import platform
import secrets
import shutil
import socket
import ssl
import sys
import uuid
from pathlib import Path
from typing import Any
from urllib.parse import urlparse, urlunparse

import websockets


AGENT_VERSION = "0.1.0"
MAX_OUTPUT_CHARS = 300_000


def default_config_path() -> Path:
    explicit = os.getenv("REMOTE_ADMIN_AGENT_CONFIG")
    if explicit:
        return Path(explicit)
    if os.name == "nt":
        local_app_data = os.getenv("LOCALAPPDATA")
        if local_app_data:
            return Path(local_app_data) / "RemoteDevops" / "agent.json"
        program_data = os.getenv("PROGRAMDATA", r"C:\ProgramData")
        return Path(program_data) / "RemoteDevops" / "agent.json"
    return Path.home() / ".remote_devops_agent.json"


def load_json(path: Path) -> dict[str, Any]:
    if not path.exists():
        return {}
    return json.loads(path.read_text(encoding="utf-8-sig"))


def save_json(path: Path, data: dict[str, Any]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
    try:
        path.chmod(0o600)
    except OSError:
        pass


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="RemoteDevops Windows agent")
    parser.add_argument("--server", help="Server URL, for example http://127.0.0.1:8000")
    parser.add_argument("--token", help="Enrollment token")
    parser.add_argument("--name", help="Friendly agent name")
    parser.add_argument("--config", type=Path, help="Agent config path")
    parser.add_argument(
        "--pair-only",
        action="store_true",
        help="Pair with the server, save the agent token, then exit.",
    )
    return parser.parse_args()


def merge_config(args: argparse.Namespace) -> tuple[Path, dict[str, Any]]:
    config_path = args.config or default_config_path()
    data = load_json(config_path)

    if args.server:
        data["server"] = args.server
    if args.token:
        data["token"] = args.token
    if args.name:
        data["name"] = args.name
    if not data.get("agent_id"):
        data["agent_id"] = str(uuid.uuid4())
    if not data.get("name"):
        data["name"] = socket.gethostname()

    if not data.get("server") and os.name == "nt" and sys.stdin.isatty() and sys.stdout.isatty():
        print("RemoteDevops agent setup")
        print(f"Config will be saved to: {config_path}")
        server = input("Server URL: ").strip()
        if server:
            data["server"] = server
        if not data.get("name"):
            name = input(f"Agent name [{socket.gethostname()}]: ").strip()
            data["name"] = name or socket.gethostname()

    missing = [key for key in ("server",) if not data.get(key)]
    if missing:
        joined = ", ".join(missing)
        raise SystemExit(f"Missing required agent config: {joined}")

    save_json(config_path, data)
    return config_path, data


def server_websocket_url(server_url: str, path: str) -> str:
    parsed = urlparse(server_url)
    if parsed.scheme not in {"http", "https", "ws", "wss"}:
        raise ValueError("server URL must start with http://, https://, ws:// or wss://")
    scheme = {"http": "ws", "https": "wss"}.get(parsed.scheme, parsed.scheme)
    return urlunparse((scheme, parsed.netloc, path, "", "", ""))


def websocket_url(server_url: str) -> str:
    return server_websocket_url(server_url, "/ws/agent")


def pairing_websocket_url(server_url: str) -> str:
    return server_websocket_url(server_url, "/ws/agent")


def bundled_ca_path() -> Path | None:
    candidates = [
        Path(__file__).resolve().with_name("cacert.pem"),
        Path(sys.executable).resolve().with_name("cacert.pem"),
    ]
    frozen_root = getattr(sys, "_MEIPASS", None)
    if frozen_root:
        candidates.append(Path(frozen_root) / "cacert.pem")

    for path in candidates:
        if path.is_file():
            return path
    return None


def websocket_connect_kwargs(url: str, max_size: int) -> dict[str, Any]:
    kwargs: dict[str, Any] = {
        "ping_interval": 20,
        "ping_timeout": 20,
        "max_size": max_size,
    }
    if urlparse(url).scheme != "wss":
        return kwargs

    verify = os.getenv("REMOTE_ADMIN_AGENT_TLS_VERIFY", "1").strip().lower()
    if verify in {"0", "false", "no", "off"}:
        kwargs["ssl"] = ssl._create_unverified_context()
        return kwargs

    ca_path = bundled_ca_path()
    kwargs["ssl"] = (
        ssl.create_default_context(cafile=str(ca_path))
        if ca_path is not None
        else ssl.create_default_context()
    )
    return kwargs


def is_admin() -> bool:
    if os.name != "nt":
        return os.geteuid() == 0 if hasattr(os, "geteuid") else False
    try:
        return bool(ctypes.windll.shell32.IsUserAnAdmin())
    except Exception:
        return False


def current_run_as() -> str:
    if os.name == "nt":
        domain = os.getenv("USERDOMAIN")
        username = os.getenv("USERNAME") or getpass.getuser()
        return f"{domain}\\{username}" if domain else username
    return getpass.getuser()


def hello_payload(config: dict[str, Any]) -> dict[str, Any]:
    username = getpass.getuser()
    return {
        "type": "hello",
        "agent_id": config["agent_id"],
        "name": config.get("name") or socket.gethostname(),
        "hostname": socket.gethostname(),
        "username": username,
        "run_as": current_run_as(),
        "os": platform.platform(),
        "version": AGENT_VERSION,
        "is_admin": is_admin(),
        "token": config["token"],
    }


def agent_identity_payload(config: dict[str, Any]) -> dict[str, Any]:
    username = getpass.getuser()
    return {
        "agent_id": config["agent_id"],
        "name": config.get("name") or socket.gethostname(),
        "hostname": socket.gethostname(),
        "username": username,
        "run_as": current_run_as(),
        "os": platform.platform(),
        "version": AGENT_VERSION,
        "is_admin": is_admin(),
    }


def truncate_output(value: str) -> tuple[str, bool]:
    if len(value) <= MAX_OUTPUT_CHARS:
        return value, False
    return value[:MAX_OUTPUT_CHARS], True


def command_args(shell: str, command: str) -> tuple[list[str], str]:
    if os.name == "nt":
        if shell == "cmd":
            return ["cmd.exe", "/d", "/s", "/c", command], "mbcs"
        utf8_command = (
            "$RemoteDevopsUtf8 = New-Object System.Text.UTF8Encoding $false; "
            "[Console]::OutputEncoding = $RemoteDevopsUtf8; "
            "$OutputEncoding = $RemoteDevopsUtf8; "
            + command
        )
        return (
            [
                "powershell.exe",
                "-NoLogo",
                "-NoProfile",
                "-NonInteractive",
                "-Command",
                utf8_command,
            ],
            "utf-8",
        )

    if shell == "powershell" and shutil.which("pwsh"):
        return (
            ["pwsh", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command],
            "utf-8",
        )

    executable = "/bin/bash" if Path("/bin/bash").exists() else "sh"
    return [executable, "-lc", command], locale.getpreferredencoding(False) or "utf-8"


async def execute_command(message: dict[str, Any]) -> dict[str, Any]:
    command_id = str(message.get("command_id") or "")
    shell = str(message.get("shell") or "powershell")
    command = str(message.get("command") or "")
    cwd = message.get("cwd") or None
    timeout_seconds = int(message.get("timeout_seconds") or 60)

    started_run_as = current_run_as()
    if not command_id or not command:
        return {
            "type": "command_result",
            "command_id": command_id,
            "status": "failed",
            "exit_code": None,
            "stdout": "",
            "stderr": "",
            "error": "Missing command_id or command.",
            "run_as": started_run_as,
        }

    try:
        args, output_encoding = command_args(shell, command)
        proc = await asyncio.create_subprocess_exec(
            *args,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
            cwd=cwd,
        )
        stdout_raw, stderr_raw = await asyncio.wait_for(
            proc.communicate(), timeout=timeout_seconds
        )
        encoding = output_encoding
        stdout = stdout_raw.decode(encoding, errors="replace")
        stderr = stderr_raw.decode(encoding, errors="replace")
        stdout, stdout_truncated = truncate_output(stdout)
        stderr, stderr_truncated = truncate_output(stderr)
        error = ""
        if stdout_truncated or stderr_truncated:
            error = f"Output truncated to {MAX_OUTPUT_CHARS} characters per stream."
        return {
            "type": "command_result",
            "command_id": command_id,
            "status": "succeeded" if proc.returncode == 0 else "failed",
            "exit_code": proc.returncode,
            "stdout": stdout,
            "stderr": stderr,
            "error": error,
            "run_as": started_run_as,
        }
    except asyncio.TimeoutError:
        proc.kill()
        await proc.wait()
        return {
            "type": "command_result",
            "command_id": command_id,
            "status": "timed_out",
            "exit_code": None,
            "stdout": "",
            "stderr": "",
            "error": f"Command exceeded {timeout_seconds} seconds.",
            "run_as": started_run_as,
        }
    except Exception as exc:
        return {
            "type": "command_result",
            "command_id": command_id,
            "status": "failed",
            "exit_code": None,
            "stdout": "",
            "stderr": "",
            "error": str(exc),
            "run_as": started_run_as,
        }


async def rotate_token(
    websocket: websockets.ClientConnection,
    config_path: Path,
    config: dict[str, Any],
    message: dict[str, Any],
) -> None:
    request_id = str(message.get("request_id") or "")
    try:
        agent_token = str(message.get("agent_token") or "")
        if not agent_token:
            raise ValueError("missing agent_token")
        config["token"] = agent_token
        config["token_type"] = "agent"
        save_json(config_path, config)
        await websocket.send(
            json.dumps(
                {
                    "type": "token_rotation_ack",
                    "request_id": request_id,
                    "ok": True,
                }
            )
        )
        print("Rotated per-agent token")
    except Exception as exc:
        await websocket.send(
            json.dumps(
                {
                    "type": "token_rotation_ack",
                    "request_id": request_id,
                    "ok": False,
                    "error": str(exc),
                }
            )
        )


async def heartbeat(websocket: websockets.ClientConnection) -> None:
    while True:
        await asyncio.sleep(20)
        await websocket.send(json.dumps({"type": "heartbeat"}))


async def receive_hello_ack(
    websocket: websockets.ClientConnection,
    config_path: Path,
    config: dict[str, Any],
) -> None:
    raw_message = await asyncio.wait_for(websocket.recv(), timeout=10)
    message = json.loads(raw_message)
    if message.get("type") != "hello_ack":
        raise RuntimeError("server did not acknowledge agent hello")

    agent_token = message.get("agent_token")
    if isinstance(agent_token, str) and agent_token:
        config["token"] = agent_token
        config["token_type"] = "agent"
        save_json(config_path, config)
        print("Received per-agent token and updated local config")


async def run_agent(config_path: Path, config: dict[str, Any]) -> None:
    backoff = 2
    while True:
        url = websocket_url(config["server"])
        try:
            async with websockets.connect(
                url, **websocket_connect_kwargs(url, 16 * 1024 * 1024)
            ) as websocket:
                backoff = 2
                await websocket.send(json.dumps(hello_payload(config)))
                await receive_hello_ack(websocket, config_path, config)
                heartbeat_task = asyncio.create_task(heartbeat(websocket))
                try:
                    async for raw_message in websocket:
                        message = json.loads(raw_message)
                        if message.get("type") == "command":
                            result = await execute_command(message)
                            await websocket.send(json.dumps(result))
                        elif message.get("type") == "rotate_token":
                            await rotate_token(websocket, config_path, config, message)
                finally:
                    heartbeat_task.cancel()
                    try:
                        await heartbeat_task
                    except asyncio.CancelledError:
                        pass
        except KeyboardInterrupt:
            raise
        except Exception as exc:
            print(f"Agent disconnected: {exc}. Reconnecting in {backoff}s", file=sys.stderr)
            await asyncio.sleep(backoff)
            backoff = min(backoff * 2, 30)


def ensure_pairing_values(config_path: Path, config: dict[str, Any]) -> None:
    if not config.get("pair_code"):
        config["pair_code"] = f"{secrets.randbelow(10_000):04d}"
    if not config.get("pair_secret"):
        config["pair_secret"] = secrets.token_urlsafe(32)
    save_json(config_path, config)


async def pair_agent(config_path: Path, config: dict[str, Any]) -> None:
    backoff = 2
    while not config.get("token"):
        ensure_pairing_values(config_path, config)
        code = str(config["pair_code"])
        print("")
        print("RemoteDevops pairing")
        print(f"Pairing code: {code}")
        print("Tell this 4-digit code to the administrator.")
        print("")

        url = pairing_websocket_url(config["server"])
        try:
            async with websockets.connect(
                url, **websocket_connect_kwargs(url, 1024 * 1024)
            ) as websocket:
                await websocket.send(
                    json.dumps(
                        {
                            "type": "pair_hello",
                            "code": code,
                            "pair_secret": config["pair_secret"],
                            **agent_identity_payload(config),
                        }
                    )
                )
                async for raw_message in websocket:
                    message = json.loads(raw_message)
                    message_type = message.get("type")
                    if message_type == "pair_registered":
                        print(f"Waiting for administrator approval: {code}")
                    elif message_type == "pair_conflict":
                        config.pop("pair_code", None)
                        config.pop("pair_secret", None)
                        save_json(config_path, config)
                        print("Pairing code conflict. Generating a new code.")
                        break
                    elif message_type == "pair_approved":
                        agent_token = str(message.get("agent_token") or "")
                        if not agent_token:
                            raise RuntimeError("server approved pairing without token")
                        config["token"] = agent_token
                        config["token_type"] = "agent"
                        config.pop("pair_code", None)
                        config.pop("pair_secret", None)
                        save_json(config_path, config)
                        print("Pairing approved. Agent token saved.")
                        return
                    elif message_type == "pair_rejected":
                        raise RuntimeError(str(message.get("error") or "pairing rejected"))
        except KeyboardInterrupt:
            raise
        except Exception as exc:
            print(f"Pairing disconnected: {exc}. Reconnecting in {backoff}s", file=sys.stderr)
            await asyncio.sleep(backoff)
            backoff = min(backoff * 2, 30)


def main() -> None:
    args = parse_args()
    config_path, config = merge_config(args)
    print(f"RemoteDevops agent {AGENT_VERSION}")
    print(f"Config: {config_path}")
    print(f"Agent id: {config['agent_id']}")
    print(f"Run as: {current_run_as()}")
    try:
        if not config.get("token"):
            asyncio.run(pair_agent(config_path, config))
            if args.pair_only:
                print("Pairing complete")
                return
        if args.pair_only:
            print("Agent is already paired")
            return
        asyncio.run(run_agent(config_path, config))
    except KeyboardInterrupt:
        print("Agent stopped")


if __name__ == "__main__":
    main()
