"""
BLE communication helper for DslrDirector.

Behavior:
- On import, nothing starts automatically. Call start() to begin the background connect/reconnect loop.
- Reads configuration from ../data/config.json (relative to src/)
- Tries to connect to BLE device using address if provided; otherwise scans for device by name.
- Keeps attempting to connect if device not present (reconnect loop).
- Exposes send_led_command(data0, data1, data2, cmd) to send a 4-byte packet to configured characteristic.

This module uses `bleak` (async BLE library). It runs an asyncio event loop in a dedicated background thread so the rest
of the Flask app can remain synchronous.

The packet format expected by the device is 4 bytes: data0, data1, data2, cmd.
Example: { data0: 0x00, data1: 0x00, data2: 0x00, cmd: 0x00 }

"""

from __future__ import annotations

import json
import os
import time
import threading
import asyncio
from typing import Optional, Any
import logging

# module logger
logger = logging.getLogger("BleComm")
logger.setLevel(logging.INFO)
if not logger.handlers:
    ch = logging.StreamHandler()
    ch.setFormatter(logging.Formatter("%(asctime)s.%(msecs)03d %(levelname)s:%(name)s:%(message)s", datefmt="%H:%M:%S"))
    logger.addHandler(ch)

# if the Flask app logger is available, prefer that for consistency
try:
    from src import app as _app_module
    app_logger = getattr(_app_module, 'app', None)
    if app_logger is not None:
        app_logger = getattr(app_logger, 'logger', None) or app_logger
    else:
        app_logger = None
except Exception:
    app_logger = None

def _log_info(msg, *args):
    try:
        if app_logger:
            app_logger.info(msg % args if args else msg)
        else:
            logger.info(msg, *args)
    except Exception:
        try:
            logger.info(msg, *args)
        except Exception:
            pass

def _log_error(msg, *args):
    try:
        if app_logger:
            app_logger.error(msg % args if args else msg)
        else:
            logger.error(msg, *args)
    except Exception:
        try:
            logger.error(msg, *args)
        except Exception:
            pass

# third-party bleak library for cross-platform BLE
try:
    from bleak import BleakClient, BleakScanner
except Exception:  # pragma: no cover
    # We don't fail import-time; start() will raise a helpful error if bleak missing
    BleakClient = None  # type: ignore
    BleakScanner = None  # type: ignore


# default paths
HERE = os.path.dirname(__file__)
CONFIG_PATH = os.path.abspath(os.path.join(HERE, "..", "data", "config.json"))
# Secondary/modern comm path (preferred for BLE/communication settings)
COMM_PATH = os.path.abspath(os.path.join(HERE, "..", "data", "comm.json"))

# runtime state
_loop_thread: Optional[threading.Thread] = None
_loop: Optional[asyncio.AbstractEventLoop] = None
_stop_event = threading.Event()
_client: Optional[Any] = None
_client_lock = threading.Lock()
_config: dict = {}
_last_connected_ts: Optional[float] = None

# connection info filled from config
_address: str = ""
_device_name: str = ""
_service_uuid: Optional[str] = None
_characteristic_uuid: Optional[str] = None

# reconnect timing
_RECONNECT_DELAY = 5.0


def _load_config():
    global _config, _address, _device_name, _service_uuid, _characteristic_uuid
    try:
        # Prefer the modern comm.json if present (keeps BLE/comm settings separate)
        cfg_path = COMM_PATH if os.path.exists(COMM_PATH) else CONFIG_PATH
        with open(cfg_path, "r", encoding="utf-8") as f:
            _config = json.load(f)
    except Exception:
        _config = {}

    ble = _config.get("ble", {}) if isinstance(_config, dict) else {}
    _address = (ble.get("address") or "").strip()
    _device_name = (ble.get("device-name") or "").strip()
    _service_uuid = ble.get("service-uuid") or None
    _filter_name = ble.get("filter-name") or None
    _characteristic_uuid = ble.get("characteristic-uuid") or None
    _log_info("Loaded BLE config: address='%s' name='%s' filter='%s' service='%s' char='%s'", _address, _device_name, _filter_name, _service_uuid, _characteristic_uuid)


