"""RFID tag matching and auto-assignment for spool inventory."""

import logging

from sqlalchemy import case, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload

from backend.app.models.spool import Spool
from backend.app.models.spool_assignment import SpoolAssignment
from backend.app.utils.tag_normalization import (
    normalize_tag_uid as _normalize_tag_uid,
    normalize_tray_uuid as _normalize_tray_uuid,
)

logger = logging.getLogger(__name__)

# Zero-value constants for tag validation
ZERO_TAG_UID = "0000000000000000"
ZERO_TRAY_UUID = "00000000000000000000000000000000"


def is_valid_tag(tag_uid: str, tray_uuid: str) -> bool:
    """Check if a tag/UUID pair contains a non-zero, non-empty value."""
    uid = _normalize_tag_uid(tag_uid)
    uuid = _normalize_tray_uuid(tray_uuid)
    uid_valid = bool(uid) and uid != ZERO_TAG_UID and uid != "0" * len(uid)
    uuid_valid = bool(uuid) and uuid != ZERO_TRAY_UUID and uuid != "0" * len(uuid)
    return uid_valid or uuid_valid


def is_bambu_tag(tag_uid: str, tray_uuid: str, tray_info_idx: str) -> bool:
    """Check if an AMS tray contains a Bambu Lab RFID spool (has valid UUID or slicer preset)."""
    uuid = _normalize_tray_uuid(tray_uuid)
    uuid_valid = bool(uuid) and uuid != ZERO_TRAY_UUID and uuid != "0" * len(uuid)
    has_preset = bool(tray_info_idx)
    return uuid_valid or (is_valid_tag(tag_uid, tray_uuid) and has_preset)


