"""Projection builder.

Generates the site-facing JSON files from canonical warehouse data
in SQLite.  Each projection run produces a timestamped output
directory with a ``current`` symlink pointing to the latest.

Layout::

    <build_root>/projection/<YYYY-MM-DD-HHMMSS>/
        status.json
        history/
            vitals.json
            sleep.json
            nutrition.json
            measurements.json
            activity.json

    <build_root>/projection/current → <latest>
"""

import json
import os
import sqlite3
from datetime import datetime, timezone
from pathlib import Path

from byteb4rb1e.homeostat.projections.config import (
    ProjectionConfig,
)


class ProjectionBuilder:
    """Builds site JSON files from a hydrated SQLite database."""

    def __init__(
        self,
        db: sqlite3.Connection,
        build_root: Path,
        config: ProjectionConfig | None = None,
    ) -> None:
        self._db = db
        self._db.row_factory = sqlite3.Row
        self._build_root = build_root
        self._config = config or ProjectionConfig()

    def build(self, date: str | None = None) -> Path:
        """Run a full projection build.

        If *date* is given, status.json reflects that date.
        Otherwise uses the most recent date in the warehouse.

        Returns the path to the timestamped output directory.
        """
        ts = datetime.now(timezone.utc).strftime(
            "%Y-%m-%d-%H%M%S"
        )
        out = self._build_root / "projection" / ts
        history_dir = out / "history"
        history_dir.mkdir(parents=True, exist_ok=True)

        target_date = date or self._latest_date()
        if not target_date:
            return out

        self._write(
            out / "status.json",
            self._project_status(target_date),
        )
        self._write(
            history_dir / "vitals.json",
            self._project_vitals_history(),
        )
        self._write(
            history_dir / "sleep.json",
            self._project_sleep_history(),
        )
        self._write(
            history_dir / "nutrition.json",
            self._project_nutrition_history(),
        )
        self._write(
            history_dir / "measurements.json",
            self._project_measurements_history(),
        )
        self._write(
            history_dir / "activity.json",
            self._project_activity_history(),
        )

        self._project_intraday(history_dir)

        self._update_symlink(out)

        return out

    # -- Status --------------------------------------------------------------

    def _project_status(self, date: str) -> dict:
        return {
            "last_updated": datetime.now(
                timezone.utc
            ).isoformat(),
            "vitals": self._status_vitals(date),
            "sleep": self._status_sleep(date),
            "activity": self._status_activity(date),
            "nutrition": self._status_nutrition(date),
            "measurements": self._status_measurements(date),
        }

    def _status_vitals(self, date: str) -> dict:
        row = self._fetchone(
            """
            SELECT steps FROM step_snapshot
            WHERE date = ?
            ORDER BY observed_at DESC LIMIT 1
            """,
            (date,),
        )
        steps = row["steps"] if row else 0

        row = self._fetchone(
            """
            SELECT calories FROM calorie_snapshot
            WHERE date = ?
            ORDER BY observed_at DESC LIMIT 1
            """,
            (date,),
        )
        calories = row["calories"] if row else 0

        row = self._fetchone(
            """
            SELECT bpm FROM heart_rate_sample
            WHERE date = ?
            ORDER BY time DESC LIMIT 1
            """,
            (date,),
        )
        current_hr = row["bpm"] if row else 0

        row = self._fetchone(
            """
            SELECT fairly_active, very_active
            FROM active_minutes_snapshot
            WHERE date = ?
            ORDER BY observed_at DESC LIMIT 1
            """,
            (date,),
        )
        active = (
            (row["fairly_active"] + row["very_active"])
            if row else 0
        )

        steps_week = self._fetchall(
            """
            SELECT date, MAX(steps) as steps
            FROM step_snapshot
            WHERE date > date(?, '-7 days')
              AND date <= ?
            GROUP BY date
            ORDER BY date
            """,
            (date, date),
        )

        goal_row = self._fetchone(
            """
            SELECT steps_goal, active_minutes_goal
            FROM goal_snapshot
            WHERE date = ?
            ORDER BY observed_at DESC LIMIT 1
            """,
            (date,),
        )

        rhr_row = self._fetchone(
            """
            SELECT bpm
            FROM resting_heart_rate_sample
            WHERE date = ?
            ORDER BY fetched_at DESC LIMIT 1
            """,
            (date,),
        )

        return {
            "resting_heart_rate": (
                rhr_row["bpm"] if rhr_row else 0
            ),
            "current_heart_rate": current_hr,
            "steps_today": steps,
            "steps_goal": (
                goal_row["steps_goal"]
                if goal_row else 0
            ),
            "steps_week": [r["steps"] for r in steps_week],
            "calories_burned": calories,
            "active_minutes": active,
            "active_minutes_goal": (
                goal_row["active_minutes_goal"]
                if goal_row else 0
            ),
        }

    def _status_sleep(self, date: str) -> dict:
        row = self._fetchone(
            """
            SELECT bedtime, wake_time, duration_hours, score
            FROM sleep_session
            WHERE date = ? AND is_main = 1
            ORDER BY fetched_at DESC LIMIT 1
            """,
            (date,),
        )
        if not row:
            return {
                "duration_hours": 0, "score": 0,
                "stages": {
                    "deep": 0, "light": 0,
                    "rem": 0, "awake": 0,
                },
                "bedtime": "", "wake_time": "",
            }

        bedtime = row["bedtime"]
        stages_rows = self._fetchall(
            """
            SELECT stage, minutes
            FROM sleep_stage
            WHERE date = ? AND bedtime = ?
            GROUP BY date, bedtime, stage
            HAVING fetched_at = MAX(fetched_at)
            """,
            (date, bedtime),
        )
        stages = {"deep": 0, "light": 0, "rem": 0, "awake": 0}
        for s in stages_rows:
            if s["stage"] in stages:
                stages[s["stage"]] = round(
                    s["minutes"] / 60, 1
                )

        return {
            "duration_hours": row["duration_hours"],
            "score": row["score"],
            "stages": stages,
            "bedtime": bedtime,
            "wake_time": row["wake_time"],
        }

    def _status_activity(self, date: str) -> dict:
        row = self._fetchone(
            """
            SELECT sedentary_minutes, lightly_active_minutes,
                   fairly_active_minutes, very_active_minutes
            FROM activity_level_snapshot
            WHERE date = ?
            ORDER BY observed_at DESC LIMIT 1
            """,
            (date,),
        )
        dist_row = self._fetchone(
            """
            SELECT distance_km, floors, elevation
            FROM distance_snapshot
            WHERE date = ?
            ORDER BY observed_at DESC LIMIT 1
            """,
            (date,),
        )
        sessions = self._fetchall(
            """
            SELECT name, calories,
                   duration_min, steps, start_time
            FROM activity_session
            WHERE date = ?
            GROUP BY date, start_time, name
            HAVING fetched_at = MAX(fetched_at)
            """,
            (date,),
        )

        return {
            "sedentary_minutes": (
                row["sedentary_minutes"] if row else 0
            ),
            "lightly_active_minutes": (
                row["lightly_active_minutes"] if row else 0
            ),
            "fairly_active_minutes": (
                row["fairly_active_minutes"] if row else 0
            ),
            "very_active_minutes": (
                row["very_active_minutes"] if row else 0
            ),
            "floors": (
                dist_row["floors"] if dist_row else 0
            ),
            "elevation": (
                dist_row["elevation"] if dist_row else 0
            ),
            "distance_km": (
                dist_row["distance_km"] if dist_row else 0
            ),
            "logged_activities": [
                {
                    "name": s["name"],
                    "calories": s["calories"],
                    "duration_min": s["duration_min"],
                    "steps": s["steps"],
                    "start_time": s["start_time"],
                }
                for s in sessions
            ],
        }

    def _status_nutrition(self, date: str) -> dict:
        row = self._fetchone(
            """
            SELECT calories_consumed, calories_target,
                   protein_g, carbs_g, fat_g
            FROM nutrition_day
            WHERE date = ?
            ORDER BY fetched_at DESC LIMIT 1
            """,
            (date,),
        )
        meals = self._fetchall(
            """
            SELECT meal_name, calories,
                   protein_g, carbs_g, fat_g
            FROM meal WHERE date = ?
            GROUP BY date, meal_name
            HAVING fetched_at = MAX(fetched_at)
            """,
            (date,),
        )

        meal_list = []
        for m in meals:
            items = self._fetchall(
                """
                SELECT item_name
                FROM meal_item
                WHERE date = ? AND meal_name = ?
                GROUP BY date, meal_name, item_name
                HAVING fetched_at = MAX(fetched_at)
                """,
                (date, m["meal_name"]),
            )
            meal_list.append({
                "name": m["meal_name"],
                "time": "",
                "items": [i["item_name"] for i in items],
                "calories": m["calories"],
                "macros": {
                    "protein_g": m["protein_g"],
                    "carbs_g": m["carbs_g"],
                    "fat_g": m["fat_g"],
                },
            })

        water_row = self._fetchone(
            """
            SELECT COALESCE(SUM(amount_ml), 0) AS total
            FROM water_intake
            WHERE date = ?
            """,
            (date,),
        )

        return {
            "calories_consumed": (
                row["calories_consumed"] if row else 0
            ),
            "calories_target": (
                row["calories_target"] if row else 0
            ),
            "macros": {
                "protein_g": (
                    row["protein_g"] if row else 0
                ),
                "carbs_g": row["carbs_g"] if row else 0,
                "fat_g": row["fat_g"] if row else 0,
            },
            "meals": meal_list,
            "water_ml": (
                water_row["total"] if water_row else 0
            ),
            "water_target_ml": 2500,
        }

    def _status_measurements(self, date: str) -> dict:
        row = self._fetchone(
            """
            SELECT
                COALESCE((
                    SELECT weight_kg
                    FROM body_measurement
                    WHERE weight_kg > 0
                      AND date <= ?
                    ORDER BY date DESC,
                             fetched_at DESC
                    LIMIT 1
                ), 0) AS weight_kg,
                COALESCE((
                    SELECT body_fat_pct
                    FROM body_measurement
                    WHERE body_fat_pct > 0
                      AND date <= ?
                    ORDER BY date DESC,
                             fetched_at DESC
                    LIMIT 1
                ), 0) AS body_fat_pct,
                COALESCE((
                    SELECT bmi
                    FROM body_measurement
                    WHERE bmi > 0 AND date <= ?
                    ORDER BY date DESC,
                             fetched_at DESC
                    LIMIT 1
                ), 0) AS bmi,
                COALESCE((
                    SELECT muscle_mass_kg
                    FROM body_measurement
                    WHERE muscle_mass_kg > 0
                      AND date <= ?
                    ORDER BY date DESC,
                             fetched_at DESC
                    LIMIT 1
                ), 0) AS muscle_mass_kg,
                COALESCE((
                    SELECT chest_cm
                    FROM body_measurement
                    WHERE chest_cm > 0
                      AND date <= ?
                    ORDER BY date DESC,
                             fetched_at DESC
                    LIMIT 1
                ), 0) AS chest_cm,
                COALESCE((
                    SELECT waist_cm
                    FROM body_measurement
                    WHERE waist_cm > 0
                      AND date <= ?
                    ORDER BY date DESC,
                             fetched_at DESC
                    LIMIT 1
                ), 0) AS waist_cm,
                COALESCE((
                    SELECT hips_cm
                    FROM body_measurement
                    WHERE hips_cm > 0
                      AND date <= ?
                    ORDER BY date DESC,
                             fetched_at DESC
                    LIMIT 1
                ), 0) AS hips_cm,
                COALESCE((
                    SELECT bicep_cm
                    FROM body_measurement
                    WHERE bicep_cm > 0
                      AND date <= ?
                    ORDER BY date DESC,
                             fetched_at DESC
                    LIMIT 1
                ), 0) AS bicep_cm,
                COALESCE((
                    SELECT thigh_cm
                    FROM body_measurement
                    WHERE thigh_cm > 0
                      AND date <= ?
                    ORDER BY date DESC,
                             fetched_at DESC
                    LIMIT 1
                ), 0) AS thigh_cm
            """,
            (date,) * 9,
        )

        weight_week = self._fetchall(
            """
            SELECT date, MAX(weight_kg) as kg
            FROM body_measurement
            WHERE date > date(?, '-7 days')
              AND date <= ?
              AND weight_kg > 0
            GROUP BY date
            ORDER BY date
            """,
            (date, date),
        )

        body = None
        if row and any(
            row[f] for f in [
                "chest_cm", "waist_cm", "hips_cm",
                "bicep_cm", "thigh_cm",
            ]
        ):
            body = {
                "chest_cm": row["chest_cm"],
                "waist_cm": row["waist_cm"],
                "hips_cm": row["hips_cm"],
                "bicep_cm": row["bicep_cm"],
                "thigh_cm": row["thigh_cm"],
            }

        streak = self._steps_streak(date)

        return {
            "weight_kg": row["weight_kg"] if row else 0,
            "body_fat_pct": (
                row["body_fat_pct"] if row else 0
            ),
            "muscle_mass_kg": (
                row["muscle_mass_kg"] if row else 0
            ),
            "bmi": row["bmi"] if row else 0,
            "body": body,
            "weight_week": [
                {"date": w["date"], "kg": w["kg"]}
                for w in weight_week
            ],
            "steps_streak_days": streak,
        }

    def _steps_streak(self, date: str) -> int:
        """Count consecutive days ending at *date* where the
        max step count met or exceeded the goal."""
        row = self._fetchone(
            """
            WITH daily AS (
                SELECT
                    s.date,
                    MAX(s.steps) AS steps,
                    (
                        SELECT g.steps_goal
                        FROM goal_snapshot g
                        WHERE g.date = s.date
                        ORDER BY g.observed_at DESC
                        LIMIT 1
                    ) AS goal
                FROM step_snapshot s
                WHERE s.date <= ?
                GROUP BY s.date
            ),
            met AS (
                SELECT
                    date,
                    CASE WHEN goal > 0
                              AND steps >= goal
                         THEN 1 ELSE 0
                    END AS hit
                FROM daily
            )
            SELECT COUNT(*) AS streak
            FROM met
            WHERE hit = 1
              AND date > COALESCE(
                  (SELECT MAX(date) FROM met
                   WHERE hit = 0 AND date <= ?),
                  ''
              )
              AND date <= ?
            """,
            (date, date, date),
        )
        return row["streak"] if row else 0

    # -- History -------------------------------------------------------------

    def _project_vitals_history(self) -> dict:
        rows = self._fetchall(
            """
            SELECT
                s.date,
                COALESCE(MAX(s.steps), 0) as steps,
                COALESCE(MAX(c.calories), 0)
                    as calories_burned,
                COALESCE(
                    MAX(a.fairly_active)
                    + MAX(a.very_active),
                    0
                ) as active_minutes,
                COALESCE(MAX(r.bpm), 0)
                    as resting_hr
            FROM step_snapshot s
            LEFT JOIN calorie_snapshot c
              ON s.date = c.date
            LEFT JOIN active_minutes_snapshot a
              ON s.date = a.date
            LEFT JOIN resting_heart_rate_sample r
              ON s.date = r.date
            GROUP BY s.date
            ORDER BY s.date DESC
            LIMIT ?
            """,
            (self._config.history_days,),
        )
        return {
            "days": [
                {
                    "date": r["date"],
                    "resting_hr": r["resting_hr"],
                    "avg_hr": 0,
                    "steps": r["steps"],
                    "calories_burned": (
                        r["calories_burned"]
                    ),
                    "active_minutes": (
                        r["active_minutes"]
                    ),
                }
                for r in reversed(rows)
            ]
        }

    def _project_sleep_history(self) -> dict:
        rows = self._fetchall(
            """
            SELECT date, duration_hours, score, bedtime
            FROM sleep_session
            WHERE is_main = 1
            GROUP BY date, bedtime
            HAVING fetched_at = MAX(fetched_at)
            ORDER BY date DESC
            LIMIT ?
            """,
            (self._config.history_days,),
        )
        days = []
        for r in reversed(rows):
            stages_rows = self._fetchall(
                """
                SELECT stage, minutes
                FROM sleep_stage
                WHERE date = ? AND bedtime = ?
                GROUP BY date, bedtime, stage
                HAVING fetched_at = MAX(fetched_at)
                """,
                (r["date"], r["bedtime"]),
            )
            stages = {
                "deep": 0, "light": 0,
                "rem": 0, "awake": 0,
            }
            for s in stages_rows:
                if s["stage"] in stages:
                    stages[s["stage"]] = round(
                        s["minutes"] / 60, 1
                    )
            days.append({
                "date": r["date"],
                "duration_hours": r["duration_hours"],
                "score": r["score"],
                "stages": stages,
            })
        return {"days": days}

    def _project_nutrition_history(self) -> dict:
        rows = self._fetchall(
            """
            SELECT date, calories_consumed, calories_target,
                   protein_g, carbs_g, fat_g
            FROM nutrition_day
            GROUP BY date
            HAVING fetched_at = MAX(fetched_at)
            ORDER BY date DESC
            LIMIT ?
            """,
            (self._config.history_days,),
        )
        days = []
        for r in reversed(rows):
            meals = self._fetchall(
                """
                SELECT meal_name, calories,
                       protein_g, carbs_g, fat_g
                FROM meal WHERE date = ?
                GROUP BY date, meal_name
                HAVING fetched_at = MAX(fetched_at)
                """,
                (r["date"],),
            )
            meal_list = []
            for m in meals:
                items = self._fetchall(
                    """
                    SELECT item_name
                    FROM meal_item
                    WHERE date = ? AND meal_name = ?
                    GROUP BY date, meal_name, item_name
                    HAVING fetched_at = MAX(fetched_at)
                    """,
                    (r["date"], m["meal_name"]),
                )
                meal_list.append({
                    "name": m["meal_name"],
                    "time": "",
                    "items": [
                        i["item_name"] for i in items
                    ],
                    "calories": m["calories"],
                    "macros": {
                        "protein_g": m["protein_g"],
                        "carbs_g": m["carbs_g"],
                        "fat_g": m["fat_g"],
                    },
                })
            water_row = self._fetchone(
                """
                SELECT COALESCE(SUM(amount_ml), 0) AS total
                FROM water_intake
                WHERE date = ?
                """,
                (r["date"],),
            )
            days.append({
                "date": r["date"],
                "calories": r["calories_consumed"],
                "target": r["calories_target"],
                "macros": {
                    "protein_g": r["protein_g"],
                    "carbs_g": r["carbs_g"],
                    "fat_g": r["fat_g"],
                },
                "water_ml": (
                    water_row["total"]
                    if water_row else 0
                ),
                "meals": meal_list,
            })
        return {"days": days}

    def _project_measurements_history(self) -> dict:
        dates = self._fetchall(
            """
            SELECT DISTINCT date FROM body_measurement
            ORDER BY date DESC
            LIMIT ?
            """,
            (self._config.history_days,),
        )
        days = []
        for dr in reversed(dates):
            date = dr["date"]
            r = self._fetchone(
                """
                SELECT
                    COALESCE((
                        SELECT weight_kg
                        FROM body_measurement
                        WHERE weight_kg > 0
                          AND date <= ?
                        ORDER BY date DESC,
                                 fetched_at DESC
                        LIMIT 1
                    ), 0) AS weight_kg,
                    COALESCE((
                        SELECT body_fat_pct
                        FROM body_measurement
                        WHERE body_fat_pct > 0
                          AND date <= ?
                        ORDER BY date DESC,
                                 fetched_at DESC
                        LIMIT 1
                    ), 0) AS body_fat_pct,
                    COALESCE((
                        SELECT bmi
                        FROM body_measurement
                        WHERE bmi > 0
                          AND date <= ?
                        ORDER BY date DESC,
                                 fetched_at DESC
                        LIMIT 1
                    ), 0) AS bmi,
                    COALESCE((
                        SELECT muscle_mass_kg
                        FROM body_measurement
                        WHERE muscle_mass_kg > 0
                          AND date <= ?
                        ORDER BY date DESC,
                                 fetched_at DESC
                        LIMIT 1
                    ), 0) AS muscle_mass_kg,
                    COALESCE((
                        SELECT chest_cm
                        FROM body_measurement
                        WHERE chest_cm > 0
                          AND date <= ?
                        ORDER BY date DESC,
                                 fetched_at DESC
                        LIMIT 1
                    ), 0) AS chest_cm,
                    COALESCE((
                        SELECT waist_cm
                        FROM body_measurement
                        WHERE waist_cm > 0
                          AND date <= ?
                        ORDER BY date DESC,
                                 fetched_at DESC
                        LIMIT 1
                    ), 0) AS waist_cm,
                    COALESCE((
                        SELECT hips_cm
                        FROM body_measurement
                        WHERE hips_cm > 0
                          AND date <= ?
                        ORDER BY date DESC,
                                 fetched_at DESC
                        LIMIT 1
                    ), 0) AS hips_cm,
                    COALESCE((
                        SELECT bicep_cm
                        FROM body_measurement
                        WHERE bicep_cm > 0
                          AND date <= ?
                        ORDER BY date DESC,
                                 fetched_at DESC
                        LIMIT 1
                    ), 0) AS bicep_cm,
                    COALESCE((
                        SELECT thigh_cm
                        FROM body_measurement
                        WHERE thigh_cm > 0
                          AND date <= ?
                        ORDER BY date DESC,
                                 fetched_at DESC
                        LIMIT 1
                    ), 0) AS thigh_cm
                """,
                (date,) * 9,
            )
            body = None
            if r and any(
                r[f] for f in [
                    "chest_cm", "waist_cm", "hips_cm",
                    "bicep_cm", "thigh_cm",
                ]
            ):
                body = {
                    "chest_cm": r["chest_cm"],
                    "waist_cm": r["waist_cm"],
                    "hips_cm": r["hips_cm"],
                    "bicep_cm": r["bicep_cm"],
                    "thigh_cm": r["thigh_cm"],
                }
            days.append({
                "date": date,
                "weight_kg": r["weight_kg"] if r else 0,
                "body_fat_pct": (
                    r["body_fat_pct"] if r else 0
                ),
                "muscle_mass_kg": (
                    r["muscle_mass_kg"] if r else 0
                ),
                "bmi": r["bmi"] if r else 0,
                "body": body,
            })
        return {"days": days}

    def _project_activity_history(self) -> dict:
        rows = self._fetchall(
            """
            SELECT DISTINCT date FROM activity_level_snapshot
            ORDER BY date DESC
            LIMIT ?
            """,
            (self._config.history_days,),
        )
        days = []
        for r in reversed(rows):
            date = r["date"]
            level = self._fetchone(
                """
                SELECT sedentary_minutes,
                       lightly_active_minutes,
                       fairly_active_minutes,
                       very_active_minutes
                FROM activity_level_snapshot
                WHERE date = ?
                ORDER BY observed_at DESC LIMIT 1
                """,
                (date,),
            )
            dist = self._fetchone(
                """
                SELECT distance_km, floors, elevation
                FROM distance_snapshot
                WHERE date = ?
                ORDER BY observed_at DESC LIMIT 1
                """,
                (date,),
            )
            sessions = self._fetchall(
                """
                SELECT name, calories,
                       duration_min, steps, start_time
                FROM activity_session WHERE date = ?
                GROUP BY date, start_time, name
                HAVING fetched_at = MAX(fetched_at)
                """,
                (date,),
            )
            days.append({
                "date": date,
                "sedentary_minutes": (
                    level["sedentary_minutes"]
                    if level else 0
                ),
                "lightly_active_minutes": (
                    level["lightly_active_minutes"]
                    if level else 0
                ),
                "fairly_active_minutes": (
                    level["fairly_active_minutes"]
                    if level else 0
                ),
                "very_active_minutes": (
                    level["very_active_minutes"]
                    if level else 0
                ),
                "floors": dist["floors"] if dist else 0,
                "elevation": (
                    dist["elevation"] if dist else 0
                ),
                "distance_km": (
                    dist["distance_km"] if dist else 0
                ),
                "logged_activities": [
                    {
                        "name": s["name"],
                        "calories": s["calories"],
                        "duration_min": s["duration_min"],
                        "steps": s["steps"],
                        "start_time": s["start_time"],
                    }
                    for s in sessions
                ],
            })
        return {"days": days}

    # -- Intraday ------------------------------------------------------------

    def _project_intraday(self, history_dir: Path) -> None:
        """Generate per-date intraday JSON files for entities
        with meaningful intraday resolution."""
        intraday_dir = history_dir / "intraday"

        self._project_intraday_heart_rate(intraday_dir)
        self._project_intraday_steps(intraday_dir)
        self._project_intraday_calories(intraday_dir)

    def _project_intraday_heart_rate(
        self, intraday_dir: Path
    ) -> None:
        resolution = self._config.heart_rate_resolution
        dates = self._fetchall(
            """
            SELECT DISTINCT date FROM heart_rate_sample
            ORDER BY date DESC
            LIMIT ?
            """,
            (self._config.history_days,),
        )
        if not dates:
            return

        hr_dir = intraday_dir / "heart-rate"
        hr_dir.mkdir(parents=True, exist_ok=True)

        for row in dates:
            date = row["date"]
            samples = self._fetchall(
                """
                SELECT time, bpm FROM heart_rate_sample
                WHERE date = ?
                ORDER BY time
                """,
                (date,),
            )
            thinned = _downsample_by_time(
                [
                    {"time": s["time"], "bpm": s["bpm"]}
                    for s in samples
                ],
                "time",
                resolution,
            )
            self._write(
                hr_dir / f"{date}.json",
                {"date": date, "samples": thinned},
            )

    def _project_intraday_steps(
        self, intraday_dir: Path
    ) -> None:
        resolution = self._config.step_resolution
        dates = self._fetchall(
            """
            SELECT DISTINCT date FROM step_snapshot
            ORDER BY date DESC
            LIMIT ?
            """,
            (self._config.history_days,),
        )
        if not dates:
            return

        steps_dir = intraday_dir / "steps"
        steps_dir.mkdir(parents=True, exist_ok=True)

        for row in dates:
            date = row["date"]
            snapshots = self._fetchall(
                """
                SELECT observed_at, steps
                FROM step_snapshot
                WHERE date = ?
                ORDER BY observed_at
                """,
                (date,),
            )
            thinned = _downsample_by_time(
                [
                    {
                        "observed_at": s["observed_at"],
                        "steps": s["steps"],
                    }
                    for s in snapshots
                ],
                "observed_at",
                resolution,
            )
            self._write(
                steps_dir / f"{date}.json",
                {"date": date, "snapshots": thinned},
            )

    def _project_intraday_calories(
        self, intraday_dir: Path
    ) -> None:
        resolution = self._config.calorie_resolution
        dates = self._fetchall(
            """
            SELECT DISTINCT date FROM calorie_snapshot
            ORDER BY date DESC
            LIMIT ?
            """,
            (self._config.history_days,),
        )
        if not dates:
            return

        cal_dir = intraday_dir / "calories"
        cal_dir.mkdir(parents=True, exist_ok=True)

        for row in dates:
            date = row["date"]
            snapshots = self._fetchall(
                """
                SELECT observed_at, calories
                FROM calorie_snapshot
                WHERE date = ?
                ORDER BY observed_at
                """,
                (date,),
            )
            thinned = _downsample_by_time(
                [
                    {
                        "observed_at": s["observed_at"],
                        "calories": s["calories"],
                    }
                    for s in snapshots
                ],
                "observed_at",
                resolution,
            )
            self._write(
                cal_dir / f"{date}.json",
                {"date": date, "snapshots": thinned},
            )

    # -- Helpers -------------------------------------------------------------

    def _latest_date(self) -> str | None:
        for table in [
            "step_snapshot", "sleep_session",
            "nutrition_day", "body_measurement",
        ]:
            try:
                row = self._fetchone(
                    f"SELECT MAX(date) as d FROM {table}"
                )
                if row and row["d"]:
                    return row["d"]
            except sqlite3.OperationalError:
                continue
        return None

    def _fetchone(
        self, sql: str, params: tuple = ()
    ) -> sqlite3.Row | None:
        return self._db.execute(sql, params).fetchone()

    def _fetchall(
        self, sql: str, params: tuple = ()
    ) -> list[sqlite3.Row]:
        return self._db.execute(sql, params).fetchall()

    def _update_symlink(self, target: Path) -> None:
        link = self._build_root / "projection" / "current"
        if link.is_symlink() or link.exists():
            link.unlink()
        link.symlink_to(
            target.name, target_is_directory=True
        )

    @staticmethod
    def _write(path: Path, data: dict) -> None:
        path.write_text(
            json.dumps(data, indent=2, ensure_ascii=False)
        )


# -- Module-level helpers ----------------------------------------------------


def _extract_minutes(timestamp: str) -> int | None:
    """Extract total minutes from midnight from a time or
    ISO timestamp string.

    Handles ``HH:MM:SS``, ``HH:MM``, and ISO 8601 timestamps
    containing a ``T`` separator.
    """
    try:
        if "T" in timestamp:
            time_part = timestamp.split("T")[1][:8]
        else:
            time_part = timestamp

        parts = time_part.split(":")
        return int(parts[0]) * 60 + int(parts[1])
    except (IndexError, ValueError):
        return None


def _downsample_by_time(
    records: list[dict],
    time_key: str,
    resolution_minutes: int,
) -> list[dict]:
    """Thin a list of records to at most one per interval.

    Keeps the last record in each *resolution_minutes* bucket.
    A resolution of 1 returns all records unchanged.
    """
    if resolution_minutes <= 1 or not records:
        return records

    result = []
    last_bucket = -1

    for record in records:
        minutes = _extract_minutes(record[time_key])
        if minutes is None:
            result.append(record)
            continue

        bucket = minutes // resolution_minutes
        if bucket != last_bucket:
            result.append(record)
            last_bucket = bucket

    return result
