"""Unit tests for the projection builder."""

import json
import sqlite3

import pytest

from byteb4rb1e.homeostat.projections.builder import (
    ProjectionBuilder,
)
from byteb4rb1e.homeostat.warehouse.canonical import (
    CanonicalWarehouse,
)
from byteb4rb1e.homeostat.warehouse.schema import (
    ActiveMinutesSnapshot,
    ActivityLevelSnapshot,
    ActivitySession,
    BodyMeasurement,
    CalorieSnapshot,
    DistanceSnapshot,
    GoalSnapshot,
    HeartRateSample,
    Meal,
    MealItem,
    NutritionDay,
    RestingHeartRateSample,
    SleepSession,
    SleepStage,
    StepSnapshot,
)


FETCHED = "2026-03-19T15:00:00+00:00"
DATE = "2026-03-19"


@pytest.fixture
def setup(tmp_path):
    """Hydrate a SQLite DB with sample data and return
    (db, builder, warehouse)."""
    wh = CanonicalWarehouse(root=tmp_path / "data")

    wh.append("step-snapshot", StepSnapshot(
        source="fitbit", fetched_at=FETCHED,
        date=DATE, observed_at=FETCHED, steps=8000,
    ), DATE)
    wh.append("calorie-snapshot", CalorieSnapshot(
        source="fitbit", fetched_at=FETCHED,
        date=DATE, observed_at=FETCHED, calories=2200,
    ), DATE)
    wh.append("heart-rate-sample", HeartRateSample(
        source="fitbit", fetched_at=FETCHED,
        date=DATE, time="14:30:00", bpm=72,
    ), DATE)
    wh.append(
        "resting-heart-rate-sample",
        RestingHeartRateSample(
            source="fitbit", fetched_at=FETCHED,
            date=DATE, bpm=62,
        ), DATE,
    )
    wh.append("active-minutes-snapshot", ActiveMinutesSnapshot(
        source="fitbit", fetched_at=FETCHED,
        date=DATE, observed_at=FETCHED,
        fairly_active=20, very_active=10,
    ), DATE)
    wh.append(
        "activity-level-snapshot", ActivityLevelSnapshot(
            source="fitbit", fetched_at=FETCHED,
            date=DATE, observed_at=FETCHED,
            sedentary_minutes=600,
            lightly_active_minutes=120,
            fairly_active_minutes=20,
            very_active_minutes=10,
        ), DATE,
    )
    wh.append("distance-snapshot", DistanceSnapshot(
        source="fitbit", fetched_at=FETCHED,
        date=DATE, observed_at=FETCHED,
        distance_km=6.5, floors=5, elevation=15.0,
    ), DATE)
    wh.append("sleep-session", SleepSession(
        source="fitbit", fetched_at=FETCHED,
        date=DATE, bedtime="2026-03-18T23:00:00",
        wake_time="2026-03-19T07:00:00",
        duration_hours=8.0, score=92, is_main=True,
    ), DATE)
    wh.append("sleep-stage", SleepStage(
        source="fitbit", fetched_at=FETCHED,
        date=DATE, bedtime="2026-03-18T23:00:00",
        stage="deep", minutes=90,
    ), DATE)
    wh.append("sleep-stage", SleepStage(
        source="fitbit", fetched_at=FETCHED,
        date=DATE, bedtime="2026-03-18T23:00:00",
        stage="light", minutes=200,
    ), DATE)
    wh.append("activity-session", ActivitySession(
        source="fitbit", fetched_at=FETCHED,
        date=DATE, start_time="2026-03-19T07:00:00",
        name="Run", calories=300, duration_min=30.0,
        steps=3000,
    ), DATE)
    wh.append("body-measurement", BodyMeasurement(
        source="fitbit", fetched_at=FETCHED,
        date=DATE, weight_kg=72.5, body_fat_pct=18.0,
        bmi=23.0, muscle_mass_kg=0.0,
        chest_cm=0.0, waist_cm=0.0, hips_cm=0.0,
        bicep_cm=0.0, thigh_cm=0.0,
    ), DATE)
    wh.append("nutrition-day", NutritionDay(
        source="fddb", fetched_at=FETCHED,
        date=DATE, calories_consumed=2400,
        calories_target=1800,
        protein_g=160.0, carbs_g=250.0, fat_g=70.0,
    ), DATE)
    wh.append("meal", Meal(
        source="fddb", fetched_at=FETCHED,
        date=DATE, meal_name="Breakfast",
        calories=500, protein_g=30.0,
        carbs_g=60.0, fat_g=15.0,
    ), DATE)
    wh.append("meal-item", MealItem(
        source="fddb", fetched_at=FETCHED,
        date=DATE, meal_name="Breakfast",
        item_name="Oatmeal",
    ), DATE)
    wh.append("goal-snapshot", GoalSnapshot(
        source="fitbit", fetched_at=FETCHED,
        date=DATE, observed_at=FETCHED,
        steps_goal=10000, active_minutes_goal=30,
    ), DATE)

    db = sqlite3.connect(":memory:")
    wh.hydrate(db)

    builder = ProjectionBuilder(
        db=db, build_root=tmp_path / "build"
    )
    return db, builder, wh


