"""
bridge_ams.py - AmsFilamentMixin for KobraXBridge.

AMS/ACE slot topology + aggregation, per-slot filament-profile resolution
(material normalization, RFID vendor matching, effective slot profile),
ACE dryer presets, the lane-data builder for OrcaSlicer sync, and the
_on_multicolor_box / _on_light MQTT callbacks (which live here because they
are all about AMS state). Mixed into KobraXBridge.

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

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

import re
import time
import logging
import threading

log = logging.getLogger("bridge")


class AmsFilamentMixin:
    def _default_ace_dry_presets(self) -> dict[str, dict]:
        return {
            "pla": {"temp": 45, "duration_sec": 4 * 3600},
        "pla_plus": {"temp": 45, "duration_sec": 4 * 3600},
        "petg": {"temp": 50, "duration_sec": 4 * 3600},
        "tpu": {"temp": 55, "duration_sec": 4 * 3600},
        "abs_asa": {"temp": 45, "duration_sec": 8 * 3600},
        "pa_pc": {"temp": 55, "duration_sec": 12 * 3600},
        "custom_1": {"name": "Custom 1", "temp": 45, "duration_sec": 4 * 3600},
        "custom_2": {"name": "Custom 2", "temp": 45, "duration_sec": 4 * 3600},
        "custom_3": {"name": "Custom 3", "temp": 45, "duration_sec": 4 * 3600},
        }

    def _sanitize_ace_dry_presets(self, presets: dict) -> dict[str, dict]:
        out = self._default_ace_dry_presets()
        for key in list(out.keys()):
            src = presets.get(key) if isinstance(presets, dict) else None
            if not isinstance(src, dict):
                continue
            try:
                t = int(src.get("temp", out[key]["temp"]))
            except Exception:
                t = out[key]["temp"]
            try:
                d = int(src.get("duration_sec", out[key]["duration_sec"]))
            except Exception:
                d = out[key]["duration_sec"]
            out[key]["temp"] = max(30, min(80, t))
            out[key]["duration_sec"] = max(10 * 60, min(24 * 3600, d))
            if key.startswith("custom_"):
                name = str(src.get("name", out[key].get("name", key.replace("_", " ").title()))).strip()
                out[key]["name"] = name or out[key].get("name", "Custom")
        return out

    def _load_ace_dry_presets_config(self) -> dict[str, dict]:
        import configparser
        defaults = self._default_ace_dry_presets()
        cfg_path = self._find_config_path()
        if not cfg_path.is_file():
            return defaults
        cfg = configparser.ConfigParser(interpolation=None)
        cfg.read(cfg_path, encoding="utf-8")
        sec = "ace_dry_presets"
        if not cfg.has_section(sec):
            return defaults
        out = {}
        for key, d in defaults.items():
            temp_k = f"{key}_temp"
            dur_k = f"{key}_duration_sec"
            try:
                temp = int(cfg.get(sec, temp_k, fallback=str(d["temp"])))
            except Exception:
                temp = d["temp"]
            try:
                dur = int(cfg.get(sec, dur_k, fallback=str(d["duration_sec"])))
            except Exception:
                dur = d["duration_sec"]
            out[key] = {
                "temp": max(30, min(80, temp)),
                "duration_sec": max(10 * 60, min(24 * 3600, dur)),
            }
            if key.startswith("custom_"):
              name_k = f"{key}_name"
              name = cfg.get(sec, name_k, fallback=str(d.get("name", key.replace("_", " ").title()))).strip()
              out[key]["name"] = name or str(d.get("name", "Custom"))
        return out

    @staticmethod
    def _detect_filament_mode(boxes: list, head_tools_model: int = -1) -> str:
        """Detect active filament topology mode.

        Modes:
        - toolhead: only toolhead slots
        - ace_direct: ACE channels directly mapped, no toolhead box present.
          Covers one unit (Kobra X) as well as multiple daisy-chained units
          (Kobra S1 with 2+ ACE Pro, Issue #95) — each unit contributes a
          block of 4 global slots at box_id * 4.
        - ace_hub: toolhead + ACE via hub (slot 4 as hub path)
        """
        toolhead = any(b.get("id") == -1 for b in boxes)
        ace = any(b.get("id", -1) >= 0 for b in boxes)
        if ace and toolhead:
            return "ace_hub"
        if ace:
            return "ace_direct"
        return "toolhead"

    @staticmethod
    def _aggregate_slots(boxes: list, mode: str = "toolhead") -> tuple:
        """Aggregate multi_color_box list into a flat global slot list."""
        toolhead = next((b for b in boxes if b.get("id") == -1), None)
        ace_boxes = sorted(
            [b for b in boxes if b.get("id", -1) >= 0],
            key=lambda b: b["id"]
        )

        global_slots: list = []
        global_loaded: int = -1

        if mode == "toolhead":
            if toolhead:
                for local_idx, s in enumerate(toolhead.get("slots") or []):
                    s = dict(s)
                    s["global_index"] = local_idx
                    s["box_id"] = -1
                    global_slots.append(s)
                loaded = toolhead.get("loaded_slot", -1)
                if loaded >= 0:
                    global_loaded = loaded
            return global_slots, global_loaded

        if mode == "ace_direct":
            # One or more ACE units, no toolhead buffer (Kobra X: 1 unit,
            # Kobra S1: up to 2+ units, Issue #95). Global index =
            # box_id * 4 + local slot, so the numbering matches
            # _global_to_box_slot's //4-%4 fallback and stays stable
            # regardless of report order.
            for ace in ace_boxes:
                ace_id = int(ace["id"])
                base = ace_id * 4
                for local_idx, s in enumerate((ace.get("slots") or [])[:4]):
                    s = dict(s)
                    s["global_index"] = base + local_idx
                    s["box_id"] = ace_id
                    global_slots.append(s)
                ace_loaded = ace.get("loaded_slot", -1)
                if 0 <= ace_loaded < 4:
                    global_loaded = base + ace_loaded
            return global_slots, global_loaded

        # ace_hub
        if toolhead:
            for local_idx, s in enumerate((toolhead.get("slots") or [])[:3]):
                s = dict(s)
                s["global_index"] = local_idx
                s["box_id"] = -1
                global_slots.append(s)
            th_loaded = toolhead.get("loaded_slot", -1)
            if 0 <= th_loaded <= 2:
                global_loaded = th_loaded

        for ace in ace_boxes:
            ace_id = ace["id"]
            base = 3 + ace_id * 4
            for local_idx, s in enumerate(ace.get("slots") or []):
                s = dict(s)
                s["global_index"] = base + local_idx
                s["box_id"] = ace_id
                global_slots.append(s)
            ace_loaded = ace.get("loaded_slot", -1)
            if ace_loaded >= 0:
                global_loaded = base + ace_loaded

        return global_slots, global_loaded

    def _global_to_box_slot(self, global_index: int) -> tuple:
        """Convert a global slot index to (box_id, local_slot_index)."""
        for s in self._ams_slots:
            if s.get("global_index") == global_index:
                return s.get("box_id", -1), s.get("index", global_index)

        ace_present = any(s.get("box_id", -1) >= 0 for s in self._ams_slots)
        if self._filament_mode == "ace_direct" and ace_present:
            return global_index // 4, global_index % 4
        if not ace_present or global_index < 3:
            return -1, global_index
        offset = global_index - 3
        return offset // 4, offset % 4

    def _slot_to_print_ams_index(self, global_index: int) -> int:
      """Convert UI/global slot index to printer print/start ams_index.

      In ace_hub mode, print/start uses global channel numbering where
      toolhead channels occupy 1..3 and ACE0 starts at index 4.
      """
      idx = int(global_index)
      if self._filament_mode == "ace_hub":
        box_id, local_slot = self._global_to_box_slot(idx)
        if box_id >= 0:
          return 4 + box_id * 4 + int(local_slot)
        return idx
      return idx

    def _slot_usable_for_print(self, global_index: int) -> bool:
        """Whether a global slot can be used for current filament mode."""
        slot = next((s for s in self._ams_slots if int(s.get("global_index", -1)) == int(global_index)), None)
        if not slot:
            return False
        if int(slot.get("status", 0)) != 5:
            return False

        box_id = int(slot.get("box_id", -1))
        if self._filament_mode == "ace_hub":
          # In hub mode, toolhead channels (0..2) and ACE channels are both printable.
          return box_id == -1 or box_id >= 0
        if self._filament_mode == "ace_direct":
            return box_id >= 0
        return box_id == -1

    def _loaded_slots_for_print(self) -> list[tuple[int, dict]]:
        """Loaded slots filtered for current filament mode."""
        loaded = [
            (int(s.get("global_index", i)), s)
            for i, s in enumerate(self._ams_slots)
        if s.get("status") == 5 and self._slot_usable_for_print(int(s.get("global_index", i)))
        ]
        return loaded

    def _select_loaded_slots_for_print(self, warn_on_empty_default: bool = False) -> list[tuple[int, dict]]:
        """Return loaded slots, honoring default_ams_slot when configured."""
        default_slot = getattr(self._args, "default_ams_slot", "auto")
        all_loaded = self._loaded_slots_for_print()
        if default_slot == "auto":
            return all_loaded

        try:
            slot_idx = int(default_slot)
        except ValueError:
            return all_loaded

        selected = [(i, s) for i, s in all_loaded if i == slot_idx]
        if selected:
            return selected

        if warn_on_empty_default:
            log.warning(f"Default slot {slot_idx} is empty - falling back to auto")
        return all_loaded

    @staticmethod
    def _slot_color_rgba(slot: dict) -> list[int]:
        color = slot.get("color", [255, 255, 255])
        if isinstance(color, list) and len(color) >= 3:
            return [int(color[0]), int(color[1]), int(color[2]), 255]
        return [255, 255, 255, 255]

    def _build_auto_ams_box_mapping(
        self,
        warn_on_empty_default: bool = False,
        loaded_slots: list[tuple[int, dict]] | None = None,
    ) -> list[dict]:
        """Build print mapping from currently loaded slots (no explicit dialog assignments)."""
        loaded = loaded_slots
        if loaded is None:
            loaded = self._select_loaded_slots_for_print(warn_on_empty_default=warn_on_empty_default)
        if not loaded:
            return []
        loaded_map = {gidx: s for gidx, s in loaded}
        max_idx = max(loaded_map.keys())
        # The printer interprets ams_box_mapping as an ordered list (entry N = TN).
        # Missing slots must be inserted as placeholders, otherwise everything shifts.
        # A placeholder must NOT reference a physically empty tray: the printer
        # rejects such an entry even for a tool the GCode never calls (printing
        # Filament 4 with the slot below it empty fails; all-full works). Point
        # gap placeholders at a definitely-loaded tray instead of the gap's own
        # (empty) index.
        fallback_gidx = max_idx  # highest loaded slot -> loaded + printable
        fallback_slot = loaded_map[fallback_gidx]
        fallback_ams = self._slot_to_print_ams_index(fallback_gidx)
        result = []
        for i in range(max_idx + 1):
            if i in loaded_map:
                s = loaded_map[i]
                result.append({
                    "paint_index": i,
                    "ams_index": self._slot_to_print_ams_index(i),
                    "paint_color": [255, 255, 255, 255],
                    "ams_color": self._slot_color_rgba(s),
                    "material_type": s.get("type", "PLA"),
                })
            else:
                result.append({
                    "paint_index": i,
                    "ams_index": fallback_ams,
                    "paint_color": [255, 255, 255, 255],
                    "ams_color": self._slot_color_rgba(fallback_slot),
                    "material_type": fallback_slot.get("type", "PLA"),
                })
        return result

    def _build_assigned_ams_box_mapping(self, assignments: list) -> tuple[list[dict], int, int]:
        """Build print mapping from UI filament assignments.

        Returns (mapping, unused_count, invalid_count).
        """
        slot_by_global_index = {
            int(s.get("global_index", i)): s
            for i, s in enumerate(self._ams_slots)
        }
        ams_box_mapping: list[dict] = []
        unused_count = 0
        invalid_count = 0

        for i, a in enumerate(assignments):
            try:
                if a.get("is_used") is False:
                    unused_count += 1
                    continue
                global_slot = int(a["slot_index"])
            except (ValueError, TypeError, KeyError):
                invalid_count += 1
                continue

            if global_slot < 0:
                unused_count += 1
                continue
            if not self._slot_usable_for_print(global_slot):
                invalid_count += 1
                continue

            slot = slot_by_global_index.get(global_slot, {})
            ams_box_mapping.append({
                # Preserve slicer paint indices (can be sparse when paint 0 is unused).
                "paint_index": a.get("paint_index", i),
                "ams_index": self._slot_to_print_ams_index(global_slot),
                "paint_color": a.get("paint_color", [255, 255, 255, 255]),
                "ams_color": self._slot_color_rgba(slot),
                "material_type": slot.get("type", a.get("material", "PLA")),
            })

        return ams_box_mapping, unused_count, invalid_count

    def _box_local_to_global(self, box_id: int, local_slot: int, boxes: list) -> int:
        """Convert (box_id, local slot) to global slot index for current topology."""
        if box_id == -1:
            return local_slot
        if self._filament_mode == "ace_direct":
            # Multi-ACE (Issue #95): each unit occupies its own block of 4.
            # Identical to the old `return local_slot` for a single unit (id 0).
            return box_id * 4 + local_slot
        return 3 + box_id * 4 + local_slot

    def _slot_activity_map(self, boxes: list, global_loaded: int = -1) -> dict:
        """Build {global_slot_index: loading|unloading} from feed_status data."""
        # Note: all boxes are considered — the old primary_ace_id filter (skip
        # every ACE box except the first in ace_direct mode) is gone since the
        # slot aggregation now handles multiple ACE units (Issue #95).
        activity: dict = {}
        for box in boxes:
            fs = box.get("feed_status") or {}
            current_status = int(fs.get("current_status", -1))
            local_slot = int(fs.get("slot_index", -1))
            feed_type = int(fs.get("type", -1))
            if current_status in (-1, 10, 11) or local_slot < 0:
                continue
            box_slots = box.get("slots") or []
            if local_slot >= len(box_slots) or (box_slots[local_slot] or {}).get("status") != 5:
                continue
            if feed_type == 1:
                act = "loading"
            elif feed_type == 2:
                act = "unloading"
            else:
                continue
            global_slot = self._box_local_to_global(int(box.get("id", -1)), local_slot, boxes)
            if feed_type == 1 and self._pending_load_slot >= 0 and global_slot != self._pending_load_slot:
                # Ignore transient firmware-reported loading slots that differ from the requested target.
                if global_loaded >= 0 and global_loaded != self._pending_load_slot:
                    activity[global_loaded] = "unloading"
                continue
            if feed_type == 1 and global_loaded >= 0 and global_slot != global_loaded:
                # During a slot swap the firmware reports the target slot immediately,
                # while the previously loaded slot is still being unloaded first.
                activity[global_loaded] = "unloading"
            activity[global_slot] = act
        return activity

    def _on_multicolor_box(self, payload: dict):
        if payload.get("state") == "failed":
            req = getattr(self, "_last_ams_set_request", None)
            log.warning(
                f"multiColorBox setInfo rejected by printer: request={req}  raw_response={payload.get('data')}"
            )
            self._state["last_ams_set_error"] = True
            return
        data = payload.get("data") or {}
        if not isinstance(data, dict):
            log.warning(f"multiColorBox/report: unexpected data shape: {data!r}")
            return
        boxes = data.get("multi_color_box") or []
        if not boxes:
            return
        self._state["last_ams_set_error"] = False
        self._head_tools_model = int(data.get("head_tools_model", self._head_tools_model))
        self._filament_mode = self._detect_filament_mode(boxes, self._head_tools_model)
        self._state["filament_mode"] = self._filament_mode

        global_slots, global_loaded = self._aggregate_slots(boxes, self._filament_mode)
        self._ams_loaded_slot = global_loaded
        self._update_ace_drying_state(data, boxes)
        for box in boxes:
            bid = int(box.get("id", -1))
            if 0 <= bid <= 3 and "auto_feed" in box:
                self._ace_auto_feed[bid] = int(box["auto_feed"])
        if self._pending_load_slot >= 0 and global_loaded == self._pending_load_slot:
          self._pending_load_slot = -1
        activity_map = self._slot_activity_map(boxes, global_loaded)
        for s in global_slots:
            s["activity"] = activity_map.get(s.get("global_index"), "")

        # Tip forming: after feed-in (status=10) or feed-out (status=11)
        # the original slicer automatically sends type=3 (extruder retract).
        # Check ALL boxes so ACE-triggered events are handled correctly.
        for box in boxes:
            fs = box.get("feed_status") or {}
            current_status = fs.get("current_status")
            slot_index = fs.get("slot_index", 0)
            box_id = box.get("id", -1)
            if current_status in (10, 11):
                def _tip_form(bi=box_id, si=slot_index, cs=current_status):
                    import time; time.sleep(2)
                    self.client.publish(
                        "multiColorBox", "feedFilament",
                        {"multi_color_box": [{"id": bi, "feed_status": {"slot_index": si, "type": 3}}]},
                        timeout=0
                    )
                    log.info(f"Tip forming (type=3) after status={cs} box={bi} slot={si}")
                threading.Thread(target=_tip_form, daemon=True).start()

        if global_slots:
            self._ams_slots = global_slots
            log.info(f"AMS slots received: {len(global_slots)}, loaded_slot={self._ams_loaded_slot}")
            self._push_status_update()

    def _update_ace_drying_state(self, data: dict, boxes: list):
        """Extract ACE drying state from multiColorBox report/getInfo payloads."""
        ace_ids = sorted({int(b.get("id", -1)) for b in boxes if int(b.get("id", -1)) >= 0})
        self._ace_box_ids = [i for i in ace_ids if 0 <= i <= 3]

        def _num_from(src: dict, keys: tuple[str, ...], default=None):
            for k in keys:
                v = src.get(k)
                if v is not None:
                    try:
                        return float(v)
                    except Exception:
                        return default
            return default

        def _humidity_from(src: dict, default=None):
            return _num_from(src, ("humidity", "current_humidity", "cur_humidity", "relative_humidity", "humidity_value"), default)

        def _current_temp_from(src: dict, default=None):
            return _num_from(src, ("current_temp", "cur_temp", "temperature", "temp", "drying_temp", "chamber_temp"), default)

        def _minutes_from(src: dict, key: str, default=0):
          raw = src.get(key, default)
          try:
            value = int(float(raw))
          except Exception:
            return int(default)
          # Some firmware payloads report dryer times in seconds while the UI uses minutes.
          if value > (24 * 60):
            return max(0, int(round(value / 60.0)))
          return max(0, value)

        per_unit: list[dict] = []
        for box in boxes:
            bid = int(box.get("id", -1))
            if bid < 0:
                continue

            bs = box.get("drying_status") or box.get("drying_settings")
            bs = bs if isinstance(bs, dict) else {}
            hu = _humidity_from(bs, _humidity_from(box))
            ct = _current_temp_from(bs, _current_temp_from(box))

            if bs or hu is not None or ct is not None:
                per_unit.append({
                    "id": bid,
                    "status": int(bs.get("status", 0)),
                    "target_temp": int(bs.get("target_temp", 0)),
                "duration": _minutes_from(bs, "duration", 0),
                "remain_time": _minutes_from(bs, "remain_time", 0),
                    "humidity": hu,
                    "current_temp": ct,
                })

        src = data.get("drying_status") or data.get("drying_settings")
        if not isinstance(src, dict):
            for box in boxes:
                if int(box.get("id", -1)) < 0:
                    continue
                cand = box.get("drying_status") or box.get("drying_settings")
                if isinstance(cand, dict):
                    src = cand
                    break

        if isinstance(src, dict):
          cur = self._state.get("ace_drying") or {}
          active = [u for u in per_unit if u.get("status", 0)]
          primary = active[0] if active else (per_unit[0] if per_unit else {})
          self._state["ace_drying"] = {
            "status": int(src.get("status", cur.get("status", 0))),
            "target_temp": int(src.get("target_temp", cur.get("target_temp", 0))),
            "duration": _minutes_from(src, "duration", cur.get("duration", 0)),
            "remain_time": _minutes_from(src, "remain_time", cur.get("remain_time", 0)),
            "humidity": _humidity_from(src, primary.get("humidity", cur.get("humidity"))),
            "current_temp": _current_temp_from(src, primary.get("current_temp", cur.get("current_temp"))),
            "units": per_unit,
          }
        elif per_unit:
            active = [u for u in per_unit if u.get("status", 0)]
            primary = active[0] if active else per_unit[0]
            self._state["ace_drying"] = {
                "status": int(primary.get("status", 0)),
                "target_temp": int(primary.get("target_temp", 0)),
                "duration": int(primary.get("duration", 0)),
                "remain_time": int(primary.get("remain_time", 0)),
                "humidity": primary.get("humidity"),
                "current_temp": primary.get("current_temp"),
                "units": per_unit,
            }

    def _on_light(self, payload: dict):
        d = payload.get("data") or {}
        self._state["light_on"]         = bool(d.get("status", 0))
        self._state["light_brightness"] = int(d.get("brightness", 80))
        self._push_status_update()

    # OrcaSlicer filament preset IDs (MoonrakerPrinterAgent.cpp mapping)
    # Default mapping per material type when the user has not set a slot
    # profile override. For the Kobra X we prefer Anycubic's own
    # filament IDs from the `@Anycubic Kobra X 0.4 nozzle` profiles - those
    # are printer-specific is_compatible and are picked up by OrcaSlicer directly
    # matched. Library fallbacks (OGF*) only for material types without
    # Kobra X-specific Anycubic profile - their @system profiles have
    # `compatible_printers: []` (= compatible with all printers).
    _TRAY_INFO_IDX = {
        # Anycubic-eigene Kobra-X-Profile
        "PLA":        "GFPLA",
        "PLA+":       "GFPLA+",
        "PLA SILK":   "GFPLA Silk",
        "PLA-SILK":   "GFPLA Silk",
        "PLASILK":    "GFPLA Silk",
        "SILK PLA":   "GFPLA Silk",
        "PLA MATTE":  "GFPLA",
        "PLA-MATTE":  "GFPLA",
        "PLA MARBLE": "GFPLA",
        "PLA WOOD":   "GFPLA",
        "PETG":       "GFPETG",
        "PETG+":      "GFPETG",
        "ABS":        "GFABS",
        "ASA":        "GFASA",
        "TPU":        "GFTPU 95A",
        "TPE":        "GFTPU 95A",
        "PVA":        "GFPVA",
        # Kein Anycubic-Kobra-X-Profil → Library-Fallback
        "PLA-CF":     "OGFL98",
        "PLA CF":     "OGFL98",
        "PETG-CF":    "OGFG98",
        "PETG CF":    "OGFG98",
        "PA":         "OGFN99",
        "PA-CF":      "OGFN98",
        "PA CF":      "OGFN98",
        "PC":         "OGFC99",
        "HIPS":       "OGFS98",
    }

    # Normalizes material type strings to the canonical key for _TRAY_INFO_IDX
    # and _default_filament_name. PLA variants without an exact match fall
    # back to their base family (PLA+ -> PLA+, PLA Matte -> PLA, etc.).
    @staticmethod
    def _normalize_material(mat: str) -> str:
        m = mat.upper().strip().replace("-", " ").replace("_", " ")
        # Bekannte Varianten normalisieren
        _ALIASES = {
            "PLAPLUS": "PLA+", "PLA PLUS": "PLA+",
            "SILK PLA": "PLA SILK", "PLASILK": "PLA SILK",
            "PLA MATTE": "PLA MATTE", "PLA MARBLE": "PLA MARBLE",
            "PLA WOOD": "PLA WOOD",
            "TPE": "TPU",
            "PETG PLUS": "PETG+",
            "PA6": "PA", "PA12": "PA", "PA66": "PA",
        }
        if m in _ALIASES:
            return _ALIASES[m]
        return m

    @staticmethod
    def _material_family(mat: str) -> str:
        """Reduce a material to its base polymer family.

        PLA / PLA+ / PLA SILK / PLA MATTE -> "PLA"; PETG / PETG+ -> "PETG"; etc.
        Used by the stale-profile guard: only a change of *family* (e.g. PETG ->
        PLA) invalidates a saved slot profile — a change within the family
        (PLA -> PLA SILK) must not discard an otherwise valid profile.
        """
        if not mat:
            return ""
        m = AmsFilamentMixin._normalize_material(mat)
        # Longer prefixes first so "PETG" is not swallowed by "PET".
        for fam in ("PETG", "PLA", "ABS", "ASA", "TPU", "PVA", "HIPS", "PA", "PC", "PET"):
            if m.startswith(fam):
                return fam
        return m

    def _parse_combined_rfid_type(self, raw_type: str) -> tuple[str, str]:
        """Split a combined ACE-RFID "VENDOR TYPE SERIAL" string (e.g.
        "GEEETECH PLA Bas", written via third-party RFID tools) into
        (vendor, material_family).

        Anycubic's ACE RFID system concatenates vendor + material + a
        truncated serial/variant into one `type` string for custom tags -
        unlike a normal spool report where `type` is just "PLA"/"PETG"/etc.
        Returns ("", "") when the first token isn't a known vendor (from the
        merged system+user filament library), which leaves plain type
        strings like "PLA" completely unaffected (Issue #101).
        """
        tokens = raw_type.split()
        if len(tokens) < 2:
            return "", ""
        first = tokens[0].strip().lower()
        vendors = {p.get("vendor", "").lower(): p.get("vendor", "") for p in self._load_orca_filaments()}
        vendor = vendors.get(first)
        if not vendor:
            return "", ""
        family = self._material_family(" ".join(tokens[1:]))
        if not family:
            return "", ""
        return vendor, family

    @staticmethod
    def _rfid_variant_tokens(raw_type: str) -> list[str]:
        """Tokens after "VENDOR TYPE" in a combined ACE-RFID string (e.g.
        ["bas"] for "GEEETECH PLA Bas") - the truncated variant/serial that
        distinguishes multiple profiles of the same (vendor, material family),
        e.g. "Basic" vs. "Matte". Kept separate from _parse_combined_rfid_type()
        so that function's 2-tuple signature (and its existing callers/tests)
        stay unchanged (Issue #101)."""
        tokens = raw_type.split()
        return [t.lower() for t in tokens[2:]]

    def _match_profile_by_vendor_family(self, vendor: str, family: str,
                                         variant_tokens: list[str] | None = None) -> dict:
        """Find an imported/system filament profile by (vendor, material
        family) - used to auto-resolve a combined ACE-RFID type string to
        the user's already-imported OrcaSlicer profile (Issue #101), since
        the exact profile `name` never appears verbatim in the truncated
        RFID string.

        When multiple profiles share the same (vendor, family) - e.g. "Geeetech
        PLA Basic" and "Geeetech PLA Matte" both matching (Geeetech, PLA) -
        variant_tokens (the RFID string's remaining tokens, e.g. ["bas"] for
        "Basic") are scored against each candidate's name: a word-prefix match
        scores higher than a plain substring match, so "bas" prefers "Basic"
        over "Matte" or an unrelated profile name containing "bas" as noise.
        Falls back to the first match when nothing disambiguates."""
        matches = [
            p for p in self._load_orca_filaments()
            if p.get("vendor", "").lower() == vendor.lower()
            and self._material_family(p.get("type", "")) == family
        ]
        if not matches:
            return {}
        if len(matches) == 1 or not variant_tokens:
            return matches[0]

        best = matches[0]
        best_score = -1
        for p in matches:
            name_words = p.get("name", "").lower().split()
            score = 0
            for tok in variant_tokens:
                if any(w.startswith(tok) for w in name_words):
                    score += 2
                elif tok in p.get("name", "").lower():
                    score += 1
            if score > best_score:
                best_score = score
                best = p
        log.debug(
            f"_match_profile_by_vendor_family: {len(matches)} profiles match "
            f"vendor={vendor!r} family={family!r}, variant_tokens={variant_tokens!r} "
            f"-> {best.get('name')!r} (score={best_score})"
        )
        return best

    def _profile_material(self, profile: dict) -> str:
        """Material type (e.g. "PETG") of a saved slot profile, resolved by
        (vendor, name) from the Orca filament library. Returns "" when the
        profile is not in the library — we do NOT guess in that case."""
        name = (profile or {}).get("name", "")
        if not name:
            return ""
        vendor = profile.get("vendor", "")
        for p in self._load_orca_filaments():
            if p.get("vendor") == vendor and p.get("name") == name:
                return p.get("type", "") or ""
        return ""

    def _effective_slot_profile(self, global_idx: int, ams_material: str) -> dict:
        """Saved slot-profile override — but only while its material *family*
        still matches the material currently loaded in the AMS. Falls back to
        auto-resolving a combined ACE-RFID type string (Issue #101) when there
        is no (usable) manual override.

        Non-destructive suppression (Option A): when the family no longer matches
        (e.g. a PETG profile but PLA loaded) the override is skipped → falls
        through to the RFID auto-match / generic default. The override stays in
        config.ini and reactivates as soon as the matching material is loaded
        again. When the profile's family is unknown we do NOT suppress (fail-safe).

        Centralized here (rather than duplicated per caller) so every consumer -
        the dashboard's /kx/filament/slots, Happy-Hare gate data, and the
        OrcaSlicer lane-data sync - benefits from RFID auto-matching identically,
        instead of only the one call site that happened to also call
        _parse_combined_rfid_type() directly."""
        # A combined ACE-RFID string ("GEEETECH PLA Bas") carries a vendor
        # prefix that _material_family() alone can't see past (it only
        # strips known polymer prefixes, so "GEEETECH PLA BAS" resolves to
        # itself, not "PLA") - resolve the plain material family through the
        # RFID parser first so the stale-profile guard below compares against
        # the actual polymer family, not the raw combined string.
        vendor, family = self._parse_combined_rfid_type(ams_material)
        plain_material = family or ams_material

        profile = self._filament_profiles.get(global_idx) or {}
        if profile.get("name"):
            prof_fam = self._material_family(self._profile_material(profile))
            ams_fam  = self._material_family(plain_material)
            if not (prof_fam and ams_fam and prof_fam != ams_fam):
                return profile

        if vendor:
            variant_tokens = self._rfid_variant_tokens(ams_material)
            auto = self._match_profile_by_vendor_family(vendor, family, variant_tokens)
            if auto.get("name"):
                return auto

        return {}

    def _build_lane_data(self) -> dict:
      """Builds BBL AMS JSON for OrcaSlicer DevFilaSystemParser::ParseV1_0.

      POSITION-FAITHFUL: every physical slot keeps its position (tray id =
      slot position). Empty slots are reported as placeholder trays, NOT
      filtered out/compacted - otherwise colors shift to wrong positions
      (e.g. slot 1=yellow, 2=empty, 3=red -> red must not land on position 2).
      """
      slots = self._ams_slots
      total = len(slots)
      if total == 0:
        return {"ams": [], "ams_exist_bits": "0", "tray_exist_bits": "0"}

      ams_count = (total + 3) // 4
      ams_exist_bits = 0
      tray_exist_bits = 0
      ams_array = []

      for ams_id in range(ams_count):
        ams_exist_bits |= (1 << ams_id)
        tray_array = []
        max_slot = min(3, total - ams_id * 4 - 1)
        for slot_id in range(max_slot + 1):
          slot_index = ams_id * 4 + slot_id
          slot = slots[slot_index] if slot_index < total else {}
          occupied = slot.get("status") == 5

          if occupied:
            tray_exist_bits |= (1 << slot_index)
            color_raw = slot.get("color", [255, 255, 255])
            if isinstance(color_raw, list) and len(color_raw) >= 3:
              color_hex = "{:02X}{:02X}{:02X}FF".format(
                int(color_raw[0]), int(color_raw[1]), int(color_raw[2])
              )
            elif isinstance(color_raw, str) and len(color_raw) >= 6:
              color_hex = color_raw[:6].upper() + "FF"
            else:
              color_hex = "FFFFFFFF"
            material = self._normalize_material(slot.get("type", "PLA"))
            # User override from config.ini [filament_profiles].slot_N_id
            # takes precedence over the default mapping by material type.
            # The vendor is sent along (tray_sub_brands + filament_vendor),
            # so a patched OrcaSlicer can match by brand + type +
            # color (analogous to SnapmakerPrinterAgent).
            # Three-layer resolution for the filament hint sent to OrcaSlicer,
            # all handled inside _effective_slot_profile() (Issue #101):
            #   1. User-Wahl (config.ini [filament_profiles]) — exakte Kontrolle
            #   2. Combined ACE-RFID "VENDOR TYPE SERIAL" string (e.g.
            #      "GEEETECH PLA Bas") auto-matched against the user's
            #      already-imported profile library. Not persisted to
            #      config.ini - re-derives on every call, so a differently
            #      tagged spool loaded later isn't stuck with a stale match.
            #   3. Generic fallback (_TRAY_INFO_IDX) per material type - no
            #      vendor hint; OrcaSlicer then picks its own generic preset
            user_profile = self._effective_slot_profile(slot_index, material)
            if user_profile.get("name"):
                material = self._material_family(user_profile.get("type", material)) or material
                vendor    = user_profile.get("vendor", "")
                fila_name = user_profile.get("name", "")
                tray_info_idx = user_profile.get("id") or self._TRAY_INFO_IDX.get(material, "OGFL99")
            else:
                # Default: Library-Generic-Profil (siehe _default_filament_name) —
                # is compatible with all printers and guaranteed to be visible.
                # The user deliberately picks a concrete brand per slot if they
                # want one; the default stays neutral.
                fila_name = self._default_filament_name(material)
                vendor    = "Generic" if fila_name.startswith("Generic ") else ""
                tray_info_idx = self._lookup_filament_id(vendor, fila_name) or self._TRAY_INFO_IDX.get(material, "OGFL99")
            tray_array.append({
              "id": str(slot_id),
              "tag_uid": "0000000000000000",
              "tray_info_idx": tray_info_idx,
              "tray_type": material,
              "tray_color": color_hex,
              "tray_sub_brands": vendor,
              # OrcaSlicer-Empfangs-Patch PR #13719 erwartet `name` +
              # `vendor_name` pro Lane (Stufen-Matching: Vendor+Name → Name →
              # filament_id_by_type). We send both spellings so that
              # older patch variants + future upstream PRs are both
              # covered.
              "name":         fila_name,
              "vendor_name":  vendor,
              # Aliases for older patch variants (variant 2,
              # MoonrakerPrinterAgent.cpp): filament_id direkt (exakt),
              # otherwise resolve the preset name via find_preset().
              "filament_id":     tray_info_idx,
              "filament_vendor": vendor,
              "filament_name":   fila_name,
              "preset":          fila_name,
            })
          else:
            tray_array.append({
              "id": str(slot_id),
              "tag_uid": "0000000000000000",
              "tray_info_idx": "",
              "tray_type": "",
              "tray_color": "00000000",
              "tray_slot_placeholder": "1",
            })

        ams_array.append({"id": str(ams_id), "info": "0002", "tray": tray_array})

      return {
        "ams": ams_array,
        "ams_exist_bits": format(ams_exist_bits, "X"),
        "tray_exist_bits": format(tray_exist_bits, "X"),
      }
