"""
Wire.Band Edge Client
=====================

Lightweight client for gateway hardware (Raspberry Pi, NVIDIA Jetson,
industrial PCs, any Linux SBC).  Runs close to the devices, buffers locally
during connectivity loss, and forwards compressed events to the Wire.Band
backend.

Designed to be:
  - pip-installable with minimal dependencies
  - Resilient to network interruptions (local ring buffer + retry)
  - Composable: wrap any MQTTConnector or feed raw dicts directly

Architecture:
    [MQTT broker] ──► MQTTConnector ──► EdgeClient ──► Wire.Band backend
                                              │
                                      (local ring buffer)
                                      survives network loss

Usage (gateway script):

    from emergent_translator.wire_band import WireBandEdgeClient, MQTTConnector

    async def main():
        client = WireBandEdgeClient(
            backend_url="http://wireband-server:8000",
            device_id="factory-floor-rpi4",
        )
        connector = MQTTConnector("mqtt://localhost:1883")
        await client.run_mqtt(connector, topics=["sensors/#", "machines/#"])

    asyncio.run(main())

Or ingest programmatically (e.g. from a serial driver):

    await client.ingest({"temp": 72.3, "humidity": 45.1}, topic="env/zone-a")
"""

import asyncio
import json
import logging
import time
from collections import deque
from dataclasses import dataclass, field
from typing import Any, Deque, Dict, List, Optional

try:
    import httpx
except ImportError:
    raise ImportError("httpx required: pip install httpx>=0.24.0")

from .mqtt_connector import MQTTConnector, MQTTEvent

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Buffered event (used in the local ring buffer)
# ---------------------------------------------------------------------------

@dataclass
class BufferedEvent:
    topic: str
    symbol: int
    frame: bytes          # theta-prefixed compact frame from MQTTConnector
    ts: float = field(default_factory=time.time)
    attempts: int = 0

    def to_payload(self, device_id: str) -> Dict[str, Any]:
        return {
            "device_id": device_id,
            "topic":     self.topic,
            "symbol":    hex(self.symbol),
            "frame":     self.frame.hex(),   # hex-encoded bytes for JSON transport
            "ts":        self.ts,
        }


# ---------------------------------------------------------------------------
# Edge Client
# ---------------------------------------------------------------------------