class TestBuild:
    def test_creates_output_directory(self, setup):
        _, builder, _ = setup
        out = builder.build(DATE)
        assert out.exists()
        assert (out / "status.json").exists()
        assert (out / "history" / "vitals.json").exists()
        assert (out / "history" / "sleep.json").exists()
        assert (out / "history" / "nutrition.json").exists()
        assert (out / "history" / "measurements.json").exists()
        assert (out / "history" / "activity.json").exists()

    def test_creates_current_symlink(self, setup, tmp_path):
        _, builder, _ = setup
        out = builder.build(DATE)
        link = tmp_path / "build" / "projection" / "current"
        assert link.is_symlink()
        assert link.resolve() == out.resolve()


class TestStatusProjection:
    def test_vitals(self, setup):
        _, builder, _ = setup
        out = builder.build(DATE)
        status = json.loads(
            (out / "status.json").read_text()
        )
        v = status["vitals"]
        assert v["steps_today"] == 8000
        assert v["calories_burned"] == 2200
        assert v["current_heart_rate"] == 72
        assert v["active_minutes"] == 30
        assert v["steps_goal"] == 10000
        assert v["active_minutes_goal"] == 30
        assert v["resting_heart_rate"] == 62

    def test_sleep(self, setup):
        _, builder, _ = setup
        out = builder.build(DATE)
        status = json.loads(
            (out / "status.json").read_text()
        )
        s = status["sleep"]
        assert s["duration_hours"] == 8.0
        assert s["score"] == 92
        assert s["stages"]["deep"] == 1.5
        assert s["bedtime"] == "2026-03-18T23:00:00"

    def test_activity(self, setup):
        _, builder, _ = setup
        out = builder.build(DATE)
        status = json.loads(
            (out / "status.json").read_text()
        )
        a = status["activity"]
        assert a["distance_km"] == 6.5
        assert len(a["logged_activities"]) == 1
        assert a["logged_activities"][0]["name"] == "Run"

    def test_nutrition(self, setup):
        _, builder, _ = setup
        out = builder.build(DATE)
        status = json.loads(
            (out / "status.json").read_text()
        )
        n = status["nutrition"]
        assert n["calories_consumed"] == 2400
        assert n["macros"]["protein_g"] == 160.0
        assert len(n["meals"]) == 1
        assert n["meals"][0]["items"] == ["Oatmeal"]

    def test_measurements(self, setup):
        _, builder, _ = setup
        out = builder.build(DATE)
        status = json.loads(
            (out / "status.json").read_text()
        )
        m = status["measurements"]
        assert m["weight_kg"] == 72.5
        assert m["body_fat_pct"] == 18.0
        assert m["body"] is None


class TestHistoryProjection:
    def test_vitals_history(self, setup):
        _, builder, _ = setup
        out = builder.build(DATE)
        data = json.loads(
            (out / "history" / "vitals.json").read_text()
        )
        assert len(data["days"]) == 1
        assert data["days"][0]["steps"] == 8000
        assert data["days"][0]["resting_hr"] == 62

    def test_sleep_history(self, setup):
        _, builder, _ = setup
        out = builder.build(DATE)
        data = json.loads(
            (out / "history" / "sleep.json").read_text()
        )
        assert len(data["days"]) == 1
        assert data["days"][0]["duration_hours"] == 8.0
        assert data["days"][0]["stages"]["deep"] == 1.5

    def test_nutrition_history(self, setup):
        _, builder, _ = setup
        out = builder.build(DATE)
        data = json.loads(
            (out / "history" / "nutrition.json").read_text()
        )
        assert len(data["days"]) == 1
        assert data["days"][0]["calories"] == 2400
        assert len(data["days"][0]["meals"]) == 1

    def test_measurements_history(self, setup):
        _, builder, _ = setup
        out = builder.build(DATE)
        data = json.loads(
            (out / "history" / "measurements.json").read_text()
        )
        assert len(data["days"]) == 1
        assert data["days"][0]["weight_kg"] == 72.5

    def test_activity_history(self, setup):
        _, builder, _ = setup
        out = builder.build(DATE)
        data = json.loads(
            (out / "history" / "activity.json").read_text()
        )
        assert len(data["days"]) == 1
        assert data["days"][0]["distance_km"] == 6.5
        assert len(
            data["days"][0]["logged_activities"]
        ) == 1


