"""Deterministic initial nutrition calculations."""

from __future__ import annotations

from datetime import date, timedelta
from decimal import Decimal, ROUND_CEILING, ROUND_HALF_UP

from pydantic import Field, field_serializer, model_validator

from checkin_cli.nutrition_onboarding_models import (
    ActivityCategory,
    EquationSexBasis,
    GoalType,
    NutritionOnboardingBaseline,
    StrictFrozenModel,
    _canonical_decimal,
)


class CalculationDeclinedError(ValueError):
    """Raised when the customer declines the selected calculation basis."""


class UnsafeGoalTrajectoryError(ValueError):
    """Raised when the requested weekly trajectory exceeds safety bounds."""


_ACTIVITY_FACTORS = {
    ActivityCategory.SEDENTARY: Decimal("1.200"),
    ActivityCategory.LIGHT: Decimal("1.375"),
    ActivityCategory.MODERATE: Decimal("1.550"),
    ActivityCategory.VERY_ACTIVE: Decimal("1.725"),
    ActivityCategory.EXTRA_ACTIVE: Decimal("1.900"),
}


def activity_factor(category: ActivityCategory) -> Decimal:
    return _ACTIVITY_FACTORS[category]


def mifflin_st_jeor_bmr(
    *,
    weight_kg: Decimal,
    height_cm: Decimal,
    adult_age: int,
    equation_sex_basis: EquationSexBasis,
) -> Decimal | None:
    if equation_sex_basis is EquationSexBasis.DECLINE:
        return None
    constant = Decimal("5") if equation_sex_basis is EquationSexBasis.MALE else Decimal("-161")
    return (
        Decimal("10") * weight_kg
        + Decimal("6.25") * height_cm
        - Decimal("5") * adult_age
        + constant
    ).quantize(Decimal("1"), rounding=ROUND_HALF_UP)


class WeeklyNutritionTarget(StrictFrozenModel):
    week: int = Field(ge=1, le=12)
    calories_kcal: int = Field(ge=1500, le=4500)
    protein_g: int = Field(ge=120, le=250)
    carbohydrate_g: int = Field(ge=0)
    fat_g: int = Field(ge=40, le=150)

    @model_validator(mode="after")
    def validate_energy(self) -> "WeeklyNutritionTarget":
        calculated = 4 * self.protein_g + 4 * self.carbohydrate_g + 9 * self.fat_g
        if calculated != self.calories_kcal:
            raise ValueError("macronutrients do not reconcile with calories")
        return self


class InitialNutritionPlan(StrictFrozenModel):
    schema_version: str = "1.0"
    method_id: str
    method_version: str
    energy_density_method: str = "7700_kcal_per_kg_v1"
    bmr_kcal: Decimal
    tdee_kcal: Decimal
    starts_on: date
    requested_trajectory_within_guardrail: bool
    recommended_target_date: date | None
    weeks: tuple[WeeklyNutritionTarget, ...]
    projected_weights_kg: tuple[Decimal, ...]
    projection_disclaimer: str

    @model_validator(mode="after")
    def validate_weeks(self) -> "InitialNutritionPlan":
        if len(self.weeks) != 12:
            raise ValueError("plan requires exactly 12 weeks")
        if tuple(row.week for row in self.weeks) != tuple(range(1, 13)):
            raise ValueError("week numbers must be 1 through 12")
        if len(self.projected_weights_kg) != 13:
            raise ValueError("projection requires 13 boundary weights")
        return self

    @field_serializer("bmr_kcal", "tdee_kcal")
    def serialize_decimal(self, value: Decimal) -> str:
        return _canonical_decimal(value)

    @field_serializer("projected_weights_kg")
    def serialize_weights(self, value: tuple[Decimal, ...]) -> list[str]:
        return [_canonical_decimal(item) for item in value]


def _round_to_twenty(value: Decimal) -> int:
    return int((value / Decimal("20")).quantize(Decimal("1"), rounding=ROUND_HALF_UP) * 20)


