"""
RemoteLink Agent
Connects to the RemoteLink server, streams the screen, and handles remote input.

Usage:
  First run (self-register):
    python agent.py --server https://myserver.com --enroll <enrollment-token>

  Subsequent runs (use saved config):
    python agent.py

  Run-once mode (no config saved, exits when session ends):
    python agent.py --server https://myserver.com --enroll <token> --run-once
"""

import asyncio
import json
import logging
import os
import platform
import subprocess
import sys
import time
from io import BytesIO
from pathlib import Path
from typing import Optional
import argparse
import signal

# Third-party — installed via requirements.txt / bundled by PyInstaller
import httpx
import websockets
import websockets.exceptions
from mss import mss
from PIL import Image

log = logging.getLogger("remotelink-agent")

# ── Config ────────────────────────────────────────────────────────────────────

def config_dir() -> Path:
    """Platform-appropriate config directory."""
    if platform.system() == "Windows":
        base = os.environ.get("PROGRAMDATA", "C:\\ProgramData")
        return Path(base) / "RemoteLink"
    elif platform.system() == "Darwin":
        return Path.home() / "Library" / "Application Support" / "RemoteLink"
    else:
        return Path("/etc/remotelink")


CONFIG_FILE = config_dir() / "agent.json"
AGENT_VERSION = "1.0.0"


def load_config() -> Optional[dict]:
    if CONFIG_FILE.exists():
        try:
            return json.loads(CONFIG_FILE.read_text())
        except Exception:
            pass
    return None


def save_config(data: dict):
    config_dir().mkdir(parents=True, exist_ok=True)
    CONFIG_FILE.write_text(json.dumps(data, indent=2))
    log.info(f"Config saved to {CONFIG_FILE}")


# ── Registration ──────────────────────────────────────────────────────────────

async def register(server_url: str, enrollment_token: str) -> dict:
    """Self-register with the server using an enrollment token."""
    hostname = platform.node()
    os_name = platform.system()
    os_version = platform.version()

    url = f"{server_url.rstrip('/')}/api/agent/register"
    async with httpx.AsyncClient(timeout=30) as client:
        resp = await client.post(url, json={
            "enrollmentToken": enrollment_token,
            "name": hostname,
            "hostname": hostname,
            "os": os_name,
            "osVersion": os_version,
            "agentVersion": AGENT_VERSION,
            "ipAddress": None,
        })
        if resp.status_code != 200:
            raise RuntimeError(f"Registration failed: {resp.status_code} {resp.text}")
        data = resp.json()
        log.info(f"Registered as machine {data['machineId']}")
        return data


async def heartbeat(server_url: str, access_key: str) -> Optional[dict]:
    """Send heartbeat, returns pending connection info if any."""
    url = f"{server_url.rstrip('/')}/api/agent/heartbeat"
    try:
        async with httpx.AsyncClient(timeout=10) as client:
            resp = await client.post(url, json={"accessKey": access_key})
            if resp.status_code == 200:
                return resp.json()
    except Exception as e:
        log.warning(f"Heartbeat failed: {e}")
    return None


async def get_session_code(server_url: str, access_key: str) -> Optional[str]:
    """Request a new session code from the server."""
    url = f"{server_url.rstrip('/')}/api/agent/session-code"
    async with httpx.AsyncClient(timeout=10) as client:
        resp = await client.post(url, json={"accessKey": access_key})
        if resp.status_code == 200:
            data = resp.json()
            return data.get("code")
    return None


# ── Screen capture ────────────────────────────────────────────────────────────

class ScreenCapture:
    def __init__(self, fps: int = 15, quality: int = 60):
        self.fps = fps
        self.quality = quality
        self._frame_delay = 1.0 / fps
        self._sct = None

    def __enter__(self):
        self._sct = mss()
        return self

    def __exit__(self, *args):
        if self._sct:
            self._sct.close()

    def capture(self) -> bytes:
        monitor = self._sct.monitors[1]  # Primary monitor
        img = self._sct.grab(monitor)
        pil = Image.frombytes("RGB", img.size, img.bgra, "raw", "BGRX")
        buf = BytesIO()
        pil.save(buf, format="JPEG", quality=self.quality, optimize=False)
        return buf.getvalue()

    @property
    def frame_delay(self) -> float:
        return self._frame_delay


# ── Input control ─────────────────────────────────────────────────────────────

