from datetime import date, datetime, timedelta, timezone from typing import Any from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from sqlalchemy.orm import Session from app.auth.deps import get_current_user from app.db.base import get_db from app.db.models import User from app.fitness.service import FitnessService from app.fitness.structuring import structure_meal, structure_workout from app.integrations.openfoodfacts import OpenFoodFactsClient from app.integrations.wger import WgerClient router = APIRouter() class ProfileUpdate(BaseModel): sex: str | None = None age: int | None = None height_cm: float | None = None weight_kg: float | None = None goal: str | None = None target_weight_kg: float | None = None neat_base_kcal: float | None = Field(default=None, ge=200, le=300) activity_level: str | None = None weekly_workouts: int | None = Field(default=None, ge=0, le=14) baseline_steps: int | None = Field(default=None, ge=0) baseline_workout_kcal: float | None = Field(default=None, ge=0) class MealCreate(BaseModel): text: str = Field(min_length=1) meal_type: str | None = None class WaterCreate(BaseModel): amount_ml: int = Field(gt=0) class WeightCreate(BaseModel): weight_kg: float = Field(gt=0) body_fat_pct: float | None = None chest_cm: float | None = None waist_cm: float | None = None neck_cm: float | None = None hip_cm: float | None = None notes: str = "" day: str | None = None days_ago: int | None = Field(default=None, ge=0, le=90) recorded_at: str | None = None class BodyCompositionCalc(BaseModel): weight_kg: float | None = None height_cm: float | None = None sex: str | None = None neck_cm: float | None = None waist_cm: float | None = None hip_cm: float | None = None body_fat_pct: float | None = None class StepsCreate(BaseModel): steps: int = Field(ge=0) active_calories: float | None = None notes: str = "" day: str | None = None days_ago: int | None = Field(default=None, ge=0, le=90) logged_at: str | None = None replace: bool = False class StepsUpdate(BaseModel): steps: int | None = Field(default=None, ge=0) active_calories: float | None = None notes: str | None = None class MealUpdate(BaseModel): description: str | None = None meal_type: str | None = None calories: float | None = Field(default=None, ge=0) protein_g: float | None = Field(default=None, ge=0) fat_g: float | None = Field(default=None, ge=0) carbs_g: float | None = Field(default=None, ge=0) fiber_g: float | None = Field(default=None, ge=0) estimated: bool | None = None class WaterUpdate(BaseModel): amount_ml: int | None = Field(default=None, ge=0) class LogMerge(BaseModel): kind: str = Field(description="meal|water|steps") keep_id: int merge_id: int class DeleteLastLog(BaseModel): kind: str = Field(description="meal|water|steps|workout") day: str | None = None days_ago: int | None = Field(default=None, ge=0, le=90) class WorkoutCreate(BaseModel): text: str = Field(min_length=1) day: str | None = None days_ago: int | None = Field(default=None, ge=0, le=90) logged_at: str | None = None class ReminderUpdate(BaseModel): enabled: bool | None = None hour: int | None = Field(default=None, ge=0, le=23) minute: int | None = Field(default=None, ge=0, le=59) interval_hours: int | None = Field(default=None, ge=1, le=12) @router.get("/fitness") def get_snapshot(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]: return FitnessService(db, user.id).snapshot() @router.get("/fitness/summary") def get_summary( day: str | None = None, db: Session = Depends(get_db), user: User = Depends(get_current_user), ) -> dict[str, Any]: d = date.fromisoformat(day) if day else None return FitnessService(db, user.id).get_daily_summary(d) @router.get("/fitness/workout-stats") def get_workout_stats( days: int = 7, end: str | None = None, db: Session = Depends(get_db), user: User = Depends(get_current_user), ) -> dict[str, Any]: end_day = date.fromisoformat(end) if end else None return FitnessService(db, user.id).get_workout_stats(days=days, end_day=end_day) @router.get("/fitness/history") def get_history( days: int = 7, end: str | None = None, db: Session = Depends(get_db), user: User = Depends(get_current_user), ) -> dict[str, Any]: end_day = date.fromisoformat(end) if end else None return FitnessService(db, user.id).get_history(days=days, end_day=end_day) @router.get("/fitness/charts") def get_charts( weeks: int = 52, trend: bool = True, end: str | None = None, db: Session = Depends(get_db), user: User = Depends(get_current_user), ) -> dict[str, Any]: end_day = date.fromisoformat(end) if end else None return FitnessService(db, user.id).get_charts(weeks=weeks, trend=trend, end_day=end_day) @router.get("/fitness/compare") def compare_days( dates: str, db: Session = Depends(get_db), user: User = Depends(get_current_user), ) -> dict[str, Any]: parsed: list[date] = [] for part in dates.split(","): part = part.strip() if part: parsed.append(date.fromisoformat(part)) if len(parsed) < 2: raise HTTPException(status_code=400, detail="Provide at least two dates (YYYY-MM-DD,YYYY-MM-DD)") if len(parsed) > 14: raise HTTPException(status_code=400, detail="At most 14 dates") return FitnessService(db, user.id).compare_days(parsed) @router.get("/fitness/profile") def get_profile(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, Any]: profile = FitnessService(db, user.id).get_profile() return profile or {"configured": False} @router.put("/fitness/profile") def update_profile( payload: ProfileUpdate, db: Session = Depends(get_db), user: User = Depends(get_current_user), ) -> dict[str, Any]: return FitnessService(db, user.id).set_profile(payload.model_dump(exclude_none=True)) @router.post("/fitness/profile/calc") def calc_targets( payload: ProfileUpdate, db: Session = Depends(get_db), user: User = Depends(get_current_user), ) -> dict[str, Any]: params = payload.model_dump(exclude_none=True) if not params: raise HTTPException(status_code=400, detail="No parameters") return FitnessService(db, user.id).calc_targets(params) @router.post("/fitness/meals") async def create_meal( payload: MealCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user), ) -> dict[str, Any]: service = FitnessService(db, user.id) try: structured = await structure_meal(payload.text) except Exception as exc: raise HTTPException(status_code=502, detail=str(exc)) from exc return service.log_meal( description=structured.get("description") or payload.text, meal_type=payload.meal_type or structured.get("meal_type") or "snack", calories=float(structured.get("calories") or 0), protein_g=float(structured.get("protein_g") or 0), fat_g=float(structured.get("fat_g") or 0), carbs_g=float(structured.get("carbs_g") or 0), fiber_g=float(structured.get("fiber_g") or 0), source="llm", estimated=bool(structured.get("estimated", True)), ) @router.post("/fitness/water") def create_water( payload: WaterCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user), ) -> dict[str, Any]: return FitnessService(db, user.id).log_water(payload.amount_ml) @router.post("/fitness/weight") def create_weight( payload: WeightCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user), ) -> dict[str, Any]: day = date.fromisoformat(payload.day) if payload.day else None return FitnessService(db, user.id).log_weight( payload.weight_kg, body_fat_pct=payload.body_fat_pct, chest_cm=payload.chest_cm, waist_cm=payload.waist_cm, neck_cm=payload.neck_cm, hip_cm=payload.hip_cm, notes=payload.notes, recorded_at=payload.recorded_at, day=day, days_ago=payload.days_ago, ) @router.post("/fitness/body-composition/calc") def calc_body_composition( payload: BodyCompositionCalc, db: Session = Depends(get_db), user: User = Depends(get_current_user), ) -> dict[str, Any]: return FitnessService(db, user.id).calc_body_composition(payload.model_dump(exclude_none=True)) @router.post("/fitness/steps") def create_steps( payload: StepsCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user), ) -> dict[str, Any]: day = date.fromisoformat(payload.day) if payload.day else None return FitnessService(db, user.id).log_steps( payload.steps, active_calories=payload.active_calories, notes=payload.notes, day=day, days_ago=payload.days_ago, logged_at=payload.logged_at, replace=payload.replace, ) @router.patch("/fitness/steps/{log_id}") def update_steps( log_id: int, payload: StepsUpdate, db: Session = Depends(get_db), user: User = Depends(get_current_user), ) -> dict[str, Any]: updates = payload.model_dump(exclude_unset=True) if not updates: raise HTTPException(status_code=400, detail="No fields to update") result = FitnessService(db, user.id).update_step_log(log_id, updates) if not result: raise HTTPException(status_code=404, detail="Not found") return result @router.delete("/fitness/steps/{log_id}") def delete_steps(log_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]: if not FitnessService(db, user.id).delete_step_log(log_id): raise HTTPException(status_code=404, detail="Not found") return {"ok": True} @router.post("/fitness/workouts") async def create_workout( payload: WorkoutCreate, db: Session = Depends(get_db), user: User = Depends(get_current_user), ) -> dict[str, Any]: service = FitnessService(db, user.id) try: structured = await structure_workout(payload.text) except Exception as exc: raise HTTPException(status_code=502, detail=str(exc)) from exc day = date.fromisoformat(payload.day) if payload.day else None return service.log_workout( title=structured.get("title") or "Тренировка", notes=structured.get("notes") or payload.text, duration_min=structured.get("duration_min"), exercises=structured.get("exercises"), active_calories=structured.get("active_calories"), total_calories=structured.get("total_calories"), steps=structured.get("steps"), activity_type=structured.get("activity_type"), met=structured.get("met"), day=day, days_ago=payload.days_ago, logged_at=payload.logged_at, ) @router.get("/fitness/body-metrics") def list_metrics( limit: int = 30, db: Session = Depends(get_db), user: User = Depends(get_current_user), ) -> list[dict[str, Any]]: return FitnessService(db, user.id).list_body_metrics(limit=limit) @router.patch("/fitness/meals/{log_id}") def update_meal( log_id: int, payload: MealUpdate, db: Session = Depends(get_db), user: User = Depends(get_current_user), ) -> dict[str, Any]: updates = payload.model_dump(exclude_unset=True) if not updates: raise HTTPException(status_code=400, detail="No fields to update") result = FitnessService(db, user.id).update_food_log(log_id, updates) if not result: raise HTTPException(status_code=404, detail="Not found") return result @router.patch("/fitness/water/{log_id}") def update_water( log_id: int, payload: WaterUpdate, db: Session = Depends(get_db), user: User = Depends(get_current_user), ) -> dict[str, Any]: updates = payload.model_dump(exclude_unset=True) if not updates: raise HTTPException(status_code=400, detail="No fields to update") result = FitnessService(db, user.id).update_water_log(log_id, updates) if not result: raise HTTPException(status_code=404, detail="Not found") return result @router.post("/fitness/logs/merge") def merge_logs( payload: LogMerge, db: Session = Depends(get_db), user: User = Depends(get_current_user), ) -> dict[str, Any]: service = FitnessService(db, user.id) kind = payload.kind.lower().strip() if kind in ("meal", "meals", "food"): result = service.merge_food_logs(payload.keep_id, payload.merge_id) elif kind == "water": result = service.merge_water_logs(payload.keep_id, payload.merge_id) elif kind in ("step", "steps"): result = service.merge_step_logs(payload.keep_id, payload.merge_id) else: raise HTTPException(status_code=400, detail="Unsupported kind") if not result: raise HTTPException(status_code=404, detail="Not found") return result @router.post("/fitness/logs/delete-last") def delete_last_log( payload: DeleteLastLog, db: Session = Depends(get_db), user: User = Depends(get_current_user), ) -> dict[str, Any]: day = date.fromisoformat(payload.day) if payload.day else None if day is None and payload.days_ago is not None: day = datetime.now(timezone.utc).date() - timedelta(days=int(payload.days_ago)) result = FitnessService(db, user.id).delete_last_log(payload.kind, day=day) if not result: raise HTTPException(status_code=404, detail="No records found") return result @router.delete("/fitness/meals/{log_id}") def delete_meal(log_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]: if not FitnessService(db, user.id).delete_food_log(log_id): raise HTTPException(status_code=404, detail="Not found") return {"ok": True} @router.delete("/fitness/water/{log_id}") def delete_water(log_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]: if not FitnessService(db, user.id).delete_water_log(log_id): raise HTTPException(status_code=404, detail="Not found") return {"ok": True} @router.delete("/fitness/workouts/{log_id}") def delete_workout(log_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict[str, bool]: if not FitnessService(db, user.id).delete_workout_log(log_id): raise HTTPException(status_code=404, detail="Not found") return {"ok": True} @router.get("/fitness/reminders") def list_reminders(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> list[dict[str, Any]]: return FitnessService(db, user.id).list_reminders() @router.put("/fitness/reminders/{kind}") def update_reminder( kind: str, payload: ReminderUpdate, db: Session = Depends(get_db), user: User = Depends(get_current_user), ) -> dict[str, Any]: return FitnessService(db, user.id).set_reminder( kind, enabled=payload.enabled, hour=payload.hour, minute=payload.minute, interval_hours=payload.interval_hours, ) @router.get("/fitness/lookup/food") def lookup_food(q: str, limit: int = 5) -> list[dict[str, Any]]: return OpenFoodFactsClient().search(q, limit=limit) @router.get("/fitness/lookup/exercise") def lookup_exercise(q: str, limit: int = 8) -> list[dict[str, Any]]: return WgerClient().search_exercises(q, limit=limit)