def _safe_weekly_change(
    baseline: NutritionOnboardingBaseline,
    starts_on: date,
) -> tuple[Decimal, bool, date | None]:
    if baseline.goal_type is GoalType.MAINTAIN:
        return Decimal("0"), True, None
    assert baseline.target_weight_kg is not None
    assert baseline.target_date is not None
    days = (baseline.target_date - starts_on).days
    if days < 1:
        raise UnsafeGoalTrajectoryError("unsafe goal trajectory")
    weeks = Decimal(days) / Decimal("7")
    weekly = (baseline.target_weight_kg - baseline.weight_kg) / weeks
    rate = abs(weekly / baseline.weight_kg)
    limit = Decimal("0.01") if baseline.goal_type is GoalType.LOSS else Decimal("0.005")
    direction_invalid = (
        baseline.goal_type is GoalType.LOSS and weekly >= 0
    ) or (
        baseline.goal_type is GoalType.GAIN and weekly <= 0
    )
    if direction_invalid:
        raise UnsafeGoalTrajectoryError("unsafe goal trajectory")
    if rate <= limit:
        return weekly, True, None
    direction = (
        Decimal("-1")
        if baseline.goal_type is GoalType.LOSS
        else Decimal("1")
    )
    safe_days = (
        abs(baseline.target_weight_kg - baseline.weight_kg)
        / (baseline.weight_kg * limit)
        * Decimal("7")
    ).to_integral_value(rounding=ROUND_CEILING)
    return (
        direction * baseline.weight_kg * limit,
        False,
        starts_on + timedelta(days=int(safe_days)),
    )


def _macros(calories: int, weight_kg: Decimal) -> tuple[int, int, int]:
    protein = min(250, max(120, int((weight_kg * 2).quantize(Decimal("1"), rounding=ROUND_HALF_UP))))
    fat = min(150, max(40, int(weight_kg.quantize(Decimal("1"), rounding=ROUND_HALF_UP))))
    while fat >= 40:
        remainder = calories - 4 * protein - 9 * fat
        if remainder >= 0 and remainder % 4 == 0:
            return protein, remainder // 4, fat
        fat -= 1
    raise ValueError("calorie target cannot be reconciled within macro bounds")


def generate_initial_plan(
    baseline: NutritionOnboardingBaseline,
    *,
    starts_on: date,
) -> InitialNutritionPlan:
    bmr = mifflin_st_jeor_bmr(
        weight_kg=baseline.weight_kg,
        height_cm=baseline.height_cm,
        adult_age=baseline.adult_age,
        equation_sex_basis=baseline.equation_sex_basis,
    )
    if bmr is None:
        raise CalculationDeclinedError("equation sex basis was declined")
    tdee = (bmr * activity_factor(baseline.activity_category)).quantize(
        Decimal("1"),
        rounding=ROUND_HALF_UP,
    )
    (
        weekly_change,
        requested_trajectory_within_guardrail,
        recommended_target_date,
    ) = _safe_weekly_change(baseline, starts_on)
    projections = tuple(
        (baseline.weight_kg + weekly_change * week).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
        for week in range(13)
    )
    rows: list[WeeklyNutritionTarget] = []
    for week in range(1, 13):
        projected_weight = projections[week - 1]
        projected_bmr = bmr + Decimal("10") * (projected_weight - baseline.weight_kg)
        projected_tdee = projected_bmr * activity_factor(baseline.activity_category)
        energy_delta = weekly_change * Decimal("7700") / Decimal("7")
        calories = _round_to_twenty(projected_tdee + energy_delta)
        if not 1500 <= calories <= 4500:
            raise UnsafeGoalTrajectoryError("unsafe goal trajectory")
        protein, carbs, fat = _macros(calories, projected_weight)
        rows.append(
            WeeklyNutritionTarget(
                week=week,
                calories_kcal=calories,
                protein_g=protein,
                carbohydrate_g=carbs,
                fat_g=fat,
            )
        )
    return InitialNutritionPlan(
        method_id="mifflin_st_jeor_1990",
        method_version="1.0",
        bmr_kcal=bmr,
        tdee_kcal=tdee,
        starts_on=starts_on,
        requested_trajectory_within_guardrail=(
            requested_trajectory_within_guardrail
        ),
        recommended_target_date=recommended_target_date,
        weeks=tuple(rows),
        projected_weights_kg=projections,
        projection_disclaimer=(
            "This deterministic 12-week projection is not a medical diagnosis "
            "or a guarantee of body-weight change."
        ),
    )