async def create_spool_from_tray(db: AsyncSession, tray_data: dict) -> Spool:
    """Create a new Spool inventory entry from AMS tray MQTT data.

    Extracts material, subtype, color, temps, and tag info from the tray dict.
    Looks up core_weight from the spool catalog if a Bambu Lab entry matches.
    """
    from backend.app.models.color_catalog import ColorCatalogEntry
    from backend.app.models.spool_catalog import SpoolCatalogEntry

    tray_type = tray_data.get("tray_type", "")  # "PLA"
    tray_sub_brands = tray_data.get("tray_sub_brands", "")  # "PLA Basic"
    tray_color = tray_data.get("tray_color", "FFFFFFFF")  # RRGGBBAA
    tray_id_name = tray_data.get("tray_id_name", "")  # Color name e.g. "Jade White"
    tag_uid = _normalize_tag_uid(tray_data.get("tag_uid", ""))
    tray_uuid = _normalize_tray_uuid(tray_data.get("tray_uuid", ""))
    tray_info_idx = tray_data.get("tray_info_idx", "")
    nozzle_min = tray_data.get("nozzle_temp_min", 0)
    nozzle_max = tray_data.get("nozzle_temp_max", 0)
    label_weight = int(tray_data.get("tray_weight", 1000))

    # Parse material and subtype from tray_sub_brands ("PLA Basic" → material="PLA", subtype="Basic")
    material = tray_type or "PLA"
    subtype = None
    if tray_sub_brands and " " in tray_sub_brands:
        parts = tray_sub_brands.split(" ", 1)
        if parts[0].upper() == material.upper():
            subtype = parts[1]
        else:
            # tray_sub_brands is the full material name (e.g. "PETG-HF")
            material = tray_sub_brands
    elif tray_sub_brands and tray_sub_brands.upper() != material.upper():
        material = tray_sub_brands

    # Upgrade subtype for gradient/multi-color variants based on tray_id_name color code.
    # Firmware sends tray_sub_brands="PLA Basic" for gradients and "PLA Silk" for dual/tri-color,
    # but the M*/T* suffix in tray_id_name distinguishes them:
    #   A00-M* = PLA Basic Gradient, A05-M* = PLA Silk Dual Color, A05-T* = PLA Silk Tri Color
    if tray_id_name and "-" in tray_id_name:
        color_code = tray_id_name.split("-", 1)[1]
        if color_code and color_code[0] == "M":
            # M* = gradient for PLA Basic (A00), dual-color for PLA Silk (A05)
            prefix = tray_id_name.split("-", 1)[0]
            if prefix == "A05":
                subtype = "Dual Color"
            else:
                subtype = "Gradient"
        elif color_code and color_code[0] == "T":
            subtype = "Tri Color"

    # Resolve color name from the color catalog by hex. The catalog is the single
    # source of truth — tray_id_name codes (e.g. "A17-R1") are NOT globally unique
    # across material families (A17-R1 is PLA Translucent Cherry Pink; A01-R1 is
    # PLA Matte Scarlet Red), so a suffix-based fallback would pick the wrong name.
    # See #857.
    #
    # Hex isn't unique either: #FFFFFF maps to "Jade White" (PLA Basic), "Ivory
    # White" (PLA Matte), and "White" (PLA Silk) in the Bambu catalog. Filter by
    # the printer-reported material variant (`tray_sub_brands`, e.g. "PLA Matte")
    # so a new Ivory White roll doesn't get auto-named Jade White just because
    # PLA Basic happens to come first in catalog insertion order. See #1227.
    rgba = tray_color if tray_color else None
    color_name = None

    if rgba and len(rgba) >= 6:
        hex_prefix = f"#{rgba[:6].upper()}"
        cat_query = (
            select(ColorCatalogEntry)
            .where(func.upper(ColorCatalogEntry.hex_color) == hex_prefix)
            .where(func.upper(ColorCatalogEntry.manufacturer) == "BAMBU LAB")
        )
        if tray_sub_brands:
            cat_query = cat_query.where(func.upper(ColorCatalogEntry.material) == tray_sub_brands.upper())
        # Deterministic tiebreak when the material filter can't disambiguate
        # (e.g. third-party spools with empty tray_sub_brands).
        cat_query = cat_query.order_by(ColorCatalogEntry.id).limit(1)
        cat_result = await db.execute(cat_query)
        entry = cat_result.scalar_one_or_none()
        if entry:
            color_name = entry.color_name

    # If tray_id_name is a human-readable name (no "-" code), fall back to it.
    if not color_name and tray_id_name and "-" not in tray_id_name:
        color_name = tray_id_name

    logger.info(
        "Color resolve: tray_id_name=%r rgba=%r → resolved=%r",
        tray_id_name,
        rgba,
        color_name,
    )

    # Look up core weight from spool catalog
    core_weight = 250  # Default for Bambu Lab plastic spools
    cat_result = await db.execute(select(SpoolCatalogEntry).where(SpoolCatalogEntry.name.ilike("Bambu Lab%")).limit(10))
    for entry in cat_result.scalars().all():
        # Pick the best match (prefer exact, fallback to first Bambu Lab entry)
        core_weight = entry.weight
        break

    # Resolve slicer filament name from builtin table
    slicer_filament_name = None
    if tray_info_idx:
        try:
            from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES

            slicer_filament_name = _BUILTIN_FILAMENT_NAMES.get(tray_info_idx)
        except Exception:
            pass
        # Fallback: use tray_sub_brands as the display name
        if not slicer_filament_name and tray_sub_brands:
            slicer_filament_name = tray_sub_brands

    # Calculate initial weight_used from AMS remain percentage
    remain_raw = tray_data.get("remain")
    try:
        remain_pct = int(remain_raw) if remain_raw is not None else 100
    except (TypeError, ValueError):
        remain_pct = 100
    # Clamp to valid range: negative means unknown, >100 is invalid
    if remain_pct < 0 or remain_pct > 100:
        remain_pct = 100  # Unknown → assume full
    weight_used = round(label_weight * (100 - remain_pct) / 100.0, 1)

    spool = Spool(
        material=material,
        subtype=subtype,
        color_name=color_name,
        rgba=rgba,
        brand="Bambu Lab",
        label_weight=label_weight,
        core_weight=core_weight,
        weight_used=weight_used,
        slicer_filament=tray_info_idx or None,
        slicer_filament_name=slicer_filament_name,
        nozzle_temp_min=int(nozzle_min) if nozzle_min else None,
        nozzle_temp_max=int(nozzle_max) if nozzle_max else None,
        tag_uid=tag_uid if tag_uid and tag_uid != ZERO_TAG_UID else None,
        tray_uuid=tray_uuid if tray_uuid and tray_uuid != ZERO_TRAY_UUID else None,
        data_origin="rfid_auto",
        tag_type="bambulab",
    )
    # Initialize relationships BEFORE db.add() to prevent lazy loads.
    # Setting them after flush() would trigger a lazy load because SQLAlchemy
    # loads the current collection before replacing it on a persistent object.
    # They must also be set before add() because cascade processing during
    # add/flush accesses these collections, and back_populates resolution
    # when creating SpoolAssignment runs synchronously outside the greenlet.
    spool.k_profiles = []
    spool.assignments = []
    db.add(spool)
    await db.flush()

    logger.info(
        "Auto-created spool %d from AMS tray data: %s %s %s (tag=%s uuid=%s)",
        spool.id,
        material,
        subtype or "",
        color_name or "",
        tag_uid,
        tray_uuid,
    )
    return spool


