# 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.
"""Entry point.

The control commands (--toggle, --show, --hide, --ping, --quit) answer
without importing Qt, so a keybind press stays cheap.
"""

import argparse
import os
import sys
from pathlib import Path

from . import __version__, config, ipc

# Where a distribution keeps its Qt6 plugins. Checked in order.
SYSTEM_QT_PLUGIN_DIRS = (
    "/usr/lib64/qt6/plugins",
    "/usr/lib/qt6/plugins",
    "/usr/lib/x86_64-linux-gnu/qt6/plugins",
)

USAGE = f"""\
llamachat {__version__} - chat client for a local llama.cpp router

  llamachat --daemon    start the background instance (Hyprland autostart)
  llamachat --toggle    show the window if hidden, hide it if visible
  llamachat --show      show the window
  llamachat --hide      hide the window
  llamachat --ping      exit 0 when an instance is running
  llamachat --quit      stop the running instance
  llamachat --version   print the version and exit
"""


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(add_help=False)
    parser.add_argument("--daemon", action="store_true")
    parser.add_argument("--toggle", action="store_true")
    parser.add_argument("--show", action="store_true")
    parser.add_argument("--hide", action="store_true")
    parser.add_argument("--ping", action="store_true")
    parser.add_argument("--quit", action="store_true")
    parser.add_argument("-h", "--help", action="store_true")
    parser.add_argument("--version", action="store_true")
    args = parser.parse_args(argv)

    if args.help:
        print(USAGE)
        return 0

    if args.version:
        print(f"llamachat {__version__}")
        return 0

    try:
        cfg = config.load()
    except Exception as exc:
        # Deliberately broad. The point is that no config problem may kill a
        # launch silently, and enumerating the coercion errors would be both
        # longer and out of date the next time a config key is added.
        #
        # Failing beats falling back to DEFAULTS: that would quietly point the
        # app at localhost:8181 and a presets.ini the user may not have, which
        # is a different app than the one they configured.
        print(
            f"llamachat: {config.CONFIG_PATH} is unusable: {exc}",
            file=sys.stderr,
        )
        return 1

    for name in ("toggle", "show", "hide", "ping", "quit"):
        if getattr(args, name):
            return _control(cfg, name)

    return _run_daemon(cfg)


def _control(cfg, command: str) -> int:
    """Send one command to the running instance."""
    if not cfg.socket_path.exists():
        print("llamachat is not running", file=sys.stderr)
        return 1
    try:
        reply = ipc.send(cfg.socket_path, command)
    except OSError as exc:
        print(f"llamachat is not reachable: {exc}", file=sys.stderr)
        return 1
    if not reply.get("ok"):
        print(reply.get("error", "command failed"), file=sys.stderr)
        return 1
    return 0


def _system_qt_version() -> str:
    """Qt6 version of the system install, or '' when it cannot be told."""
    for directory in SYSTEM_QT_PLUGIN_DIRS:
        path = Path(directory)
        if not path.is_dir():
            continue
        # libQt6Core.so.6.11.1 -> 6.11.1
        for libdir in (path.parent.parent, Path("/usr/lib64"), Path("/usr/lib")):
            for lib in sorted(libdir.glob("libQt6Core.so.6.*")):
                suffix = lib.name.split("libQt6Core.so.", 1)[1]
                if suffix.count(".") == 2:
                    return suffix
    return ""


def _use_system_qt_theme() -> None:
    """Let the system platform theme plugins load into a venv PySide6.

    A pip-installed PySide6 bundles its own Qt but almost no plugins, so
    QT_QPA_PLATFORMTHEME=qt6ct finds nothing and everything falls back to
    Fusion. Adding the system plugin directory fixes that, but only when
    the two Qt builds are the same version: loading a plugin built against
    a different Qt would crash rather than look wrong.
    """
    if os.environ.get("QT_PLUGIN_PATH"):
        return  # the user has already chosen; do not override

    from PySide6.QtCore import qVersion

    system = _system_qt_version()
    if not system or system != qVersion():
        return

    for directory in SYSTEM_QT_PLUGIN_DIRS:
        if Path(directory, "platformthemes").is_dir():
            os.environ["QT_PLUGIN_PATH"] = directory
            return


