"""Modbus profile loader, validator, and decoder.

A *profile* is a YAML file that describes the protocol-level knowledge for
a particular Modbus device model:

- Which registers to read (``blocks`` for efficient bulk reads).
- How to decode each quantity (``metrics``: register address, type, unit, …).
- Home Assistant metadata per metric (``device_class``, ``ha_component``, …).

Profiles live in ``app/integrations/modbus/profiles/<name>.yaml`` and are
located at runtime relative to *this file* (not CWD), so they work whether
the package is installed as a wheel, run in-process, or invoked via
``python -m scripts.modbus_cli``.

Design notes
------------
- **Purely data + functions** — no abstract base classes or inheritance.
- ``ModbusProfile`` and ``MetricSpec`` are Pydantic models: they validate on
  construction and raise ``pydantic.ValidationError`` for malformed YAML.
- ``decode`` uses ``driver.registers_to_float`` for float32 quantities.
  Unsupported types are silently skipped with a warning (future-proofing for
  int16/uint16 etc.).
"""

from __future__ import annotations

import logging
from pathlib import Path
from typing import Optional

import yaml
from pydantic import BaseModel, ValidationError

from app.integrations.modbus.driver import registers_to_float

logger = logging.getLogger(__name__)

# Directory containing the YAML profiles, located relative to *this* file.
_PROFILES_DIR = Path(__file__).parent / "profiles"


# ---------------------------------------------------------------------------
# Pydantic models for profile validation
# ---------------------------------------------------------------------------


class MetricSpec(BaseModel):
    """Specification for a single measurable quantity within a profile."""

    key: str
    """Stable identifier used as the key in decoded payloads (e.g. ``"voltage"``)."""

    address: int
    """Starting Modbus register address (0-based, e.g. ``0x0000`` for voltage)."""

    type: str
    """Encoding type — currently only ``"float32"`` is supported."""

    unit: str
    """Physical unit string (e.g. ``"V"``, ``"A"``, ``"kWh"``).  Empty string for dimensionless."""

    device_class: str
    """Home Assistant device class (e.g. ``"voltage"``, ``"power"``, ``"energy"``)."""

    ha_component: str
    """Home Assistant component type (e.g. ``"sensor"``)."""

    state_class: Optional[str] = None
    """Home Assistant state class, if applicable (e.g. ``"total_increasing"``)."""


class BlockSpec(BaseModel):
    """A contiguous register block for bulk reading."""

    start: int
    """First register address in the block."""

    count: int
    """Number of 16-bit registers to read."""


class ModbusProfile(BaseModel):
    """Complete description of a Modbus device's protocol-level knowledge."""

    name: str
    """Short identifier matching the YAML file name (e.g. ``"sdm120"``)."""

    description: str
    """Human-readable description of the device."""

    function_code: int
    """Modbus function code for reading measurements (4 = FC04 input registers)."""

    word_order: str
    """Word (register) order: ``"big"`` means high register first."""

    byte_order: str
    """Byte order within each register: ``"big"`` means standard network order."""

    blocks: list[BlockSpec]
    """Register blocks to read in bulk, reducing Modbus transaction count."""

    metrics: list[MetricSpec]
    """List of individual measurable quantities and their decoding rules."""


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


class ProfileNotFoundError(FileNotFoundError):
    """Raised when the requested profile YAML file does not exist."""


class ProfileValidationError(ValueError):
    """Raised when a profile YAML file fails Pydantic validation."""


def load_profile(name: str) -> ModbusProfile:
    """Load and validate a Modbus device profile by name.

    The profile file is expected at
    ``app/integrations/modbus/profiles/<name>.yaml``.

    Parameters
    ----------
    name:
        Profile name without extension (e.g. ``"sdm120"``).

    Returns
    -------
    ModbusProfile
        Validated profile model.

    Raises
    ------
    ProfileNotFoundError
        If ``profiles/<name>.yaml`` does not exist.
    ProfileValidationError
        If the YAML is syntactically valid but the content fails schema
        validation (missing required fields, wrong types, etc.).
    """
    path = _PROFILES_DIR / f"{name}.yaml"
    if not path.exists():
        raise ProfileNotFoundError(
            f"Modbus profile '{name}' not found (looked for {path})"
        )

    with path.open("r", encoding="utf-8") as fh:
        raw = yaml.safe_load(fh)

    try:
        profile = ModbusProfile.model_validate(raw)
    except ValidationError as exc:
        raise ProfileValidationError(
            f"Profile '{name}' failed validation: {exc}"
        ) from exc

    return profile


def list_profiles() -> list[tuple[str, str]]:
    """Return ``(name, description)`` pairs for all available profiles.

    Scans ``app/integrations/modbus/profiles/*.yaml``, loads each, and
    returns a sorted list of ``(name, description)`` tuples.  Profiles that
    fail to load are skipped with a warning.
    """
    results: list[tuple[str, str]] = []
    if not _PROFILES_DIR.exists():
        return results

    for path in sorted(_PROFILES_DIR.glob("*.yaml")):
        name = path.stem
        try:
            profile = load_profile(name)
            results.append((profile.name, profile.description))
        except (ProfileNotFoundError, ProfileValidationError, Exception) as exc:
            logger.warning("Skipping malformed profile '%s': %s", name, exc)

    return results


def decode(profile: ModbusProfile, registers: dict[int, int]) -> dict[str, float]:
    """Decode raw register values into a mapping of ``{key: float_value}``.

    For each metric in *profile*, the function looks up the two registers at
    ``address`` and ``address + 1`` in *registers* and decodes them as a
    big-endian float32 (high register first).

    Metrics whose registers are absent from *registers* are skipped (not
    an error — this can happen if a block was not requested or a device does
    not populate that register).  Metrics with unsupported ``type`` values
    are also skipped.

    Parameters
    ----------
    profile:
        Validated ``ModbusProfile`` describing the device.
    registers:
        Mapping of ``{register_address: 16-bit_value}`` as returned by
        ``driver.read_blocks``.

    Returns
    -------
    dict[str, float]
        Decoded engineering values keyed by each metric's ``key`` field.
    """
    result: dict[str, float] = {}
    for metric in profile.metrics:
        if metric.type == "float32":
            hi_addr = metric.address
            lo_addr = metric.address + 1
            if hi_addr not in registers or lo_addr not in registers:
                logger.debug(
                    "Skipping metric '%s': registers 0x%04X/0x%04X not available",
                    metric.key,
                    hi_addr,
                    lo_addr,
                )
                continue
            value = registers_to_float(registers[hi_addr], registers[lo_addr])
            result[metric.key] = value
        else:
            logger.warning(
                "Metric '%s' has unsupported type '%s', skipping",
                metric.key,
                metric.type,
            )
    return result