async def find_matching_untagged_spool(db: AsyncSession, tray_data: dict) -> Spool | None:
    """Find an existing untagged Bambu inventory spool matching material/color.

    When a Bambu Lab spool is detected in the AMS but no tag match exists,
    check if the user has a manually-added spool with the same properties
    that hasn't been linked to a tag yet. Returns the best match (#918):

    - **Brand**: only consider spools whose brand is unspecified or contains
      "bambu" (case-insensitive — covers both "Bambu" and "Bambu Lab" as
      stored by the form's brand dropdown). This prevents a same-color
      Polymaker / generic spool from accidentally attracting a Bambu UUID.
    - **Subtype**: prefer an exact match (e.g. AMS "Basic" → spool subtype
      "Basic"), but fall back to a NULL-subtype spool — the form's Quick Add
      mode leaves subtype empty, so bulk-logged spools rely on this fallback
      to attract their RFID tag instead of duplicating on first AMS read.
    - **FIFO** within each preference group (user likely logged in purchase
      order).
    """
    tray_type = tray_data.get("tray_type", "")
    tray_sub_brands = tray_data.get("tray_sub_brands", "")
    tray_color = tray_data.get("tray_color", "")  # RRGGBBAA

    if not tray_type or not tray_color:
        return None

    # Parse material the same way create_spool_from_tray does
    material = tray_type
    subtype = None
    if tray_sub_brands and " " in tray_sub_brands:
        parts = tray_sub_brands.split(" ", 1)
        if parts[0].upper() == material.upper():
            subtype = parts[1]
        else:
            material = tray_sub_brands
    elif tray_sub_brands and tray_sub_brands.upper() != material.upper():
        material = tray_sub_brands

    # Upgrade subtype for gradient/multi-color variants (same logic as create_spool_from_tray)
    tray_id_name = tray_data.get("tray_id_name", "")
    if tray_id_name and "-" in tray_id_name:
        color_code = tray_id_name.split("-", 1)[1]
        if color_code and color_code[0] == "M":
            prefix = tray_id_name.split("-", 1)[0]
            if prefix == "A05":
                subtype = "Dual Color"
            else:
                subtype = "Gradient"
        elif color_code and color_code[0] == "T":
            subtype = "Tri Color"

    # Active, untagged spools matching material + color + Bambu-or-unset brand.
    query = (
        select(Spool)
        .options(selectinload(Spool.k_profiles), selectinload(Spool.assignments))
        .where(
            Spool.archived_at.is_(None),
            Spool.tag_uid.is_(None),
            Spool.tray_uuid.is_(None),
            func.upper(Spool.material) == material.upper(),
            func.upper(Spool.rgba) == tray_color.upper(),
            or_(
                Spool.brand.is_(None),
                func.lower(Spool.brand).like("%bambu%"),
            ),
        )
    )

    if subtype:
        # Exact subtype OR NULL fallback. The CASE in ORDER BY ensures an
        # exact-subtype row beats a NULL-subtype row when both exist; FIFO
        # within each group.
        query = query.where(
            or_(
                func.upper(Spool.subtype) == subtype.upper(),
                Spool.subtype.is_(None),
            )
        ).order_by(
            case((func.upper(Spool.subtype) == subtype.upper(), 0), else_=1),
            Spool.created_at.asc(),
        )
    else:
        query = query.where(Spool.subtype.is_(None)).order_by(Spool.created_at.asc())

    query = query.limit(1)

    result = await db.execute(query)
    spool = result.scalar_one_or_none()

    if spool:
        logger.info(
            "Found matching untagged spool %d: %s %s %s (rgba=%s)",
            spool.id,
            spool.brand or "",
            spool.material,
            spool.color_name or "",
            spool.rgba or "",
        )

    return spool