class TestIntradayProjection:
    def test_heart_rate_intraday(self, setup):
        _, builder, _ = setup
        out = builder.build(DATE)
        path = (
            out / "history" / "intraday"
            / "heart-rate" / f"{DATE}.json"
        )
        assert path.exists()
        data = json.loads(path.read_text())
        assert data["date"] == DATE
        assert len(data["samples"]) == 1
        assert data["samples"][0]["bpm"] == 72
        assert data["samples"][0]["time"] == "14:30:00"

    def test_steps_intraday(self, setup):
        _, builder, _ = setup
        out = builder.build(DATE)
        path = (
            out / "history" / "intraday"
            / "steps" / f"{DATE}.json"
        )
        assert path.exists()
        data = json.loads(path.read_text())
        assert data["date"] == DATE
        assert len(data["snapshots"]) == 1
        assert data["snapshots"][0]["steps"] == 8000

    def test_calories_intraday(self, setup):
        _, builder, _ = setup
        out = builder.build(DATE)
        path = (
            out / "history" / "intraday"
            / "calories" / f"{DATE}.json"
        )
        assert path.exists()
        data = json.loads(path.read_text())
        assert data["date"] == DATE
        assert len(data["snapshots"]) == 1
        assert data["snapshots"][0]["calories"] == 2200


class TestDownsampling:
    def test_heart_rate_downsampled(self, tmp_path):
        """With many HR samples, resolution=15 should thin them."""
        from byteb4rb1e.homeostat.projections.config import (
            ProjectionConfig,
        )

        wh = CanonicalWarehouse(root=tmp_path / "data")

        # Write 60 samples (one per minute, 14:00-14:59)
        for m in range(60):
            wh.append(
                "heart-rate-sample",
                HeartRateSample(
                    source="fitbit",
                    fetched_at=FETCHED,
                    date=DATE,
                    time=f"14:{m:02d}:00",
                    bpm=70 + m % 10,
                ),
                DATE,
            )

        db = sqlite3.connect(":memory:")
        wh.hydrate(db)

        config = ProjectionConfig(
            heart_rate_resolution=15
        )
        builder = ProjectionBuilder(
            db=db,
            build_root=tmp_path / "build",
            config=config,
        )
        out = builder.build(DATE)
        data = json.loads(
            (
                out / "history" / "intraday"
                / "heart-rate" / f"{DATE}.json"
            ).read_text()
        )
        # 60 minutes / 15 min resolution = 4 buckets
        assert len(data["samples"]) == 4

    def test_resolution_1_keeps_all(self, tmp_path):
        from byteb4rb1e.homeostat.projections.config import (
            ProjectionConfig,
        )

        wh = CanonicalWarehouse(root=tmp_path / "data")
        for m in range(10):
            wh.append(
                "heart-rate-sample",
                HeartRateSample(
                    source="fitbit",
                    fetched_at=FETCHED,
                    date=DATE,
                    time=f"14:{m:02d}:00",
                    bpm=72,
                ),
                DATE,
            )

        db = sqlite3.connect(":memory:")
        wh.hydrate(db)

        config = ProjectionConfig(
            heart_rate_resolution=1
        )
        builder = ProjectionBuilder(
            db=db,
            build_root=tmp_path / "build",
            config=config,
        )
        out = builder.build(DATE)
        data = json.loads(
            (
                out / "history" / "intraday"
                / "heart-rate" / f"{DATE}.json"
            ).read_text()
        )
        assert len(data["samples"]) == 10


