"""Data models returned by EufyLifeClient."""

from __future__ import annotations

import datetime
from dataclasses import dataclass, field
from typing import Any


@dataclass(frozen=True)
class AuthSession:
    """A logged-in session: access token plus the account/device IDs it unlocks."""

    access_token: str
    user_id: str
    expires_at: float
    device_id: str | None
    customer_ids: list[str]

    @property
    def is_expired(self) -> bool:
        return self.expires_at <= _now()


# Fields confirmed against 68 real measurements from a Eufy Smart Scale P3
# (2025-01-29 .. 2026-08-21): populated in 67-68/68 readings, and where an
# independent source of truth existed (the EufyLife app's own display,
# hand-recorded by the device owner), the parsed value matched exactly.
_MEASURED_FIELDS = (
    "weight_kg",
    "body_fat_percent",
    "body_fat_mass_kg",
    "muscle_mass_kg",
    "skeletal_muscle_mass_kg",
    "bone_mass_kg",
    "water_percent",
    "subcutaneous_fat_rate_percent",
    "visceral_fat_level",
    "bmi",
    "bmr_kcal_per_day",
    "body_age_years",
    "protein_ratio_percent",
    "heart_rate_bpm",
)

# Fields present in the API's scale_data payload but never observed non-zero
# across those 68 readings (single P3 unit). Kept as unscaled raw values —
# there's no confirmed real-world number to derive a unit/scale factor from.
# Likely populated by other device models, or by fields the app collects
# separately (e.g. height is normally set once in your profile, not sent
# with each weigh-in).
_UNCONFIRMED_FIELDS = (
    "fat_free_weight_raw",
    "muscle_raw",
    "water_weight_raw",
    "visceral_fat_index_raw",
    "bone_raw",
    "standard_weight_raw",
    "height_raw",
    "head_size_raw",
    "impedance_raw",
)