async def link_tag_to_inventory_spool(db: AsyncSession, spool: Spool, tray_data: dict) -> None:
    """Link RFID tag data from AMS tray to an existing inventory spool."""
    tag_uid = tray_data.get("tag_uid", "")
    tray_uuid = tray_data.get("tray_uuid", "")
    tray_info_idx = tray_data.get("tray_info_idx", "")

    if tag_uid and tag_uid != ZERO_TAG_UID:
        spool.tag_uid = tag_uid
    if tray_uuid and tray_uuid != ZERO_TRAY_UUID:
        spool.tray_uuid = tray_uuid
    spool.data_origin = "rfid_linked"
    spool.tag_type = "bambulab"

    # Update slicer preset if not already set
    if tray_info_idx and not spool.slicer_filament:
        spool.slicer_filament = tray_info_idx
        try:
            from backend.app.api.routes.cloud import _BUILTIN_FILAMENT_NAMES

            name = _BUILTIN_FILAMENT_NAMES.get(tray_info_idx)
            if name and not spool.slicer_filament_name:
                spool.slicer_filament_name = name
        except Exception:
            pass

    await db.flush()
    logger.info(
        "Linked RFID tag to existing spool %d (tag=%s uuid=%s origin=rfid_linked)",
        spool.id,
        spool.tag_uid or "",
        spool.tray_uuid or "",
    )


