#!/usr/bin/env python3
"""BlueZ GATT transport for the Pi Car Companion Android application."""

import json
import os
import threading
import urllib.error
import urllib.request
import uuid
from collections import deque

import dbus
import dbus.exceptions
import dbus.mainloop.glib
import dbus.service
from gi.repository import GLib

BLUEZ = "org.bluez"
DBUS_OM = "org.freedesktop.DBus.ObjectManager"
DBUS_PROPERTIES = "org.freedesktop.DBus.Properties"
GATT_MANAGER = "org.bluez.GattManager1"
GATT_SERVICE = "org.bluez.GattService1"
GATT_CHARACTERISTIC = "org.bluez.GattCharacteristic1"
AD_MANAGER = "org.bluez.LEAdvertisingManager1"
ADVERTISEMENT = "org.bluez.LEAdvertisement1"

SERVICE_UUID = "6c7a0001-7c6d-4f74-9bb0-c5a4e5efc001"
RX_UUID = "6c7a0002-7c6d-4f74-9bb0-c5a4e5efc001"
TX_UUID = "6c7a0003-7c6d-4f74-9bb0-c5a4e5efc001"
API_BASE = os.environ.get("MOBILE_API_BASE", "http://127.0.0.1:8787")
MAX_MESSAGE_BYTES = 64 * 1024
NOTIFY_CHUNK_BYTES = 180


class InvalidArgs(dbus.exceptions.DBusException):
    _dbus_error_name = "org.freedesktop.DBus.Error.InvalidArgs"


class NotSupported(dbus.exceptions.DBusException):
    _dbus_error_name = "org.bluez.Error.NotSupported"


class Application(dbus.service.Object):
    def __init__(self, bus):
        self.path = "/org/pi_car_companion"
        self.services = []
        super().__init__(bus, self.path)

    def add_service(self, service):
        self.services.append(service)

    @dbus.service.method(DBUS_OM, out_signature="a{oa{sa{sv}}}")
    def GetManagedObjects(self):
        objects = {}
        for service in self.services:
            objects[service.path] = service.properties()
            for characteristic in service.characteristics:
                objects[characteristic.path] = characteristic.properties()
        return objects


class Service(dbus.service.Object):
    def __init__(self, bus, index, uuid_value):
        self.path = f"/org/pi_car_companion/service{index}"
        self.uuid = uuid_value
        self.characteristics = []
        super().__init__(bus, self.path)

    def add_characteristic(self, characteristic):
        self.characteristics.append(characteristic)

    def properties(self):
        return {GATT_SERVICE: {"UUID": self.uuid, "Primary": dbus.Boolean(True), "Characteristics": dbus.Array([item.path for item in self.characteristics], signature="o")}}

    @dbus.service.method(DBUS_PROPERTIES, in_signature="s", out_signature="a{sv}")
    def GetAll(self, interface):
        if interface != GATT_SERVICE:
            raise InvalidArgs()
        return self.properties()[GATT_SERVICE]


class Characteristic(dbus.service.Object):
    def __init__(self, bus, index, uuid_value, flags, service):
        self.path = service.path + f"/char{index}"
        self.uuid = uuid_value
        self.flags = flags
        self.service = service
        super().__init__(bus, self.path)

    def properties(self):
        return {GATT_CHARACTERISTIC: {"Service": dbus.ObjectPath(self.service.path), "UUID": self.uuid, "Flags": dbus.Array(self.flags, signature="s")}}

    @dbus.service.method(DBUS_PROPERTIES, in_signature="s", out_signature="a{sv}")
    def GetAll(self, interface):
        if interface != GATT_CHARACTERISTIC:
            raise InvalidArgs()
        return self.properties()[GATT_CHARACTERISTIC]

    @dbus.service.signal(DBUS_PROPERTIES, signature="sa{sv}as")
    def PropertiesChanged(self, interface, changed, invalidated):
        pass


class TxCharacteristic(Characteristic):
    def __init__(self, bus, service):
        super().__init__(bus, 1, TX_UUID, ["notify"], service)
        self.notifying = False
        self.queue = deque()
        self.sending = False

    @dbus.service.method(GATT_CHARACTERISTIC, in_signature="a{sv}")
    def StartNotify(self, _options):
        self.notifying = True

    @dbus.service.method(GATT_CHARACTERISTIC, in_signature="a{sv}")
    def StopNotify(self, _options):
        self.notifying = False
        self.queue.clear()

    def send(self, message):
        encoded = (json.dumps(message, separators=(",", ":")) + "\n").encode("utf-8")
        for offset in range(0, len(encoded), NOTIFY_CHUNK_BYTES):
            self.queue.append(encoded[offset:offset + NOTIFY_CHUNK_BYTES])
        if not self.sending:
            self.sending = True
            GLib.timeout_add(20, self._send_next)

    def _send_next(self):
        if not self.notifying or not self.queue:
            self.sending = False
            return False
        chunk = self.queue.popleft()
        value = dbus.Array([dbus.Byte(byte) for byte in chunk], signature="y")
        self.PropertiesChanged(GATT_CHARACTERISTIC, {"Value": value}, [])
        return bool(self.queue)


