"""
Scentience BLE device client.
"""
import asyncio
import csv
import json
import threading
from typing import Callable, List, Optional

from bleak import BleakClient, BleakScanner
from bleak.backends.device import BLEDevice


class ScentienceDevice:
    """
    Python client for Scentience olfaction instruments over BLE.

    Wraps the async bleak library behind a synchronous API, running all BLE
    operations on a dedicated background event loop so callers do not need
    to manage asyncio themselves.

    Usage::

        device = ScentienceDevice(api_key="your_api_key")
        device.connect_ble(char_uuid="your_char_uuid")

        # Single reading
        data = device.sample_ble()

        # Continuous stream (non-blocking)
        device.stream_ble(callback=lambda d: print(d))
        # ... later ...
        device.stop_stream()
        device.disconnect()

    Context manager::

        with ScentienceDevice(api_key="your_api_key") as device:
            device.connect_ble(char_uuid="your_char_uuid")
            data = device.sample_ble()
    """

    _DEVICE_NAME_PREFIX = "Scentience"
    _SCAN_TIMEOUT = 10.0
    _OPERATION_TIMEOUT = 15.0

    def __init__(self, api_key: str) -> None:
        """
        Initialize a ScentienceDevice.

        Args:
            api_key: Developer API key from the Scentience portal.

        Raises:
            ValueError: If api_key is empty or None.
        """
        if not api_key:
            raise ValueError("api_key is required")

        self._api_key: str = api_key
        self._client: Optional[BleakClient] = None
        self._char_uuid: Optional[str] = None
        self._streaming: bool = False
        self._log: List[dict] = []

        # All BLE operations run on this dedicated background event loop so
        # that bleak's internal state is never touched from multiple threads.
        self._loop: asyncio.AbstractEventLoop = asyncio.new_event_loop()
        self._loop_thread: threading.Thread = threading.Thread(
            target=self._run_event_loop, daemon=True, name="scentience-ble-loop"
        )
        self._loop_thread.start()

    # ------------------------------------------------------------------
    # Internal event-loop helpers
    # ------------------------------------------------------------------

    def _run_event_loop(self) -> None:
        asyncio.set_event_loop(self._loop)
        self._loop.run_forever()

    def _submit(self, coro, timeout: float = None) -> object:
        """Submit a coroutine to the BLE event loop and block for result."""
        if timeout is None:
            timeout = self._OPERATION_TIMEOUT
        future = asyncio.run_coroutine_threadsafe(coro, self._loop)
        return future.result(timeout=timeout)

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def connect_ble(
        self, char_uuid: str, device_uid: Optional[str] = None
    ) -> None:
        """
        Scan for and connect to a Scentience device via BLE.

        Args:
            char_uuid:  GATT characteristic UUID for command/data exchange.
            device_uid: Optional device serial number or BLE address.  When
                        provided only that device is targeted; otherwise the
                        first discovered Scentience device is used.

        Raises:
            RuntimeError: If no matching device is found or connection fails.
        """
        self._char_uuid = char_uuid
        self._submit(
            self._connect_async(device_uid),
            timeout=self._SCAN_TIMEOUT + self._OPERATION_TIMEOUT,
        )

    def sample_ble(self, **kwargs) -> dict:
        """
        Take a single BLE sensor reading.

        Returns a dict whose keys may include:

        * ``UID`` – device serial number
        * ``TIMESTAMP`` – reading timestamp
        * ``ENV_temperatureC``, ``ENV_humidity``, ``ENV_pressureHpa``
        * ``BATT_health``, ``BATT_v``, ``BATT_charge``, ``BATT_time``
        * ``STATUS_opuA``
        * Chemical compounds: ``CO2``, ``NH3``, ``NO``, ``NO2``, ``CO``,
          ``C2H5OH``, ``H2``, ``CH4``, ``C3H8``, ``C4H10``, ``H2S``,
          ``HCHO``, ``SO2``, ``VOC``  (only non-zero compounds are included)

        Returns:
            dict: Decoded JSON payload from the device.

        Raises:
            RuntimeError: If not connected.
        """
        self._require_connected()
        data = self._submit(self._sample_async())
        self._log.append(data)
        return data

    def stream_ble(
        self, callback: Optional[Callable[[dict], None]] = None, **kwargs
    ) -> None:
        """
        Start continuous BLE data streaming (non-blocking).

        Subscribes to GATT notifications on the command characteristic.
        The *callback* is invoked on the background BLE thread for every
        received packet.  Call :meth:`stop_stream` to unsubscribe.

        Args:
            callback: Called with each decoded data dict.  Defaults to
                      ``print`` when not provided.

        Raises:
            RuntimeError: If not connected.
        """
        self._require_connected()
        if callback is None:
            callback = print
        self._streaming = True
        asyncio.run_coroutine_threadsafe(self._stream_async(callback), self._loop)

    def stop_stream(self) -> None:
        """Stop continuous BLE streaming."""
        self._streaming = False

    def disconnect(self) -> None:
        """Disconnect from the BLE device and release resources."""
        self._streaming = False
        if self._client is not None and self._client.is_connected:
            try:
                self._submit(self._client.disconnect(), timeout=10.0)
            except Exception:
                pass
        self._client = None

    @property
    def is_connected(self) -> bool:
        """``True`` if a BLE connection is active."""
        return self._client is not None and self._client.is_connected

    # ------------------------------------------------------------------
    # Async internals
    # ------------------------------------------------------------------

    async def _connect_async(self, device_uid: Optional[str]) -> None:
        device = await self._scan_for_device(device_uid)
        self._client = BleakClient(device)
        await self._client.connect()

    async def _scan_for_device(self, device_uid: Optional[str]) -> BLEDevice:
        devices = await BleakScanner.discover(timeout=self._SCAN_TIMEOUT)

        for device in devices:
            if device_uid:
                if device.address == device_uid or (
                    device.name and device.name == device_uid
                ):
                    return device
            else:
                if device.name and self._DEVICE_NAME_PREFIX.lower() in device.name.lower():
                    return device

        if device_uid:
            raise RuntimeError(
                f"Device '{device_uid}' not found. "
                "Ensure the device is powered on and in Bluetooth range."
            )
        raise RuntimeError(
            "No Scentience device found. "
            "Ensure the device is powered on and in Bluetooth range."
        )

    async def _sample_async(self) -> dict:
        raw = await self._client.read_gatt_char(self._char_uuid)
        return json.loads(raw.decode("utf-8"))

    async def _stream_async(self, callback: Callable[[dict], None]) -> None:
        def _handler(sender, data: bytearray) -> None:
            try:
                payload = json.loads(data.decode("utf-8"))
                self._log.append(payload)
                callback(payload)
            except (json.JSONDecodeError, UnicodeDecodeError):
                pass  # drop malformed packets silently

        await self._client.start_notify(self._char_uuid, _handler)
        try:
            while self._streaming and self._client.is_connected:
                await asyncio.sleep(0.05)
        finally:
            if self._client.is_connected:
                await self._client.stop_notify(self._char_uuid)

    # ------------------------------------------------------------------
    # Logging and export
    # ------------------------------------------------------------------

    @property
    def log(self) -> List[dict]:
        """All readings captured since the last :meth:`clear_log` call."""
        return list(self._log)

    def clear_log(self) -> None:
        """Discard all buffered readings."""
        self._log.clear()

    def export_json(self, path: str) -> None:
        """
        Write all buffered readings to a JSON file.

        The file contains a JSON array where each element is one reading dict.

        Args:
            path: Destination file path (e.g. ``"readings.json"``).

        Raises:
            ValueError: If there are no readings to export.
        """
        if not self._log:
            raise ValueError("No data to export. Capture readings first.")
        with open(path, "w", encoding="utf-8") as f:
            json.dump(self._log, f, indent=2)

    def export_csv(self, path: str) -> None:
        """
        Write all buffered readings to a CSV file.

        Column order: ``UID`` and ``TIMESTAMP`` first (when present), then all
        remaining keys sorted alphabetically.  Readings that are missing a
        field are written with an empty value for that column.

        Args:
            path: Destination file path (e.g. ``"readings.csv"``).

        Raises:
            ValueError: If there are no readings to export.
        """
        if not self._log:
            raise ValueError("No data to export. Capture readings first.")

        # Build a stable, deterministic column order.
        priority = ["UID", "TIMESTAMP"]
        all_keys: List[str] = list(
            dict.fromkeys(
                priority
                + sorted(k for row in self._log for k in row if k not in priority)
            )
        )

        with open(path, "w", newline="", encoding="utf-8") as f:
            writer = csv.DictWriter(f, fieldnames=all_keys, extrasaction="ignore")
            writer.writeheader()
            writer.writerows(self._log)

    # ------------------------------------------------------------------
    # Helpers
    # ------------------------------------------------------------------

    def _require_connected(self) -> None:
        if not self.is_connected:
            raise RuntimeError("Not connected. Call connect_ble() first.")

    # ------------------------------------------------------------------
    # Context manager
    # ------------------------------------------------------------------

    def __enter__(self) -> "ScentienceDevice":
        return self

    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
        self.disconnect()

    def __del__(self) -> None:
        try:
            if self._client and self._client.is_connected:
                asyncio.run_coroutine_threadsafe(
                    self._client.disconnect(), self._loop
                )
        except Exception:
            pass
        try:
            self._loop.call_soon_threadsafe(self._loop.stop)
        except Exception:
            pass
