"""Battery-fleet aggregation over the Battery Notes integration.

The user does NOT want one maintenance task per battery (30-70+ devices would
bury the task list). Instead this aggregates every Battery Notes ``battery_plus``
sensor into ONE fleet view: which batteries are low now, grouped by battery
type (so you know *what to buy*), plus a simple deterministic forecast of what
will be needed soon (so you can order in time).

Battery Notes exposes everything we need as ATTRIBUTES on the single
``battery_plus`` sensor (device_class ``battery``): ``battery_type``,
``battery_quantity``, ``battery_low``, ``battery_low_threshold``,
``battery_last_replaced``. We read that one sensor kind — no dependency on the
(optional, often-disabled) battery-low binary.

The pure builder ``build_overview`` takes plain battery dicts + an injected
``today`` so the forecast is unit-testable with synthetic dates; ``read_batteries``
is the thin HA-reading adapter.
"""

from __future__ import annotations

from collections import OrderedDict
from dataclasses import dataclass, field
from datetime import date, timedelta
from typing import Any

from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util

# Editorial typical service life per battery type, in MONTHS — the forecast
# anchor (battery_last_replaced + lifetime = predicted replacement). These are
# deliberately conservative sensor-use estimates and are meant to be tunable;
# unknown types fall back to DEFAULT_LIFETIME_MONTHS.
TYPICAL_LIFETIME_MONTHS: dict[str, int] = {
    "AAAA": 10,
    "AAA": 10,
    "AA": 12,
    "C": 18,
    "D": 24,
    "9V": 12,
    "CR2": 18,
    "CR123A": 18,
    "CR2032": 18,
    "CR2450": 24,
    "CR2477": 24,
    "CR2016": 18,
    "CR2025": 18,
}
DEFAULT_LIFETIME_MONTHS = 12

# How far ahead "needed soon" looks by default (days).
DEFAULT_HORIZON_DAYS = 28

# A native battery %-sensor without a dedicated low binary is treated as low
# at or below this level (editorial — HA has no universal low threshold).
NATIVE_LOW_PERCENT = 20

# States that mean "no reading" — a removed device leaves nothing; an offline
# one leaves these. We keep an OFFLINE battery visible only when its last-known
# low flag says it needs attention (a dead battery often takes its device
# offline — that's exactly the one you must not hide).
_NO_READING = {"unavailable", "unknown", "none", ""}

# How long a NATIVE battery that was last seen LOW stays in the fleet after
# its entity goes unavailable. Battery Notes covers this case via its retained
# ``battery_low`` attribute; native entities have no equivalent, so without a
# snapshot the battery would vanish at the exact moment it died and took its
# device offline. Bounded so a permanently removed device eventually drops.
_NATIVE_RETENTION = timedelta(hours=48)

# Heuristic (sensors WITHOUT device_class): a %-sensor whose object_id talks
# about a battery — some Zigbee2MQTT/ESPHome devices ship battery levels
# without the device class. Deliberately strict: the exclusion words keep out
# charging electronics and home-storage state-of-charge sensors (a Powerwall
# is not a battery you replace).
_HEURISTIC_EXCLUDE = ("charging", "current", "power", "voltage", "energy", "load", "soc", "state_of_charge", "storage", "temp")


def _is_native_battery_sensor(state: Any) -> bool:
    """Whether a sensor state looks like a replaceable-battery level."""
    attrs = state.attributes
    if attrs.get("device_class") == "battery":
        return True
    if attrs.get("unit_of_measurement") != "%":
        return False
    object_id = state.entity_id.split(".", 1)[1]
    if "battery" not in object_id:
        return False
    return not any(word in object_id for word in _HEURISTIC_EXCLUDE)


def _native_snapshot_cache(hass: HomeAssistant) -> dict[str, dict[str, Any]]:
    """Runtime cache of last-known native battery readings (per entity_id)."""
    from ..const import DOMAIN

    cache: dict[str, dict[str, Any]] = hass.data.setdefault(DOMAIN, {}).setdefault("battery_fleet_native_cache", {})
    return cache


def _norm_type(raw: Any) -> str:
    """Canonicalize a battery-type label for grouping (upper, trimmed)."""
    s = str(raw or "").strip()
    return s.upper() if s else "UNKNOWN"


def lifetime_months(battery_type: str) -> int:
    """Typical service life for a (canonicalized) battery type."""
    return TYPICAL_LIFETIME_MONTHS.get(_norm_type(battery_type), DEFAULT_LIFETIME_MONTHS)


@dataclass
class Battery:
    """One battery-powered device — from Battery Notes (rich) or a native
    ``device_class: battery`` entity (degraded: no type/quantity/forecast)."""

    entity_id: str
    device_name: str
    battery_type: str
    quantity: int
    low: bool
    level: float | None
    last_replaced: date | None
    available: bool = True
    source: str = "battery_notes"


