"""Sensor platform for TaskMate integration."""

from __future__ import annotations

import logging
from datetime import date

from homeassistant.components.sensor import (
    SensorEntity,
    SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util

from . import images, photos
from .const import DOMAIN
from .coordinator import TaskMateCoordinator
from .entity import taskmate_device_info
from .models import Child

_LOGGER = logging.getLogger(__name__)


def _safe_float(value, default: float) -> float:
    """Convert value to float, returning default on failure."""
    try:
        return float(value) if value is not None else default
    except (ValueError, TypeError):
        return default


def _safe_int(value, default: int) -> int:
    """Convert value to int, returning default on failure."""
    try:
        return int(value) if value is not None else default
    except (ValueError, TypeError):
        return default


# ---------------------------------------------------------------------------
# Shared attribute builders
#
# The overview sensor used to pack every data slice into one 16KB+ attribute
# payload, which Home Assistant's recorder refuses to store. The data is now
# split across five sensors (overview / chores / rewards / activity /
# incentives); the helpers below build each slice once per coordinator update
# and share intermediate lookups so all sensors stay cheap to render.
# ---------------------------------------------------------------------------


_COMMON_CACHE_ATTR = "_taskmate_sensor_common_cache"


def _compute_common(coordinator: TaskMateCoordinator) -> dict:
    """Build the per-update context shared by all TaskMate sensors.

    Cached on the coordinator so each sensor re-uses a single pass over
    completions / transactions / pool allocations.
    """
    data = coordinator.data
    data_id = id(data)
    cached = getattr(coordinator, _COMMON_CACHE_ATTR, None)
    if cached and cached.get("data_id") == data_id:
        return cached["common"]

    children = data.get("children", [])
    chores = data.get("chores", [])
    rewards = data.get("rewards", [])
    all_completions = data.get("completions", [])
    pending_completions = data.get("pending_completions", [])
    pending_reward_claim_objs = data.get("pending_reward_claims", [])
    pool_alloc_objs = data.get("pool_allocations", [])

    child_lookup = {c.id: c for c in children}
    chore_lookup = {c.id: c for c in chores}
    reward_lookup = {r.id: r for r in rewards}

    # Current-month leaderboard points per child (FEAT-2).
    season_points = coordinator.storage.get_season_points(dt_util.now().strftime("%Y-%m"))

    # Pending chore points per child (awaiting approval -> points to be earned).
    pending_points_by_child: dict[str, int] = {}
    for comp in pending_completions:
        chore = chore_lookup.get(comp.chore_id)
        if chore:
            pending_points_by_child[comp.child_id] = pending_points_by_child.get(comp.child_id, 0) + chore.points

    # Committed points per child (reward claims awaiting approval = points reserved).
    # Pool-mode pending claims are skipped because their cost was already deducted
    # from child.points at allocation time.
    committed_points_by_child: dict[str, int] = {}
    for rc in pending_reward_claim_objs:
        if coordinator.is_pool_mode_claim(rc):
            continue
        reward = reward_lookup.get(rc.reward_id)
        if reward:
            committed_points_by_child[rc.child_id] = committed_points_by_child.get(rc.child_id, 0) + reward.cost

    # Pool allocation lookups for v3.0 pool mode.
    pool_by_child_reward: dict[str, dict[str, int]] = {}
    pool_total_by_reward: dict[str, int] = {}
    total_allocated_by_child: dict[str, int] = {}
    for pa in pool_alloc_objs:
        pool_by_child_reward.setdefault(pa.child_id, {})[pa.reward_id] = pa.allocated_points
        pool_total_by_reward[pa.reward_id] = pool_total_by_reward.get(pa.reward_id, 0) + pa.allocated_points
        total_allocated_by_child[pa.child_id] = total_allocated_by_child.get(pa.child_id, 0) + pa.allocated_points

    common = {
        "data_id": data_id,
        "hass": coordinator.hass,
        "data": data,
        "children": children,
        "chores": chores,
        "rewards": rewards,
        "child_lookup": child_lookup,
        "chore_lookup": chore_lookup,
        "reward_lookup": reward_lookup,
        "season_points": season_points,
        "all_completions": all_completions,
        "pending_completions": pending_completions,
        "pending_reward_claim_objs": pending_reward_claim_objs,
        "pool_alloc_objs": pool_alloc_objs,
        "pending_points_by_child": pending_points_by_child,
        "committed_points_by_child": committed_points_by_child,
        "pool_by_child_reward": pool_by_child_reward,
        "pool_total_by_reward": pool_total_by_reward,
        "total_allocated_by_child": total_allocated_by_child,
    }
    setattr(coordinator, _COMMON_CACHE_ATTR, {"data_id": data_id, "common": common})
    return common


def _build_children_summary(coordinator: TaskMateCoordinator, common: dict) -> list[dict]:
    """Build compact per-child summary used on the overview sensor."""
    children = common["children"]
    pending = common["pending_points_by_child"]
    committed = common["committed_points_by_child"]
    allocated = common["total_allocated_by_child"]
    season = common["season_points"]
    summary = []
    for c in children:
        committed_amount = committed.get(c.id, 0)
        lvl = coordinator.level_info(c)
        summary.append(
            {
                "level": lvl["level"],
                "level_progress": lvl["progress"],
                "level_target": lvl["target"],
                "id": c.id,
                "name": c.name,
                "points": c.points,
                "pending_points": pending.get(c.id, 0),
                # Guest profiles (#690): cards filter these out of competitive views.
                **(
                    {"is_guest": True, "guest_expires_on": getattr(c, "guest_expires_on", "")}
                    if getattr(c, "is_guest", False)
                    else {}
                ),
                # Chore roulette (#677): today's pick + spins left, so the card can
                # show the result and disable the button once the allowance is used.
                **(
                    {
                        "roulette": {
                            **(coordinator.roulette_selection(c.id) or {}),
                            "spins_left": coordinator.roulette_spins_left(c.id),
                        }
                    }
                    if coordinator.roulette_enabled()
                    else {}
                ),
                "committed_points": committed_amount,
                "allocated_points": allocated.get(c.id, 0),
                # Allocations were deducted from child.points already, so spendable
                # only needs to account for pending-claim commitments.
                "spendable_balance": max(0, c.points - committed_amount),
                "chore_order": c.chore_order,
                "current_streak": getattr(c, "current_streak", 0) or 0,
                "best_streak": getattr(c, "best_streak", 0) or 0,
                "season_points": int(season.get(c.id, 0)),
                "total_points_earned": getattr(c, "total_points_earned", 0) or 0,
                "total_chores_completed": getattr(c, "total_chores_completed", 0) or 0,
                "avatar": getattr(c, "avatar", "mdi:account-circle") or "mdi:account-circle",
                "last_completion_date": getattr(c, "last_completion_date", None),
                "streak_paused": getattr(c, "streak_paused", False),
                "on_vacation": coordinator._is_child_on_vacation(c),
                "streak_milestones_achieved": getattr(c, "streak_milestones_achieved", None) or [],
                "awarded_perfect_weeks": getattr(c, "awarded_perfect_weeks", None) or [],
                "career_score": getattr(c, "career_score", 0) or 0,
                "total_penalties_received": getattr(c, "total_penalties_received", 0) or 0,
                "quests": coordinator.quest_progress_for_child(c.id),
                "avatar_options": coordinator.avatar_options_for_child(c),
                "challenges": coordinator.challenge_progress_for_child(c.id),
            }
        )
    return summary


def _build_chores_list(coordinator: TaskMateCoordinator, common: dict) -> list[dict]:
    """Build the chores list, omitting rarely-used / empty fields.

    Fields dropped entirely (no frontend consumer):
      - publish_calendar_entities, last_completed_at,
        first_occurrence_mode, assignment_rotation_anchor.
    Optional fields are only emitted when they hold a non-default value so
    small / simple chores produce compact records and the slice stays under
    the 16KB recorder limit.
    Per-child availability is split into its own attribute (`chore_availability`)
    on the chore-availability sensor to keep this record compact.
    """
    chores = common["chores"]
    chores_list = []
    for c in chores:
        assigned_to = c.assigned_to if isinstance(c.assigned_to, list) else []
        depends_on = c.depends_on if isinstance(getattr(c, "depends_on", None), list) else []
        record: dict = {
            "id": c.id,
            "name": c.name,
            "points": c.points,
            "time_category": c.time_category,
            "assigned_to": assigned_to,
            "depends_on": depends_on,
            "schedule_mode": getattr(c, "schedule_mode", "specific_days"),
            "enabled": getattr(c, "enabled", True),
            "assignment_mode": getattr(c, "assignment_mode", "everyone"),
        }
        # Difficulty tier + the points it actually awards. Emitted only when
        # non-default (medium / ×1.0) so simple chores stay compact.
        difficulty = getattr(c, "difficulty", "medium") or "medium"
        if difficulty != "medium":
            record["difficulty"] = difficulty
        effective_points = coordinator.effective_chore_points(c)
        if effective_points != c.points:
            record["effective_points"] = effective_points
        # Optional fields — emit only when non-default to save bytes.
        description = getattr(c, "description", "") or ""
        if description:
            record["description"] = description
        daily_limit = getattr(c, "daily_limit", 1)
        if daily_limit != 1:
            record["daily_limit"] = daily_limit
        claim_allowance_minutes = getattr(c, "claim_allowance_minutes", 0) or 0
        if claim_allowance_minutes:
            record["claim_allowance_minutes"] = claim_allowance_minutes
        due_days = getattr(c, "due_days", []) or []
        if due_days:
            record["due_days"] = due_days
        requires_approval = getattr(c, "requires_approval", True)
        if not requires_approval:
            record["requires_approval"] = False
        # Mandatory chores (#532): emit only when set so the child card can
        # show the mandatory styling/badge. penalty rides along when non-zero.
        if getattr(c, "mandatory", False):
            record["mandatory"] = True
            penalty = getattr(c, "mandatory_penalty_points", 0) or 0
            if penalty:
                record["mandatory_penalty_points"] = penalty
        if getattr(c, "require_photo", False):
            record["require_photo"] = True
        recurrence = getattr(c, "recurrence", "weekly")
        if recurrence != "weekly":
            record["recurrence"] = recurrence
        recurrence_day = getattr(c, "recurrence_day", "")
        if recurrence_day:
            record["recurrence_day"] = recurrence_day
        recurrence_start = getattr(c, "recurrence_start", "")
        if recurrence_start:
            record["recurrence_start"] = recurrence_start
        visibility_entity = getattr(c, "visibility_entity", "")
        if visibility_entity:
            record["visibility_entity"] = visibility_entity
            record["visibility_operator"] = getattr(c, "visibility_operator", "equals")
            record["visibility_state"] = getattr(c, "visibility_state", "on")
        weather_entity = getattr(c, "weather_entity", "")
        if weather_entity:
            record["weather_entity"] = weather_entity
            record["weather_block_conditions"] = list(getattr(c, "weather_block_conditions", []) or [])
            for limit in ("weather_temp_min", "weather_temp_max", "weather_wind_max"):
                value = getattr(c, limit, None)
                if value is not None:
                    record[limit] = value
            # Why it's hidden, so the panel can say "rained off" instead of
            # just dropping the chore out of the list.
            reason = coordinator.weather_block_reason(c)
            if reason:
                record["weather_blocked"] = reason
        deadline_at = getattr(c, "deadline_at", "")
        if deadline_at:
            record["deadline_at"] = deadline_at
            speed_bonus = getattr(c, "speed_bonus_points", 0)
            if speed_bonus:
                record["speed_bonus_points"] = speed_bonus
        disabled_for = getattr(c, "disabled_for", [])
        if disabled_for:
            record["disabled_for"] = disabled_for
        created_date = getattr(c, "created_date", "")
        if created_date:
            record["created_date"] = created_date
        assignment_current_child_id = getattr(c, "assignment_current_child_id", "")
        if assignment_current_child_id:
            record["assignment_current_child_id"] = assignment_current_child_id
        icon = getattr(c, "icon", "")
        if icon:
            record["icon"] = icon
        # Signed so the card's <img> loads; emitted only when set, matching
        # `icon` above, to keep records under the 16KB recorder limit.
        image_url = getattr(c, "image_url", "")
        if image_url:
            record["image_url"] = images.sign_image_url(common["hass"], image_url)
        completion_sound = getattr(c, "completion_sound", "coin")
        if completion_sound and completion_sound != "coin":
            record["completion_sound"] = completion_sound
        task_type = getattr(c, "task_type", "standard")
        if task_type == "timed":
            record["task_type"] = "timed"
            record["timed_rate_points"] = getattr(c, "timed_rate_points", 10)
            record["timed_rate_minutes"] = getattr(c, "timed_rate_minutes", 5)
            record["timed_max_daily_minutes"] = getattr(c, "timed_max_daily_minutes", 0)
        bonus_subtasks = getattr(c, "bonus_subtasks", [])
        if bonus_subtasks:
            record["bonus_subtasks"] = [{"id": b.id, "name": b.name, "points": b.points} for b in bonus_subtasks]
        chores_list.append(record)
    return chores_list


def _build_chore_availability(coordinator: TaskMateCoordinator, common: dict) -> dict:
    """Build the chore availability matrix: {chore_id: {child_id: bool}}.

    Extracted from the chores sensor so `sensor.taskmate_chores` carries only
    definitions and stays compact even for families with many chores/kids.
    """
    children = common["children"]
    chores = common["chores"]
    availability: dict[str, dict[str, bool]] = {}
    with coordinator.availability_build_scope():
        for c in chores:
            per_child = {}
            for child in children:
                per_child[child.id] = coordinator.is_chore_available_for_child(c, child.id)
            availability[c.id] = per_child
    return availability


def _build_todays_completions(common: dict) -> list[dict]:
    """Build today's completions (both approved and pending)."""
    now = dt_util.now()
    today = now.date()
    child_lookup = common["child_lookup"]
    chore_lookup = common["chore_lookup"]
    out = []
    for comp in common["all_completions"]:
        comp_dt = comp.completed_at
        if hasattr(comp_dt, "astimezone"):
            comp_dt = dt_util.as_local(comp_dt)
        comp_date = comp_dt.date() if hasattr(comp_dt, "date") else comp_dt
        if comp_date != today:
            continue
        matched_chore = chore_lookup.get(comp.chore_id)
        bonus_subtask_id = getattr(comp, "bonus_subtask_id", "") or ""
        if bonus_subtask_id and matched_chore:
            subtask = next((b for b in matched_chore.bonus_subtasks if b.id == bonus_subtask_id), None)
            display_name = f"{matched_chore.name} › {subtask.name}" if subtask else matched_chore.name
            display_points = subtask.points if subtask else 0
        else:
            display_name = matched_chore.name if matched_chore else ""
            display_points = matched_chore.points if matched_chore else 0
        timed_secs = getattr(comp, "timed_duration_seconds", 0) or 0
        if timed_secs > 0 and matched_chore and getattr(matched_chore, "task_type", "") == "timed":
            rate_seconds = matched_chore.timed_rate_minutes * 60
            if rate_seconds > 0:
                display_points = (timed_secs // rate_seconds) * matched_chore.timed_rate_points
        rec = {
            "completion_id": comp.id,
            "chore_id": comp.chore_id,
            "child_id": comp.child_id,
            "child_name": "Parent"
            if comp.child_id == "__parent__"
            else (child_lookup[comp.child_id].name if comp.child_id in child_lookup else ""),
            "chore_name": display_name,
            "points": display_points,
            "approved": comp.approved,
            "completed_at": comp.completed_at.isoformat()
            if hasattr(comp.completed_at, "isoformat")
            else str(comp.completed_at),
            "bonus_subtask_id": bonus_subtask_id,
        }
        if timed_secs > 0:
            rec["timed_duration_seconds"] = timed_secs
        # Sign the evidence photo so a card's <img> (which carries no bearer
        # token) can load it from the auth-gated serve view.
        photo = getattr(comp, "photo_url", "") or ""
        if photo:
            rec["photo_url"] = photos.sign_photo_url(common["hass"], photo)
        out.append(rec)
    return out


def _build_active_timed_sessions(coordinator: TaskMateCoordinator) -> list[dict]:
    """Build active timed sessions for frontend consumption."""
    sessions = coordinator.data.get("timed_sessions", [])
    out = []
    for s in sessions:
        if hasattr(s, "state"):
            state = s.state
            if state not in ("running", "paused"):
                continue
            segments = s.segments or []
            current_segment_start = ""
            if state == "running" and segments:
                last_seg = segments[-1]
                if isinstance(last_seg, dict) and last_seg.get("end") is None:
                    current_segment_start = last_seg.get("start", "")
            out.append(
                {
                    "chore_id": s.chore_id,
                    "child_id": s.child_id,
                    "state": state,
                    "total_seconds_today": s.total_seconds_today,
                    "current_segment_start": current_segment_start,
                }
            )
        else:
            state = s.get("state", "stopped") if isinstance(s, dict) else "stopped"
            if state not in ("running", "paused"):
                continue
            segments = s.get("segments", []) if isinstance(s, dict) else []
            current_segment_start = ""
            if state == "running" and segments:
                last_seg = segments[-1]
                if isinstance(last_seg, dict) and last_seg.get("end") is None:
                    current_segment_start = last_seg.get("start", "")
            out.append(
                {
                    "chore_id": s.get("chore_id", "") if isinstance(s, dict) else "",
                    "child_id": s.get("child_id", "") if isinstance(s, dict) else "",
                    "state": state,
                    "total_seconds_today": s.get("total_seconds_today", 0) if isinstance(s, dict) else 0,
                    "current_segment_start": current_segment_start,
                }
            )
    return out


def _build_rewards_list(common: dict) -> list[dict]:
    """Build the rewards list with calculated_costs and pool allocations."""
    rewards = common["rewards"]
    children = common["children"]
    pool_by_child_reward = common["pool_by_child_reward"]
    pool_total_by_reward = common["pool_total_by_reward"]
    today = dt_util.now().date()
    out = []
    for r in rewards:
        assigned = r.assigned_to if isinstance(r.assigned_to, list) and r.assigned_to else [c.id for c in children]
        calculated_costs = dict.fromkeys(assigned, r.cost)
        reward_pool_allocations = {cid: pool_by_child_reward.get(cid, {}).get(r.id, 0) for cid in assigned}
        jackpot_pool_total = pool_total_by_reward.get(r.id, 0) if getattr(r, "is_jackpot", False) else None
        quantity = getattr(r, "quantity", None)
        expires_at = getattr(r, "expires_at", None)
        is_sold_out = quantity is not None and quantity <= 0
        is_expired = False
        days_until_expiry: int | None = None
        if expires_at:
            try:
                deadline = date.fromisoformat(expires_at)
                is_expired = deadline <= today
                days_until_expiry = (deadline - today).days
            except (TypeError, ValueError):
                pass
        out.append(
            {
                "id": r.id,
                "name": r.name,
                "cost": r.cost,
                "description": getattr(r, "description", ""),
                "icon": r.icon,
                "assigned_to": r.assigned_to if isinstance(r.assigned_to, list) else [],
                "is_jackpot": getattr(r, "is_jackpot", False),
                "pool_enabled": getattr(r, "pool_enabled", False),
                "calculated_costs": calculated_costs,
                "pool_allocations": reward_pool_allocations,
                "jackpot_pool_total": jackpot_pool_total,
                "quantity": quantity,
                "expires_at": expires_at,
                "is_sold_out": is_sold_out,
                "is_expired": is_expired,
                "is_available": not (is_sold_out or is_expired),
                "days_until_expiry": days_until_expiry,
            }
        )
    return out


def _build_pending_reward_claims(common: dict) -> list[dict]:
    """Build enriched pending reward claims list."""
    reward_lookup = common["reward_lookup"]
    child_lookup = common["child_lookup"]
    out = []
    for rc in common["pending_reward_claim_objs"]:
        reward = reward_lookup.get(rc.reward_id)
        child = child_lookup.get(rc.child_id)
        if not reward or not child:
            continue
        out.append(
            {
                "claim_id": rc.id,
                "reward_id": rc.reward_id,
                "child_id": rc.child_id,
                "child_name": child.name,
                "child_avatar": getattr(child, "avatar", "mdi:account-circle") or "mdi:account-circle",
                "reward_name": reward.name,
                "reward_icon": reward.icon or "mdi:gift",
                "cost": reward.cost,
                "claimed_at": rc.claimed_at.isoformat() if hasattr(rc.claimed_at, "isoformat") else str(rc.claimed_at),
            }
        )
    return out


def _build_recent_completions(common: dict, limit: int = 35) -> list[dict]:
    """Return the most recent completions, capped to `limit`.

    Lowered from 200 to 35 so the activity sensor's attribute payload stays
    under the 16KB recorder limit even for families with heavy chore activity.
    """
    child_lookup = common["child_lookup"]
    chore_lookup = common["chore_lookup"]
    recent = sorted(common["all_completions"], key=lambda c: c.completed_at, reverse=True)[:limit]
    out = []
    for comp in recent:
        matched_chore = chore_lookup.get(comp.chore_id)
        display_points = matched_chore.points if matched_chore else 0
        timed_secs = getattr(comp, "timed_duration_seconds", 0) or 0
        if timed_secs > 0 and matched_chore and getattr(matched_chore, "task_type", "") == "timed":
            rate_seconds = matched_chore.timed_rate_minutes * 60
            if rate_seconds > 0:
                display_points = (timed_secs // rate_seconds) * matched_chore.timed_rate_points
        out.append(
            {
                "completion_id": comp.id,
                "chore_id": comp.chore_id,
                "child_id": comp.child_id,
                "child_name": "Parent"
                if comp.child_id == "__parent__"
                else (child_lookup[comp.child_id].name if comp.child_id in child_lookup else ""),
                "chore_name": matched_chore.name if matched_chore else "",
                "points": display_points,
                "approved": comp.approved,
                "completed_at": comp.completed_at.isoformat()
                if hasattr(comp.completed_at, "isoformat")
                else str(comp.completed_at),
            }
        )
    return out


def _build_photo_gallery(common: dict, limit: int = 40) -> list[dict]:
    """Recent completions that carry an evidence photo, newest first (FEAT-13).

    Returns the raw (unsigned) ``/api/taskmate/photo/<file>`` path; the gallery
    card signs each via ``auth/sign_path`` before rendering (a card's <img> can't
    send a bearer token).
    """
    child_lookup = common["child_lookup"]
    chore_lookup = common["chore_lookup"]
    with_photos = [c for c in common["all_completions"] if getattr(c, "photo_url", "")]
    recent = sorted(with_photos, key=lambda c: c.completed_at, reverse=True)[:limit]
    out = []
    for comp in recent:
        chore = chore_lookup.get(comp.chore_id)
        out.append(
            {
                "completion_id": comp.id,
                "child_name": child_lookup[comp.child_id].name if comp.child_id in child_lookup else "",
                "chore_name": chore.name if chore else "",
                "approved": comp.approved,
                "completed_at": comp.completed_at.isoformat()
                if hasattr(comp.completed_at, "isoformat")
                else str(comp.completed_at),
                "photo_url": comp.photo_url,
            }
        )
    return out


def _build_recent_transactions(common: dict, limit: int = 20) -> list[dict]:
    """Unified activity feed of manual point adjustments and reward claims.

    Capped at 20 so the combined activity slice (completions + transactions)
    stays under the 16KB recorder limit.
    """
    data = common["data"]
    child_lookup = common["child_lookup"]
    reward_lookup = common["reward_lookup"]
    points_transactions = data.get("points_transactions", [])
    all_reward_claims = data.get("reward_claims", [])
    events = []

    for t in points_transactions:
        child = child_lookup.get(t.child_id)
        if not child:
            continue
        events.append(
            {
                "transaction_id": t.id,
                "type": "points_added" if t.points > 0 else "points_removed",
                "child_id": t.child_id,
                "child_name": child.name,
                "points": t.points,
                "reason": t.reason or "",
                "created_at": t.created_at.isoformat() if hasattr(t.created_at, "isoformat") else str(t.created_at),
            }
        )

    for rc in all_reward_claims:
        child = child_lookup.get(rc.child_id)
        reward = reward_lookup.get(rc.reward_id)
        if not child or not reward:
            continue
        event_type = "reward_approved" if rc.approved else "reward_claimed"
        timestamp = rc.approved_at if rc.approved and rc.approved_at else rc.claimed_at
        events.append(
            {
                "transaction_id": rc.id,
                "type": event_type,
                "child_id": rc.child_id,
                "child_name": child.name,
                "reward_id": rc.reward_id,
                "reward_name": reward.name,
                "reward_icon": reward.icon or "mdi:gift",
                "points": -reward.cost,
                "approved": rc.approved,
                "created_at": timestamp.isoformat() if hasattr(timestamp, "isoformat") else str(timestamp),
            }
        )

    events.sort(key=lambda e: e["created_at"], reverse=True)
    return events[:limit]


def _build_penalties_list(common: dict) -> list[dict]:
    return [
        {
            "id": p.id,
            "name": p.name,
            "points": p.points,
            "description": p.description,
            "icon": p.icon,
            "assigned_to": p.assigned_to or [],
        }
        for p in common["data"].get("penalties", [])
    ]


def _build_bonuses_list(common: dict) -> list[dict]:
    return [
        {
            "id": b.id,
            "name": b.name,
            "points": b.points,
            "description": b.description,
            "icon": b.icon,
            "assigned_to": b.assigned_to or [],
        }
        for b in common["data"].get("bonuses", [])
    ]


async def async_setup_entry(
    hass: HomeAssistant,
    entry: ConfigEntry,
    async_add_entities: AddEntitiesCallback,
) -> None:
    """Set up TaskMate sensors."""
    coordinator: TaskMateCoordinator = hass.data[DOMAIN][entry.entry_id]

    entities: list[SensorEntity] = []

    # Track child IDs that have sensors created
    tracked_child_ids: set[str] = set()

    # Add global sensors. The overview sensor is kept for backward compatibility
    # with existing Lovelace dashboards; its heavy attributes have been split
    # across four companion sensors to stay below the 16KB recorder limit.
    entities.append(TaskMateOverallStatsSensor(coordinator, entry))
    entities.append(TaskMateChoresSensor(coordinator, entry))
    entities.append(TaskMateChoreAvailabilitySensor(coordinator, entry))
    entities.append(TaskMateRewardsSensor(coordinator, entry))
    entities.append(TaskMateActivitySensor(coordinator, entry))
    entities.append(TaskMateIncentivesSensor(coordinator, entry))

    # Add sensors for each child
    for child in coordinator.data.get("children", []):
        entities.append(ChildPointsSensor(coordinator, entry, child))
        entities.append(ChildStatsSensor(coordinator, entry, child))
        entities.append(ChildBadgesSensor(coordinator, entry, child))
        tracked_child_ids.add(child.id)

    # Add pending approvals sensor
    entities.append(PendingApprovalsSensor(coordinator, entry))

    async_add_entities(entities)

    # Set up listener for new children
    @callback
    def async_add_child_sensors() -> None:
        """Add sensors for newly added children."""
        new_entities: list[SensorEntity] = []

        for child in coordinator.data.get("children", []):
            if child.id not in tracked_child_ids:
                new_entities.append(ChildPointsSensor(coordinator, entry, child))
                new_entities.append(ChildStatsSensor(coordinator, entry, child))
                new_entities.append(ChildBadgesSensor(coordinator, entry, child))
                tracked_child_ids.add(child.id)

        if new_entities:
            async_add_entities(new_entities)

    coordinator.async_add_listener(async_add_child_sensors)


class TaskMateBaseSensor(CoordinatorEntity, SensorEntity):
    """Base class for TaskMate sensors."""

    def __init__(
        self,
        coordinator: TaskMateCoordinator,
        entry: ConfigEntry,
    ) -> None:
        """Initialize the sensor."""
        super().__init__(coordinator)
        self._entry = entry

    @property
    def device_info(self) -> DeviceInfo:
        """Return device info."""
        return taskmate_device_info(self._entry.entry_id)


class _CachedAttrsSensor(TaskMateBaseSensor):
    """Base for sensors whose attributes are expensive to build."""

    def __init__(self, coordinator: TaskMateCoordinator, entry: ConfigEntry) -> None:
        super().__init__(coordinator, entry)
        self._cached_attrs: dict | None = None
        self._cached_key: tuple[int, int] | None = None

    @property
    def extra_state_attributes(self) -> dict:
        # The coordinator reuses one snapshot object while storage.data_version
        # is unchanged, so id(data) alone would pin these attributes forever
        # when only an external entity moved — stranding the visibility and
        # weather gates on stale state. external_state_version covers that.
        key = (id(self.coordinator.data), getattr(self.coordinator, "external_state_version", 0))
        if self._cached_key == key and self._cached_attrs is not None:
            return self._cached_attrs
        attrs = self._build_attributes()
        self._cached_attrs = attrs
        self._cached_key = key
        return attrs

    def _build_attributes(self) -> dict:  # pragma: no cover - abstract
        raise NotImplementedError


class TaskMateOverallStatsSensor(_CachedAttrsSensor):
    """Overview sensor — scalars plus the compact per-child summary.

    Heavy lists (chores/rewards/activity/incentives) live on companion
    sensors so this entity stays well under the 16KB recorder limit.
    """

    # The per-child summary grows with the family; the scalars stay recorded
    # so history/statistics on them keep working (#817).
    _unrecorded_attributes = frozenset({"children", "vacation_periods", "season_champions"})

    def __init__(
        self,
        coordinator: TaskMateCoordinator,
        entry: ConfigEntry,
    ) -> None:
        super().__init__(coordinator, entry)
        self._attr_unique_id = f"{entry.entry_id}_overall_stats"
        self._attr_name = "TaskMate Overview"

    @property
    def native_value(self) -> int:
        """Return the total number of children."""
        return len(self.coordinator.data.get("children", []))

    @property
    def icon(self) -> str:
        return "mdi:clipboard-check-multiple"

    def _build_attributes(self) -> dict:
        common = _compute_common(self.coordinator)
        data = common["data"]
        children = common["children"]
        chores = common["chores"]
        rewards = common["rewards"]

        total_points = sum(c.points for c in children)
        total_chores_completed = sum(c.total_chores_completed for c in children)
        today_dow = dt_util.now().strftime("%A").lower()
        settings = data.get("settings", {})

        time_periods = self.coordinator.get_time_periods()
        period_by_id = {p["id"]: p for p in time_periods}
        active_vacation = self.coordinator.active_vacation()

        # Legacy shape kept for cards that still read the four fixed keys.
        legacy_defaults = {
            "morning": ("06:00", "12:00"),
            "afternoon": ("12:00", "17:00"),
            "evening": ("17:00", "21:00"),
            "night": ("21:00", "23:59"),
        }
        time_boundaries = {}
        for cat, (def_start, def_end) in legacy_defaults.items():
            period = period_by_id.get(cat)
            time_boundaries[f"{cat}_start"] = period["start"] if period else def_start
            time_boundaries[f"{cat}_end"] = period["end"] if period else def_end

        return {
            "today_day_of_week": today_dow,
            "streak_reset_mode": settings.get("streak_reset_mode", "reset"),
            "weekend_multiplier": _safe_float(settings.get("weekend_multiplier"), 2.0),
            "streak_milestones_enabled": settings.get("streak_milestones_enabled", "true") == "true",
            "streak_milestones": settings.get("streak_milestones", "3:5, 7:10, 14:20, 30:50, 60:100, 100:200"),
            "perfect_week_enabled": settings.get("perfect_week_enabled", "true") == "true",
            "perfect_week_bonus": _safe_int(settings.get("perfect_week_bonus"), 50),
            "streak_requires_all_chores": settings.get("streak_requires_all_chores", "false") in (True, "true"),
            "perfect_week_requires_all_chores": settings.get("perfect_week_requires_all_chores", "false")
            in (True, "true"),
            "total_children": len(children),
            "total_chores": len(chores),
            "total_rewards": len(rewards),
            "total_points_available": total_points,
            "total_chores_completed": total_chores_completed,
            "total_completions_all_time": len(common["all_completions"]),
            "total_pending_completions": len(common["pending_completions"]),
            "points_name": data.get("points_name", "Stars"),
            "points_icon": data.get("points_icon", "mdi:star"),
            # Global default card-design style; cards read this when no per-card override (#design).
            "card_design": settings.get("card_design", "classic"),
            # Non-admin parent role (#661): cards unlock parent controls for these HA users.
            "parent_user_ids": self.coordinator.storage.get_parent_user_ids(),
            "children": _build_children_summary(self.coordinator, common),
            "time_boundaries": time_boundaries,
            "time_periods": time_periods,
            "vacation_active": active_vacation is not None,
            "vacation_name": active_vacation.get("name", "") if active_vacation else "",
            "vacation_end": active_vacation.get("end", "") if active_vacation else "",
            "vacation_periods": self.coordinator.get_vacation_periods(),
            # Leaderboard seasons (FEAT-2): current-month standings + champion history.
            "season_month": dt_util.now().strftime("%Y-%m"),
            "season_champions": list(reversed(self.coordinator.storage.get_season_champions()))[:12],
            # Family co-op goal (FEAT-4).
            "family_goal": {
                "enabled": settings.get("family_goal_enabled", False) in (True, "true"),
                "name": settings.get("family_goal_name", "") or "",
                "target": _safe_int(settings.get("family_goal_target"), 0),
                "reward": settings.get("family_goal_reward", "") or "",
                "progress": sum(c.points for c in children),
                "achieved": settings.get("family_goal_achieved", False) in (True, "true"),
            },
        }


class TaskMateChoresSensor(_CachedAttrsSensor):
    """Chores catalog + today's completions."""

    _unrecorded_attributes = frozenset({"chores", "todays_completions", "task_groups", "active_timed_sessions"})

    def __init__(
        self,
        coordinator: TaskMateCoordinator,
        entry: ConfigEntry,
    ) -> None:
        super().__init__(coordinator, entry)
        self._attr_unique_id = f"{entry.entry_id}_chores"
        self._attr_name = "TaskMate Chores"

    @property
    def native_value(self) -> int:
        return len(self.coordinator.data.get("chores", []))

    @property
    def icon(self) -> str:
        return "mdi:format-list-checks"

    def _build_attributes(self) -> dict:
        common = _compute_common(self.coordinator)
        task_groups = [
            {
                "id": g.id,
                "name": g.name,
                "policy": g.policy,
                "chore_ids": list(g.chore_ids or []),
            }
            for g in self.coordinator.storage.get_task_groups()
        ]
        active_vacation = self.coordinator.active_vacation()
        return {
            "chores": _build_chores_list(self.coordinator, common),
            "todays_completions": _build_todays_completions(common),
            "task_groups": task_groups,
            "active_timed_sessions": _build_active_timed_sessions(self.coordinator),
            "vacation_active": active_vacation is not None,
            "vacation_name": active_vacation.get("name", "") if active_vacation else "",
            "vacation_end": active_vacation.get("end", "") if active_vacation else "",
        }


class TaskMateChoreAvailabilitySensor(_CachedAttrsSensor):
    """Per-chore × per-child availability matrix.

    Split off from the chores sensor so `sensor.taskmate_chores` can stay
    under the 16 KB recorder limit even for families with lots of chores.
    The map is `{chore_id: {child_id: bool}}`.
    """

    _unrecorded_attributes = frozenset({"chore_availability"})

    def __init__(
        self,
        coordinator: TaskMateCoordinator,
        entry: ConfigEntry,
    ) -> None:
        super().__init__(coordinator, entry)
        self._attr_unique_id = f"{entry.entry_id}_chore_availability"
        self._attr_name = "TaskMate Chore Availability"

    @property
    def native_value(self) -> int:
        common = _compute_common(self.coordinator)
        total = 0
        availability = _build_chore_availability(self.coordinator, common)
        for per_child in availability.values():
            total += sum(1 for v in per_child.values() if v)
        return total

    @property
    def icon(self) -> str:
        return "mdi:calendar-check"

    def _build_attributes(self) -> dict:
        common = _compute_common(self.coordinator)
        return {
            "chore_availability": _build_chore_availability(self.coordinator, common),
        }


class TaskMateRewardsSensor(_CachedAttrsSensor):
    """Rewards catalog + pending claims + pool allocations."""

    _unrecorded_attributes = frozenset({"rewards", "pending_reward_claims", "pool_allocations"})

    def __init__(
        self,
        coordinator: TaskMateCoordinator,
        entry: ConfigEntry,
    ) -> None:
        super().__init__(coordinator, entry)
        self._attr_unique_id = f"{entry.entry_id}_rewards"
        self._attr_name = "TaskMate Rewards"

    @property
    def native_value(self) -> int:
        return len(self.coordinator.data.get("rewards", []))

    @property
    def icon(self) -> str:
        return "mdi:gift-outline"

    def _build_attributes(self) -> dict:
        common = _compute_common(self.coordinator)
        return {
            "rewards": _build_rewards_list(common),
            "pending_reward_claims": _build_pending_reward_claims(common),
            "pool_allocations": [pa.to_dict() for pa in common["pool_alloc_objs"]],
        }


class TaskMateActivitySensor(_CachedAttrsSensor):
    """Recent completions + recent points/reward transactions."""

    _unrecorded_attributes = frozenset(
        {"recent_completions", "recent_transactions", "career_score_history", "photo_gallery"}
    )

    def __init__(
        self,
        coordinator: TaskMateCoordinator,
        entry: ConfigEntry,
    ) -> None:
        super().__init__(coordinator, entry)
        self._attr_unique_id = f"{entry.entry_id}_activity"
        self._attr_name = "TaskMate Activity"

    @property
    def native_value(self) -> int:
        return len(self.coordinator.data.get("completions", []))

    @property
    def icon(self) -> str:
        return "mdi:history"

    def _build_attributes(self) -> dict:
        common = _compute_common(self.coordinator)
        children = common["children"]
        career_history = {}
        for c in children:
            career_history[c.id] = self.coordinator.storage.get_career_score_history(c.id)
        return {
            "recent_completions": _build_recent_completions(common),
            "recent_transactions": _build_recent_transactions(common),
            "career_score_history": career_history,
            "photo_gallery": _build_photo_gallery(common),
        }


class TaskMateIncentivesSensor(_CachedAttrsSensor):
    """Penalties + bonuses catalogue."""

    _unrecorded_attributes = frozenset({"penalties", "bonuses"})

    def __init__(
        self,
        coordinator: TaskMateCoordinator,
        entry: ConfigEntry,
    ) -> None:
        super().__init__(coordinator, entry)
        self._attr_unique_id = f"{entry.entry_id}_incentives"
        self._attr_name = "TaskMate Incentives"

    @property
    def native_value(self) -> int:
        data = self.coordinator.data
        return len(data.get("penalties", [])) + len(data.get("bonuses", []))

    @property
    def icon(self) -> str:
        return "mdi:scale-balance"

    def _build_attributes(self) -> dict:
        common = _compute_common(self.coordinator)
        return {
            "penalties": _build_penalties_list(common),
            "bonuses": _build_bonuses_list(common),
        }


class ChildPointsSensor(TaskMateBaseSensor):
    """Sensor for a child's points."""

    def __init__(
        self,
        coordinator: TaskMateCoordinator,
        entry: ConfigEntry,
        child: Child,
    ) -> None:
        """Initialize the sensor."""
        super().__init__(coordinator, entry)
        self.child_id = child.id
        self._attr_unique_id = f"{entry.entry_id}_{child.id}_points"
        self._attr_name = f"{child.name} Points"
        self._attr_state_class = SensorStateClass.TOTAL

    @property
    def native_value(self) -> int:
        """Return the child's current points."""
        child = self.coordinator.get_child(self.child_id)
        return child.points if child else 0

    @property
    def native_unit_of_measurement(self) -> str:
        """Return the unit of measurement."""
        return self.coordinator.data.get("points_name", "Stars")

    @property
    def icon(self) -> str:
        """Return the icon."""
        return self.coordinator.data.get("points_icon", "mdi:star")

    @property
    def extra_state_attributes(self) -> dict:
        """Return additional attributes."""
        child = self.coordinator.get_child(self.child_id)
        if not child:
            return {}

        return {
            "child_id": child.id,
            "child_name": child.name,
            "avatar": child.avatar,
            "total_points_earned": child.total_points_earned,
            "total_chores_completed": child.total_chores_completed,
            "current_streak": child.current_streak,
            "best_streak": child.best_streak,
            "career_score": child.career_score,
            "total_penalties_received": child.total_penalties_received,
        }


class ChildStatsSensor(TaskMateBaseSensor):
    """Sensor for a child's statistics."""

    _attr_translation_key = "child_stats"
    _unrecorded_attributes = frozenset({"assigned_chores", "chore_order"})

    def __init__(
        self,
        coordinator: TaskMateCoordinator,
        entry: ConfigEntry,
        child: Child,
    ) -> None:
        """Initialize the sensor."""
        super().__init__(coordinator, entry)
        self.child_id = child.id
        self._attr_unique_id = f"{entry.entry_id}_{child.id}_stats"
        self._attr_name = f"{child.name} Stats"
        self._attr_state_class = SensorStateClass.TOTAL

    @property
    def native_value(self) -> int:
        """Return the child's total chores completed."""
        child = self.coordinator.get_child(self.child_id)
        return child.total_chores_completed if child else 0

    @property
    def icon(self) -> str:
        """Return the icon."""
        child = self.coordinator.get_child(self.child_id)
        return child.avatar if child else "mdi:account-circle"

    @property
    def extra_state_attributes(self) -> dict:
        """Return additional attributes."""
        child = self.coordinator.get_child(self.child_id)
        if not child:
            return {}

        # Get chores assigned to this child. For alternating/random/balanced
        # assignment, only include a chore when this child is the active one
        # today. first_come is competitive: the whole pool sees it (no single
        # active child) until one member fills the shared quota. Either way,
        # drop it once any pool member has completed it today (so a parent
        # crediting the off-rotation child clears the chore for everyone).
        chores = self.coordinator.data.get("chores", [])

        def _included(c):
            if not (child.id in c.assigned_to or not c.assigned_to):
                return False
            mode = getattr(c, "assignment_mode", "everyone")
            if mode not in ("everyone", "first_come") and getattr(c, "assignment_current_child_id", "") != child.id:
                return False
            if mode != "everyone" and self.coordinator._is_rotation_done_today(c):
                return False
            return True

        assigned_chores = [c for c in chores if _included(c)]

        return {
            "child_id": child.id,
            "child_name": child.name,
            "avatar": child.avatar,
            "points": child.points,
            "total_points_earned": child.total_points_earned,
            "total_chores_completed": child.total_chores_completed,
            "current_streak": child.current_streak,
            "best_streak": child.best_streak,
            "career_score": child.career_score,
            "total_penalties_received": child.total_penalties_received,
            "assigned_chores": [
                {"id": c.id, "name": c.name, "points": c.points, "time_category": c.time_category}
                for c in assigned_chores
            ],
            "chore_order": child.chore_order,
        }


class ChildBadgesSensor(TaskMateBaseSensor):
    """Sensor exposing a child's earned and available badges."""

    _attr_icon = "mdi:trophy-award"
    _attr_translation_key = "child_badges"
    _unrecorded_attributes = frozenset({"earned", "available"})

    def __init__(
        self,
        coordinator: TaskMateCoordinator,
        entry: ConfigEntry,
        child: Child,
    ) -> None:
        super().__init__(coordinator, entry)
        self.child_id = child.id
        self._attr_unique_id = f"{entry.entry_id}_{child.id}_badges"
        self._attr_name = f"{child.name} Badges"

    @property
    def native_value(self) -> int:
        """Number of badges earned by this child."""
        return len(self.coordinator.storage.get_awarded_badges_for_child(self.child_id))

    @property
    def extra_state_attributes(self) -> dict:
        from .coord_badges import resolve_metric

        storage = self.coordinator.storage
        child = storage.get_child(self.child_id)
        if child is None:
            return {"earned": [], "available": [], "total_badges": 0}

        all_badges = [b for b in storage.get_badges() if b.enabled]
        applicable = [b for b in all_badges if not b.assigned_to or self.child_id in b.assigned_to]

        awarded_records = storage.get_awarded_badges_for_child(self.child_id)
        record_by_id = {a.badge_id: a for a in awarded_records}

        earned: list[dict] = []
        available: list[dict] = []
        for b in applicable:
            if b.id in record_by_id:
                rec = record_by_id[b.id]
                earned.append(
                    {
                        "badge_id": b.id,
                        "name": b.name,
                        "icon": b.icon,
                        "tier": b.tier,
                        "earned_at": rec.earned_at.isoformat() if rec.earned_at else None,
                        "manually_awarded": rec.manually_awarded,
                        "silent": rec.silent,
                    }
                )
            else:
                # The criterion that actually gates the award: for an AND badge
                # that is the weakest one, for an OR badge the strongest. Using
                # min() for both understated OR badges (and could report <100%
                # for one already earned) — see coord_badges.BadgeEvaluator.
                combinator = (getattr(b, "combinator", "AND") or "AND").upper()
                scored = []
                for c in b.criteria:
                    cur = resolve_metric(c.metric, child, storage)
                    pct = min(100, int(100 * cur / max(c.value, 1)))
                    scored.append((pct, c.metric, cur, c.value))
                if not scored:
                    progress_pct = 0
                    closest = None
                else:
                    pick = max(scored) if combinator == "OR" else min(scored)
                    progress_pct = pick[0]
                    closest = {"metric": pick[1], "current": pick[2], "target": pick[3]}
                available.append(
                    {
                        "badge_id": b.id,
                        "name": b.name,
                        "icon": b.icon,
                        "tier": b.tier,
                        "progress_pct": progress_pct,
                        "closest_criterion": closest,
                        "criteria_summary": ", ".join(f"{c.metric} >= {c.value}" for c in b.criteria),
                    }
                )

        earned.sort(key=lambda e: e.get("earned_at") or "", reverse=True)

        return {
            "earned": earned,
            "available": available,
            "total_badges": len(applicable),
        }


class PendingApprovalsSensor(TaskMateBaseSensor):
    """Sensor for pending approvals."""

    _unrecorded_attributes = frozenset({"chore_completions", "reward_claims", "mandatory_misses"})

    def __init__(
        self,
        coordinator: TaskMateCoordinator,
        entry: ConfigEntry,
    ) -> None:
        """Initialize the sensor."""
        super().__init__(coordinator, entry)
        self._attr_unique_id = f"{entry.entry_id}_pending_approvals"
        self._attr_name = "Pending Approvals"
        self._attr_state_class = SensorStateClass.MEASUREMENT

    @property
    def native_value(self) -> int:
        """Return the number of pending approvals."""
        pending_completions = self.coordinator.data.get("pending_completions", [])
        pending_rewards = self.coordinator.data.get("pending_reward_claims", [])
        return len(pending_completions) + len(pending_rewards)

    @property
    def icon(self) -> str:
        """Return the icon."""
        return "mdi:clipboard-clock"

    @property
    def extra_state_attributes(self) -> dict:
        """Return additional attributes."""
        pending_completions = self.coordinator.data.get("pending_completions", [])
        pending_rewards = self.coordinator.data.get("pending_reward_claims", [])

        completion_details = []
        for comp in pending_completions:
            child = self.coordinator.get_child(comp.child_id)
            chore = self.coordinator.get_chore(comp.chore_id)
            if child and chore:
                # Bonus sub-task completions carry the sub-task's own name and
                # points; mirror _build_todays_completions so cards reading this
                # list render them identically.
                bonus_subtask_id = getattr(comp, "bonus_subtask_id", "") or ""
                if bonus_subtask_id:
                    subtask = next((b for b in chore.bonus_subtasks if b.id == bonus_subtask_id), None)
                    chore_name = f"{chore.name} › {subtask.name}" if subtask else chore.name
                    pts = subtask.points if subtask else 0
                else:
                    chore_name = chore.name
                    pts = chore.points
                timed_secs = getattr(comp, "timed_duration_seconds", 0) or 0
                if timed_secs > 0 and getattr(chore, "task_type", "") == "timed":
                    rate_seconds = chore.timed_rate_minutes * 60
                    if rate_seconds > 0:
                        pts = (timed_secs // rate_seconds) * chore.timed_rate_points
                detail = {
                    "completion_id": comp.id,
                    "type": "chore",
                    "child_name": child.name,
                    "child_id": child.id,
                    "chore_name": chore_name,
                    "chore_id": chore.id,
                    "points": pts,
                    "time_category": chore.time_category,
                    "completed_at": comp.completed_at.isoformat(),
                    "bonus_subtask_id": bonus_subtask_id,
                }
                if timed_secs > 0:
                    detail["timed_duration_seconds"] = timed_secs
                photo = getattr(comp, "photo_url", "") or ""
                if photo:
                    detail["photo_url"] = photos.sign_photo_url(self.coordinator.hass, photo)
                completion_details.append(detail)

        reward_details = []
        for claim in pending_rewards:
            child = self.coordinator.get_child(claim.child_id)
            reward = self.coordinator.get_reward(claim.reward_id)
            if child and reward:
                reward_details.append(
                    {
                        "claim_id": claim.id,
                        "type": "reward",
                        "child_name": child.name,
                        "child_id": child.id,
                        "reward_name": reward.name,
                        "reward_id": reward.id,
                        "cost": reward.cost,
                        "claimed_at": claim.claimed_at.isoformat(),
                    }
                )

        mandatory_misses = self.coordinator.mandatory_misses_state()
        return {
            "pending_chore_completions": len(pending_completions),
            "pending_reward_claims": len(pending_rewards),
            "pending_mandatory_misses": len(mandatory_misses),
            "chore_completions": completion_details,
            "reward_claims": reward_details,
            "mandatory_misses": mandatory_misses,
        }
