import bisect
import time
from collections import deque

from digimat.mbio.task import MBIOTask
from digimat.mbio.xmlconfig import XMLConfig


class MBIOTrendingStore(MBIOTask):
    """Time-series store for MBIOValue objects.

    Each value is identified by its key. Points are stored as 4-tuples
    (timestamp, value, flags, unit). Deduplication filters redundant points
    based on value type (digital, multistate, analog with resolution).

    Data is persisted via pickle files (lazy load on first updateValue(),
    staggered writes every SAVE_STAGGER seconds after SAVE_MIN_AGE dirty delay).
    Orphaned keys (whose MBIOValue no longer exists) are purged after ORPHAN_TTL.

    _total_points is maintained as an O(1) counter: incremented on append,
    decremented on eviction and removal. total_points() is therefore O(1).
    """

    DEFAULT_MAX_AGE = 30 * 86400  # 30 days retention
    SAVE_MIN_AGE = 900           # dirty for 15 min → eligible for save
    SAVE_STAGGER = 5             # seconds between successive pickleWrite calls
    ORPHAN_TTL = 900             # seconds before orphan key is purged

    def __init__(self, parent, name='trends', xml: XMLConfig = None):
        super().__init__(parent, name)
        if xml is not None:
            self.onLoad(xml)

    def onInit(self):
        self._data: dict = {}          # key → deque[(timestamp, value, flags, unit)]
        self._meta: dict = {}          # key → {'isDigital', 'isMultistate', 'resolution'}
        self._dirty_since: dict = {}   # key → timestamp of first push since last save
        self._orphan_since: dict = {}  # key → timestamp of first absence in mbio.value(key)
        self._save_queue: dict = {}    # {key: None} ordered set — pending saves (staggered processing)
        self._next_save_at: float = 0
        self._pending_loads: list = [] # [key, ...] deferred pickle loads
        self._timeout_dirty: float = 0
        self._timeout_orphan: float = 0
        self._total_points: int = 0    # O(1) counter maintained by updateValue/_load_one/remove/purge
        self._max_age: float = self.DEFAULT_MAX_AGE   # 30 * 86400 par défaut
        self._enabled: bool = True

    def onLoad(self, xml: XMLConfig):
        self._enabled = xml.getBool('enabled', True)
        depth = xml.getInt('depth', 30, vmin=1, vmax=3650)
        self._max_age = depth * 86400

    def __bool__(self):
        return True

    def __len__(self):
        """Number of known keys in the store."""
        return len(self._data)

    def __contains__(self, key):
        """'key in store' → True if the key is known."""
        return key in self._data

    def __repr__(self):
        return '%s(%s, %s#%ds, keys=%d, pts=%d, dirty=%d)' % (
            self.__class__.__name__,
            self.key,
            self.statestr(),
            self.statetime(),
            len(self._data),
            self._total_points,
            len(self._dirty_since),
        )

    def richstr(self):
        return '[yellow]%s[/yellow]([bold]%s[/bold], %s#%ds, keys=[cyan]%d[/cyan], pts=[cyan]%d[/cyan], dirty=[magenta]%d[/magenta])' % (
            self.__class__.__name__,
            self.key,
            self.richstatestr(),
            self.statetime(),
            len(self._data),
            self._total_points,
            len(self._dirty_since),
        )

    # ------------------------------------------------------------------
    # Push

    def updateValue(self, value):
        """Record a new point for the given MBIOValue.

        Deduplication: a new point is stored only when at least one of
        (value, flags, unit) has changed beyond the configured resolution.
        """
        key = value.key
        v = value.value
        if v is None:
            return
        if not self._enabled:
            return
        flags = value.flags
        unit = value.unitstr() or ''
        now = time.time()

        if key not in self._data:
            self._data[key] = deque()
            self._meta[key] = {
                'isDigital': value.isDigital(),
                'isMultistate': value.isMultistate(),
                'resolution': value.resolution,
            }
            if key not in self._pending_loads:
                self._pending_loads.append(key)

        meta = self._meta[key]

        # Type normalisation
        if meta['isDigital']:
            v = bool(v)
        elif meta['isMultistate']:
            v = int(v)
        else:
            try:
                v = float(v)
            except (TypeError, ValueError):
                return

        dq = self._data[key]

        # Deduplication
        if dq:
            last_t, last_v, last_flags, last_unit = dq[-1]
            if flags == last_flags and unit == last_unit:
                if meta['isDigital'] or meta['isMultistate']:
                    if v == last_v:
                        return
                else:
                    resolution = meta['resolution']
                    if resolution and resolution > 0:
                        if abs(v - last_v) < resolution:
                            return
                    elif v == last_v:
                        return

        dq.append((now, v, flags, unit))
        self._total_points += 1

        # Evict points older than retention window
        cutoff = now - self._max_age
        while dq and dq[0][0] < cutoff:
            dq.popleft()
            self._total_points -= 1

        # Dirty tracking: only record the first push since the last save
        if key not in self._dirty_since:
            self._dirty_since[key] = now

        # Reset orphan flag if we receive data
        self._orphan_since.pop(key, None)

    # ------------------------------------------------------------------
    # Read

    def last_point(self, key):
        """Return (timestamp, value, flags, unit) or None."""
        dq = self._data.get(key)
        return dq[-1] if dq else None

    def keys(self):
        return list(self._data.keys())

    def count(self, key):
        """Number of stored points for a key."""
        dq = self._data.get(key)
        return len(dq) if dq else 0

    def total_points(self):
        """Total number of points across all keys — O(1)."""
        return self._total_points

    def is_dirty(self, key):
        """True if the key has unsaved data."""
        return key in self._dirty_since

    def stats(self):
        """Overview of the store — useful for monitoring.

        Returns a JSON-serializable dict:
          keys          : number of tracked values
          total_points  : total number of points in memory
          dirty_keys    : number of keys with unsaved data
          pending_loads : number of pending pickle loads
          pending_saves : number of keys queued for save
        """
        return {
            'keys': len(self._data),
            'total_points': self._total_points,
            'dirty_keys': len(self._dirty_since),
            'pending_loads': len(self._pending_loads),
            'pending_saves': len(self._save_queue),
        }

    def metrics(self):
        m = super().metrics()
        s = self.stats()
        m['trend_keys'] = s['keys']
        m['trend_total_points'] = s['total_points']
        m['trend_dirty_keys'] = s['dirty_keys']
        m['trend_pending_saves'] = s['pending_saves']
        return m

    # ------------------------------------------------------------------
    # Web API helpers

    def get_history(self, key, since=0.0, until=None):
        """Return list of points (t, v, flags, unit) within [since, until].

        Uses binary search (bisect) on the sorted timestamp axis so that
        filtering is O(log N) rather than a full O(N) scan.
        """
        dq = self._data.get(key)
        if not dq:
            return []
        points = list(dq)
        if since:
            lo = bisect.bisect_left(points, since, key=lambda p: p[0])
            points = points[lo:]
        if until:
            hi = bisect.bisect_right(points, until, key=lambda p: p[0])
            points = points[:hi]
        return points

    def get_history_sampled(self, key, max_points=500, since=0.0, until=None):
        """Return at most max_points points, uniformly distributed in the range."""
        points = self.get_history(key, since, until)
        if len(points) <= max_points:
            return points
        step = max(1, len(points) // max_points)
        return points[::step]

    def info(self, key):
        """Return a JSON-serializable metadata dict for a key."""
        dq = self._data.get(key)
        meta = self._meta.get(key, {})
        count = len(dq) if dq else 0
        first_t = last_t = last_v = last_u = None
        if dq:
            first_t = dq[0][0]
            last_t, last_v, _flags, last_u = dq[-1]
        return {
            'key': key,
            'isDigital': meta.get('isDigital', False),
            'isMultistate': meta.get('isMultistate', False),
            'resolution': meta.get('resolution'),
            'count': count,
            'firstTimestamp': first_t,
            'lastTimestamp': last_t,
            'lastValue': last_v,
            'unit': last_u,
            'dirty': key in self._dirty_since,
        }

    def all_info(self):
        """Return the list of info() for all known keys."""
        return [self.info(key) for key in self._data.keys()]

    # ------------------------------------------------------------------
    # Manual removal

    def remove(self, key):
        """Completely remove a key from the store (memory + pickle).

        After remove(), the next updateValue() recreates the key from scratch.
        """
        if key in self._data:
            self.logger.info('MBIOTrend remove: %s' % key)
            self._total_points -= len(self._data[key])
            self._data.pop(key, None)
            self._meta.pop(key, None)
            self._dirty_since.pop(key, None)
            self._orphan_since.pop(key, None)
            self._save_queue.pop(key, None)
            self.pickleRAZ(self._dataname(key))

    def purge(self, key):
        """Clear the history of a key without removing it from the store.

        The key stays active: subsequent updateValue() calls are still recorded.
        """
        if key in self._data:
            count = len(self._data[key])
            self._total_points -= count
            self._data[key].clear()
            self._dirty_since.pop(key, None)
            self._save_queue.pop(key, None)
            self.pickleRAZ(self._dataname(key))
            self.logger.info('MBIOTrend purge: %s (%d pts supprimés)' % (key, count))

    def raz(self):
        """Remove all records from the store (memory + all pickles)."""
        self.logger.warning('MBIOTrend RAZ: suppression de %d clés' % len(self._data))
        for key in list(self._data.keys()):
            self.remove(key)
            self.microsleep()

    # ------------------------------------------------------------------
    # Persistence

    def _dataname(self, key):
        """Pickle key — sanitise special characters."""
        return 'trend.' + key.replace('/', '_').replace('\\', '_').replace(' ', '_')

    def _save_one(self, key):
        """Save data for one key. Returns True on success."""
        dq = self._data.get(key)
        points = list(dq) if dq else []
        if not points:
            return False
        ok = self.pickleWrite(self._dataname(key), points, force=True)
        if ok:
            self._dirty_since.pop(key, None)
            self.logger.info('MBIOTrend saved: %s (%d pts)' % (key, len(points)))
        return ok

    def _load_one(self, key):
        """Reload history from pickle for a key.

        Defensive unpacking: tolerates older formats (fewer fields) by using
        default values. Adding a field at the end of the tuple remains backward-
        compatible.

        Correct merge when called after updateValue() has already pushed points:
        pickle points are always older than in-memory points, so they are
        prepended directly — no sort needed, O(N) instead of O(N log N).
        """
        points = self.pickleRead(self._dataname(key))
        if not points:
            return
        cutoff = time.time() - self._max_age
        if key not in self._data:
            self._data[key] = deque()
        dq = self._data[key]
        loaded_points = []
        for pt in points:
            try:
                t = pt[0]
                if t < cutoff:
                    continue
                v = pt[1]
                flags = pt[2] if len(pt) > 2 else None
                unit = pt[3] if len(pt) > 3 else ''
                loaded_points.append((t, v, flags, unit))
            except (IndexError, TypeError):
                pass
        loaded = len(loaded_points)
        # Pickle points are always older than in-memory points (pushed after restart)
        # → prepend them directly, no sort needed
        self._data[key] = deque(loaded_points)
        self._data[key].extend(dq)
        self._total_points += loaded
        self.logger.info('MBIOTrend restored: %s (%d pts)' % (key, loaded))

    def save(self):
        """Immediate save of all dirty keys (no stagger — for poweroff)."""
        for key in list(self._dirty_since.keys()):
            self._save_one(key)
            self.microsleep()

    def _get_due_for_save(self):
        now = time.time()
        return [k for k, ts in self._dirty_since.items() if now - ts >= self.SAVE_MIN_AGE]

    # ------------------------------------------------------------------
    # MBIOTask state machine

    def poweron(self):
        self._save_queue.clear()
        self._next_save_at = 0
        self._timeout_dirty = 0
        self._timeout_orphan = 0
        return True

    def run(self):
        now = time.time()

        # 0. Deferred pickle loads (32 key per run cycle to avoid blocking)
        count = 32
        while self._pending_loads and count > 0:
            count -= 1
            key = self._pending_loads.pop(0)
            self._load_one(key)
            self.microsleep()
            # Points loaded are OLDER than points already pushed by updateValue()
            # → final sort in _load_one() restores chronological order

        # 1. Staggered save: one file per SAVE_STAGGER seconds
        if self._save_queue and self.isTimeout(self._next_save_at):
            key = next(iter(self._save_queue))
            del self._save_queue[key]
            self._save_one(key)
            self._next_save_at = self.timeout(self.SAVE_STAGGER)

        # 2. Feed the save queue (~every 60 s)
        if self.isTimeout(self._timeout_dirty):
            for key in self._get_due_for_save():
                if key not in self._save_queue:
                    self._save_queue[key] = None
            self._timeout_dirty = self.timeout(60)

        # 3. Orphan cleanup (~every 60 s)
        if self.isTimeout(self._timeout_orphan):
            mbio = self.getMBIO()
            for key in list(self._data.keys()):
                self.microsleep()
                if mbio.value(key) is None:
                    if key not in self._orphan_since:
                        self._orphan_since[key] = now
                    elif now - self._orphan_since[key] >= self.ORPHAN_TTL:
                        # Double-check before destruction: value may have been
                        # re-declared between detection and TTL expiry
                        if mbio.value(key) is None:
                            self.logger.info('MBIOTrend orphan purge: %s' % key)
                            self._total_points -= len(self._data.get(key, []))
                            self._data.pop(key, None)
                            self._meta.pop(key, None)
                            self._dirty_since.pop(key, None)
                            self._orphan_since.pop(key, None)
                            self.pickleRAZ(self._dataname(key))
                        else:
                            # Re-declared between the two checks → reset
                            self._orphan_since.pop(key, None)
                else:
                    self._orphan_since.pop(key, None)
            self._timeout_orphan = self.timeout(60)

        return 0.1

    def poweroff(self):
        self.save()
        return True