@dataclass
class BatteryOverview:
    """The aggregated fleet view backing the single fleet task + its detail."""

    total: int = 0
    low: list[dict[str, Any]] = field(default_factory=list)
    soon: list[dict[str, Any]] = field(default_factory=list)
    # Grouped quantities by canonical type.
    needs_now: OrderedDict[str, int] = field(default_factory=OrderedDict)
    needs_soon: OrderedDict[str, int] = field(default_factory=OrderedDict)
    types: list[str] = field(default_factory=list)

    @property
    def low_count(self) -> int:
        return len(self.low)


def _predicted_date(bat: Battery) -> date | None:
    if bat.last_replaced is None:
        return None
    months = lifetime_months(bat.battery_type)
    # Month arithmetic without dateutil: add whole months, clamp the day.
    y, m = bat.last_replaced.year, bat.last_replaced.month + months
    y += (m - 1) // 12
    m = (m - 1) % 12 + 1
    day = min(bat.last_replaced.day, 28)
    return date(y, m, day)


def build_overview(
    batteries: list[Battery],
    *,
    today: date,
    horizon_days: int = DEFAULT_HORIZON_DAYS,
) -> BatteryOverview:
    """Aggregate batteries into the fleet view.

    * ``low`` = reported low right now (Battery Notes' own threshold).
    * ``soon`` = NOT low yet but predicted to reach end-of-life within
      ``horizon_days`` (deterministic last_replaced + typical-lifetime forecast).
      A battery already low is never double-counted into soon.
    * ``needs_now`` / ``needs_soon`` = summed quantities per type — the shopping
      grouping ("2× AA, 4× AAA").
    """
    ov = BatteryOverview(total=len(batteries))
    types_seen: OrderedDict[str, None] = OrderedDict()

    for bat in sorted(batteries, key=lambda b: b.device_name.lower()):
        t = _norm_type(bat.battery_type)
        types_seen[t] = None
        if bat.low:
            ov.low.append(_row(bat, t, None))
            ov.needs_now[t] = ov.needs_now.get(t, 0) + bat.quantity
            continue
        pred = _predicted_date(bat)
        if pred is not None:
            days = (pred - today).days
            if days <= horizon_days:
                ov.soon.append(_row(bat, t, days))
                ov.needs_soon[t] = ov.needs_soon.get(t, 0) + bat.quantity

    ov.soon.sort(key=lambda r: r["days_until"] if r["days_until"] is not None else 1 << 30)
    ov.types = sorted(types_seen)
    ov.needs_now = OrderedDict(sorted(ov.needs_now.items()))
    ov.needs_soon = OrderedDict(sorted(ov.needs_soon.items()))
    return ov


def _row(bat: Battery, canon_type: str, days_until: int | None) -> dict[str, Any]:
    return {
        "entity_id": bat.entity_id,
        "device_name": bat.device_name,
        "battery_type": canon_type,
        "quantity": bat.quantity,
        "level": bat.level,
        "days_until": days_until,
        "available": bat.available,
    }


def _parse_last_replaced(raw: Any) -> date | None:
    if not raw:
        return None
    try:
        parsed = dt_util.parse_datetime(str(raw))
        if parsed is not None:
            return parsed.date()
        return date.fromisoformat(str(raw)[:10])
    except (ValueError, TypeError):
        return None


def _level_of(state_val: str) -> float | None:
    try:
        return float(state_val)
    except (ValueError, TypeError):
        return None


def fleet_excluded_entities(hass: HomeAssistant) -> set[str]:
    """Manually excluded battery entity_ids, stored on the fleet object entry.

    Inlined lookup (not via battery_fleet_setup.find_fleet_entry) to keep this
    module import-cycle-free — setup imports the aggregation, not vice versa.
    """
    from ..const import CONF_OBJECT, DOMAIN

    for entry in hass.config_entries.async_entries(DOMAIN):
        obj = entry.data.get(CONF_OBJECT, {})
        if obj.get("battery_fleet"):
            return set(obj.get("battery_fleet_excluded") or [])
    return set()


def _is_self_charging(hass: HomeAssistant, device_id: str | None) -> bool:
    """Whether a device recharges itself — its battery is never REPLACED.

    Issue #107: a Roborock's native battery sensor reads "low" mid-clean, but
    nobody swaps its cells. Heuristics (native pickup only — an explicit
    Battery Notes note always wins): the device also has a vacuum/lawn_mower
    entity, exposes a ``battery_charging`` binary, or is a Companion-app
    phone/tablet (``mobile_app`` identifiers).
    """
    if not device_id:
        return False
    from homeassistant.helpers import device_registry as dr
    from homeassistant.helpers import entity_registry as er

    device = dr.async_get(hass).async_get(device_id)
    if device and any(domain == "mobile_app" for domain, _ in device.identifiers):
        return True
    for reg_entry in er.async_entries_for_device(er.async_get(hass), device_id, include_disabled_entities=True):
        if reg_entry.domain in ("vacuum", "lawn_mower"):
            return True
        if reg_entry.domain == "binary_sensor" and (reg_entry.device_class or reg_entry.original_device_class) == "battery_charging":
            return True
    return False


