using System.Text.Json;
using Server.WatchBody.Core.Dal.Ref.Domain;
using Server.WatchBody.Core.Dal.Ref.Repository;
using Server.WatchBody.Core.Dal.Src.Domain;
using Server.WatchBody.Core.Dal.Src.Repository;
using Server.WatchBody.Core.Programs;
namespace Server.WatchBody.Core.Metrics;
///
/// Детерминированный расчёт метрик одной тренировки (Ф-12.1, документ
/// метрик п. 2.5, правила 1 и 6) → src.workout_metrics (upsert).
/// Все выборки по athleteId (🛡 изоляция); в план ничего не пишет —
/// это m05 (🛡3); сбой расчёта не должен ломать sync (🛡4, см. m02).
///
public class MetricsEngine(
IWorkoutRepository workouts,
IExerciseRepository exercises,
IExerciseGroupRepository groups,
IDailyLogRepository dailyLogs,
IWorkoutMetricsRepository metrics,
ProgramStore programs,
MetricsConfig safety)
{
private static readonly string[] DayNames =
["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"];
public WorkoutMetrics ComputeForWorkout(int athleteId, Guid workoutId)
{
var workout = workouts.Get(athleteId, workoutId)
?? throw new InvalidOperationException(
$"тренировка {workoutId} не найдена (athlete {athleteId})");
var sets = workout.Sets.ToList();
var exById = exercises.Select(new ExerciseFilter { AthleteId = athleteId })
.ToDictionary(e => e.Id);
var groupCodeById = groups.Select().ToDictionary(g => g.Id, g => g.Code);
var date = DateOnly.FromDateTime(workout.StartedAt);
var logs = dailyLogs.Select(new DailyLogFilter
{
AthleteId = athleteId,
DateFrom = date.AddDays(-safety.HrBaselineDays - safety.LightDayCount),
DateTo = date,
})
.GroupBy(l => l.Date).ToDictionary(g => g.Key, g => g.First());
var result = new WorkoutMetrics
{
WorkoutId = workoutId,
AthleteId = athleteId,
Adherence = Adherence(workout, sets, date),
Tonnage = Tonnage(sets),
TonnageByGroup = JsonSerializer.Serialize(TonnageByGroup(sets, exById, groupCodeById)),
E1pmByExercise = JsonSerializer.Serialize(E1pmByExercise(sets)),
HrMetrics = HrMetrics(workout),
RedFlags = JsonSerializer.Serialize(RedFlags(workout, sets, date, logs, exById)),
};
metrics.Upsert(athleteId, result);
return result;
}
/// % подходов «по плану» от планового числа подходов сессии дня;
/// план не сопоставлен → доля in_plan среди фактических подходов (spec 3.1).
private decimal Adherence(Workout workout, List sets, DateOnly date)
{
var inPlan = sets.Count(s => s.InPlan);
var planned = PlannedSets(workout, date);
if (planned != null)
return planned.Value == 0 ? 0 : Percent(inPlan, planned.Value);
return sets.Count == 0 ? 0 : Percent(inPlan, sets.Count);
}
/// Плановое число подходов сессии дня недели из активной версии;
/// null — плана нет (нет версии / сессии дня / body не парсится).
private int? PlannedSets(Workout workout, DateOnly date)
{
var active = programs.GetActive(workout.AthleteId);
if (active == null || string.IsNullOrWhiteSpace(active.Body)) return null;
try
{
using var doc = JsonDocument.Parse(active.Body);
var day = DayNames[((int)date.DayOfWeek + 6) % 7];
foreach (var week in doc.RootElement.GetProperty("weeks").EnumerateArray())
foreach (var session in week.GetProperty("sessions").EnumerateArray())
{
if (!session.TryGetProperty("day", out var d) ||
!string.Equals(d.GetString(), day, StringComparison.OrdinalIgnoreCase))
continue;
var count = 0;
foreach (var exercise in session.GetProperty("exercises").EnumerateArray())
count += exercise.GetProperty("sets").GetArrayLength();
return count;
}
return null;
}
catch (Exception)
{
return null; // body не парсится — метрики считаем без плана
}
}
private static decimal Tonnage(List sets) =>
sets.Where(s => s.WeightKg > 0 && s.Reps > 0).Sum(s => s.WeightKg * s.Reps);
private static Dictionary TonnageByGroup(
List sets, Dictionary exById, Dictionary groupCodeById)
{
var result = new Dictionary();
foreach (var set in sets)
{
if (set.WeightKg <= 0 || set.Reps <= 0) continue;
if (set.ExerciseId == null || !exById.TryGetValue(set.ExerciseId.Value, out var ex)) continue;
var code = groupCodeById.GetValueOrDefault(ex.GroupId, $"group_{ex.GroupId}");
result[code] = result.GetValueOrDefault(code) + set.WeightKg * set.Reps;
}
return result;
}
/// Эпли (ОВ-7): e1pm = w × (1 + reps/30) по лучшему подходу
/// упражнения (max вес, при равном — max reps), spec 3.4.
private static Dictionary E1pmByExercise(List sets)
{
var best = new Dictionary();
foreach (var set in sets)
{
if (set.ExerciseId == null || set.WeightKg <= 0 || set.Reps <= 0) continue;
if (!best.TryGetValue(set.ExerciseId.Value, out var b) ||
set.WeightKg > b.WeightKg ||
(set.WeightKg == b.WeightKg && set.Reps > b.Reps))
best[set.ExerciseId.Value] = set;
}
return best.ToDictionary(
p => p.Key.ToString(),
p => Math.Round(p.Value.WeightKg * (1m + p.Value.Reps / 30m), 1, MidpointRounding.AwayFromZero));
}
/// Из фактов .fit (m06), что есть; фактов нет — телефонные
/// avg_hr/hr_recovery_s (spec 3.5).
private static string HrMetrics(Workout workout)
{
var result = new Dictionary();
if (!string.IsNullOrWhiteSpace(workout.FactsJson))
{
try
{
using var doc = JsonDocument.Parse(workout.FactsJson);
foreach (var key in new[] { "avg_hr", "peak_hr", "rest_between_s" })
if (doc.RootElement.TryGetProperty(key, out var v) && v.TryGetDecimal(out var d))
result[key] = d;
}
catch (JsonException)
{
// битые факты — метрики по телефонным полям
}
}
if (result.Count == 0)
{
if (workout.AvgHr != null) result["avg_hr"] = workout.AvgHr.Value;
if (workout.HrRecoveryS != null) result["hr_recovery_s"] = workout.HrRecoveryS.Value;
}
return JsonSerializer.Serialize(result);
}
private List