from __future__ import annotations

import asyncio
import contextlib
import ctypes
import json
import os
import platform
import socket
import threading
import sys
import time
import uuid
from pathlib import Path
from typing import Any
from urllib.parse import urlparse

import requests
from websockets.client import connect

try:
    from winotify import Notification, audio
except Exception:
    Notification = None
    audio = None

try:
    import pystray
    from PIL import Image, ImageDraw, ImageTk
except Exception:
    pystray = None
    Image = None
    ImageDraw = None
    ImageTk = None

try:
    import tkinter as tk
except Exception:
    tk = None

AGENT_VERSION = "4.0.0-alpha"
APP_NAME = "NotifyPulseAgent"
SPI_SETDESKWALLPAPER = 0x0014
SPIF_UPDATEINIFILE = 0x01
SPIF_SENDCHANGE = 0x02


def appdata_dir() -> Path:
    base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
    path = base / "NotifyPulseV4"
    path.mkdir(parents=True, exist_ok=True)
    (path / "cache").mkdir(parents=True, exist_ok=True)
    return path


DATA_DIR = appdata_dir()
CLIENT_FILE = DATA_DIR / "client.json"
SETTINGS_FILE = DATA_DIR / "settings.json"
STOP_EVENT = threading.Event()


def load_json(path: Path, fallback: Any) -> Any:
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except Exception:
        return fallback


def save_json(path: Path, payload: Any) -> None:
    path.write_text(json.dumps(payload, indent=2), encoding="utf-8")


def app_base_dir() -> Path:
    if getattr(sys, "frozen", False):
        return Path(sys.executable).resolve().parent
    return Path(__file__).resolve().parent


def load_agent_config() -> dict[str, Any]:
    cfg_file = app_base_dir() / "agent_config.json"
    cfg = load_json(cfg_file, {})
    return cfg if isinstance(cfg, dict) else {}


def load_or_create_client_id() -> str:
    raw = load_json(CLIENT_FILE, {})
    did = raw.get("device_id", "").strip()
    if did:
        return did
    did = f"win_{uuid.uuid4().hex[:12]}"
    save_json(CLIENT_FILE, {"device_id": did, "created_at": int(time.time())})
    return did


DEVICE_ID = load_or_create_client_id()
_cfg = load_agent_config()
SERVER_URL = str(
    os.getenv("NP4_SERVER_URL", _cfg.get("server_url", "http://127.0.0.1:8080"))
).rstrip("/")
API_TOKEN = str(os.getenv("NP4_API_TOKEN", _cfg.get("api_token", "")))


def http_headers() -> dict[str, str]:
    headers: dict[str, str] = {}
    if API_TOKEN:
        headers["X-Api-Token"] = API_TOKEN
    return headers


def ws_url_from_http(base: str) -> str:
    parsed = urlparse(base)
    scheme = "wss" if parsed.scheme == "https" else "ws"
    netloc = parsed.netloc
    return f"{scheme}://{netloc}/ws/device"


def toast(title: str, message: str) -> None:
    if Notification is None:
        print(f"[notify fallback] {title}: {message}")
        return
    n = Notification(app_id=APP_NAME, title=title, msg=message, duration="short")
    if audio:
        n.set_audio(audio.Default, loop=False)
    n.show()


def _load_tray_icon_image():
    if Image is None:
        return None
    icon_path = app_base_dir() / "icon.ico"
    if icon_path.exists():
        try:
            return Image.open(icon_path)
        except Exception:
            pass
    png_path = app_base_dir() / "icon.png"
    if png_path.exists():
        try:
            return Image.open(png_path)
        except Exception:
            pass
    img = Image.new("RGBA", (64, 64), (0, 0, 0, 0))
    d = ImageDraw.Draw(img)
    d.ellipse((8, 8, 56, 56), fill=(79, 142, 247, 255))
    d.ellipse((20, 20, 44, 44), fill=(255, 255, 255, 235))
    return img


def start_tray() -> None:
    if pystray is None:
        return

    def on_quit(icon, _item):
        STOP_EVENT.set()
        icon.stop()

    image = _load_tray_icon_image()
    if image is None:
        return
    icon = pystray.Icon(
        "NotifyPulseAgentV4",
        image,
        f"NotifyPulse Agent V4 ({DEVICE_ID})",
        menu=pystray.Menu(pystray.MenuItem("Quit", on_quit)),
    )
    t = threading.Thread(target=icon.run, daemon=True)
    t.start()