async def get_spool_by_tag(db: AsyncSession, tag_uid: str, tray_uuid: str) -> Spool | None:
    """Look up an active spool by RFID tag UID or Bambu Lab tray UUID.

    Prefers tray_uuid match over tag_uid (more reliable).
    """
    tray_uuid_norm = _normalize_tray_uuid(tray_uuid)
    tag_uid_norm = _normalize_tag_uid(tag_uid)

    # Try tray_uuid first (Bambu Lab spools — more reliable)
    if tray_uuid_norm and tray_uuid_norm != ZERO_TRAY_UUID and tray_uuid_norm != "0" * len(tray_uuid_norm):
        result = await db.execute(
            select(Spool)
            .options(selectinload(Spool.k_profiles), selectinload(Spool.assignments))
            .where(func.upper(Spool.tray_uuid) == tray_uuid_norm, Spool.archived_at.is_(None))
            .limit(1)
        )
        spool = result.scalar_one_or_none()
        if spool:
            return spool

    # Fall back to tag_uid
    if tag_uid_norm and tag_uid_norm != ZERO_TAG_UID and tag_uid_norm != "0" * len(tag_uid_norm):
        result = await db.execute(
            select(Spool)
            .options(selectinload(Spool.k_profiles), selectinload(Spool.assignments))
            .where(func.upper(Spool.tag_uid) == tag_uid_norm, Spool.archived_at.is_(None))
            .limit(1)
        )
        spool = result.scalar_one_or_none()
        if spool:
            return spool

        # Compatibility fallback: some readers report 4-byte UID (8 hex) while
        # stored values may contain longer forms. Prefer suffix match only.
        if len(tag_uid_norm) >= 8:
            suffix8 = tag_uid_norm[-8:]
            short_uid_body = tag_uid_norm[1:] if len(tag_uid_norm) == 8 else ""

            # Build LIKE patterns for candidates search
            like_patterns = [
                func.upper(Spool.tag_uid).like(f"%{tag_uid_norm}"),
                func.upper(Spool.tag_uid).like(f"%{suffix8}"),
            ]
            if short_uid_body:
                like_patterns.append(func.upper(Spool.tag_uid).like(f"%{short_uid_body}%"))

            candidates = await db.execute(
                select(Spool)
                .options(selectinload(Spool.k_profiles), selectinload(Spool.assignments))
                .where(
                    Spool.tag_uid.is_not(None),
                    Spool.archived_at.is_(None),
                    or_(*like_patterns),
                )
                .limit(100)
            )
            for candidate in candidates.scalars().all():
                candidate_uid = _normalize_tag_uid(candidate.tag_uid)
                if not candidate_uid:
                    continue
                if candidate_uid == tag_uid_norm:
                    return candidate
                if len(candidate_uid) > len(tag_uid_norm) and candidate_uid.endswith(tag_uid_norm):
                    return candidate
                if len(tag_uid_norm) > len(candidate_uid) and tag_uid_norm.endswith(candidate_uid):
                    return candidate
                # Backward-compatible matching: allow first-character mismatch
                # when remaining characters match. This handles cases where the same
                # physical tag reports different first bytes across different readers
                # (e.g., one reader reports "A45012F", another reports "B45012F").
                if len(tag_uid_norm) == len(candidate_uid) and len(tag_uid_norm) > 1:
                    # Same length: check if all chars except the first match
                    if candidate_uid[1:] == tag_uid_norm[1:]:
                        logger.warning(
                            "Matched spool %d via first-char variance: stored=%s → scanned=%s",
                            candidate.id,
                            candidate_uid,
                            tag_uid_norm,
                        )
                        return candidate
                # Short UID (8 chars) matching: allow first-character mismatch
                # within the first 8 bytes when remaining 7 chars match.
                if len(tag_uid_norm) == 8 and len(candidate_uid) >= 8:
                    if candidate_uid[:8][1:] == tag_uid_norm[1:]:
                        logger.warning(
                            "Matched spool %d via short UID variance: stored=%s → scanned=%s",
                            candidate.id,
                            candidate_uid,
                            tag_uid_norm,
                        )
                        return candidate

    return None