class InputController:
    """Replay mouse and keyboard events on the local machine."""

    def __init__(self):
        self._mouse = None
        self._keyboard = None
        self._available = False
        try:
            from pynput.mouse import Button, Controller as MouseController
            from pynput.keyboard import Key, Controller as KeyboardController
            self._mouse = MouseController()
            self._keyboard = KeyboardController()
            self._Button = Button
            self._Key = Key
            self._available = True
        except Exception as e:
            log.warning(f"Input control unavailable: {e}")

    def handle(self, event: dict):
        if not self._available:
            return
        try:
            t = event.get("type")
            if t == "mouse_move":
                self._mouse.position = (event["x"], event["y"])
            elif t == "mouse_click":
                self._mouse.position = (event["x"], event["y"])
                btn = self._Button.right if event.get("button") == "right" else self._Button.left
                if event.get("double"):
                    self._mouse.click(btn, 2)
                else:
                    self._mouse.click(btn, 1)
            elif t == "mouse_scroll":
                self._mouse.scroll(event.get("dx", 0), event.get("dy", 0))
            elif t == "key_press":
                key_str = event.get("key", "")
                self._keyboard.type(key_str)
            elif t == "key_special":
                # Special keys like Enter, Tab, Escape, etc.
                key_name = event.get("key", "")
                try:
                    key = getattr(self._Key, key_name)
                    self._keyboard.press(key)
                    self._keyboard.release(key)
                except AttributeError:
                    pass
        except Exception as e:
            log.debug(f"Input error: {e}")


# ── Script execution ──────────────────────────────────────────────────────────

async def exec_script(script: str, shell: str, session_id: str, ws) -> None:
    """Execute a script and stream output back through the WebSocket."""
    exec_id = str(time.time())
    if platform.system() == "Windows":
        if shell == "powershell":
            cmd = ["powershell", "-NonInteractive", "-Command", script]
        else:
            cmd = ["cmd", "/c", script]
    else:
        cmd = ["bash", "-c", script]

    try:
        proc = await asyncio.create_subprocess_exec(
            *cmd,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.STDOUT,
        )
        async for line in proc.stdout:
            await ws.send(json.dumps({
                "type": "script_output",
                "session_id": session_id,
                "id": exec_id,
                "output": line.decode(errors="replace"),
                "done": False,
            }))
        await proc.wait()
        await ws.send(json.dumps({
            "type": "script_output",
            "session_id": session_id,
            "id": exec_id,
            "output": "",
            "done": True,
            "exit_code": proc.returncode,
        }))
    except Exception as e:
        await ws.send(json.dumps({
            "type": "script_output",
            "session_id": session_id,
            "id": exec_id,
            "output": f"Error: {e}\n",
            "done": True,
            "exit_code": -1,
        }))


# ── Main agent loop ───────────────────────────────────────────────────────────

class Agent:
    def __init__(self, server_url: str, machine_id: str, access_key: str, relay_url: str):
        self.server_url = server_url
        self.machine_id = machine_id
        self.access_key = access_key
        self.relay_url = relay_url.rstrip("/")

        self._streaming = False
        self._active_session: Optional[str] = None
        self._input = InputController()
        self._stop_event = asyncio.Event()

    async def run(self):
        log.info(f"Agent starting. Machine ID: {self.machine_id}")
        log.info(f"Connecting to relay: {self.relay_url}")

        # Heartbeat loop in background
        asyncio.create_task(self._heartbeat_loop())

        # Connect to relay (with reconnect)
        while not self._stop_event.is_set():
            try:
                await self._connect()
            except Exception as e:
                log.warning(f"Relay disconnected: {e}. Reconnecting in 5s…")
                await asyncio.sleep(5)

    async def _heartbeat_loop(self):
        while not self._stop_event.is_set():
            await heartbeat(self.server_url, self.access_key)
            await asyncio.sleep(30)

    async def _connect(self):
        ws_url = (
            f"{self.relay_url}/ws/agent"
            f"?machine_id={self.machine_id}&access_key={self.access_key}"
        )
        async with websockets.connect(ws_url, ping_interval=20, ping_timeout=10) as ws:
            log.info("Connected to relay")
            await self._message_loop(ws)

    async def _message_loop(self, ws):
        with ScreenCapture(fps=15, quality=60) as screen:
            stream_task: Optional[asyncio.Task] = None

            async def stream_frames():
                while self._streaming and not self._stop_event.is_set():
                    t0 = time.monotonic()
                    try:
                        frame = screen.capture()
                        await ws.send(frame)
                    except Exception:
                        break
                    elapsed = time.monotonic() - t0
                    delay = max(0, screen.frame_delay - elapsed)
                    await asyncio.sleep(delay)

            try:
                async for raw_msg in ws:
                    if isinstance(raw_msg, bytes):
                        continue  # agents don't receive binary

                    try:
                        msg = json.loads(raw_msg)
                    except json.JSONDecodeError:
                        continue

                    msg_type = msg.get("type")

                    if msg_type == "start_stream":
                        session_id = msg.get("session_id")
                        log.info(f"Viewer connected — session {session_id}")
                        self._streaming = True
                        self._active_session = session_id
                        if stream_task and not stream_task.done():
                            stream_task.cancel()
                        stream_task = asyncio.create_task(stream_frames())

                    elif msg_type == "stop_stream":
                        log.info("Viewer disconnected — stopping stream")
                        self._streaming = False
                        self._active_session = None
                        if stream_task and not stream_task.done():
                            stream_task.cancel()

                    elif msg_type in ("mouse_move", "mouse_click", "mouse_scroll",
                                      "key_press", "key_special"):
                        self._input.handle(msg)

                    elif msg_type == "exec_script":
                        asyncio.create_task(exec_script(
                            msg.get("script", ""),
                            msg.get("shell", "bash"),
                            msg.get("session_id", ""),
                            ws,
                        ))

                    elif msg_type == "ping":
                        await ws.send(json.dumps({"type": "pong"}))

            finally:
                self._streaming = False
                if stream_task and not stream_task.done():
                    stream_task.cancel()

    def stop(self):
        self._stop_event.set()