async def _scan_for_name(name: str, timeout: float = 5.0) -> Optional[str]:
    if BleakScanner is None:
        raise RuntimeError("bleak library not available")
    _log_info("Scanning for BLE device name '%s' (timeout=%.1fs)", name, timeout)
    try:
        devices = await BleakScanner.discover(timeout=timeout)
    except Exception as e:
        _log_error("BLE scan failed: %s", e)
        return None
    for d in devices:
        # d.name may be None on some platforms
        if d.name and d.name == name:
            _log_info("Found device by name: %s -> %s", d.name, d.address)
            return d.address
    return None


async def _connect_loop():
    global _client, _last_connected_ts
    if BleakClient is None:
        raise RuntimeError("bleak library not installed. Install via `pip install bleak`")

    while not _stop_event.is_set():
        try:
            # ensure config is loaded
            _load_config()
            target_addr = _address
            if not target_addr and _device_name:
                try:
                    target_addr = await _scan_for_name(_device_name, timeout=5.0)
                except Exception:
                    target_addr = None

            if not target_addr:
                # nothing found, wait and retry
                _log_info("No BLE target found; retrying in %.1fs", _RECONNECT_DELAY)
                await asyncio.sleep(_RECONNECT_DELAY)
                continue

            client = BleakClient(target_addr)
            try:
                _log_info("Attempting to connect to BLE address %s", target_addr)
                await client.connect(timeout=10.0)
                _log_info("Connected to BLE device %s", target_addr)
                # store client under lock and record last-connected timestamp
                with _client_lock:
                    _client = client
                _last_connected_ts = time.time()
                # stay connected until disconnected or stop requested
                while client.is_connected and not _stop_event.is_set():
                    await asyncio.sleep(0.5)
            except Exception:
                _log_error("Failed to connect or disconnect cleanly to %s", target_addr)
                try:
                    await client.disconnect()
                except Exception:
                    pass
                with _client_lock:
                    _client = None
                # fall through to reconnect after delay
            finally:
                with _client_lock:
                    if _client is client and not client.is_connected:
                        _client = None
                        _log_info("Client no longer connected: %s", target_addr)

        except Exception:
            # keep looping
            _log_error("Exception in connect loop", )
            pass

        await asyncio.sleep(_RECONNECT_DELAY)


def _ensure_loop_running():
    global _loop_thread, _loop
    if _loop_thread and _loop_thread.is_alive():
        return

    if BleakClient is None:
        raise RuntimeError("bleak is required. Install with: pip install bleak")

    def _runner():
        global _loop
        _loop = asyncio.new_event_loop()
        asyncio.set_event_loop(_loop)
        try:
            _loop.run_until_complete(_connect_loop())
        finally:
            # cleanup tasks
            try:
                _loop.run_until_complete(_loop.shutdown_asyncgens())
            except Exception:
                pass
            try:
                _loop.close()
            except Exception:
                pass

    _stop_event.clear()
    _loop_thread = threading.Thread(target=_runner, name="BleCommLoop", daemon=True)
    _loop_thread.start()


def start():
    """Start background loop to manage BLE connection."""
    
    _load_config()
    _ensure_loop_running()
    _log_info("BLE background loop started")


def stop():
    """Request background loop to stop and disconnect client."""
    _stop_event.set()
    # disconnect client if present
    try:
        with _client_lock:
            c = _client
        if c is not None:
            # schedule disconnect on the loop
            _log_info("Scheduling BLE disconnect")
            fut = asyncio.run_coroutine_threadsafe(c.disconnect(), _loop)
            try:
                fut.result(3)
            except Exception:
                pass
    except Exception:
        pass


def _get_client_now() -> Optional[Any]:
    with _client_lock:
        return _client