async def auto_assign_spool(
    printer_id: int,
    ams_id: int,
    tray_id: int,
    spool: Spool,
    printer_manager,
    db: AsyncSession,
    tray_info_idx: str = "",
) -> SpoolAssignment:
    """Create a SpoolAssignment and auto-configure the AMS slot via MQTT.

    For BL spools (RFID-detected), only K-profile commands are sent.
    ams_set_filament_setting is NOT sent because the firmware already has
    filament configuration from the RFID tag, and sending it would destroy
    the RFID-detected state (eye → pen icon in BambuStudio).
    """
    # Get current tray state for fingerprint
    fingerprint_color = None
    fingerprint_type = None
    tray = None
    state = printer_manager.get_status(printer_id)
    if state and state.raw_data:
        from backend.app.api.routes.inventory import _find_tray_in_ams_data

        ams = state.raw_data.get("ams", [])
        if isinstance(ams, dict):
            ams = ams.get("ams", [])
        tray = _find_tray_in_ams_data(
            ams,
            ams_id,
            tray_id,
        )
        if tray:
            fingerprint_color = tray.get("tray_color", "")
            fingerprint_type = tray.get("tray_type", "")

    # Upsert: remove old assignment for this slot
    existing = await db.execute(
        select(SpoolAssignment).where(
            SpoolAssignment.printer_id == printer_id,
            SpoolAssignment.ams_id == ams_id,
            SpoolAssignment.tray_id == tray_id,
        )
    )
    old = existing.scalar_one_or_none()
    if old:
        await db.delete(old)
        await db.flush()

    assignment = SpoolAssignment(
        spool_id=spool.id,
        printer_id=printer_id,
        ams_id=ams_id,
        tray_id=tray_id,
        fingerprint_color=fingerprint_color,
        fingerprint_type=fingerprint_type,
    )
    db.add(assignment)
    await db.flush()

    # Apply K-profile via MQTT (if available)
    # NOTE: Do NOT send ams_set_filament_setting here. This function is only
    # called for BL spools (RFID-detected). The firmware already has the filament
    # configuration from the RFID tag. Sending ams_set_filament_setting would
    # destroy the RFID-detected state (eye → pen icon in BambuStudio/OrcaSlicer).
    try:
        client = printer_manager.get_client(printer_id)
        if client:
            # Apply K-profile if available
            nozzle_diameter = "0.4"
            if state and state.nozzles:
                nd = state.nozzles[0].nozzle_diameter
                if nd:
                    nozzle_diameter = nd

            matching_kp = None
            for kp in spool.k_profiles:
                if kp.printer_id == printer_id and kp.nozzle_diameter == nozzle_diameter:
                    matching_kp = kp
                    break

            if matching_kp and matching_kp.cali_idx is not None:
                # The filament_id in extrusion_cali_sel must match the filament preset
                # under which the K-profile was calibrated. Use spool.slicer_filament
                # (the preset assigned in inventory), falling back to tray's RFID value.
                cali_filament_id = spool.slicer_filament or tray_info_idx or ""
                client.extrusion_cali_sel(
                    ams_id=ams_id,
                    tray_id=tray_id,
                    cali_idx=matching_kp.cali_idx,
                    filament_id=cali_filament_id,
                    nozzle_diameter=nozzle_diameter,
                )

                # NOTE: Do NOT send extrusion_cali_set here. extrusion_cali_sel already
                # selected the correct profile by cali_idx. Sending extrusion_cali_set
                # with the same cali_idx would MODIFY the existing profile's metadata
                # (extruder_id, nozzle_id, name), corrupting it.

                logger.info(
                    "Applied K-profile cali_idx=%d for spool %d on printer %d AMS%d-T%d",
                    matching_kp.cali_idx,
                    spool.id,
                    printer_id,
                    ams_id,
                    tray_id,
                )
            elif tray is not None:
                # No stored K-profile: fall back to the slot's current live cali_idx
                # so the printer keeps its existing calibration selection.
                live_cali_idx = tray.get("cali_idx")
                if live_cali_idx is not None and live_cali_idx >= 0:
                    cali_filament_id = spool.slicer_filament or tray_info_idx or ""
                    client.extrusion_cali_sel(
                        ams_id=ams_id,
                        tray_id=tray_id,
                        cali_idx=live_cali_idx,
                        filament_id=cali_filament_id,
                        nozzle_diameter=nozzle_diameter,
                    )
                    logger.info(
                        "No stored K-profile for spool %d on printer %d AMS%d-T%d — preserved live cali_idx=%d",
                        spool.id,
                        printer_id,
                        ams_id,
                        tray_id,
                        live_cali_idx,
                    )

            logger.info(
                "Auto-assigned spool %d to printer %d AMS%d-T%d (RFID match)",
                spool.id,
                printer_id,
                ams_id,
                tray_id,
            )
    except Exception as e:
        logger.warning("K-profile apply failed for spool %d (RFID match): %s", spool.id, e)

    return assignment