class RxCharacteristic(Characteristic):
    def __init__(self, bus, service, tx):
        super().__init__(bus, 0, RX_UUID, ["write", "write-without-response"], service)
        self.tx = tx
        self.buffer = bytearray()

    @dbus.service.method(GATT_CHARACTERISTIC, in_signature="aya{sv}")
    def WriteValue(self, value, _options):
        self.buffer.extend(bytes(value))
        if len(self.buffer) > MAX_MESSAGE_BYTES:
            self.buffer.clear()
            self.tx.send({"id": None, "ok": False, "error": {"code": "MESSAGE_TOO_LARGE", "message": "Bluetooth message was too large"}})
            return
        while b"\n" in self.buffer:
            raw, _, remaining = self.buffer.partition(b"\n")
            self.buffer = bytearray(remaining)
            if raw:
                threading.Thread(target=self._handle, args=(bytes(raw),), daemon=True).start()

    def _handle(self, raw):
        request_id = None
        try:
            message = json.loads(raw.decode("utf-8"))
            request_id = message.get("id")
            operation = message.get("op")
            token = message.get("token")
            payload = message.get("payload") or {}
            method, path, body = route_operation(operation, payload)
            data = call_api(method, path, body, token)
            response = {"id": request_id, "ok": True, "data": data}
        except ProtocolError as error:
            response = {"id": request_id, "ok": False, "error": {"code": error.code, "message": str(error)}}
        except Exception:
            response = {"id": request_id, "ok": False, "error": {"code": "BRIDGE_ERROR", "message": "The Pi Bluetooth bridge could not complete the request"}}
        GLib.idle_add(self.tx.send, response)


class ProtocolError(Exception):
    def __init__(self, code, message):
        super().__init__(message)
        self.code = code


def route_operation(operation, payload):
    if operation == "pair":
        return "POST", "/internal/mobile/pair", payload
    if operation == "snapshot":
        return "GET", "/internal/mobile/snapshot", None
    if operation == "location":
        return "POST", "/internal/mobile/location", payload
    if operation == "launchShortcut":
        shortcut_id = payload.get("id")
        if not isinstance(shortcut_id, int) or shortcut_id < 1:
            raise ProtocolError("INVALID_SHORTCUT", "Shortcut ID was invalid")
        return "POST", f"/internal/mobile/shortcuts/{shortcut_id}/launch", {}
    raise ProtocolError("UNKNOWN_OPERATION", "That Bluetooth operation is not available")


def call_api(method, path, body, token):
    data = None if body is None else json.dumps(body).encode("utf-8")
    headers = {"Accept": "application/json"}
    if data is not None:
        headers["Content-Type"] = "application/json"
    if isinstance(token, str):
        headers["X-Mobile-Token"] = token
    request = urllib.request.Request(API_BASE + path, data=data, headers=headers, method=method)
    try:
        with urllib.request.urlopen(request, timeout=15) as response:
            raw = response.read()
            return json.loads(raw) if raw else {}
    except urllib.error.HTTPError as error:
        try:
            detail = json.loads(error.read())
            api_error = detail.get("error", {})
            raise ProtocolError(api_error.get("code", "REQUEST_FAILED"), api_error.get("message", "The Pi rejected the request"))
        except (ValueError, AttributeError):
            raise ProtocolError("REQUEST_FAILED", "The Pi rejected the request") from error
    except urllib.error.URLError as error:
        raise ProtocolError("PI_SERVICE_UNAVAILABLE", "The Pi companion service is unavailable") from error


class Advertisement(dbus.service.Object):
    def __init__(self, bus):
        self.path = "/org/pi_car_companion/advertisement0"
        super().__init__(bus, self.path)

    @dbus.service.method(DBUS_PROPERTIES, in_signature="s", out_signature="a{sv}")
    def GetAll(self, interface):
        if interface != ADVERTISEMENT:
            raise InvalidArgs()
        return {"Type": "peripheral", "ServiceUUIDs": dbus.Array([SERVICE_UUID], signature="s"), "LocalName": "Pi Car"}

    @dbus.service.method(ADVERTISEMENT)
    def Release(self):
        pass


def find_adapter(bus):
    objects = dbus.Interface(bus.get_object(BLUEZ, "/"), DBUS_OM).GetManagedObjects()
    for path, interfaces in objects.items():
        if GATT_MANAGER in interfaces and AD_MANAGER in interfaces:
            return path
    raise RuntimeError("No Bluetooth LE adapter with GATT server support was found")


def main():
    dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
    bus = dbus.SystemBus()
    adapter_path = find_adapter(bus)
    adapter = bus.get_object(BLUEZ, adapter_path)
    dbus.Interface(adapter, DBUS_PROPERTIES).Set("org.bluez.Adapter1", "Powered", dbus.Boolean(True))
    application = Application(bus)
    service = Service(bus, 0, SERVICE_UUID)
    tx = TxCharacteristic(bus, service)
    rx = RxCharacteristic(bus, service, tx)
    service.add_characteristic(rx)
    service.add_characteristic(tx)
    application.add_service(service)
    advertisement = Advertisement(bus)
    dbus.Interface(adapter, GATT_MANAGER).RegisterApplication(application.path, {}, reply_handler=lambda: None, error_handler=lambda error: (_ for _ in ()).throw(error))
    dbus.Interface(adapter, AD_MANAGER).RegisterAdvertisement(advertisement.path, {}, reply_handler=lambda: None, error_handler=lambda error: (_ for _ in ()).throw(error))
    GLib.MainLoop().run()


if __name__ == "__main__":
    main()