def send_led_command(data0: int, data1: int, data2: int, cmd: int) -> bool:
    """Send 4-byte LED command to the configured characteristic. Returns True on success."""
    # Diagnostic: log caller/process/thread and current client/loop state
    try:
        import os as _os
        import threading as _threading
        import inspect as _inspect
        caller = _inspect.stack()[1]
        _log_info("send_led_command called from %s:%s (pid=%s, thread=%s) args=(%s,%s,%s,%s)",
                  caller.filename, caller.lineno, _os.getpid(), _threading.get_ident(),
                  str(data0), str(data1), str(data2), str(cmd))
    except Exception:
        pass
    # Accept ints, floats that are whole numbers, or numeric strings (hex or decimal).
    def _to_int_byte(x):
        # strings: allow hex (0x..) or decimal
        if isinstance(x, str):
            s = x.strip()
            if s.lower().startswith('0x'):
                return int(s, 16)
            return int(s, 10)
        if isinstance(x, float):
            if not x.is_integer():
                raise ValueError('data bytes must be integer values')
            return int(x)
        if isinstance(x, int):
            return x
        raise ValueError('data bytes must be integer-like')

    try:
        data0 = _to_int_byte(data0)
        data1 = _to_int_byte(data1)
        data2 = _to_int_byte(data2)
        cmd = _to_int_byte(cmd)
    except Exception:
        raise ValueError("data bytes must be integers 0..255")

    for v in (data0, data1, data2, cmd):
        if not (0 <= v <= 0xFF):
            raise ValueError("data bytes must be integers 0..255")

    # prepare payload
    payload = bytes([data0 & 0xFF, data1 & 0xFF, data2 & 0xFF, cmd & 0xFF])

    client = _get_client_now()
    if client is None:
        _log_error("send_led_command: no BLE client available (maybe not connected yet)")
        return False
    if not getattr(client, 'is_connected', False):
        _log_error("send_led_command: client present but not connected")
        return False

    # write in the event loop thread
    if _loop is None:
        return False

    async def _write():
        # resolve characteristic uuid (string like '0xFFE1' or full UUID)
        char = _characteristic_uuid
        if not char:
            raise RuntimeError("characteristic_uuid not configured in data/config.json")
        # Normalize common short forms like '0xFFE1' or 'FFE1' to full 128-bit UUID.
        try:
            if isinstance(char, int):
                # integer 0xFFE1 -> hex string
                short = f"{char:04X}"
                resolved = f"0000{short}-0000-1000-8000-00805f9b34fb"
            else:
                s = str(char).strip()
                if s.lower().startswith('0x'):
                    s = s[2:]
                # remove braces if present
                s_clean = s.strip('{}')
                if len(s_clean) == 4:
                    resolved = f"0000{s_clean}-0000-1000-8000-00805f9b34fb"
                elif len(s_clean) == 8 and '-' not in s_clean:
                    resolved = f"{s_clean}-0000-1000-8000-00805f9b34fb"
                else:
                    resolved = s
        except Exception:
            resolved = char

        _log_info("Writing to characteristic (resolved) %s (original: %s)", resolved, _characteristic_uuid)
        await client.write_gatt_char(resolved, payload)

    try:
        fut = asyncio.run_coroutine_threadsafe(_write(), _loop)
        fut.result(timeout=3)
        _log_info("Wrote payload to characteristic %s: %s", _characteristic_uuid, payload.hex())
        return True
    except Exception as e:
        # include exception text for diagnostics
        _log_error("Failed to write payload to characteristic %s: %s", _characteristic_uuid, e)
        return False


# simple module-level README usage in comments
# Example usage:
# from src import BleComm
# BleComm.start()
# BleComm.send_led_command(0x00, 0x00, 0x00, 0x01)
# BleComm.stop()


def get_status() -> dict:
    """Return a small status dict for diagnostics.

    Fields:
      - connected: bool
      - address: Optional[str]
      - last_connected_ts: Optional[float]
    """
    try:
        with _client_lock:
            client = _client
        addr = globals().get('_address')
        connected = False
        if client is not None:
            connected = bool(getattr(client, 'is_connected', False))
        return {
            'connected': connected,
            'address': addr,
            'last_connected_ts': globals().get('_last_connected_ts')
        }
    except Exception as e:
        _log_error(f"get_status error: {e}")
        return {'connected': False, 'address': None, 'error': str(e)}


# Public helpers used by the Flask app (synchronous wrappers)
def scan(prefix: str = '', timeout: float = 5.0):
    """Scan for BLE devices and return a list of dicts {name,address,id}.

    This is a synchronous wrapper around BleakScanner.discover.
    """
    results = []
    if BleakScanner is None:
        _log_error('scan: bleak not available')
        return results
    try:
        devices = asyncio.run(BleakScanner.discover(timeout=timeout))
        for d in devices:
            name = getattr(d, 'name', None) or ''
            addr = getattr(d, 'address', None) or getattr(d, 'id', None) or ''
            results.append({'name': name, 'address': addr, 'id': addr})
        if prefix:
            pf = prefix.lower()
            results = [r for r in results if (r.get('name') or '').lower().startswith(pf)]
    except Exception as e:
        _log_error('scan failed: %s', e)
    return results


