# SPDX-License-Identifier: GPL-2.0-only
#
# llamachat - a small native chat client for a local llama.cpp router
# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
"""Unix-socket control channel.

Protocol: one JSON object per line, one response line per request.

    -> {"cmd": "toggle"}    <- {"ok": true, "visible": false}
    -> {"cmd": "show"}      <- {"ok": true, "visible": true}
    -> {"cmd": "hide"}      <- {"ok": true, "visible": false}
    -> {"cmd": "ping"}      <- {"ok": true}
    -> {"cmd": "quit"}      <- {"ok": true}

Unknown commands answer {"ok": false, "error": "..."}.

This module imports nothing from Qt, so the client path stays fast enough
to sit behind a keybind.
"""

import json
import socket
from pathlib import Path

COMMANDS = ("toggle", "show", "hide", "ping", "quit")


def send(socket_path: Path, cmd: str, timeout: float = 2.0) -> dict:
    """Send one command to a running instance and return its reply."""
    sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    sock.settimeout(timeout)
    try:
        sock.connect(str(socket_path))
        sock.sendall((json.dumps({"cmd": cmd}) + "\n").encode())
        buf = b""
        while not buf.endswith(b"\n"):
            chunk = sock.recv(4096)
            if not chunk:
                break
            buf += chunk
    finally:
        sock.close()
    if not buf:
        return {"ok": False, "error": "no reply"}
    try:
        return json.loads(buf.decode().strip())
    except ValueError as exc:
        return {"ok": False, "error": f"bad reply: {exc}"}


def is_running(socket_path: Path) -> bool:
    """True when a live instance answers on the socket."""
    if not socket_path.exists():
        return False
    try:
        return bool(send(socket_path, "ping").get("ok"))
    except OSError:
        return False


def clear_stale(socket_path: Path) -> None:
    """Remove a socket file left behind by a crashed instance."""
    if socket_path.exists() and not is_running(socket_path):
        socket_path.unlink(missing_ok=True)


def create_server(socket_path: Path) -> socket.socket:
    """Bind and listen. Caller must have cleared any stale socket first."""
    socket_path.parent.mkdir(parents=True, exist_ok=True)
    srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    srv.bind(str(socket_path))
    socket_path.chmod(0o600)
    srv.listen(4)
    srv.setblocking(False)
    return srv


def read_request(conn: socket.socket) -> str:
    """Read one command name from a connection. '' when unreadable."""
    conn.settimeout(2.0)
    buf = b""
    try:
        while not buf.endswith(b"\n") and len(buf) < 4096:
            chunk = conn.recv(4096)
            if not chunk:
                break
            buf += chunk
    except OSError:
        return ""
    try:
        return str(json.loads(buf.decode().strip()).get("cmd", ""))
    except ValueError:
        return ""


def write_reply(conn: socket.socket, payload: dict) -> None:
    try:
        conn.sendall((json.dumps(payload) + "\n").encode())
    except OSError:
        pass