class WireBandEdgeClient:
    """
    Gateway-side Wire.Band agent.

    Args:
        backend_url:    Wire.Band backend HTTP base URL.
                        e.g. "http://localhost:8000" or "https://your-backend.com"
        device_id:      Unique identifier for this gateway node.
        api_key:        Optional bearer token for backend auth.
        buffer_size:    Local ring buffer capacity (events). When full, oldest
                        events are dropped. Default: 50,000.
        flush_interval: How often to attempt flushing the buffer (seconds).
        flush_batch:    Max events per flush HTTP request. Default: 200.
        max_retries:    Retry attempts per flush before backing off. Default: 3.
        backoff_base:   Base seconds for exponential backoff. Default: 2.
        delta_threshold: Forwarded to MQTTConnector for numeric delta filtering.
                         0.0 = forward everything; 0.02 = only if >2% change.
    """

    def __init__(
        self,
        backend_url: str = "http://localhost:8000",
        device_id: str = "edge-node",
        api_key: Optional[str] = None,
        buffer_size: int = 50_000,
        flush_interval: float = 1.0,
        flush_batch: int = 200,
        max_retries: int = 3,
        backoff_base: float = 2.0,
        delta_threshold: float = 0.0,
    ) -> None:
        self._backend_url = backend_url.rstrip("/")
        self._device_id = device_id
        self._api_key = api_key
        self._buffer_size = buffer_size
        self._flush_interval = flush_interval
        self._flush_batch = flush_batch
        self._max_retries = max_retries
        self._backoff_base = backoff_base
        self._delta_threshold = delta_threshold

        self._buffer: Deque[BufferedEvent] = deque(maxlen=buffer_size)
        self._lock = asyncio.Lock()
        self._running = False
        self._flush_task: Optional[asyncio.Task] = None

        self._http = httpx.AsyncClient(
            base_url=self._backend_url,
            headers=self._auth_headers(),
            timeout=10.0,
        )

        self._stats: Dict[str, Any] = {
            "events_ingested":   0,
            "events_flushed":    0,
            "events_dropped":    0,
            "flush_errors":      0,
            "bytes_sent":        0,
            "started_at":        time.time(),
        }

    # ------------------------------------------------------------------
    # High-level: run a full MQTT bridge
    # ------------------------------------------------------------------

    async def run_mqtt(
        self,
        connector: MQTTConnector,
        topics: List[str],
    ) -> None:
        """
        Connect *connector* to the broker, ingest all events, and flush to
        the backend.  Runs until cancelled.

        This is the typical entry-point for a gateway script.
        """
        connector._delta_threshold = self._delta_threshold
        await self.start()
        await connector.connect(topics)

        logger.info(
            "WireBandEdgeClient running: device=%s backend=%s topics=%s",
            self._device_id, self._backend_url, topics,
        )

        try:
            async for event in connector.events():
                await self._buffer_event(event)
        finally:
            await connector.disconnect()
            await self.stop()

    # ------------------------------------------------------------------
    # Programmatic ingest (for serial drivers, custom sensors, etc.)
    # ------------------------------------------------------------------

    async def ingest(
        self,
        data: Any,
        topic: str = "default",
        symbol: Optional[int] = None,
    ) -> None:
        """
        Ingest a raw dict / value directly (not from MQTT).

        Args:
            data:   The payload — dict, list, str, or numeric.
            topic:  Logical topic name (used as a routing key).
            symbol: Override theta symbol.  If None, a generic gauge is used.
        """
        from .symbols import METRICS_GAUGE_SET as _gauge_set
        class MetricsGaugeSymbol:
            SET = _gauge_set
        sym = symbol or MetricsGaugeSymbol.SET

        body = json.dumps(
            {"t": topic, "d": data},
            separators=(",", ":"),
            ensure_ascii=False,
        ).encode("utf-8")
        frame = bytes([sym >> 8, sym & 0xFF]) + body

        buffered = BufferedEvent(topic=topic, symbol=sym, frame=frame)
        async with self._lock:
            self._buffer.append(buffered)
            self._stats["events_ingested"] += 1

    # ------------------------------------------------------------------
    # Lifecycle
    # ------------------------------------------------------------------

    async def start(self) -> None:
        """Start the background flush loop."""
        self._running = True
        self._flush_task = asyncio.create_task(self._flush_loop())
        logger.debug("WireBandEdgeClient flush loop started (interval=%.1fs)", self._flush_interval)

    async def stop(self) -> None:
        """Flush remaining events and shut down."""
        self._running = False
        if self._flush_task:
            self._flush_task.cancel()
            try:
                await self._flush_task
            except asyncio.CancelledError:
                pass
        # Final flush
        await self._flush_once()
        await self._http.aclose()
        logger.info("WireBandEdgeClient stopped. Stats: %s", self.stats())

    async def flush(self) -> int:
        """Manually flush the buffer. Returns number of events sent."""
        return await self._flush_once()

    def stats(self) -> Dict[str, Any]:
        s = dict(self._stats)
        uptime = time.time() - s.pop("started_at")
        return {
            **s,
            "buffer_depth":   len(self._buffer),
            "buffer_capacity": self._buffer_size,
            "uptime_seconds": round(uptime, 1),
            "device_id":      self._device_id,
            "backend_url":    self._backend_url,
        }

    # ------------------------------------------------------------------
    # Internal
    # ------------------------------------------------------------------

    async def _buffer_event(self, event: MQTTEvent) -> None:
        buffered = BufferedEvent(
            topic=event.topic,
            symbol=event.symbol,
            frame=event.compressed_frame,
            ts=event.ts,
        )
        async with self._lock:
            if len(self._buffer) >= self._buffer_size:
                self._stats["events_dropped"] += 1
            self._buffer.append(buffered)
            self._stats["events_ingested"] += 1

    async def _flush_loop(self) -> None:
        while self._running:
            await asyncio.sleep(self._flush_interval)
            await self._flush_once()

    async def _flush_once(self) -> int:
        async with self._lock:
            if not self._buffer:
                return 0
            batch = [self._buffer.popleft() for _ in range(
                min(self._flush_batch, len(self._buffer))
            )]

        if not batch:
            return 0

        payload = [e.to_payload(self._device_id) for e in batch]
        sent = await self._send_with_retry(payload)

        if sent:
            self._stats["events_flushed"] += len(batch)
            self._stats["bytes_sent"] += sum(len(e.frame) for e in batch)
        else:
            # Put failed events back at the front
            async with self._lock:
                for e in reversed(batch):
                    self._buffer.appendleft(e)

        return len(batch) if sent else 0

    async def _send_with_retry(self, payload: List[Dict]) -> bool:
        for attempt in range(self._max_retries):
            try:
                resp = await self._http.post(
                    "/iot/v1/ingest/batch",
                    json={"events": payload},
                )
                if resp.status_code < 500:
                    return True
                logger.warning(
                    "Flush attempt %d/%d — server error %d",
                    attempt + 1, self._max_retries, resp.status_code,
                )
            except (httpx.ConnectError, httpx.TimeoutException) as exc:
                logger.warning(
                    "Flush attempt %d/%d — network error: %s",
                    attempt + 1, self._max_retries, exc,
                )

            if attempt < self._max_retries - 1:
                await asyncio.sleep(self._backoff_base ** attempt)

        self._stats["flush_errors"] += 1
        return False

    def _auth_headers(self) -> Dict[str, str]:
        if self._api_key:
            return {"Authorization": f"Bearer {self._api_key}"}
        return {}

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

    async def __aenter__(self) -> "WireBandEdgeClient":
        await self.start()
        return self

    async def __aexit__(self, *_: Any) -> None:
        await self.stop()