def connect(addr_or_name: str) -> bool:
    """Synchronous connect helper: try to connect by address; if looks like a name, write it to config and let the background loop handle connecting."""
    try:
        # if looks like an address, try direct connect via BleakClient
        if BleakClient is None:
            _log_error('connect: bleak not available')
            return False
        # Quick heuristic for MAC-like addresses (colon or dash separated) or hex
        candidate = (addr_or_name or '').strip()
        if not candidate:
            return False
        if ':' in candidate or '-' in candidate or len(candidate) >= 12:
            # attempt a direct connect using a temporary client
            try:
                async def _do_connect():
                    c = BleakClient(candidate)
                    await c.connect(timeout=10.0)
                    await asyncio.sleep(0.5)
                    await c.disconnect()
                    return True
                return asyncio.run(_do_connect())
            except Exception as e:
                _log_error('direct connect attempt failed: %s', e)
                return False
        else:
            # assume it's a device name: write to config and let background loop find it
            try:
                # persist desired device name into config file
                _load_config()
                _config['ble'] = _config.get('ble', {})
                _config['ble']['device_name'] = candidate
                # prefer writing into comm.json to match the Flask app's expectations
                try:
                    out_path = COMM_PATH
                    with open(out_path, 'w', encoding='utf-8') as f:
                        json.dump(_config, f, indent=2)
                except Exception:
                    # fallback to legacy config.json if write to comm.json fails
                    with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
                        json.dump(_config, f, indent=2)
                _log_info('connect: wrote device_name to config: %s', candidate)
                return True
            except Exception as e:
                _log_error('connect write config failed: %s', e)
                return False
    except Exception as e:
        _log_error('connect wrapper failed: %s', e)
        return False


def disconnect() -> bool:
    """Synchronous disconnect helper: schedule disconnect on running loop if client exists."""
    try:
        with _client_lock:
            c = _client
        if c is None:
            _log_info('disconnect: no client present')
            return True
        if _loop is None:
            _log_error('disconnect: no event loop available')
            return False
        fut = asyncio.run_coroutine_threadsafe(c.disconnect(), _loop)
        try:
            fut.result(3)
        except Exception:
            pass
        _log_info('disconnect: requested')
        return True
    except Exception as e:
        _log_error('disconnect failed: %s', e)
        return False


def _interactive_menu():
    """Simple interactive CLI for manual testing when run as a script."""
    print("BleComm interactive test menu")
    print("Commands:")
    print("  1) Send Command (prompt for 4 bytes)")
    print("  2) Status")
    print("  3) Quit")

    while True:
        try:
            choice = input('> ').strip()
        except (KeyboardInterrupt, EOFError):
            print('\nExiting')
            break

        if choice in ('1', 'send', 'send command'):
            try:
                print('Enter a single byte value for animation (hex like 0x1A or decimal). Leave empty to cancel.')
                s = input('animation: ').strip()
                if s == '':
                    raise KeyboardInterrupt
                if s.lower().startswith('0x'):
                    v = int(s, 16)
                else:
                    v = int(s, 10)
                if not (0 <= v <= 0xFF):
                    raise ValueError('byte out of range')
                # keep the call form unchanged as requested (second arg is float literal 0.)
                ok = send_led_command(v, 0., 0, 0x2C)
                print('send_led_command returned', ok)
            except KeyboardInterrupt:
                print('\nSend cancelled')
            except Exception as e:
                print('Send failed:', e)
        elif choice in ('2', 'status'):
            try:
                st = get_status()
                print('Status:', st)
            except Exception as e:
                print('Status failed:', e)
        elif choice in ('3', 'quit', 'q', 'exit'):
            print('Quitting')
            break
        else:
            print('Unknown command')


if __name__ == '__main__':
    # Allow running this module directly for manual BLE tests
    try:
        print('Starting BleComm background loop...')
        start()
        # wait for connection (timeout after ~30s)
        waited = 0.0
        interval = 0.5
        timeout = 30.0
        print('Waiting for device to connect (timeout 30s)...')
        while waited < timeout:
            st = get_status()
            if st.get('connected'):
                print('Device connected:', st.get('address'))
                break
            time.sleep(interval)
            waited += interval
        else:
            print('Timeout waiting for device to connect; you can still use the menu and try send commands (they will fail until connected).')

        _interactive_menu()
    except Exception as e:
        print('Interactive menu error:', e)