def _run_daemon(cfg) -> int:
    """Start the persistent instance: window hidden, tray icon, socket."""
    ipc.clear_stale(cfg.socket_path)
    if cfg.socket_path.exists():
        print("llamachat is already running", file=sys.stderr)
        return 1

    # Must happen before QApplication exists, or the theme plugin is never
    # consulted.
    _use_system_qt_theme()

    # Heavy imports live here so the control path above stays fast.
    from PySide6.QtCore import QSocketNotifier, Qt
    from PySide6.QtGui import QAction, QIcon, QPixmap, QPainter, QColor, QFont
    from PySide6.QtWidgets import QApplication, QMenu, QSystemTrayIcon

    from .backend import MultiClient
    from .db import History
    from . import models
    from .ui import ChatWindow

    config.write_default()

    app = QApplication(sys.argv)
    app.setApplicationName("llamachat")
    app.setDesktopFileName("llamachat")
    app.setQuitOnLastWindowClosed(False)

    history = History(cfg.db_path)
    client = MultiClient(cfg.providers, cfg.request_timeout)
    presets = config.parse_presets(cfg.presets_path)
    store = models.ModelStore(cfg.models_path)
    window = ChatWindow(cfg, history, client, presets, store)

    def toggle() -> bool:
        if window.isVisible():
            window.hide()
        else:
            show()
        return window.isVisible()

    def show() -> None:
        window.show()
        window.raise_()
        window.activateWindow()
        window.input.setFocus()

    # Tray icon. Waybar's tray module hosts it.
    tray = QSystemTrayIcon(_icon(QPixmap, QPainter, QColor, QFont, QIcon), app)
    tray.setToolTip("llamachat")
    menu = QMenu()
    show_action = QAction("Show / Hide", menu)
    show_action.triggered.connect(toggle)
    menu.addAction(show_action)
    menu.addSeparator()
    quit_action = QAction("Quit", menu)
    quit_action.triggered.connect(app.quit)
    menu.addAction(quit_action)
    tray.setContextMenu(menu)
    tray.activated.connect(
        lambda reason: toggle() if reason == QSystemTrayIcon.Trigger else None
    )
    tray.show()

    # Socket, serviced by the Qt event loop rather than a second thread.
    server = ipc.create_server(cfg.socket_path)

    def on_connection() -> None:
        try:
            conn, _ = server.accept()
        except OSError:
            return
        try:
            command = ipc.read_request(conn)
            if command == "toggle":
                ipc.write_reply(conn, {"ok": True, "visible": toggle()})
            elif command == "show":
                show()
                ipc.write_reply(conn, {"ok": True, "visible": True})
            elif command == "hide":
                window.hide()
                ipc.write_reply(conn, {"ok": True, "visible": False})
            elif command == "ping":
                ipc.write_reply(conn, {"ok": True})
            elif command == "quit":
                ipc.write_reply(conn, {"ok": True})
                app.quit()
            else:
                ipc.write_reply(
                    conn, {"ok": False, "error": f"unknown cmd: {command}"}
                )
        finally:
            conn.close()

    notifier = QSocketNotifier(server.fileno(), QSocketNotifier.Read, app)
    notifier.activated.connect(on_connection)

    def cleanup() -> None:
        notifier.setEnabled(False)
        server.close()
        cfg.socket_path.unlink(missing_ok=True)
        history.close()

    app.aboutToQuit.connect(cleanup)

    try:
        return app.exec()
    finally:
        cleanup()


def _icon(QPixmap, QPainter, QColor, QFont, QIcon):
    """A drawn tray icon, so there is no icon file to install."""
    pixmap = QPixmap(64, 64)
    pixmap.fill(QColor(0, 0, 0, 0))
    painter = QPainter(pixmap)
    painter.setRenderHint(QPainter.Antialiasing)
    painter.setBrush(QColor("#3F5EFB"))
    painter.setPen(QColor("#3F5EFB"))
    painter.drawRoundedRect(4, 8, 56, 40, 8, 8)
    painter.drawPolygon([_pt(16, 46), _pt(32, 46), _pt(18, 60)])
    painter.setPen(QColor("white"))
    font = QFont()
    font.setPixelSize(26)
    font.setBold(True)
    painter.setFont(font)
    painter.drawText(pixmap.rect().adjusted(0, -8, 0, -8), 0x0084, "λ")
    painter.end()
    return QIcon(pixmap)


def _pt(x: int, y: int):
    from PySide6.QtCore import QPoint

    return QPoint(x, y)


if __name__ == "__main__":
    sys.exit(main())
