"""
bridge_spoolman.py - SpoolmanMixin for KobraXBridge.

Filament-usage attribution + reporting to a Spoolman server, and the
/kx/spoolman/* endpoints. Mixed into KobraXBridge; relies on the shared bridge
state (self._state, self._store, self._spoolman*, self._json_cors) provided by
the core class.

────────────────────────────────────────────────────────────────────────────
Copyright (C) 2026 viewit (KX-Bridge contributors)

Licensed under GPLv3 — see LICENSE in the project root. See NOTICE.md.
"""

import asyncio
import logging
import threading

log = logging.getLogger("bridge")


class SpoolmanMixin:
    # ── Spoolman helpers ──────────────────────────────────────────────────────

    def _spoolman_filament_mm(self) -> float:
        """Total filament_used_mm for the current print file from the GCode DB."""
        filename = self._state.get("filename", "")
        if not filename:
            return 0.0
        try:
            gf = self._store.get_file_by_name(filename)
            return float(gf.get("filament_used_mm") or 0.0) if gf else 0.0
        except Exception:
            return 0.0

    def _spoolman_attribute_tick(self, activity_map: dict) -> None:
        """Attribute the supplies_usage delta since last tick to the active slot.

        Skips attribution during loading/unloading transitions (tool changes +
        purges) to avoid charging the wrong spool for purge material."""
        if not self._spoolman or not self._spoolman_slot_spools:
            return
        if self._state.get("print_state") != "printing":
            return
        current = self._state.get("supplies_usage", 0)
        delta = current - self._spoolman_last_usage
        self._spoolman_last_usage = current
        if delta <= 0:
            return
        loaded = self._ams_loaded_slot
        if loaded < 0:
            return
        if activity_map.get(loaded):
            return
        self._spoolman_slot_usage[loaded] = self._spoolman_slot_usage.get(loaded, 0.0) + delta

    def _spoolman_unreported(self) -> dict[int, float]:
        """Return {slot_idx: mm} of usage not yet reported to Spoolman.

        Falls back to crediting the single mapped slot with the full
        supplies_usage when per-slot attribution data is absent (single-
        extruder setup with no AMS - there's only ever one spool it could be).
        With more than one mapped slot, splitting unattributed usage equally
        across all of them would silently deduct filament from spools not
        even used in the current print (Issue: filament removed from spools
        not part of the print) - safer to report nothing for those slots and
        wait for real attribution data than to guess wrong."""
        total_used = self._state.get("supplies_usage", 0)
        if self._spoolman_slot_usage:
            return {
                slot: self._spoolman_slot_usage.get(slot, 0.0)
                       - self._spoolman_slot_reported.get(slot, 0.0)
                for slot in self._spoolman_slot_spools
            }
        if len(self._spoolman_slot_spools) == 1:
            slot = next(iter(self._spoolman_slot_spools))
            already = self._spoolman_slot_reported.get(slot, 0.0)
            return {slot: total_used - already}
        return {}

    def _spoolman_report(self, unreported: dict[int, float], min_mm: float = 0.1) -> None:
        """Fire-and-forget report of unreported mm to each mapped spool."""
        sm = self._spoolman
        for slot_idx, mm in unreported.items():
            if mm < min_mm:
                continue
            spool_id = self._spoolman_slot_spools.get(slot_idx)
            if not spool_id:
                continue
            self._spoolman_slot_reported[slot_idx] = (
                self._spoolman_slot_reported.get(slot_idx, 0.0) + mm
            )
            def _send(sid=spool_id, length=mm):
                try:
                    sm.use_filament(sid, length)
                    log.info(f"Spoolman: {length:.1f} mm → spool {sid}")
                except Exception as e:
                    log.warning(f"Spoolman: report failed (spool {sid}): {e}")
            threading.Thread(target=_send, daemon=True, name="spoolman-report").start()

    def _spoolman_notify_end(self):
        """Report remaining filament on print end."""
        if not self._spoolman or not self._spoolman_slot_spools:
            return
        self._spoolman_report(self._spoolman_unreported())

    def _spoolman_sync_midprint(self):
        """Report incremental filament usage during a print (sync_rate interval)."""
        if not self._spoolman or not self._spoolman_slot_spools:
            return
        self._spoolman_report(self._spoolman_unreported(), min_mm=10.0)

    # ── Spoolman API handlers ─────────────────────────────────────────────────

    async def handle_kx_spoolman_status(self, request):
        """GET /kx/spoolman/status"""
        return self._json_cors({
            "configured":   bool(self._spoolman),
            "reachable":    self._spoolman_reachable if self._spoolman else False,
            "server":       self._spoolman.server_url if self._spoolman else "",
            "sync_rate":    self._spoolman.sync_rate if self._spoolman else 0,
            "slot_spools":  {str(k): v for k, v in self._spoolman_slot_spools.items()},
        })

    async def handle_kx_spoolman_spools(self, request):
        """GET /kx/spoolman/spools — proxied from Spoolman."""
        if not self._spoolman:
            return self._json_cors({"error": "Spoolman not configured"}, status=503)
        try:
            spools = await asyncio.get_event_loop().run_in_executor(
                None, self._spoolman.list_spools
            )
            return self._json_cors({"spools": spools})
        except Exception as e:
            log.warning(f"Spoolman: list_spools failed: {e}")
            return self._json_cors({"error": str(e)}, status=502)

    async def handle_kx_spoolman_set_active(self, request):
        """POST /kx/spoolman/active-spool
        Body: {"slot_map": {"0": 42, "2": 17}}  — AMS slot index → Spoolman spool ID."""
        try:
            data = await request.json()
        except Exception:
            return self._json_cors({"error": "invalid JSON"}, status=400)
        slot_map = data.get("slot_map") or data.get("slot_spools") or {}
        self._spoolman_slot_spools = {
            int(k): int(v) for k, v in slot_map.items()
            if str(v).isdigit() and int(v) > 0
        }
        # Persist per printer (own [spoolman_<id>] section) so the
        # assignment survives bridge restarts and two AMS units don't overwrite each other.
        # (Previously: NameError on `config_loader` -> nothing was ever saved.)
        try:
            import config_loader as _cl
            _cl.save_spool_map(self._spoolman_slot_spools, self._printer_id)
        except Exception as _e:
            log.warning("Spoolman: failed to save slot map: %s", _e)
        self._spoolman_slot_usage = {}
        self._spoolman_slot_reported = {}
        self._spoolman_last_usage = 0.0
        return self._json_cors({"slot_spools": {str(k): v for k, v in self._spoolman_slot_spools.items()}})
