"""Storage management for TaskMate integration."""

from __future__ import annotations

import logging
from datetime import date, timedelta
from typing import Any

from homeassistant.core import HomeAssistant
from homeassistant.helpers.storage import Store

from .const import DEFAULT_NOTIFICATION_NAV_URL, DOMAIN
from .models import (
    AwardedBadge,
    Badge,
    Bonus,
    Challenge,
    Child,
    Chore,
    ChoreCompletion,
    CustomNotification,
    MandatoryMiss,
    NotificationConfig,
    NotificationRoute,
    ParentRecipient,
    Penalty,
    PointsTransaction,
    PoolAllocation,
    Quest,
    Reward,
    RewardClaim,
    ScheduledChange,
    TaskGroup,
    TimedSession,
)

_LOGGER = logging.getLogger(__name__)

STORAGE_VERSION = 1
STORAGE_KEY = f"{DOMAIN}.storage"

# Debounce window for coalescing the 2-4 saves a single operation can trigger
# into one disk write (PERF-3). Small enough that a crash loses at most this
# much; HA flushes pending delayed saves on shutdown.
_SAVE_DEBOUNCE_SECONDS = 1.0


class TaskMateStorage:
    """Manage TaskMate data storage."""

    def __init__(self, hass: HomeAssistant, entry_id: str) -> None:
        """Initialize storage."""
        self.hass = hass
        self.entry_id = entry_id
        self._store = Store(hass, STORAGE_VERSION, f"{STORAGE_KEY}.{entry_id}")
        self._data: dict[str, Any] = {}
        # Monotonic counter bumped on every persisted mutation (PERF-2). Lets the
        # coordinator skip rebuilding its data snapshot when nothing has changed.
        self._data_version = 0

    async def async_load(self) -> dict[str, Any]:
        """Load data from storage."""
        data = await self._store.async_load()
        is_fresh = data is None
        if is_fresh:
            data = {
                "children": [],
                "chores": [],
                "rewards": [],
                "completions": [],
                "mandatory_misses": [],
                "reward_claims": [],
                "points_transactions": [],
                "pool_allocations": [],
                "task_groups": [],
                "badges": [],
                "awarded_badges": [],
                "points_name": "Stars",
                "points_icon": "mdi:star",
                "last_completed": {},
            }
        self._data = data

        # Ensure last_completed store exists (migration for existing installs)
        if "last_completed" not in self._data:
            self._data["last_completed"] = {}

        # Ensure pool_allocations store exists (migration for v3.0 pool mode)
        if "pool_allocations" not in self._data:
            self._data["pool_allocations"] = []

        # Ensure task_groups store exists (migration for existing installs)
        if "task_groups" not in self._data:
            self._data["task_groups"] = []

        # Ensure timed_sessions store exists (migration for timed tasks feature)
        if "timed_sessions" not in self._data:
            self._data["timed_sessions"] = []

        # Ensure career_score_history store exists
        if "career_score_history" not in self._data:
            self._data["career_score_history"] = {}

        # Ensure templates store exists
        if "templates" not in self._data:
            self._data["templates"] = []

        # Ensure badges store exists (migration for achievement badges feature)
        if "badges" not in self._data:
            self._data["badges"] = []

        # Ensure awarded_badges store exists (migration for achievement badges feature)
        if "awarded_badges" not in self._data:
            self._data["awarded_badges"] = []

        # Ensure chore_display_order store exists (global admin ordering)
        if "chore_display_order" not in self._data:
            self._data["chore_display_order"] = []

        # Ensure scheduled_changes store exists (#675)
        if "scheduled_changes" not in self._data:
            self._data["scheduled_changes"] = []

        # Drop settled swap requests (#783) and ones pointing at a chore or child
        # that no longer exists (#785). Approval used to only flip a status flag,
        # and deletes did not cascade, so existing installs carry both kinds —
        # the orphans surface in the parent's approval queue as an unclearable
        # "? wants to swap ?" row. Runs on every load rather than behind a
        # one-shot flag: it is a cheap list filter, and it also sweeps up records
        # left by a downgrade to a version without the cascades.
        self._drop_dead_swap_requests()

        # Notifications overhaul (v3.9.0)
        if "parent_recipients" not in self._data:
            self._data["parent_recipients"] = []
        if "notification_config" not in self._data:
            self._data["notification_config"] = {}
        if "custom_notifications" not in self._data:
            self._data["custom_notifications"] = []
        if "notifications_migration_done" not in self._data:
            self._run_notifications_migration()
            self._data["notifications_migration_done"] = True

        self._migrate_nav_url_default()

        # Badge migration / seeding
        self._seed_builtin_badges(is_fresh=is_fresh)

        # Run data migrations
        await self._migrate_assigned_to_child_ids()
        await self._migrate_pool_allocations_v2()
        await self._migrate_career_score()

        return data

    async def _migrate_pool_allocations_v2(self) -> None:
        """Migrate beta1 pool allocations to beta2 semantics.

        In v3.0.0-beta1, pool allocations did NOT deduct from child.points (the points
        stayed in the gross balance until redeem). From beta2 onward, allocations deduct
        immediately so the visible balance reflects commitment.

        For existing installs: subtract each allocation's allocated_points from the
        corresponding child's points, once. Guarded by a version flag so it runs only
        on the first beta2 load.
        """
        if self._data.get("_pool_semantics_version", 1) >= 2:
            return

        allocations = self._data.get("pool_allocations", [])
        children = self._data.get("children", [])
        if not allocations or not children:
            # Mark as migrated even if nothing to do
            self._data["_pool_semantics_version"] = 2
            await self.async_save()
            return

        # Build child lookup keyed by id
        children_by_id = {c.get("id"): c for c in children}
        adjusted = 0
        for alloc in allocations:
            child_data = children_by_id.get(alloc.get("child_id"))
            if not child_data:
                continue
            allocated = int(alloc.get("allocated_points", 0) or 0)
            if allocated <= 0:
                continue
            current_points = int(child_data.get("points", 0) or 0)
            child_data["points"] = max(0, current_points - allocated)
            adjusted += 1

        self._data["_pool_semantics_version"] = 2
        if adjusted:
            _LOGGER.info(
                "TaskMate: migrated %d pool allocation(s) to beta2 semantics (points now deducted at allocation time)",
                adjusted,
            )
        await self.async_save()

    async def _migrate_assigned_to_child_ids(self) -> None:
        """Migrate chore assigned_to from child names to child IDs if needed.

        This handles legacy data where assigned_to might contain child names
        instead of child IDs.
        """
        children = self._data.get("children", [])
        chores = self._data.get("chores", [])

        if not children or not chores:
            return

        # Build a map of child name -> child ID for migration
        name_to_id = {}
        valid_ids = set()
        for child in children:
            child_id = child.get("id", "")
            child_name = child.get("name", "")
            if child_id:
                valid_ids.add(child_id)
            if child_name and child_id:
                name_to_id[child_name] = child_id

        # Check and migrate each chore's assigned_to
        data_modified = False
        for chore in chores:
            assigned_to = chore.get("assigned_to", [])
            if not assigned_to:
                continue

            new_assigned_to = []
            chore_modified = False

            for assignment in assigned_to:
                if assignment in valid_ids:
                    # Already a valid child ID
                    new_assigned_to.append(assignment)
                elif assignment in name_to_id:
                    # This is a child name, convert to ID
                    new_assigned_to.append(name_to_id[assignment])
                    chore_modified = True
                    _LOGGER.warning(
                        "Migrating chore '%s' assigned_to: '%s' -> '%s' (name to ID)",
                        chore.get("name", "unknown"),
                        assignment,
                        name_to_id[assignment],
                    )
                else:
                    # Unknown value, keep it but log a warning
                    new_assigned_to.append(assignment)
                    _LOGGER.warning(
                        "Chore '%s' has unknown assigned_to value: '%s'", chore.get("name", "unknown"), assignment
                    )

            if chore_modified:
                chore["assigned_to"] = new_assigned_to
                data_modified = True

        if data_modified:
            _LOGGER.info("Data migration completed: converted child names to IDs in assigned_to")
            await self.async_save()

    async def _migrate_career_score(self) -> None:
        """Initialize career_score for existing installations.

        Sets career_score = total_points_earned for each child (we cannot
        retroactively determine penalty totals from the pruned transaction
        buffer).  Runs once, guarded by _career_score_initialized flag.
        """
        if self._data.get("_career_score_initialized"):
            return

        children = self._data.get("children", [])
        for child_data in children:
            earned = int(child_data.get("total_points_earned", 0) or 0)
            child_data.setdefault("career_score", earned)
            child_data.setdefault("total_penalties_received", 0)

        self._data["_career_score_initialized"] = True
        if children:
            _LOGGER.info("TaskMate: initialized career_score for %d child(ren) from total_points_earned", len(children))
        await self.async_save()

    async def async_save(self) -> None:
        """Persist data, debounced (PERF-3).

        A single TaskMate operation often calls this 2-4× (e.g. mutate a record,
        award points, refresh). ``Store.async_delay_save`` coalesces all of them
        into one disk write after a short delay, instead of serialising the whole
        ``_data`` blob each time. HA flushes any pending delayed write on
        shutdown; ``async_shutdown`` also forces a flush via ``async_save_now``.
        Use ``async_save_now`` when an immediate on-disk write is required.
        """
        # Bump synchronously, before any await: a mutation method calls this with
        # no interleaving await after touching _data, so the new version is
        # visible the instant anything else (e.g. the 30 s poll) can run.
        self._data_version = getattr(self, "_data_version", 0) + 1
        self._store.async_delay_save(lambda: self._data, _SAVE_DEBOUNCE_SECONDS)

    async def async_save_now(self) -> None:
        """Persist data immediately, bypassing the debounce (shutdown/flush)."""
        self._data_version = getattr(self, "_data_version", 0) + 1
        await self._store.async_save(self._data)

    @property
    def data_version(self) -> int:
        """Monotonic version of the in-memory data; bumped on each save."""
        return getattr(self, "_data_version", 0)

    @property
    def data(self) -> dict[str, Any]:
        """Return current data."""
        return self._data

    # Children management
    def get_children(self) -> list[Child]:
        """Get all children."""
        return [Child.from_dict(c) for c in self._data.get("children", [])]

    def get_child(self, child_id: str) -> Child | None:
        """Get a child by ID."""
        for child_data in self._data.get("children", []):
            if child_data.get("id") == child_id:
                return Child.from_dict(child_data)
        return None

    def add_child(self, child: Child) -> None:
        """Add a child."""
        if "children" not in self._data:
            self._data["children"] = []
        self._data["children"].append(child.to_dict())

    def update_child(self, child: Child) -> None:
        """Update a child."""
        children = self._data.get("children", [])
        for i, c in enumerate(children):
            if c.get("id") == child.id:
                children[i] = child.to_dict()
                return
        # If not found, add it
        self.add_child(child)

    def remove_child(self, child_id: str) -> None:
        """Remove a child and cascade-delete their awarded badges."""
        self._data["children"] = [c for c in self._data.get("children", []) if c.get("id") != child_id]
        self.remove_awards_for_child(child_id)

    # Chores management
    def get_chores(self) -> list[Chore]:
        """Get all chores."""
        return [Chore.from_dict(c) for c in self._data.get("chores", [])]

    def get_chore(self, chore_id: str) -> Chore | None:
        """Get a chore by ID."""
        for chore_data in self._data.get("chores", []):
            if chore_data.get("id") == chore_id:
                return Chore.from_dict(chore_data)
        return None

    def add_chore(self, chore: Chore) -> None:
        """Add a chore."""
        if "chores" not in self._data:
            self._data["chores"] = []
        self._data["chores"].append(chore.to_dict())

    def update_chore(self, chore: Chore) -> None:
        """Update a chore."""
        chores = self._data.get("chores", [])
        for i, c in enumerate(chores):
            if c.get("id") == chore.id:
                chores[i] = chore.to_dict()
                return
        self.add_chore(chore)

    def remove_chore(self, chore_id: str) -> None:
        """Remove a chore."""
        self._data["chores"] = [c for c in self._data.get("chores", []) if c.get("id") != chore_id]
        order = self._data.get("chore_display_order", [])
        if chore_id in order:
            order.remove(chore_id)

    def set_chore_display_order(self, order: list[str]) -> None:
        """Set the global chore display order."""
        self._data["chore_display_order"] = list(order)

    # Rewards management
    def get_rewards(self) -> list[Reward]:
        """Get all rewards."""
        return [Reward.from_dict(r) for r in self._data.get("rewards", [])]

    def get_reward(self, reward_id: str) -> Reward | None:
        """Get a reward by ID."""
        for reward_data in self._data.get("rewards", []):
            if reward_data.get("id") == reward_id:
                return Reward.from_dict(reward_data)
        return None

    def add_reward(self, reward: Reward) -> None:
        """Add a reward."""
        if "rewards" not in self._data:
            self._data["rewards"] = []
        self._data["rewards"].append(reward.to_dict())

    def update_reward(self, reward: Reward) -> None:
        """Update a reward."""
        rewards = self._data.get("rewards", [])
        for i, r in enumerate(rewards):
            if r.get("id") == reward.id:
                rewards[i] = reward.to_dict()
                return
        self.add_reward(reward)

    def remove_reward(self, reward_id: str) -> None:
        """Remove a reward."""
        self._data["rewards"] = [r for r in self._data.get("rewards", []) if r.get("id") != reward_id]

    # Completions management
    def get_completions(self) -> list[ChoreCompletion]:
        """Get all chore completions."""
        return [ChoreCompletion.from_dict(c) for c in self._data.get("completions", [])]

    def get_pending_completions(self) -> list[ChoreCompletion]:
        """Get pending (unapproved) completions."""
        return [c for c in self.get_completions() if not c.approved]

    def add_completion(self, completion: ChoreCompletion) -> None:
        """Add a completion record."""
        if "completions" not in self._data:
            self._data["completions"] = []
        self._data["completions"].append(completion.to_dict())

    def update_completion(self, completion: ChoreCompletion) -> None:
        """Update a completion record."""
        completions = self._data.get("completions", [])
        for i, c in enumerate(completions):
            if c.get("id") == completion.id:
                completions[i] = completion.to_dict()
                return
        _LOGGER.warning(
            "update_completion: completion %s not found (possibly pruned mid-update)",
            completion.id,
        )

    def remove_completion(self, completion_id: str) -> None:
        """Remove a completion record."""
        self._data["completions"] = [c for c in self._data.get("completions", []) if c.get("id") != completion_id]

    # Mandatory-miss management (#532)
    def get_mandatory_misses(self) -> list[MandatoryMiss]:
        """Get all pending mandatory-miss review items."""
        return [MandatoryMiss.from_dict(m) for m in self._data.get("mandatory_misses", [])]

    def add_mandatory_miss(self, miss: MandatoryMiss) -> None:
        """Add a mandatory-miss item."""
        self._data.setdefault("mandatory_misses", []).append(miss.to_dict())

    def update_mandatory_miss(self, miss: MandatoryMiss) -> None:
        """Replace a mandatory-miss item by id."""
        items = self._data.get("mandatory_misses", [])
        for i, m in enumerate(items):
            if m.get("id") == miss.id:
                items[i] = miss.to_dict()
                return

    def remove_mandatory_miss(self, miss_id: str) -> None:
        """Remove a mandatory-miss item by id."""
        self._data["mandatory_misses"] = [m for m in self._data.get("mandatory_misses", []) if m.get("id") != miss_id]

    def replace_mandatory_misses(self, misses: list[MandatoryMiss]) -> None:
        """Replace the whole mandatory-miss collection."""
        self._data["mandatory_misses"] = [m.to_dict() for m in misses]

    # Reward claims management
    def get_reward_claims(self) -> list[RewardClaim]:
        """Get all reward claims."""
        return [RewardClaim.from_dict(r) for r in self._data.get("reward_claims", [])]

    def get_pending_reward_claims(self) -> list[RewardClaim]:
        """Get pending (unapproved) reward claims."""
        return [c for c in self.get_reward_claims() if not c.approved]

    def add_reward_claim(self, claim: RewardClaim) -> None:
        """Add a reward claim."""
        if "reward_claims" not in self._data:
            self._data["reward_claims"] = []
        self._data["reward_claims"].append(claim.to_dict())

    def update_reward_claim(self, claim: RewardClaim) -> None:
        """Update a reward claim."""
        claims = self._data.get("reward_claims", [])
        for i, c in enumerate(claims):
            if c.get("id") == claim.id:
                claims[i] = claim.to_dict()
                return
        _LOGGER.warning(
            "update_reward_claim: claim %s not found (possibly removed mid-update)",
            claim.id,
        )

    def remove_reward_claim(self, claim_id: str) -> None:
        """Remove a reward claim."""
        self._data["reward_claims"] = [c for c in self._data.get("reward_claims", []) if c.get("id") != claim_id]

    # Penalties management
    def get_penalties(self) -> list[Penalty]:
        """Get all penalties."""
        return [Penalty.from_dict(p) for p in self._data.get("penalties", [])]

    def get_penalty(self, penalty_id: str) -> Penalty | None:
        """Get a penalty by ID."""
        for p in self._data.get("penalties", []):
            if p.get("id") == penalty_id:
                return Penalty.from_dict(p)
        return None

    def add_penalty(self, penalty) -> None:
        """Add a new penalty."""
        self._data.setdefault("penalties", []).append(penalty.to_dict())

    def update_penalty(self, penalty) -> None:
        """Update an existing penalty."""
        penalties = self._data.get("penalties", [])
        for i, p in enumerate(penalties):
            if p.get("id") == penalty.id:
                penalties[i] = penalty.to_dict()
                return
        penalties.append(penalty.to_dict())

    def remove_penalty(self, penalty_id: str) -> None:
        """Remove a penalty."""
        self._data["penalties"] = [p for p in self._data.get("penalties", []) if p.get("id") != penalty_id]

    # Bonuses management
    def get_bonuses(self) -> list[Bonus]:
        """Get all bonuses."""
        return [Bonus.from_dict(b) for b in self._data.get("bonuses", [])]

    def get_bonus(self, bonus_id: str) -> Bonus | None:
        """Get a bonus by ID."""
        for b in self._data.get("bonuses", []):
            if b.get("id") == bonus_id:
                return Bonus.from_dict(b)
        return None

    def add_bonus(self, bonus) -> None:
        """Add a new bonus."""
        self._data.setdefault("bonuses", []).append(bonus.to_dict())

    def update_bonus(self, bonus) -> None:
        """Update an existing bonus."""
        bonuses = self._data.get("bonuses", [])
        for i, b in enumerate(bonuses):
            if b.get("id") == bonus.id:
                bonuses[i] = bonus.to_dict()
                return
        bonuses.append(bonus.to_dict())

    def remove_bonus(self, bonus_id: str) -> None:
        """Remove a bonus."""
        self._data["bonuses"] = [b for b in self._data.get("bonuses", []) if b.get("id") != bonus_id]

    # Badges management
    def get_badges(self) -> list[Badge]:
        """Get all badges."""
        return [Badge.from_dict(b) for b in self._data.get("badges", [])]

    def get_badge(self, badge_id: str) -> Badge | None:
        """Get a badge by ID."""
        for b in self._data.get("badges", []):
            if b.get("id") == badge_id:
                return Badge.from_dict(b)
        return None

    def add_badge(self, badge: Badge) -> None:
        """Add a new badge."""
        self._data.setdefault("badges", []).append(badge.to_dict())

    def update_badge(self, badge: Badge) -> None:
        """Update an existing badge."""
        badges = self._data.get("badges", [])
        for i, b in enumerate(badges):
            if b.get("id") == badge.id:
                badges[i] = badge.to_dict()
                return
        badges.append(badge.to_dict())

    def remove_badge(self, badge_id: str) -> None:
        """Remove a badge and cascade-delete its awards."""
        self._data["badges"] = [b for b in self._data.get("badges", []) if b.get("id") != badge_id]
        self.remove_awards_for_badge(badge_id)

    # Awarded badges management
    def get_awarded_badges(self) -> list[AwardedBadge]:
        """Get all awarded badges."""
        return [AwardedBadge.from_dict(a) for a in self._data.get("awarded_badges", [])]

    def get_awarded_badges_for_child(self, child_id: str) -> list[AwardedBadge]:
        """Get awarded badges for a specific child."""
        return [a for a in self.get_awarded_badges() if a.child_id == child_id]

    def add_awarded_badge(self, awarded: AwardedBadge) -> None:
        """Add an awarded-badge record."""
        self._data.setdefault("awarded_badges", []).append(awarded.to_dict())

    def remove_awarded_badge(self, awarded_id: str) -> None:
        """Remove an awarded-badge record by id."""
        self._data["awarded_badges"] = [a for a in self._data.get("awarded_badges", []) if a.get("id") != awarded_id]

    def remove_awards_for_badge(self, badge_id: str) -> None:
        """Cascade-delete all awards referencing a badge id."""
        self._data["awarded_badges"] = [
            a for a in self._data.get("awarded_badges", []) if a.get("badge_id") != badge_id
        ]

    def remove_awards_for_child(self, child_id: str) -> None:
        """Cascade-delete all awards for a child id."""
        self._data["awarded_badges"] = [
            a for a in self._data.get("awarded_badges", []) if a.get("child_id") != child_id
        ]

    def has_awarded(self, child_id: str, badge_id: str) -> bool:
        """Check whether the child has already earned this badge."""
        for a in self._data.get("awarded_badges", []):
            if a.get("child_id") == child_id and a.get("badge_id") == badge_id:
                return True
        return False

    def _seed_builtin_badges(self, *, is_fresh: bool) -> None:
        """Seed the built-in badge catalogue.

        On fresh install (is_fresh=True): add all built-ins and set the
        backfill_pending flag so existing kid state can be retro-awarded silently.
        On existing install (is_fresh=False): add only built-ins missing from
        storage; preserve parent customisations; do not set backfill flag.
        Idempotent.
        """
        from .coord_badges import BUILTIN_CATALOGUE

        existing = self._data.get("badges", [])
        existing_ids = {b.get("id") for b in existing}

        for builtin in BUILTIN_CATALOGUE:
            if builtin.id not in existing_ids:
                existing.append(builtin.to_dict())

        self._data["badges"] = existing

        if is_fresh:
            self._data["badges_backfill_pending"] = True

    def _migrate_nav_url_default(self) -> None:
        """Rewrite the broken v5.0.2 notification tap target.

        v5.0.2 shipped "/taskmate" as the default, but the panel lives at
        /taskmate-admin (/taskmate is the static-files prefix and 403s), so a
        persisted "/taskmate" is broken for every install. Idempotent.
        """
        settings = self._data.get("settings", {}) or {}
        if settings.get("notification_nav_url") == "/taskmate":
            settings["notification_nav_url"] = DEFAULT_NOTIFICATION_NAV_URL
        for cfg in (self._data.get("notification_config", {}) or {}).values():
            if isinstance(cfg, dict) and cfg.get("nav_url") == "/taskmate":
                cfg["nav_url"] = DEFAULT_NOTIFICATION_NAV_URL

    def _run_notifications_migration(self) -> None:
        """Seed parent_recipients + notification_config from legacy notify_service.

        Idempotent: a guard flag is written by the caller in async_load.
        """
        legacy = (self._data.get("settings", {}) or {}).get("notify_service", "")
        parents: list[dict] = []
        existing_parents = self._data.setdefault("parent_recipients", [])
        existing_services = {r.get("notify_service") for r in existing_parents}
        if legacy and legacy not in existing_services:
            seeded = ParentRecipient(name="Parent", notify_service=legacy)
            parents.append(seeded.to_dict())
        existing_parents.extend(parents)

        # Defaults: previously-active types ON (preserves existing behaviour);
        # new types OFF (no surprise pings on upgrade).
        defaults_on = {"pending_chore_approval", "pending_reward_claim", "badge_earned"}
        defaults_off = {"bedtime_reminder", "streak_at_risk", "all_chores_done"}
        nc = self._data.setdefault("notification_config", {})
        seeded_parent_id = parents[0]["id"] if parents else None

        for tid in defaults_on:
            cfg = NotificationConfig(
                type_id=tid,
                master_enabled=True,
                routes={seeded_parent_id: NotificationRoute(enabled=True)} if seeded_parent_id else {},
            )
            nc[tid] = cfg.to_dict()

        for tid in defaults_off:
            nc[tid] = NotificationConfig(type_id=tid, master_enabled=False).to_dict()

    # --- parent recipients ---
    def get_parent_recipients(self) -> list[ParentRecipient]:
        return [ParentRecipient.from_dict(d) for d in self._data.get("parent_recipients", [])]

    def upsert_parent_recipient(self, p: ParentRecipient) -> None:
        rows = self._data.setdefault("parent_recipients", [])
        for i, row in enumerate(rows):
            if row.get("id") == p.id:
                rows[i] = p.to_dict()
                return
        rows.append(p.to_dict())

    def delete_parent_recipient(self, parent_id: str) -> None:
        self._data["parent_recipients"] = [
            r for r in self._data.get("parent_recipients", []) if r.get("id") != parent_id
        ]

    # --- notification config ---
    def get_notification_config(self, type_id: str) -> NotificationConfig:
        raw = (self._data.get("notification_config", {}) or {}).get(type_id)
        if not raw:
            return NotificationConfig(type_id=type_id)
        return NotificationConfig.from_dict(raw)

    def set_notification_master(self, type_id: str, enabled: bool) -> None:
        cfg = self.get_notification_config(type_id)
        cfg.master_enabled = enabled
        self._data.setdefault("notification_config", {})[type_id] = cfg.to_dict()

    def set_notification_nav_url(self, type_id: str, nav_url: str) -> None:
        cfg = self.get_notification_config(type_id)
        cfg.nav_url = nav_url
        self._data.setdefault("notification_config", {})[type_id] = cfg.to_dict()

    def set_notification_group(self, type_id: str, group: str) -> None:
        cfg = self.get_notification_config(type_id)
        cfg.group = group
        self._data.setdefault("notification_config", {})[type_id] = cfg.to_dict()

    def set_notification_route(self, type_id: str, recipient_id: str, route: NotificationRoute) -> None:
        cfg = self.get_notification_config(type_id)
        cfg.routes[recipient_id] = route
        self._data.setdefault("notification_config", {})[type_id] = cfg.to_dict()

    def get_all_notification_configs(self) -> dict[str, NotificationConfig]:
        return {
            tid: NotificationConfig.from_dict(raw)
            for tid, raw in (self._data.get("notification_config", {}) or {}).items()
        }

    # --- custom notifications ---
    def get_custom_notifications(self) -> list[CustomNotification]:
        return [CustomNotification.from_dict(d) for d in self._data.get("custom_notifications", [])]

    def upsert_custom_notification(self, n: CustomNotification) -> None:
        rows = self._data.setdefault("custom_notifications", [])
        for i, row in enumerate(rows):
            if row.get("id") == n.id:
                rows[i] = n.to_dict()
                return
        rows.append(n.to_dict())

    def delete_custom_notification(self, custom_id: str) -> None:
        self._data["custom_notifications"] = [
            r for r in self._data.get("custom_notifications", []) if r.get("id") != custom_id
        ]

    # --- streak-at-risk cutoff ---
    def get_streak_at_risk_cutoff(self) -> str:
        return (self._data.get("settings", {}) or {}).get("streak_at_risk_cutoff_time", "20:00")

    def set_streak_at_risk_cutoff(self, hhmm: str) -> None:
        self._data.setdefault("settings", {})["streak_at_risk_cutoff_time"] = hhmm

    def get_parent_user_ids(self) -> list[str]:
        """HA user IDs granted the non-admin TaskMate parent role (#661)."""
        raw = (self._data.get("settings", {}) or {}).get("parent_user_ids", [])
        if not isinstance(raw, list):
            return []
        return [x for x in raw if isinstance(x, str) and x]

    def set_parent_user_ids(self, ids: list[str]) -> None:
        """Replace the parent role list (deduped, order-preserving, strings only)."""
        seen: list[str] = []
        for x in ids or []:
            if isinstance(x, str) and x and x not in seen:
                seen.append(x)
        self._data.setdefault("settings", {})["parent_user_ids"] = seen

    # --- mandatory reminder escalation (FEAT-6) ---
    def get_escalation_reminder_minutes(self) -> int:
        """Minutes after a mandatory miss before the child reminder escalates."""
        try:
            return max(1, int((self._data.get("settings", {}) or {}).get("mandatory_escalation_reminder_minutes", 30)))
        except (TypeError, ValueError):
            return 30

    def get_escalation_parent_minutes(self) -> int:
        """Minutes after a mandatory miss before the parent alert escalates."""
        try:
            return max(1, int((self._data.get("settings", {}) or {}).get("mandatory_escalation_parent_minutes", 120)))
        except (TypeError, ValueError):
            return 120

    def set_escalation_minutes(self, reminder_minutes: int, parent_minutes: int) -> None:
        s = self._data.setdefault("settings", {})
        s["mandatory_escalation_reminder_minutes"] = max(1, int(reminder_minutes))
        s["mandatory_escalation_parent_minutes"] = max(1, int(parent_minutes))

    # Task groups management
    def get_task_groups(self) -> list[TaskGroup]:
        """Get all task groups."""
        return [TaskGroup.from_dict(g) for g in self._data.get("task_groups", [])]

    def get_task_group(self, group_id: str) -> TaskGroup | None:
        """Get a task group by ID."""
        for g in self._data.get("task_groups", []):
            if g.get("id") == group_id:
                return TaskGroup.from_dict(g)
        return None

    def get_task_group_for_chore(self, chore_id: str) -> TaskGroup | None:
        """Return the group that contains this chore, or None."""
        for g in self._data.get("task_groups", []):
            if chore_id in g.get("chore_ids", []):
                return TaskGroup.from_dict(g)
        return None

    def add_task_group(self, group: TaskGroup) -> None:
        """Add a task group."""
        self._data.setdefault("task_groups", []).append(group.to_dict())

    def update_task_group(self, group: TaskGroup) -> None:
        """Update an existing task group."""
        groups = self._data.get("task_groups", [])
        for i, g in enumerate(groups):
            if g.get("id") == group.id:
                groups[i] = group.to_dict()
                return
        groups.append(group.to_dict())

    def remove_task_group(self, group_id: str) -> None:
        """Remove a task group."""
        self._data["task_groups"] = [g for g in self._data.get("task_groups", []) if g.get("id") != group_id]

    def remove_chore_from_task_groups(self, chore_id: str) -> None:
        """Strip a chore ID from every group (used on chore delete)."""
        for g in self._data.get("task_groups", []):
            if chore_id in g.get("chore_ids", []):
                g["chore_ids"] = [c for c in g["chore_ids"] if c != chore_id]

    # Scheduled config changes (#675)
    def get_scheduled_changes(self) -> list[ScheduledChange]:
        """All scheduled changes, pending and already applied."""
        return [ScheduledChange.from_dict(c) for c in self._data.get("scheduled_changes", [])]

    def get_scheduled_change(self, change_id: str) -> ScheduledChange | None:
        for c in self._data.get("scheduled_changes", []):
            if c.get("id") == change_id:
                return ScheduledChange.from_dict(c)
        return None

    def add_scheduled_change(self, change: ScheduledChange) -> None:
        self._data.setdefault("scheduled_changes", []).append(change.to_dict())

    def update_scheduled_change(self, change: ScheduledChange) -> None:
        changes = self._data.setdefault("scheduled_changes", [])
        for i, c in enumerate(changes):
            if c.get("id") == change.id:
                changes[i] = change.to_dict()
                return
        changes.append(change.to_dict())

    def remove_scheduled_change(self, change_id: str) -> None:
        self._data["scheduled_changes"] = [
            c for c in self._data.get("scheduled_changes", []) if c.get("id") != change_id
        ]

    def remove_scheduled_changes_for_chore(self, chore_id: str) -> None:
        """Drop a deleted chore's queued changes so they can't fire on nothing."""
        self._data["scheduled_changes"] = [
            c for c in self._data.get("scheduled_changes", []) if c.get("chore_id") != chore_id
        ]

    # Points transactions management
    def get_points_transactions(self) -> list[PointsTransaction]:
        """Get all points transactions."""
        return [PointsTransaction.from_dict(t) for t in self._data.get("points_transactions", [])]

    def add_points_transaction(self, transaction: PointsTransaction) -> None:
        """Add a points transaction record."""
        if "points_transactions" not in self._data:
            self._data["points_transactions"] = []
        self._data["points_transactions"].append(transaction.to_dict())

        # Accumulate season (leaderboard) points from every positive award here,
        # the single choke point all awards flow through — the rolling 200-cap on
        # transactions makes them unreliable for a monthly total (FEAT-2).
        if transaction.points > 0:
            self.record_season_points(transaction.child_id, transaction.points, transaction.created_at)

        # Keep only the last 200 transactions to avoid unbounded storage growth
        if len(self._data["points_transactions"]) > 200:
            self._data["points_transactions"] = self._data["points_transactions"][-200:]

    # ── Leaderboard seasons (FEAT-2) ──────────────────────────────────────
    def record_season_points(self, child_id: str, points: int, when) -> None:
        """Add ``points`` to ``child_id``'s tally for the calendar month of ``when``."""
        if points <= 0 or not child_id:
            return
        key = when.strftime("%Y-%m")
        seasons = self._data.setdefault("season_points", {})
        month = seasons.setdefault(key, {})
        month[child_id] = month.get(child_id, 0) + int(points)
        # Keep ~13 months so year-over-year stays available without unbounded growth.
        if len(seasons) > 13:
            for stale in sorted(seasons)[:-13]:
                del seasons[stale]

    def get_season_points(self, ym: str) -> dict[str, int]:
        """Per-child points earned in calendar month ``ym`` ("YYYY-MM")."""
        return dict((self._data.get("season_points", {}) or {}).get(ym, {}))

    # ── Allowance payout ledger (FEAT-3) ──────────────────────────────────
    def get_allowance_payouts(self) -> list[dict]:
        """Recorded allowance payouts, oldest first."""
        return list(self._data.get("allowance_payouts", []))

    def add_allowance_payout(self, entry: dict) -> None:
        """Append an allowance payout; cap the ledger at 500 entries."""
        ledger = self._data.setdefault("allowance_payouts", [])
        ledger.append(entry)
        if len(ledger) > 500:
            del ledger[:-500]

    def get_season_champions(self) -> list[dict]:
        """Recorded monthly champions, oldest first."""
        return list(self._data.get("season_champions", []))

    def add_season_champion(self, entry: dict) -> None:
        """Record a month's champion once; cap history at 24 months."""
        champs = self._data.setdefault("season_champions", [])
        if any(c.get("month") == entry.get("month") for c in champs):
            return
        champs.append(entry)
        if len(champs) > 24:
            del champs[:-24]

    def remove_points_transaction(self, transaction_id: str) -> bool:
        """Remove a points transaction by id. Returns True if one was removed."""
        txns = self._data.get("points_transactions", [])
        for i, t in enumerate(txns):
            if t.get("id") == transaction_id:
                del txns[i]
                return True
        return False

    # ── Admin audit log ──────────────────────────────────────────────────
    def get_audit_log(self) -> list[dict]:
        """Return admin audit entries, newest first."""
        return list(reversed(self._data.get("audit_log", [])))

    def add_audit_entry(self, entry: dict) -> None:
        """Append an admin audit entry, capping the log at 500 (oldest dropped)."""
        log = self._data.setdefault("audit_log", [])
        log.append(entry)
        if len(log) > 500:
            del log[:-500]

    def clear_audit_log(self) -> None:
        """Remove all audit entries."""
        self._data["audit_log"] = []

    # ── Chore swap requests ──────────────────────────────────────────────
    def get_swap_requests(self) -> list[dict]:
        return list(self._data.get("swap_requests", []))

    def add_swap_request(self, req: dict) -> None:
        self._data.setdefault("swap_requests", []).append(req)

    def update_swap_request(self, req_id: str, **changes) -> bool:
        for r in self._data.get("swap_requests", []):
            if r.get("id") == req_id:
                r.update(changes)
                return True
        return False

    def remove_swap_request(self, req_id: str) -> bool:
        reqs = self._data.get("swap_requests", [])
        for i, r in enumerate(reqs):
            if r.get("id") == req_id:
                del reqs[i]
                return True
        return False

    def _drop_dead_swap_requests(self) -> None:
        """Prune swap requests that are settled or dangling (#783, #785).

        Called from ``async_load``. A request survives only when it is still
        pending *and* every id it names still resolves — the chore, the
        requester, and ``from_child_id`` when set (it is "" for a chore with no
        cached assignee yet, which is a normal request, not a dead reference).
        """
        swap_requests = self._data.get("swap_requests")
        if not swap_requests:
            return

        chore_ids = {c.get("id") for c in self._data.get("chores", [])}
        child_ids = {c.get("id") for c in self._data.get("children", [])}

        def _alive(req: dict) -> bool:
            if req.get("status") != "pending":
                return False
            if req.get("chore_id") not in chore_ids:
                return False
            if req.get("requester_id") not in child_ids:
                return False
            from_id = req.get("from_child_id") or ""
            return not from_id or from_id in child_ids

        kept = [r for r in swap_requests if _alive(r)]
        if len(kept) != len(swap_requests):
            _LOGGER.debug(
                "Dropped %d settled or orphaned swap request(s) from storage",
                len(swap_requests) - len(kept),
            )
            self._data["swap_requests"] = kept

    def remove_swap_requests_for_chore(self, chore_id: str) -> None:
        """Drop a deleted chore's swap requests so they can't linger in the
        approval queue with nothing to approve."""
        self._data["swap_requests"] = [r for r in self._data.get("swap_requests", []) if r.get("chore_id") != chore_id]

    def remove_swap_requests_for_child(self, child_id: str) -> None:
        """Drop swap requests a deleted child is either side of.

        Both ends matter: the requester is who the handover goes *to*, and
        `from_child_id` is who it comes from and is rendered in the queue.
        Either being gone makes the request undeliverable.
        """
        self._data["swap_requests"] = [
            r
            for r in self._data.get("swap_requests", [])
            if r.get("requester_id") != child_id and r.get("from_child_id") != child_id
        ]

    # ── Quests (chore chains) ────────────────────────────────────────────
    def get_quests(self) -> list[Quest]:
        return [Quest.from_dict(q) for q in self._data.get("quests", [])]

    def get_quest(self, quest_id: str) -> Quest | None:
        for q in self._data.get("quests", []):
            if q.get("id") == quest_id:
                return Quest.from_dict(q)
        return None

    def add_quest(self, quest: Quest) -> None:
        self._data.setdefault("quests", []).append(quest.to_dict())

    def update_quest(self, quest: Quest) -> None:
        quests = self._data.get("quests", [])
        for i, q in enumerate(quests):
            if q.get("id") == quest.id:
                quests[i] = quest.to_dict()
                return
        self.add_quest(quest)

    def remove_quest(self, quest_id: str) -> None:
        self._data["quests"] = [q for q in self._data.get("quests", []) if q.get("id") != quest_id]
        # Drop any progress tracked for this quest
        prog = self._data.get("quest_progress", {})
        prog.pop(quest_id, None)

    def get_quest_progress(self) -> dict:
        """All quest progress: {quest_id: {child_id: {step, completed_count, last_completed}}}."""
        return self._data.setdefault("quest_progress", {})

    def get_quest_child_progress(self, quest_id: str, child_id: str) -> dict:
        return self.get_quest_progress().setdefault(quest_id, {}).get(child_id, {})

    def set_quest_child_progress(self, quest_id: str, child_id: str, progress: dict) -> None:
        self.get_quest_progress().setdefault(quest_id, {})[child_id] = progress

    def remove_quest_progress_for_child(self, child_id: str) -> None:
        for child_map in self.get_quest_progress().values():
            child_map.pop(child_id, None)

    # ── Challenges (daily / weekly) ──────────────────────────────────────
    def get_challenges(self) -> list[Challenge]:
        return [Challenge.from_dict(c) for c in self._data.get("challenges", [])]

    def get_challenge(self, challenge_id: str) -> Challenge | None:
        for c in self._data.get("challenges", []):
            if c.get("id") == challenge_id:
                return Challenge.from_dict(c)
        return None

    def add_challenge(self, challenge: Challenge) -> None:
        self._data.setdefault("challenges", []).append(challenge.to_dict())

    def update_challenge(self, challenge: Challenge) -> None:
        items = self._data.get("challenges", [])
        for i, c in enumerate(items):
            if c.get("id") == challenge.id:
                items[i] = challenge.to_dict()
                return
        self.add_challenge(challenge)

    def remove_challenge(self, challenge_id: str) -> None:
        self._data["challenges"] = [c for c in self._data.get("challenges", []) if c.get("id") != challenge_id]
        self._data.get("challenge_progress", {}).pop(challenge_id, None)

    def get_challenge_progress(self) -> dict:
        """All challenge progress: {challenge_id: {child_id: {period, awarded}}}."""
        return self._data.setdefault("challenge_progress", {})

    def get_challenge_child_progress(self, challenge_id: str, child_id: str) -> dict:
        return self.get_challenge_progress().setdefault(challenge_id, {}).get(child_id, {})

    def set_challenge_child_progress(self, challenge_id: str, child_id: str, progress: dict) -> None:
        self.get_challenge_progress().setdefault(challenge_id, {})[child_id] = progress

    def remove_challenge_progress_for_child(self, child_id: str) -> None:
        for child_map in self.get_challenge_progress().values():
            child_map.pop(child_id, None)

    # ── Backup / restore ─────────────────────────────────────────────────
    def export_data(self) -> dict:
        """Return a deep copy of the full stored data (for backup/export)."""
        import copy

        return copy.deepcopy(self._data)

    def import_data(self, data: dict) -> None:
        """Replace all stored data with ``data`` (restore from a backup).

        Core collection keys are ensured so downstream readers never KeyError on
        a partial import.
        """
        import copy

        if not isinstance(data, dict):
            raise ValueError("import data must be an object")
        self._data = copy.deepcopy(data)
        list_keys = (
            "children",
            "chores",
            "rewards",
            "penalties",
            "bonuses",
            "task_groups",
            "completions",
            "mandatory_misses",
            "reward_claims",
            "points_transactions",
            "pool_allocations",
            "badges",
            "awarded_badges",
            "parent_recipients",
            "audit_log",
            "timed_sessions",
            "quests",
            "challenges",
        )
        for k in list_keys:
            if not isinstance(self._data.get(k), list):
                self._data[k] = []
        if not isinstance(self._data.get("settings"), dict):
            self._data["settings"] = {}
        if not isinstance(self._data.get("quest_progress"), dict):
            self._data["quest_progress"] = {}
        if not isinstance(self._data.get("challenge_progress"), dict):
            self._data["challenge_progress"] = {}
        self._sanitize_imported_records()

    def _sanitize_imported_records(self) -> None:
        """Re-validate untrusted inner records after a full-replace import (SEC-5).

        ``import_data`` deep-copies the payload in with only top-level coercion,
        so a crafted backup could smuggle a ``photo_url`` that bypasses the
        ``is_taskmate_photo_url`` gate enforced at the ``complete_chore``
        boundary. Strip any completion ``photo_url`` that isn't one of our own
        well-formed photo URLs so the panel never renders a foreign/dangerous one.
        """
        from .photos import is_taskmate_photo_url

        for comp in self._data.get("completions", []):
            if not isinstance(comp, dict):
                continue
            url = comp.get("photo_url")
            if url and not is_taskmate_photo_url(url):
                _LOGGER.warning(
                    "Import: dropped non-TaskMate photo_url on completion %s",
                    comp.get("id", "?"),
                )
                comp["photo_url"] = ""

    def replace_completions(self, completions: list[ChoreCompletion]) -> None:
        """Replace all completions with the given list."""
        self._data["completions"] = [c.to_dict() for c in completions]

    def remove_completions_for_child(self, child_id: str) -> None:
        """Remove all completions for a given child."""
        self._data["completions"] = [c for c in self._data.get("completions", []) if c.get("child_id") != child_id]

    def remove_completions_for_chore(self, chore_id: str) -> None:
        """Remove all completions for a given chore."""
        self._data["completions"] = [c for c in self._data.get("completions", []) if c.get("chore_id") != chore_id]

    def remove_reward_claims_for_child(self, child_id: str) -> None:
        """Remove all reward claims for a given child."""
        self._data["reward_claims"] = [c for c in self._data.get("reward_claims", []) if c.get("child_id") != child_id]

    def remove_reward_claims_for_reward(self, reward_id: str) -> None:
        """Remove all reward claims for a given reward."""
        self._data["reward_claims"] = [
            c for c in self._data.get("reward_claims", []) if c.get("reward_id") != reward_id
        ]

    # Pool allocations management (v3.0 pool mode)
    def get_pool_allocations(self) -> list[PoolAllocation]:
        """Get all pool allocations."""
        return [PoolAllocation.from_dict(a) for a in self._data.get("pool_allocations", [])]

    def get_pool_allocation(self, child_id: str, reward_id: str) -> PoolAllocation | None:
        """Get a pool allocation for a specific (child, reward) pair."""
        for a in self._data.get("pool_allocations", []):
            if a.get("child_id") == child_id and a.get("reward_id") == reward_id:
                return PoolAllocation.from_dict(a)
        return None

    def upsert_pool_allocation(self, allocation: PoolAllocation) -> None:
        """Insert or update a pool allocation (keyed by child_id + reward_id)."""
        allocations = self._data.setdefault("pool_allocations", [])
        for i, a in enumerate(allocations):
            if a.get("child_id") == allocation.child_id and a.get("reward_id") == allocation.reward_id:
                allocations[i] = allocation.to_dict()
                return
        allocations.append(allocation.to_dict())

    def remove_pool_allocation(self, child_id: str, reward_id: str) -> None:
        """Remove a pool allocation for a specific (child, reward) pair."""
        self._data["pool_allocations"] = [
            a
            for a in self._data.get("pool_allocations", [])
            if not (a.get("child_id") == child_id and a.get("reward_id") == reward_id)
        ]

    def remove_pool_allocations_for_child(self, child_id: str) -> None:
        """Remove all pool allocations for a given child."""
        self._data["pool_allocations"] = [
            a for a in self._data.get("pool_allocations", []) if a.get("child_id") != child_id
        ]

    def remove_pool_allocations_for_reward(self, reward_id: str) -> None:
        """Remove all pool allocations for a given reward."""
        self._data["pool_allocations"] = [
            a for a in self._data.get("pool_allocations", []) if a.get("reward_id") != reward_id
        ]

    def get_total_allocated_for_child(self, child_id: str) -> int:
        """Return the sum of this child's allocations across all rewards."""
        return sum(
            a.get("allocated_points", 0)
            for a in self._data.get("pool_allocations", [])
            if a.get("child_id") == child_id
        )

    def get_total_allocated_for_reward(self, reward_id: str) -> int:
        """Return the sum of all children's allocations for a specific reward (used for jackpots)."""
        return sum(
            a.get("allocated_points", 0)
            for a in self._data.get("pool_allocations", [])
            if a.get("reward_id") == reward_id
        )

    def remove_transactions_for_child(self, child_id: str) -> None:
        """Remove all points transactions for a given child."""
        self._data["points_transactions"] = [
            t for t in self._data.get("points_transactions", []) if t.get("child_id") != child_id
        ]

    def remove_last_completed_for_child(self, child_id: str) -> None:
        """Remove all last_completed records for a given child."""
        lc = self._data.get("last_completed", {})
        for chore_id in list(lc.keys()):
            lc[chore_id].pop(child_id, None)
            if not lc[chore_id]:
                del lc[chore_id]

    def remove_last_completed_for_chore(self, chore_id: str) -> None:
        """Remove last_completed records for a given chore."""
        self._data.get("last_completed", {}).pop(chore_id, None)

    # Last completed store — used for recurrence window checks
    def get_last_completed(self, chore_id: str, child_id: str) -> dict:
        """Get last_completed record for a chore/child pair.

        Returns dict with 'current' and 'previous' ISO datetime strings,
        or empty dict if never completed.
        """
        return self._data.get("last_completed", {}).get(chore_id, {}).get(child_id, {})

    def set_last_completed(self, chore_id: str, child_id: str, completed_at_iso: str) -> None:
        """Record a new completion — shifts current to previous."""
        if "last_completed" not in self._data:
            self._data["last_completed"] = {}
        if chore_id not in self._data["last_completed"]:
            self._data["last_completed"][chore_id] = {}

        existing = self._data["last_completed"][chore_id].get(child_id, {})
        current = existing.get("current")

        self._data["last_completed"][chore_id][child_id] = {
            "current": completed_at_iso,
            "previous": current,  # may be None
        }

    def undo_last_completed(self, chore_id: str, child_id: str) -> None:
        """Undo the most recent completion — restores previous as current."""
        record = self._data.get("last_completed", {}).get(chore_id, {}).get(child_id)
        if not record:
            return

        previous = record.get("previous")
        if previous:
            self._data["last_completed"][chore_id][child_id] = {
                "current": previous,
                "previous": None,
            }
        else:
            # No previous — remove the record entirely
            del self._data["last_completed"][chore_id][child_id]
            if not self._data["last_completed"][chore_id]:
                del self._data["last_completed"][chore_id]

    # Timed sessions management
    def get_timed_sessions(self) -> list[TimedSession]:
        """Get all timed sessions."""
        return [TimedSession.from_dict(s) for s in self._data.get("timed_sessions", [])]

    def get_timed_session(self, chore_id: str, child_id: str, session_date: str) -> TimedSession | None:
        """Get a timed session for a specific chore/child/date."""
        for s in self._data.get("timed_sessions", []):
            if (
                s.get("chore_id") == chore_id
                and s.get("child_id") == child_id
                and s.get("session_date") == session_date
            ):
                return TimedSession.from_dict(s)
        return None

    def get_active_timed_session(self, chore_id: str, child_id: str) -> TimedSession | None:
        """Get a running or paused session for a chore/child pair."""
        for s in self._data.get("timed_sessions", []):
            if (
                s.get("chore_id") == chore_id
                and s.get("child_id") == child_id
                and s.get("state") in ("running", "paused")
            ):
                return TimedSession.from_dict(s)
        return None

    def save_timed_session(self, session: TimedSession) -> None:
        """Insert or update a timed session."""
        sessions = self._data.setdefault("timed_sessions", [])
        for i, s in enumerate(sessions):
            if s.get("id") == session.id:
                sessions[i] = session.to_dict()
                return
        sessions.append(session.to_dict())

    def remove_timed_session(self, session_id: str) -> None:
        """Remove a timed session."""
        self._data["timed_sessions"] = [s for s in self._data.get("timed_sessions", []) if s.get("id") != session_id]

    # Generic settings
    def get_setting(self, key: str, default: Any = "") -> Any:
        """Get a generic setting value (may be a bool/number/list, not just str)."""
        return self._data.get("settings", {}).get(key, default)

    def set_setting(self, key: str, value: Any) -> None:
        """Set a generic setting value."""
        if "settings" not in self._data:
            self._data["settings"] = {}
        self._data["settings"][key] = value

    def get_settings(self) -> dict[str, Any]:
        """The whole settings dict (live reference; callers must not assume a copy)."""
        return self._data.get("settings", {}) or {}

    # ── Typed top-level flags (ARCH-1) ────────────────────────────────────
    # These live at the root of _data (not under "settings"). Accessors keep the
    # key names + defaults in one place so setup/migration logic can't drift.
    def is_initial_setup_done(self) -> bool:
        return bool(self._data.get("_initial_setup_done"))

    def mark_initial_setup_done(self) -> None:
        self._data["_initial_setup_done"] = True

    def is_badges_backfill_pending(self) -> bool:
        return bool(self._data.get("badges_backfill_pending"))

    def clear_badges_backfill_pending(self) -> None:
        self._data.pop("badges_backfill_pending", None)

    # Settings
    def get_points_name(self) -> str:
        """Get the points currency name."""
        return self._data.get("points_name", "Stars")

    def set_points_name(self, name: str) -> None:
        """Set the points currency name."""
        self._data["points_name"] = name

    def get_points_icon(self) -> str:
        """Get the points icon."""
        return self._data.get("points_icon", "mdi:star")

    def set_points_icon(self, icon: str) -> None:
        """Set the points icon."""
        self._data["points_icon"] = icon

    # Career score history management
    def get_career_score_history(self, child_id: str) -> list[dict]:
        """Get career score history for a child."""
        return list(self._data.get("career_score_history", {}).get(child_id, []))

    def append_career_score_snapshot(self, child_id: str, date_str: str, score: int) -> None:
        """Upsert a daily career score snapshot for a child.

        If an entry for the given date already exists, its score is updated
        (last-write-wins).  Entries older than 90 days are pruned.
        """
        history = self._data.setdefault("career_score_history", {})
        entries = history.setdefault(child_id, [])

        # Upsert: update existing date or append
        for entry in entries:
            if entry.get("date") == date_str:
                entry["score"] = score
                break
        else:
            entries.append({"date": date_str, "score": score})

        # Prune entries older than 90 days
        cutoff = (date.today() - timedelta(days=90)).isoformat()
        history[child_id] = [e for e in entries if e.get("date", "") >= cutoff]

    def remove_career_score_history_for_child(self, child_id: str) -> None:
        """Remove all career score history for a child."""
        history = self._data.get("career_score_history", {})
        history.pop(child_id, None)

    def prune_all_done_flags(self, keep_date: str) -> None:
        """Drop all-chores-done flags for dates other than keep_date.

        Keys are "all_done_<child_id>_<isodate>".
        """
        flags = self._data.get("all_done_flags", {})
        for key in list(flags):
            if not key.endswith(keep_date):
                flags.pop(key, None)

    # Template management
    def get_custom_templates(self) -> list[dict]:
        """Get all custom (user-created) templates."""
        return list(self._data.get("templates", []))

    def get_custom_template(self, template_id: str) -> dict | None:
        """Get a single custom template by ID."""
        for tpl in self._data.get("templates", []):
            if tpl.get("id") == template_id:
                return dict(tpl)
        return None

    def add_custom_template(self, template: dict) -> None:
        """Add a custom template."""
        if "templates" not in self._data:
            self._data["templates"] = []
        self._data["templates"].append(template)

    def update_custom_template(self, template_id: str, updates: dict) -> None:
        """Update a custom template's fields."""
        templates = self._data.get("templates", [])
        for tpl in templates:
            if tpl.get("id") == template_id:
                tpl.update(updates)
                return
        raise ValueError(f"Template {template_id} not found")

    def remove_custom_template(self, template_id: str) -> None:
        """Remove a custom template."""
        templates = self._data.get("templates", [])
        original_len = len(templates)
        self._data["templates"] = [t for t in templates if t.get("id") != template_id]
        if len(self._data["templates"]) == original_len:
            raise ValueError(f"Template {template_id} not found")