class TestStepsStreak:
    def test_streak_zero_when_goal_not_met(self, setup):
        _, builder, _ = setup
        out = builder.build(DATE)
        status = json.loads(
            (out / "status.json").read_text()
        )
        # Fixture: 8000 steps, 10000 goal → not met
        assert status["measurements"][
            "steps_streak_days"
        ] == 0

    def test_multi_day_streak(self, tmp_path):
        wh = CanonicalWarehouse(root=tmp_path / "data")
        for i in range(5):
            d = f"2026-03-{15 + i:02d}"
            wh.append("step-snapshot", StepSnapshot(
                source="fitbit", fetched_at=FETCHED,
                date=d, observed_at=FETCHED,
                steps=12000,
            ), d)
            wh.append("goal-snapshot", GoalSnapshot(
                source="fitbit", fetched_at=FETCHED,
                date=d, observed_at=FETCHED,
                steps_goal=10000,
                active_minutes_goal=30,
            ), d)

        db = sqlite3.connect(":memory:")
        wh.hydrate(db)
        builder = ProjectionBuilder(
            db=db, build_root=tmp_path / "build"
        )
        out = builder.build("2026-03-19")
        status = json.loads(
            (out / "status.json").read_text()
        )
        assert status["measurements"][
            "steps_streak_days"
        ] == 5

    def test_streak_broken_by_missed_day(self, tmp_path):
        wh = CanonicalWarehouse(root=tmp_path / "data")
        for i, steps in [
            (0, 12000), (1, 5000), (2, 12000),
            (3, 12000),
        ]:
            d = f"2026-03-{16 + i:02d}"
            wh.append("step-snapshot", StepSnapshot(
                source="fitbit", fetched_at=FETCHED,
                date=d, observed_at=FETCHED,
                steps=steps,
            ), d)
            wh.append("goal-snapshot", GoalSnapshot(
                source="fitbit", fetched_at=FETCHED,
                date=d, observed_at=FETCHED,
                steps_goal=10000,
                active_minutes_goal=30,
            ), d)

        db = sqlite3.connect(":memory:")
        wh.hydrate(db)
        builder = ProjectionBuilder(
            db=db, build_root=tmp_path / "build"
        )
        out = builder.build("2026-03-19")
        status = json.loads(
            (out / "status.json").read_text()
        )
        assert status["measurements"][
            "steps_streak_days"
        ] == 2

    def test_streak_zero_when_no_goals(self, tmp_path):
        wh = CanonicalWarehouse(root=tmp_path / "data")
        wh.append("step-snapshot", StepSnapshot(
            source="fitbit", fetched_at=FETCHED,
            date=DATE, observed_at=FETCHED,
            steps=12000,
        ), DATE)

        db = sqlite3.connect(":memory:")
        wh.hydrate(db)
        builder = ProjectionBuilder(
            db=db, build_root=tmp_path / "build"
        )
        out = builder.build(DATE)
        status = json.loads(
            (out / "status.json").read_text()
        )
        assert status["measurements"][
            "steps_streak_days"
        ] == 0


class TestPartialMeasurements:
    def test_partial_record_preserves_prior_values(
        self, tmp_path
    ):
        wh = CanonicalWarehouse(root=tmp_path / "data")

        # Day 1: full measurement with weight
        wh.append("body-measurement", BodyMeasurement(
            source="fitbit", fetched_at=FETCHED,
            date="2026-03-18", weight_kg=72.5,
            body_fat_pct=18.0, bmi=23.0,
            muscle_mass_kg=0.0,
            chest_cm=0.0, waist_cm=0.0,
            hips_cm=0.0, bicep_cm=0.0,
            thigh_cm=0.0,
        ), "2026-03-18")

        # Day 2: partial — only waist
        wh.append("body-measurement", BodyMeasurement(
            source="manual", fetched_at=FETCHED,
            date=DATE, weight_kg=0.0,
            body_fat_pct=0.0, bmi=0.0,
            muscle_mass_kg=0.0,
            chest_cm=0.0, waist_cm=82.0,
            hips_cm=0.0, bicep_cm=0.0,
            thigh_cm=0.0,
        ), DATE)

        db = sqlite3.connect(":memory:")
        wh.hydrate(db)
        builder = ProjectionBuilder(
            db=db, build_root=tmp_path / "build"
        )
        out = builder.build(DATE)
        status = json.loads(
            (out / "status.json").read_text()
        )
        m = status["measurements"]

        # Weight from day 1 should be preserved
        assert m["weight_kg"] == 72.5
        assert m["body_fat_pct"] == 18.0
        assert m["bmi"] == 23.0
        # Waist from day 2
        assert m["body"]["waist_cm"] == 82.0


class TestEmptyWarehouse:
    def test_build_with_no_data(self, tmp_path):
        db = sqlite3.connect(":memory:")
        wh = CanonicalWarehouse(root=tmp_path / "data")
        wh.hydrate(db)
        builder = ProjectionBuilder(
            db=db, build_root=tmp_path / "build"
        )
        out = builder.build()
        assert out.exists()