def set_wallpaper(path: str) -> bool:
    try:
        ok = ctypes.windll.user32.SystemParametersInfoW(
            SPI_SETDESKWALLPAPER, 0, str(path), SPIF_UPDATEINIFILE | SPIF_SENDCHANGE
        )
        return bool(ok)
    except Exception:
        return False


def _download_to_cache(url: str) -> Path:
    ext = Path(urlparse(url).path).suffix.lower() or ".jpg"
    out = DATA_DIR / "cache" / f"wallpaper_{int(time.time())}{ext}"
    r = requests.get(url, headers=http_headers(), timeout=30)
    r.raise_for_status()
    out.write_bytes(r.content)
    return out


def _prepare_overlay_image(pil_img, sw: int, sh: int, stretch: bool):
    if stretch:
        return pil_img.resize((sw, sh), Image.Resampling.LANCZOS)
    img_w, img_h = pil_img.size
    scale = min(sw / img_w, sh / img_h)
    nw, nh = int(img_w * scale), int(img_h * scale)
    resized = pil_img.resize((nw, nh), Image.Resampling.LANCZOS)
    canvas = Image.new("RGB", (sw, sh), "black")
    canvas.paste(resized, ((sw - nw) // 2, (sh - nh) // 2))
    return canvas


def show_overlay(path: str, duration_ms: int, opacity: float = 0.4, stretch: bool = False) -> bool:
    if tk is None or Image is None or ImageTk is None:
        return False

    try:
        pil_img = Image.open(path)
    except Exception:
        return False

    def _run():
        try:
            root = tk.Tk()
            root.withdraw()
            sw, sh = root.winfo_screenwidth(), root.winfo_screenheight()
            win = tk.Toplevel(root)
            win.attributes("-topmost", True)
            win.attributes("-alpha", max(0.05, min(1.0, float(opacity))))
            win.overrideredirect(True)
            win.geometry(f"{sw}x{sh}+0+0")
            prepared = _prepare_overlay_image(pil_img, sw, sh, stretch)
            tk_img = ImageTk.PhotoImage(prepared)
            label = tk.Label(win, image=tk_img, bg="black")
            label.image = tk_img
            label.pack(fill="both", expand=True)
            win.after(max(500, int(duration_ms)), root.destroy)
            root.mainloop()
        except Exception:
            pass

    threading.Thread(target=_run, daemon=True).start()
    return True


def version_tuple(v: str) -> tuple[int, ...]:
    nums: list[int] = []
    for part in v.replace("-", ".").split("."):
        if part.isdigit():
            nums.append(int(part))
        else:
            break
    return tuple(nums or [0])


async def send_event(ws, name: str, data: dict[str, Any]) -> None:
    await ws.send(json.dumps({"type": "event", "name": name, "data": data}))


async def send_ack(ws, command_id: str, status: str = "ok", detail: str = "") -> None:
    await ws.send(
        json.dumps(
            {
                "type": "ack",
                "command_id": command_id,
                "status": status,
                "detail": detail,
            }
        )
    )


def load_local_settings() -> dict[str, Any]:
    return load_json(SETTINGS_FILE, {})


def save_local_settings(settings: dict[str, Any]) -> None:
    save_json(SETTINGS_FILE, settings)


async def apply_command(ws, msg: dict[str, Any]) -> None:
    cid = str(msg.get("id", ""))
    action = str(msg.get("action", ""))
    data = msg.get("data", {}) if isinstance(msg.get("data"), dict) else {}

    try:
        if action == "notify":
            title = str(data.get("title", "NotifyPulse"))
            message = str(data.get("message", ""))
            toast(title, message)
            await send_ack(ws, cid, "ok", "notification shown")
            return

        if action == "wallpaper_set":
            path = str(data.get("path", "")).strip()
            url = str(data.get("url", "")).strip()
            if url:
                local = _download_to_cache(url)
                ok = set_wallpaper(str(local))
            elif path:
                ok = set_wallpaper(path)
            else:
                await send_ack(ws, cid, "error", "missing wallpaper path/url")
                return
            await send_ack(ws, cid, "ok" if ok else "error", "wallpaper applied" if ok else "wallpaper failed")
            return

        if action == "overlay_show":
            path = str(data.get("path", "")).strip()
            url = str(data.get("url", "")).strip()
            duration_ms = int(data.get("duration_ms", 6000))
            opacity = float(data.get("opacity", 0.4))
            stretch = bool(data.get("stretch", False))
            if url:
                local = _download_to_cache(url)
                ok = show_overlay(str(local), duration_ms, opacity=opacity, stretch=stretch)
            elif path:
                ok = show_overlay(path, duration_ms, opacity=opacity, stretch=stretch)
            else:
                await send_ack(ws, cid, "error", "missing overlay path/url")
                return
            await send_ack(ws, cid, "ok" if ok else "error", "overlay shown" if ok else "overlay failed")
            return

        if action == "sync_settings":
            settings = data.get("settings", {})
            if isinstance(settings, dict):
                save_local_settings(settings)
            await send_ack(ws, cid, "ok", "settings synced")
            return

        if action == "ping":
            await send_ack(ws, cid, "ok", "pong")
            return

        await send_ack(ws, cid, "unsupported", f"unknown action: {action}")
    except Exception as exc:
        await send_ack(ws, cid, "error", str(exc))


async def heartbeat_loop(ws) -> None:
    while not STOP_EVENT.is_set():
        await ws.send(json.dumps({"type": "heartbeat", "time": int(time.time())}))
        await asyncio.sleep(15)


def check_update_manifest() -> tuple[bool, dict[str, Any]]:
    try:
        r = requests.get(
            f"{SERVER_URL}/api/v4/update/manifest/windows",
            headers=http_headers(),
            timeout=10,
        )
        r.raise_for_status()
        manifest = r.json()
        remote_v = str(manifest.get("version", "0.0.0"))
        return version_tuple(remote_v) > version_tuple(AGENT_VERSION), manifest
    except Exception:
        return False, {}


async def run_client() -> None:
    ws_url = ws_url_from_http(SERVER_URL)
    backoff_s = 2
    while not STOP_EVENT.is_set():
        try:
            async with connect(ws_url, ping_interval=20, ping_timeout=20, open_timeout=15) as ws:
                hello = {
                    "type": "hello",
                    "token": API_TOKEN,
                    "payload": {
                        "device_id": DEVICE_ID,
                        "platform": "windows",
                        "version": AGENT_VERSION,
                        "hostname": socket.gethostname(),
                        "capabilities": ["notify", "wallpaper_set", "overlay_show", "sync_settings"],
                    },
                }
                await ws.send(json.dumps(hello))

                heartbeat_task = asyncio.create_task(heartbeat_loop(ws))
                try:
                    update_available, manifest = check_update_manifest()
                    if update_available:
                        await send_event(
                            ws,
                            "update_available",
                            {
                                "current_version": AGENT_VERSION,
                                "target_version": manifest.get("version", ""),
                                "download_url": manifest.get("download_url", ""),
                            },
                        )

                    while True:
                        raw = await ws.recv()
                        msg = json.loads(raw)
                        mtype = msg.get("type", "")
                        if mtype == "welcome":
                            incoming = msg.get("settings")
                            if isinstance(incoming, dict):
                                save_local_settings(incoming)
                        elif mtype == "command":
                            await apply_command(ws, msg)
                        elif mtype == "heartbeat_ack":
                            pass
                finally:
                    heartbeat_task.cancel()
                    with contextlib.suppress(Exception):
                        await heartbeat_task

            backoff_s = 2
        except Exception as exc:
            print(f"[agent] disconnected: {exc}")
            await asyncio.sleep(backoff_s)
            backoff_s = min(backoff_s * 2, 30)


def main() -> None:
    print(f"{APP_NAME} {AGENT_VERSION}")
    print(f"Server: {SERVER_URL}")
    print(f"Device: {DEVICE_ID}")
    print(f"Host: {platform.node()}")
    start_tray()
    toast("NotifyPulse Agent", "Agent started and connecting to server")
    asyncio.run(run_client())


if __name__ == "__main__":
    main()