# ── Helpers ───────────────────────────────────────────────────────────────────

def _default_relay_url(server_url: str) -> str:
    """Derive the relay WebSocket URL from a server HTTP URL.

    Strips any existing port, swaps the scheme, and appends :8765.
    e.g. http://10.10.20.70:3000  →  ws://10.10.20.70:8765
         https://remotelink.example.com  →  wss://remotelink.example.com:8765
    """
    from urllib.parse import urlparse, urlunparse
    parsed = urlparse(server_url)
    ws_scheme = "wss" if parsed.scheme == "https" else "ws"
    # netloc may include a port — strip it, use only the hostname
    host = parsed.hostname
    return f"{ws_scheme}://{host}:8765"


# ── Entry point ───────────────────────────────────────────────────────────────

def setup_logging(verbose: bool):
    level = logging.DEBUG if verbose else logging.INFO
    logging.basicConfig(
        level=level,
        format="%(asctime)s [%(levelname)s] %(message)s",
        handlers=[logging.StreamHandler()],
    )
    # Also log to file
    log_dir = config_dir()
    log_dir.mkdir(parents=True, exist_ok=True)
    fh = logging.FileHandler(log_dir / "agent.log")
    fh.setLevel(level)
    fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
    logging.getLogger().addHandler(fh)


async def main():
    parser = argparse.ArgumentParser(description="RemoteLink Agent")
    parser.add_argument("--server", help="Server URL (e.g. https://remotelink.example.com)")
    parser.add_argument("--relay", help="Relay WebSocket URL (e.g. ws://remotelink.example.com:8765)")
    parser.add_argument("--enroll", metavar="TOKEN", help="Enrollment token for first-time registration")
    parser.add_argument("--run-once", action="store_true", help="Exit after first session ends")
    parser.add_argument("--verbose", "-v", action="store_true")
    args = parser.parse_args()

    setup_logging(args.verbose)

    config = load_config()

    # ── First-time registration ──────────────────────────────────────────────
    if args.enroll:
        if not args.server:
            log.error("--server is required with --enroll")
            sys.exit(1)
        log.info(f"Enrolling with server {args.server}…")
        reg = await register(args.server, args.enroll)
        relay_url = args.relay or _default_relay_url(args.server)
        config = {
            "server_url": args.server,
            "relay_url": relay_url,
            "machine_id": reg["machineId"],
            "access_key": reg["accessKey"],
        }
        if not args.run_once:
            save_config(config)

    elif not config:
        log.error(
            f"No config found at {CONFIG_FILE}.\n"
            "Run with --server <url> --enroll <token> to register this machine."
        )
        sys.exit(1)

    server_url = config["server_url"]
    relay_url = config.get("relay_url") or _default_relay_url(server_url)
    machine_id = config["machine_id"]
    access_key = config["access_key"]

    agent = Agent(server_url, machine_id, access_key, relay_url)

    # Handle Ctrl+C / SIGTERM gracefully
    loop = asyncio.get_event_loop()
    for sig in (signal.SIGINT, signal.SIGTERM):
        try:
            loop.add_signal_handler(sig, agent.stop)
        except NotImplementedError:
            pass  # Windows

    log.info("RemoteLink Agent running. Press Ctrl+C to stop.")
    await agent.run()


if __name__ == "__main__":
    asyncio.run(main())
