using System.Text.Json;
using System.Text.Json.Nodes;
using Server.WatchBody.Core.Dal.Src.Domain;
using Server.WatchBody.Core.Dal.Src.Repository;
namespace Server.WatchBody.Core.Programs;
///
/// Детерминированные правила прогрессии (Ф-12.2) и делоада (Ф-12.4):
/// результат — предложение draft-версии (author=rules) через
/// ProgramStore; активация — только человеком (🛡 vision 2.4).
/// Ограничения атлета — через персональную библиотеку: is_active=false
/// не попадает в draft, пара-замена подставляется при наличии
/// (module_reqs разд. 2.4). 🛡 Активная версия не меняется; в факт
/// ничего не пишет.
///
public class ProgressionRules(
IWorkoutRepository workouts,
IExerciseRepository exercises,
ProgramStore programs,
ProgressionConfig config)
{
private static readonly string[] DayNames =
["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"];
/// Микроправка весов после тренировки (spec 02): правила 1–5
/// по порядку, первое сработавшее; draft одна на активную версию.
public ProgramVersion? EvaluateAfterWorkout(int athleteId, Guid workoutId)
{
var active = programs.GetActive(athleteId);
if (active == null || string.IsNullOrWhiteSpace(active.Body)) return null;
if (HasRulesDraft(athleteId, active.Version)) return null;
var workout = workouts.Get(athleteId, workoutId);
if (workout == null) return null;
var byName = NameIndex(athleteId);
var history = workouts
.Select(new WorkoutFilter { AthleteId = athleteId })
.OrderByDescending(w => w.StartedAt)
.ToList();
var body = JsonNode.Parse(active.Body) as JsonObject
?? throw new InvalidOperationException("body активной версии не является объектом");
var rationale = new List();
foreach (var week in Weeks(body))
foreach (var session in Sessions(week))
{
if (session["exercises"] is not JsonArray exerciseNodes) continue;
foreach (var exerciseNode in exerciseNodes.OfType().ToList())
{
var planId = exerciseNode["exercise_id"]?.GetValue();
if (planId == null || !byName.TryGetValue(planId, out var ex))
{
rationale.Add($"{planId}: нет в библиотеке атлета — решение не генерируется");
continue;
}
if (!ex.IsActive)
{
ApplyExclusion(exerciseNodes, exerciseNode, ex, byName, rationale);
continue;
}
var decision = Decide(workout, ex, history, Sets(exerciseNode));
if (decision == null) continue;
ApplyDecision(exerciseNode, decision.Value, rationale);
}
// сессия только из исключённых упражнений → из плана целиком
if (session["exercises"] is JsonArray remaining && remaining.Count == 0 &&
session.Parent is JsonArray sessionList)
sessionList.Remove(session);
}
if (rationale.Count == 0) return null;
var draft = programs.CreateDraft(athleteId,
body.ToJsonString(), ProgramVersionAuthors.Rules,
"Микроправка после тренировки: " + string.Join("; ", rationale));
return draft;
}
/// Делоад (spec 03): DeloadMaxWeeks без делаода → draft
/// (веса ×0.6, подходы −1, session_kind=deload в week 1).
public ProgramVersion? CheckDeload(int athleteId, DateOnly today)
{
var active = programs.GetActive(athleteId);
if (active == null || string.IsNullOrWhiteSpace(active.Body)) return null;
if (HasRulesDraft(athleteId, active.Version)) return null;
var all = workouts.Select(new WorkoutFilter { AthleteId = athleteId })
.OrderByDescending(w => w.StartedAt).ToList();
if (all.Count == 0) return null;
// атлет давно не тренируется — это домен break, не делоад
if (today > DateOnly.FromDateTime(all[0].StartedAt).AddDays(config.DeloadMaxWeeks * 7))
return null;
var lastDeload = all.FirstOrDefault(w => w.SessionKind == SessionKinds.Deload);
var anchor = lastDeload != null
? DateOnly.FromDateTime(lastDeload.StartedAt)
: DateOnly.FromDateTime(active.CreatedAt);
if (today < anchor.AddDays(config.DeloadMaxWeeks * 7)) return null;
var body = JsonNode.Parse(active.Body) as JsonObject
?? throw new InvalidOperationException("body активной версии не является объектом");
var firstWeek = Weeks(body).FirstOrDefault();
if (firstWeek == null) return null;
foreach (var session in Sessions(firstWeek))
{
session["session_kind"] = SessionKinds.Deload;
if (session["exercises"] is not JsonArray exerciseNodes) continue;
foreach (var exerciseNode in exerciseNodes.OfType())
foreach (var set in Sets(exerciseNode))
if (WeightOf(set) is decimal weight)
set["weight_kg"] = Halves(weight * 0.6m);
TrimSets(session, remove: 1);
}
return programs.CreateDraft(athleteId, body.ToJsonString(),
ProgramVersionAuthors.Rules,
$"Делоад: {config.DeloadMaxWeeks} недель без лёгкой недели — объём −40–50 %");
}
// ---------- микроправка: решение по одному упражнению ----------
private (decimal? newWeight, int? newReps, string why)? Decide(
Workout workout, Exercise ex, List history, List planSets)
{
if (planSets.Count == 0) return null;
var planWeight = WeightOf(planSets[0]);
var planReps = planSets[0]["reps"]?.GetValue();
if (planWeight == null || planReps == null) return null; // калибровка
var setsOf = (Workout w) => w.Sets
.Where(s => s.ExerciseId == ex.Id && s.Source != WorkoutSetSources.Edit)
.ToList();
var current = setsOf(workout);
if (current.Count == 0) return null;
var step = ex.IsBase ? config.BaseStepKg : config.IsoStepKg;
// правило 1: 2 тренировки подряд все подходы «по плану» при RPE ≤ 7
var withExercise = history.Where(w => setsOf(w).Count > 0).Take(2).ToList();
if (withExercise.Count == 2 && withExercise.All(ClosedEasy))
return (Halves(planWeight.Value + step), null,
$"+{step} (2 тренировки подряд закрыты при RPE≤7)");
// правило 2: RPE ≥ 9 в 2+ сетах
if (current.Count(s => s.Rpe >= 9) >= 2)
return (Halves(planWeight.Value * (1 - config.BackoffPercent / 100m)), null,
$"−{config.BackoffPercent}% (RPE≥9 в {current.Count(s => s.Rpe >= 9)} сетах)");
// правило 3: верхняя граница диапазона повторений (15–20)
if (current.All(s => s.InPlan) && planReps >= 15)
return (Halves(planWeight.Value + step), 12,
$"+{step}, повторения к 12 (верх диапазона достигнут)");
// правило 4: ≥ 2 подходов не дотянуты по повторениям
var shortfalls = current.Count(s => s.Reps < planReps);
if (shortfalls >= 2)
return (Halves(planWeight.Value * (1 - config.BackoffPercent / 100m)), null,
$"−{config.BackoffPercent}% ({shortfalls} подходов не дотянуты)");
return null; // правило 5: без изменения
}
private static bool ClosedEasy(Workout w) =>
w.Sets.Where(s => s.Source != WorkoutSetSources.Edit).All(
s => s.InPlan && s.Rpe != null && s.Rpe <= 7);
private static void ApplyDecision(
JsonObject exerciseNode, (decimal? newWeight, int? newReps, string why) decision,
List rationale)
{
var name = exerciseNode["exercise_id"]?.GetValue();
var from = WeightOf(Sets(exerciseNode).FirstOrDefault());
foreach (var set in Sets(exerciseNode))
{
if (decision.newWeight != null)
set["weight_kg"] = decision.newWeight;
if (decision.newReps != null)
set["reps"] = decision.newReps;
}
rationale.Add($"{name}: {from} → {decision.newWeight ?? from} ({decision.why})");
}
/// Ограничения (spec 02 §4): неактивное упражнение → замена
/// из пары (активная, та же группа) или удаление из сессии.
private void ApplyExclusion(
JsonArray siblings, JsonObject exerciseNode, Exercise ex,
Dictionary byName, List rationale)
{
var replacement = ex.ReplacementIds
.Select(id => byName.Values.FirstOrDefault(e => e.Id == id))
.FirstOrDefault(r => r != null && r.IsActive);
if (replacement != null)
{
exerciseNode["exercise_id"] = replacement.Name;
rationale.Add($"{ex.Name}: исключено (is_active=false) → замена {replacement.Name}");
return;
}
siblings.Remove(exerciseNode);
rationale.Add($"{ex.Name}: исключено (is_active=false), замены нет");
}
// ---------- делоад/общие помощники ----------
private static void TrimSets(JsonObject session, int remove)
{
if (session["exercises"] is not JsonArray exerciseNodes) return;
foreach (var exerciseNode in exerciseNodes.OfType())
{
if (exerciseNode["sets"] is not JsonArray sets) continue;
while (sets.Count > 1 && remove > 0) { sets.RemoveAt(sets.Count - 1); remove--; }
}
}
// материализуем: ApplyExclusion удаляет узлы во время обхода
private static List Weeks(JsonObject body) =>
body["weeks"] is JsonArray weeks ? weeks.OfType().ToList() : [];
private static List Sessions(JsonObject week) =>
week?["sessions"] is JsonArray sessions ? sessions.OfType().ToList() : [];
private static List Sets(JsonObject exerciseNode) =>
exerciseNode["sets"] is JsonArray sets ? sets.OfType().ToList() : [];
private static decimal? WeightOf(JsonObject? set)
{
if (set?["weight_kg"] is not JsonValue value) return null;
return value.TryGetValue(out decimal weight) ? weight : null;
}
private static decimal Halves(decimal value) => Math.Round(value * 2m, MidpointRounding.AwayFromZero) / 2m;
private Dictionary NameIndex(int athleteId) =>
exercises.Select(new ExerciseFilter { AthleteId = athleteId })
.GroupBy(e => e.Name, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
/// Rules-draft, основанный на активной версии или новее
/// (spec 02 §5.3) — пока он есть, новых предложений не создаём.
private bool HasRulesDraft(int athleteId, int activeVersion) =>
programs.ListVersions(athleteId).Any(v =>
v.Author == ProgramVersionAuthors.Rules &&
v.Status == ProgramVersionStatuses.Draft &&
(v.BasedOnVersion ?? 0) >= activeVersion);
}