#!/usr/bin/env python3
"""3D Print Pricing — прямая связь с Klipper-принтером через Moonraker,
БЕЗ Home Assistant.

Что делает: подключается к Moonraker по WebSocket (тот же протокол, что
используют Fluidd/Mainsail/Spoolman), следит за состоянием печати
(print_stats.state) и сам меняет статус заказа в 3D Print Pricing через
PUT /print-jobs/{id} — то есть закрывает ровно ту же цепочку, что и
интеграция с Home Assistant, но без промежуточного HA-хаба, если он у вас
не стоит.

Как 3DP узнаёт, какой заказ печатается: скрипт ищет UUID заказа прямо в
имени G-code файла. Проще всего — называть файл при экспорте из слайсера
так: <order_id>.gcode (полный UUID заказа, скопированный из карточки
заказа в 3DP, или из sandbox на https://api.3dprintpricing.pro/). Скрипт
ищет любую UUID-подстроку в имени файла, поэтому префикс/суффикс не
критичен: "b6b6c8b0-....gcode" и "bracket_b6b6c8b0-....gcode" оба сработают.

Установка:
    pip install websockets
    python3 moonraker-order-watcher.py --moonraker-ip 192.168.1.50 \\
        --api-base https://3dprintpricing.pro/api --token pat_xxxxxxxx

Документация: https://api.3dprintpricing.pro/
"""
import argparse
import asyncio
import itertools
import json
import logging
import re
import urllib.error
import urllib.request

import websockets

log = logging.getLogger("moonraker-order-watcher")

_UUID_RE = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")

# Moonraker print_stats.state -> статус заказа в 3DP. "paused" сознательно
# не меняет статус — печать на паузе всё ещё "printing" с точки зрения заказа.
_STATE_TO_WORKFLOW = {
    "printing": "printing",
    "complete": "done",
    "cancelled": "cancelled",
    "error": "print_error",
}

_OBJECTS = {"print_stats": None}
_ids = itertools.count(1)


def extract_order_id(filename: str) -> str | None:
    match = _UUID_RE.search(filename or "")
    return match.group(0) if match else None


def update_order_status(api_base: str, token: str, order_id: str, status: str) -> None:
    url = f"{api_base.rstrip('/')}/print-jobs/{order_id}"
    body = json.dumps({"workflow_status": status}).encode("utf-8")
    req = urllib.request.Request(url, data=body, method="PUT", headers={
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/json",
    })
    try:
        with urllib.request.urlopen(req, timeout=10) as resp:
            log.info("order %s -> %s (HTTP %s)", order_id, status, resp.status)
    except urllib.error.HTTPError as exc:
        log.error("order %s -> %s FAILED: HTTP %s %s", order_id, status, exc.code, exc.read().decode(errors="replace"))
    except urllib.error.URLError as exc:
        log.error("order %s -> %s FAILED: %s", order_id, status, exc)


async def watch(moonraker_ip: str, moonraker_port: int, api_base: str, token: str) -> None:
    url = f"ws://{moonraker_ip}:{moonraker_port}/websocket"
    # (order_id, workflow_status), чтобы не слать один и тот же PUT
    # повторно на каждое notify_status_update — Moonraker шлёт их часто.
    last_sent: tuple[str, str] | None = None

    while True:
        try:
            async with websockets.connect(url, open_timeout=10) as ws:
                log.info("Подключено к Moonraker %s:%s", moonraker_ip, moonraker_port)
                await ws.send(json.dumps({
                    "jsonrpc": "2.0",
                    "method": "printer.objects.subscribe",
                    "params": {"objects": _OBJECTS},
                    "id": next(_ids),
                }))

                async for raw in ws:
                    try:
                        msg = json.loads(raw)
                    except Exception:
                        continue

                    print_stats = None
                    if "result" in msg and isinstance(msg["result"], dict):
                        print_stats = msg["result"].get("status", {}).get("print_stats")
                    elif msg.get("method") == "notify_status_update":
                        params = msg.get("params") or []
                        if params and isinstance(params[0], dict):
                            print_stats = params[0].get("print_stats")
                    if not print_stats:
                        continue

                    state = (print_stats.get("state") or "").lower()
                    filename = print_stats.get("filename")
                    workflow_status = _STATE_TO_WORKFLOW.get(state)
                    if not workflow_status or not filename:
                        continue

                    order_id = extract_order_id(filename)
                    if not order_id:
                        log.debug("В имени файла %r не найден UUID заказа — пропускаю", filename)
                        continue

                    key = (order_id, workflow_status)
                    if key == last_sent:
                        continue
                    last_sent = key
                    update_order_status(api_base, token, order_id, workflow_status)
        except Exception as exc:
            log.warning("Соединение с Moonraker потеряно: %s — переподключение через 10с", exc)
            await asyncio.sleep(10)


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument("--moonraker-ip", required=True, help="IP принтера/хоста Moonraker")
    parser.add_argument("--moonraker-port", type=int, default=7125)
    parser.add_argument("--api-base", default="https://3dprintpricing.pro/api", help="Базовый URL API 3D Print Pricing")
    parser.add_argument("--token", required=True, help="Персональный токен (PAT), pat_...")
    args = parser.parse_args()

    logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
    asyncio.run(watch(args.moonraker_ip, args.moonraker_port, args.api_base, args.token))


if __name__ == "__main__":
    main()