def read_batteries(hass: HomeAssistant) -> list[Battery]:
    """Read the battery fleet from HA state — Battery Notes AND native.

    * **Battery Notes** ``battery_plus`` sensors (device_class ``battery`` + a
      ``battery_type`` attribute) give the rich view: type, quantity, low,
      last-replaced. When the source goes offline the sensor reads
      unavailable/unknown but RETAINS its last-known ``battery_low`` — so a
      dead battery that took its device offline stays visible.
    * **Native** ``device_class: battery`` entities (a %-sensor and/or a
      battery-low binary) — plus %-sensors matching the strict battery-name
      heuristic for devices that ship no device class — grouped per device,
      give a degraded view (type "Unknown", quantity 1, no forecast). A
      device already covered by a Battery Notes note is skipped (dedup by
      the note's source entity + its device) so it isn't counted twice;
      self-charging devices (vacuums, mowers, phones — see
      :func:`_is_self_charging`) are skipped entirely. A native battery
      last seen LOW that goes unavailable is retained from a runtime
      snapshot for ``_NATIVE_RETENTION`` (the Battery Notes path gets this
      for free via its retained ``battery_low`` attribute).
    * Manually excluded entity_ids (fleet detail → exclude) are dropped from
      BOTH passes.
    """
    from homeassistant.helpers import device_registry as dr
    from homeassistant.helpers import entity_registry as er

    ent_reg = er.async_get(hass)
    dev_reg = dr.async_get(hass)
    excluded = fleet_excluded_entities(hass)

    out: list[Battery] = []
    covered_sources: set[str] = set()
    covered_devices: set[str] = set()

    # ── Pass 1: Battery Notes battery_plus ──────────────────────────────────
    for state in hass.states.async_all("sensor"):
        attrs = state.attributes
        if attrs.get("device_class") != "battery" or "battery_type" not in attrs:
            continue
        level = _level_of(state.state)
        available = state.state not in _NO_READING and level is not None
        # B2 (roadmap 2026-07-22 audit): ONE low floor across both passes.
        # Battery Notes' own threshold (default 10 %) still counts via its
        # battery_low flag, but the fleet-wide NATIVE_LOW_PERCENT floor is
        # OR-ed in — a CR2032 at 11.5 % was "healthy" here while the same
        # level counted low in the native pass. A HIGHER Battery Notes
        # threshold (e.g. 30 %) still wins through battery_low.
        low = bool(attrs.get("battery_low")) or (level is not None and level <= NATIVE_LOW_PERCENT)
        last_replaced = _parse_last_replaced(attrs.get("battery_last_replaced"))
        # B1 (roadmap 2026-07-22 audit): a forecast-only note — no level
        # sensor, so the state reads unknown forever — must SURVIVE when it
        # carries a replacement date: that date is all `_predicted_date`
        # needs, and dropping these hid 11 overdue batteries in a live fleet.
        # Offline AND not low AND no date = pure connectivity noise → drop.
        if not available and not low and last_replaced is None:
            continue
        # B3: only a KEPT note covers its source/device — a dropped dead note
        # must not suppress the native fallback for its own device (a device
        # with a dead note and a working level sensor was invisible in BOTH
        # passes).
        src = attrs.get("source_entity_id")
        if src:
            covered_sources.add(src)
        reg = ent_reg.async_get(state.entity_id)
        if reg and reg.device_id:
            covered_devices.add(reg.device_id)
        # An EXCLUDED note still covers (above): exclusion hides the battery —
        # it must not resurrect as a degraded native "Unknown" row.
        if state.entity_id in excluded:
            continue
        out.append(
            Battery(
                entity_id=state.entity_id,
                device_name=attrs.get("device_name") or attrs.get("friendly_name") or state.entity_id,
                battery_type=str(attrs.get("battery_type") or "Unknown"),
                quantity=int(attrs.get("battery_quantity") or 1),
                low=low,
                level=level,
                last_replaced=last_replaced,
                available=available,
                source="battery_notes",
            )
        )

    # ── Pass 2: native battery entities, grouped per device ─────────────────
    # {group_key: {"level_state": s, "low_state": s, "name": ..., "device_id": ..., "eid": ...}}
    native: dict[str, dict[str, Any]] = {}
    for domain in ("sensor", "binary_sensor"):
        for state in hass.states.async_all(domain):
            # Sensors: device_class battery OR the strict name/% heuristic
            # (Zigbee2MQTT/ESPHome levels without a device class). Binaries:
            # device_class only — name-guessing booleans is too risky.
            if domain == "sensor":
                if not _is_native_battery_sensor(state):
                    continue
            elif state.attributes.get("device_class") != "battery":
                continue
            eid = state.entity_id
            if "battery_type" in state.attributes:  # Battery Notes battery_plus — handled above
                continue
            if eid in covered_sources or eid in excluded:
                continue
            reg = ent_reg.async_get(eid)
            dev_id = reg.device_id if reg else None
            if dev_id and dev_id in covered_devices:
                continue
            if _is_self_charging(hass, dev_id):  # #107: vacuums/mowers/phones
                continue
            key = dev_id or eid
            rec = native.setdefault(
                key,
                {"level_state": None, "low_state": None, "device_id": dev_id, "eid": eid, "name": None},
            )
            friendly = state.attributes.get("friendly_name")
            if domain == "sensor":
                rec["level_state"] = state.state
                rec["eid"] = eid
            else:
                rec["low_state"] = state.state
            if rec["name"] is None and friendly:
                rec["name"] = friendly

    snapshot_cache = _native_snapshot_cache(hass)
    now = dt_util.utcnow()
    for rec in native.values():
        level = _level_of(rec["level_state"]) if rec["level_state"] is not None else None
        low_state = rec["low_state"]
        level_available = rec["level_state"] not in _NO_READING if rec["level_state"] is not None else False
        low_available = low_state not in _NO_READING if low_state is not None else False
        available = level_available or low_available
        if low_state is not None:
            low = low_available and str(low_state).lower() in ("on", "true", "1")
        else:
            low = level is not None and level <= NATIVE_LOW_PERCENT
        if available:
            # Remember the last real reading — the retention path below needs
            # it once the entity goes unavailable.
            snapshot_cache[rec["eid"]] = {"low": low, "level": level, "ts": now}
        elif not low:
            # Native dead-battery retention: an entity that was LOW and then
            # went unavailable (the battery died and took the device offline)
            # stays visible for _NATIVE_RETENTION instead of vanishing at the
            # exact moment it needs replacing.
            snap = snapshot_cache.get(rec["eid"])
            if snap and snap.get("low") and now - snap["ts"] <= _NATIVE_RETENTION:
                low = True
                level = snap.get("level")
        if not available and not low:
            continue
        name = rec["name"]
        if not name and rec["device_id"] and (dev := dev_reg.async_get(rec["device_id"])):
            name = dev.name_by_user or dev.name
        out.append(
            Battery(
                entity_id=rec["eid"],
                device_name=name or rec["eid"],
                battery_type="Unknown",
                quantity=1,
                low=low,
                level=level,
                last_replaced=None,
                available=available,
                source="native",
            )
        )
    return out