@dataclass(frozen=True)
class Measurement:
    """A single scale reading — every field the API's device/data endpoint sends.

    Every field except ``customer_id``, ``timestamp``, and ``raw`` is optional:
    not every EufyLife scale model reports every metric, and the API omits or
    zeroes fields it has no value for rather than sending null. ``None`` here
    means "not reported" — check ``missing_fields`` rather than assuming every
    attribute is populated.

    Fields are grouped by how well their meaning is confirmed:

    - The 14 attributes matching :data:`_MEASURED_FIELDS` were verified
      against 68 real readings from a Smart Scale P3: consistently reported,
      and where an independent source existed (the EufyLife app's own
      display, hand-recorded separately), the value matched.
    - The ``*_raw`` attributes (matching :data:`_UNCONFIRMED_FIELDS`) exist in
      every response but were 0 in all 68 samples used to build this library.
      They're exposed unscaled, as-is, because there's no real data point to
      confirm a unit or scale factor from. If you get a non-zero reading for
      one of these, please open an issue with the raw value and what the app
      displayed for it.
    - ``body_type``, ``mode``, and ``fat_mode`` look categorical (small
      integers, not physical quantities), so they are *not* subject to the
      zero-means-missing rule the measured fields use — 0 may be a real
      category, not an absence.

    ⚠ On ``body_fat_mass_kg``: this field exists (contrary to what you'd
    conclude from reverse-engineering only ``m4ary/eufylife-api-hacs``, which
    doesn't map it). But across the 68-reading sample, it equalled
    ``round(weight_kg * body_fat_percent / 100, 1)`` in 32 cases and was
    exactly 0.1kg below that in the other 35 — no other deviation ever
    appeared. That bimodal, zero-variance pattern is what you'd expect from a
    server-side computation off slightly different-precision inputs, not from
    an independent bioimpedance measurement. In other words: this field, and
    quite possibly the EufyLife app's own "body fat mass" display, may
    already *be* the weight×percentage calculation — not a separately
    measured quantity. Treat it as convenient, not as independently
    corroborating ``body_fat_percent``.
    """

    customer_id: str
    timestamp: datetime.datetime

    # -- confirmed: scaled, verified against real readings ------------------
    weight_kg: float | None = None
    body_fat_percent: float | None = None
    body_fat_mass_kg: float | None = None  # see caveat in class docstring
    muscle_mass_kg: float | None = None
    skeletal_muscle_mass_kg: float | None = None
    bone_mass_kg: float | None = None
    water_percent: float | None = None
    subcutaneous_fat_rate_percent: float | None = None
    visceral_fat_level: float | None = None
    bmi: float | None = None
    bmr_kcal_per_day: int | None = None
    body_age_years: int | None = None
    protein_ratio_percent: float | None = None
    heart_rate_bpm: int | None = None

    # -- unconfirmed: raw, unscaled, never observed non-zero -----------------
    fat_free_weight_raw: float | None = None
    muscle_raw: float | None = None
    water_weight_raw: float | None = None
    visceral_fat_index_raw: float | None = None
    bone_raw: float | None = None
    standard_weight_raw: float | None = None
    height_raw: float | None = None
    head_size_raw: float | None = None
    impedance_raw: float | None = None

    # -- categorical: 0 may be a real value, not "missing" -------------------
    body_type: int | None = None
    mode: int | None = None
    fat_mode: int | None = None

    device_id: str | None = None
    product_code: str | None = None
    raw: dict[str, Any] = field(default_factory=dict, repr=False, compare=False)

    @property
    def missing_fields(self) -> list[str]:
        """Names of the measured metrics this reading did not report.

        Only covers ``_MEASURED_FIELDS`` and ``_UNCONFIRMED_FIELDS`` — the
        categorical fields (``body_type``/``mode``/``fat_mode``) are excluded
        since 0 isn't necessarily an absence for them.
        """
        return [
            name
            for name in (*_MEASURED_FIELDS, *_UNCONFIRMED_FIELDS)
            if getattr(self, name) is None
        ]

    @classmethod
    def from_device_record(cls, record: dict[str, Any]) -> Measurement | None:
        """Parse one record from the ``/v1/device/data`` response.

        Returns None if the record has no ``customer_id`` or ``scale_data``
        (both are required to identify who took the measurement and what it
        contains).
        """
        customer_id = record.get("customer_id")
        scale = record.get("scale_data")
        if not customer_id or not scale:
            return None

        ts = record.get("update_time") or record.get("create_time")
        timestamp = (
            datetime.datetime.fromtimestamp(ts, tz=datetime.timezone.utc)
            if ts
            else _now_dt()
        )

        return cls(
            customer_id=customer_id,
            timestamp=timestamp,
            weight_kg=_num(scale.get("weight"), scale=0.1),
            body_fat_percent=_num(scale.get("body_fat")),
            body_fat_mass_kg=_num(scale.get("body_fat_mass")),
            muscle_mass_kg=_num(scale.get("muscle_mass")),
            skeletal_muscle_mass_kg=_num(scale.get("skeletal_muscle_mass")),
            bone_mass_kg=_num(scale.get("bone_mass")),
            water_percent=_num(scale.get("water")),
            subcutaneous_fat_rate_percent=_num(scale.get("subcutaneous_fat_rate")),
            visceral_fat_level=_num(scale.get("visceral_fat")),
            bmi=_num(scale.get("bmi")),
            bmr_kcal_per_day=_int(scale.get("bmr")),
            body_age_years=_int(scale.get("body_age")),
            protein_ratio_percent=_num(scale.get("protein_ratio")),
            heart_rate_bpm=_int(scale.get("heart_rate")),
            fat_free_weight_raw=_num(scale.get("fat_free_weight")),
            muscle_raw=_num(scale.get("muscle")),
            water_weight_raw=_num(scale.get("water_weight")),
            visceral_fat_index_raw=_num(scale.get("visceral_fat_index")),
            bone_raw=_num(scale.get("bone")),
            standard_weight_raw=_num(scale.get("standard_weight")),
            height_raw=_num(scale.get("height")),
            head_size_raw=_num(scale.get("head_size")),
            impedance_raw=_num(scale.get("impedance")),
            body_type=_raw_int(scale.get("body_type")),
            mode=_raw_int(scale.get("mode")),
            fat_mode=_raw_int(scale.get("fat_mode")),
            device_id=record.get("device_id"),
            product_code=record.get("product_code"),
            raw=record,
        )


def _num(value: Any, *, scale: float = 1.0) -> float | None:
    """Parse a scale_data numeric field.

    Treats an exact 0 as "not reported", not as a measured zero. Observed in
    the wild: a quick weigh-in without full foot contact returns a real
    weight but 0 for every bioimpedance-derived field (body fat %, muscle
    mass, BMI, ...) instead of omitting them. None of those can legitimately
    be zero for a living person, so silently accepting 0 here would present
    a partial reading as a complete one.
    """
    if value is None or not isinstance(value, (int, float)) or value == 0:
        return None
    return round(float(value) * scale, 2)


def _int(value: Any) -> int | None:
    """Integer counterpart of ``_num`` — same zero-is-missing rule."""
    if value is None or not isinstance(value, (int, float)) or value == 0:
        return None
    return int(value)


def _raw_int(value: Any) -> int | None:
    """Integer field where 0 may be a real category — no zero-is-missing rule."""
    if value is None or not isinstance(value, (int, float)):
        return None
    return int(value)


def _now() -> float:
    import time

    return time.time()


def _now_dt() -> datetime.datetime:
    return datetime.datetime.now(tz=datetime.timezone.utc)
