"""Bluetooth quad: powered by AstalBluetooth (bluez wrapper).

Discovery, connect, disconnect and a local connection history (bluez keeps none, so
we record successful connects in ~/.cache/astal-menu/bt-history.json). 'discovery'
and 'history' are per-quad feature toggles.
"""

from __future__ import annotations

import json
import time

import gi

gi.require_version("Gtk", "4.0")
from gi.repository import GLib, Gtk  # noqa: E402

from lib.proc import run_text
from module_base import Feature, ModuleContext, ModuleInstance, ModuleSpec
from paths import CACHE_DIR

_HISTORY = CACHE_DIR / "bt-history.json"


def _load_history() -> list[dict]:
    try:
        return json.loads(_HISTORY.read_text())
    except (FileNotFoundError, json.JSONDecodeError):
        return []


def _record_history(address: str, name: str) -> None:
    hist = [h for h in _load_history() if h.get("address") != address]
    hist.insert(0, {"address": address, "name": name, "ts": int(time.time())})
    CACHE_DIR.mkdir(parents=True, exist_ok=True)
    _HISTORY.write_text(json.dumps(hist[:50], indent=2))


class _BluetoothView(Gtk.Box):
    def __init__(self, ctx: ModuleContext, full: bool):
        super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8)
        self.add_css_class("bt-view")
        self.ctx = ctx
        self.full = full
        self.bt = ctx.services.bluetooth
        self._recorded: set[str] = set()
        self._hooked: set[str] = set()    # devices whose state signals we've wired
        self._pending: set[str] = set()   # addresses with an in-flight connect
        self._failed: set[str] = set()    # addresses whose last connect failed

        self.append(self._build_header())

        self._list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
        self._list.add_css_class("bt-list")
        if full:
            scroller = Gtk.ScrolledWindow(vexpand=True,
                                          hscrollbar_policy=Gtk.PolicyType.NEVER)
            scroller.set_child(self._list)
            self.append(scroller)
        else:
            self.append(self._list)

        if self.bt is not None:
            self.bt.connect("notify::devices", lambda *_: self._refresh())
            self.bt.connect("notify::is-powered", lambda *_: self._refresh())
        self._refresh()

    def _adapter(self):
        return self.bt.get_adapter() if self.bt else None

    def _build_header(self) -> Gtk.Widget:
        header = Gtk.CenterBox()
        header.add_css_class("bt-header")
        left = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        left.append(Gtk.Label(label="Power"))
        self._power = Gtk.Switch(active=bool(self.bt and self.bt.get_is_powered()),
                                 valign=Gtk.Align.CENTER)
        self._power.connect("state-set", self._on_power)
        left.append(self._power)
        header.set_start_widget(left)

        if self.full and self.ctx.feature("discovery", True):
            self._scan = Gtk.ToggleButton(label="  Scan")
            self._scan.add_css_class("quad-action")
            self._scan.connect("toggled", self._on_scan)
            header.set_end_widget(self._scan)
        return header

    def _on_power(self, _sw, value: bool) -> bool:
        ad = self._adapter()
        if value:
            # The adapter is frequently rfkill soft-blocked at boot; while blocked,
            # AstalBluetooth.set_powered(True) is a silent no-op. Unblock first, then
            # power on (bluez usually auto-powers on unblock, so set_powered is a
            # belt-and-suspenders follow-up).
            run_text(["rfkill", "unblock", "bluetooth"],
                     lambda *_a: ad.set_powered(True) if ad else None)
        elif ad:
            ad.set_powered(False)
        return False

    def _on_scan(self, btn: Gtk.ToggleButton) -> None:
        ad = self._adapter()
        if not ad:
            return
        if btn.get_active():
            ad.start_discovery()
        else:
            ad.stop_discovery()

    # -- device list -------------------------------------------------------
    def _refresh(self) -> None:
        child = self._list.get_first_child()
        while child:
            self._list.remove(child)
            child = self._list.get_first_child()
        if not self.bt:
            self._list.append(Gtk.Label(label="No Bluetooth adapter"))
            return

        devices = list(self.bt.get_devices())
        devices.sort(key=lambda d: (not d.get_connected(), not d.get_paired(),
                                    (d.get_name() or d.get_address() or "").lower()))
        if not self.full:
            devices = [d for d in devices if d.get_connected() or d.get_paired()][:4]
        for dev in devices:
            self._hook_device(dev)
            self._list.append(self._device_row(dev))

        if self.full and self.ctx.feature("history", True):
            self._list.append(self._history_section())

    def _hook_device(self, dev) -> None:
        # React to a device's own state changes (adapter-level notify::devices doesn't
        # fire for per-device connect/disconnect). Hook once each.
        key = dev.get_address() or ""
        if key in self._hooked:
            return
        self._hooked.add(key)
        for sig in ("notify::connected", "notify::connecting", "notify::paired"):
            dev.connect(sig, lambda *_a, d=dev: self._on_device_state(d))

    def _on_device_state(self, dev) -> None:
        addr = dev.get_address() or ""
        if dev.get_connected():
            self._pending.discard(addr)
            self._failed.discard(addr)
            if self.ctx.feature("history", True):
                self._maybe_record(dev, force=True)
        elif addr in self._pending and not dev.get_connecting():
            # bluez stopped trying without establishing a link → the connect failed
            self._pending.discard(addr)
            self._failed.add(addr)
        self._refresh()

    def _device_row(self, dev) -> Gtk.Widget:
        row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
        row.add_css_class("bt-row")
        addr = dev.get_address() or ""
        icon = Gtk.Image.new_from_icon_name((dev.get_icon() or "bluetooth") + "-symbolic")
        row.append(icon)
        name = dev.get_name() or addr or "Unknown"

        connected = dev.get_connected()
        connecting = dev.get_connecting() or addr in self._pending
        failed = addr in self._failed

        lbl = Gtk.Label(label=name, xalign=0.0, hexpand=True)
        row.append(lbl)

        status = ("connected" if connected else "connecting…" if connecting else
                  "failed" if failed else "paired" if dev.get_paired() else "")
        if status:
            tag = Gtk.Label(label=status)
            tag.add_css_class("bt-status")
            if failed:
                tag.add_css_class("bt-failed")
            row.append(tag)

        actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
        if connecting:
            spinner = Gtk.Spinner(valign=Gtk.Align.CENTER)
            spinner.start()
            actions.append(spinner)
            actions.append(self._act("Cancel", lambda: self._cancel(dev)))
        else:
            if dev.get_paired():
                actions.append(self._act("Forget", lambda: self._forget(dev)))
            if connected:
                actions.append(self._act("Disconnect",
                                         lambda: dev.disconnect_device(None, self._noop)))
            else:
                actions.append(self._act("Retry" if failed else "Connect",
                                         lambda: self._start_connect(dev)))
        row.append(actions)

        if connected and self.ctx.feature("history", True):
            self._maybe_record(dev)
        return row

    @staticmethod
    def _act(label: str, cb) -> Gtk.Button:
        b = Gtk.Button(label=label, valign=Gtk.Align.CENTER)
        b.add_css_class("quad-action")
        b.connect("clicked", lambda *_a: cb())
        return b

    # -- connect / cancel / forget ----------------------------------------
    def _start_connect(self, dev) -> None:
        addr = dev.get_address() or ""
        self._failed.discard(addr)
        self._pending.add(addr)

        def done(d, res):
            try:
                d.connect_device_finish(res)
            except Exception:
                a = d.get_address() or ""
                if not d.get_connected():
                    self._pending.discard(a)
                    self._failed.add(a)
                    self._refresh()
        dev.connect_device(None, done)
        # Backstop: some failures never resolve the async call, so time out.
        GLib.timeout_add_seconds(25, lambda: self._connect_timeout(addr))
        self._refresh()

    def _connect_timeout(self, addr: str) -> bool:
        if addr in self._pending:
            self._pending.discard(addr)
            self._failed.add(addr)
            self._refresh()
        return GLib.SOURCE_REMOVE

    def _cancel(self, dev) -> None:
        addr = dev.get_address() or ""
        self._pending.discard(addr)
        dev.disconnect_device(None, self._noop)  # abort the in-flight attempt
        self._refresh()

    def _forget(self, dev) -> None:
        ad = self._adapter()
        addr = dev.get_address() or ""
        self._pending.discard(addr)
        self._failed.discard(addr)
        self._hooked.discard(addr)
        if ad:
            ad.remove_device(dev)  # fires notify::devices → refresh
        self._refresh()

    def _maybe_record(self, dev, force: bool = False) -> None:
        addr = dev.get_address() or ""
        if not addr or (addr in self._recorded and not force):
            return
        self._recorded.add(addr)
        _record_history(addr, dev.get_name() or addr)

    @staticmethod
    def _noop(obj, res) -> None:
        try:
            obj.disconnect_device_finish(res)
        except Exception:
            pass

    def _history_section(self) -> Gtk.Widget:
        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
        box.add_css_class("bt-history")
        box.append(Gtk.Separator())
        box.append(Gtk.Label(label="History", xalign=0.0))
        for h in _load_history()[:8]:
            when = time.strftime("%d.%m %H:%M", time.localtime(h.get("ts", 0)))
            box.append(Gtk.Label(label=f"{h.get('name', '?')}  ·  {when}", xalign=0.0))
        return box


def build(ctx: ModuleContext) -> ModuleInstance:
    # Feature toggles (discovery/history) rebuild the whole card via QuadCard, so
    # both views are recreated with the current features — no partial refresh here.
    compact = _BluetoothView(ctx, full=False)
    expanded = _BluetoothView(ctx, full=True)
    return ModuleInstance(compact=compact, expanded=expanded)


SPEC = ModuleSpec(
    id="bluetooth",
    title="Bluetooth",
    icon="",  # nf-fa-bluetooth
    build=build,
    default_enabled=True,
    features=[Feature("discovery", "Device discovery", True),
              Feature("history", "Connection history", True)],
)