def has_battery_notes(hass: HomeAssistant) -> bool:
    """Whether the Battery Notes integration is present (any battery_plus)."""
    for state in hass.states.async_all("sensor"):
        a = state.attributes
        if a.get("device_class") == "battery" and "battery_type" in a:
            return True
    return False


def has_batteries(hass: HomeAssistant) -> bool:
    """Whether ANY battery is trackable — Battery Notes OR native. Gates setup."""
    if any(_is_native_battery_sensor(s) for s in hass.states.async_all("sensor")):
        return True
    return any(s.attributes.get("device_class") == "battery" for s in hass.states.async_all("binary_sensor"))


def compute_overview(hass: HomeAssistant, *, horizon_days: int = DEFAULT_HORIZON_DAYS) -> BatteryOverview:
    """Read + aggregate in one call (HA-side entry point)."""
    today = dt_util.now().date()
    return build_overview(read_batteries(hass), today=today, horizon_days=horizon_days)


def discover_battery_types(hass: HomeAssistant) -> OrderedDict[str, int]:
    """Battery types present across the fleet → total quantity, for part setup."""
    totals: OrderedDict[str, int] = OrderedDict()
    for bat in read_batteries(hass):
        t = _norm_type(bat.battery_type)
        totals[t] = totals.get(t, 0) + bat.quantity
    return OrderedDict(sorted(totals.items()))


__all__ = [
    "DEFAULT_HORIZON_DAYS",
    "NATIVE_LOW_PERCENT",
    "TYPICAL_LIFETIME_MONTHS",
    "Battery",
    "BatteryOverview",
    "build_overview",
    "compute_overview",
    "discover_battery_types",
    "fleet_excluded_entities",
    "has_batteries",
    "has_battery_notes",
    "lifetime_months",
    "read_batteries",
]
