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;
///
/// Перерывы (Ф-14): авто-детект по пропускам (source=auto), закрытие
/// первой сессией, формула возврата (документ перерывов разд. 3.2) →
/// draft-версия (author=rules) с session_kind=return|test в week 1.
/// 🛡 Активная версия не меняется; запись разрешена только в breaks
/// и через ProgramStore.CreateDraft.
///
public class BreakEngine(
IWorkoutRepository workouts,
IBreakRepository breaks,
ProgramStore programs,
ProgressionConfig config)
{
private static readonly string[] DayNames =
["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"];
/// Авто-детект (spec 04 §2): все плановые дни окна
/// MissDaysForBreak без единой тренировки → break_insert(auto).
public bool DetectBreak(int athleteId, DateOnly today)
{
if (OpenBreak(athleteId) != null) return false;
var active = programs.GetActive(athleteId);
if (active == null || string.IsNullOrWhiteSpace(active.Body)) return false;
var plannedDays = PlannedDays(active.Body);
var windowStart = today.AddDays(-(config.MissDaysForBreak - 1));
var workoutDays = workouts
.Select(new WorkoutFilter { AthleteId = athleteId, DateFrom = windowStart, DateTo = today })
.Select(w => DateOnly.FromDateTime(w.StartedAt))
.ToHashSet();
if (workoutDays.Count > 0) return false; // в окне есть тренировка — не перерыв
var missed = new List();
for (var day = windowStart; day <= today; day = day.AddDays(1))
{
if (!plannedDays.Contains(DayName(day))) continue;
missed.Add(day);
}
if (missed.Count == 0) return false;
breaks.Insert(athleteId, new Break
{
Id = Guid.NewGuid(),
AthleteId = athleteId,
Source = BreakSources.Auto,
StartedOn = missed[0],
Note = $"авто: пропущены все сессии за {config.MissDaysForBreak} дней",
});
return true;
}
/// Закрытие первой сессией после break (ended_on) и
/// draft-версия возврата с коэффициентом по таблице (spec 04 §3).
public ProgramVersion? CloseOnReturn(int athleteId, Guid workoutId, DateOnly today)
{
var open = OpenBreak(athleteId);
if (open == null) return null;
var active = programs.GetActive(athleteId);
if (active == null || string.IsNullOrWhiteSpace(active.Body)) return null;
var workout = workouts.Get(athleteId, workoutId);
if (workout == null) return null;
var endedOn = DateOnly.FromDateTime(workout.StartedAt);
open.EndedOn = endedOn;
breaks.Update(athleteId, open); // closed_by_first_session (db-m-8)
if (HasRulesDraft(athleteId, active.Version)) return null;
var days = endedOn.DayNumber - open.StartedOn.DayNumber;
var factor = open.Kind == BreakKinds.Illness
? config.ReturnMinFactor // болезнь — перезапуск (правило 12)
: days switch
{
<= 6 => 1m, // 4–6 дней: веса без снижения
<= 13 => 0.9m, // 7–13: −10 %
<= 27 => 0.85m, // 14–27: −15 %
<= 59 => 0.75m, // 28–59: −25 %
_ => config.ReturnMinFactor, // 60+: перезапуск
};
if (factor < 1m)
factor = Math.Clamp(factor, config.ReturnMinFactor, config.ReturnMaxFactor);
var kind = days >= 14 && open.Kind != BreakKinds.Illness
? SessionKinds.Test // правило 17: тест-сессия RPE ≤ 6
: SessionKinds.Return;
var body = JsonNode.Parse(active.Body) as JsonObject
?? throw new InvalidOperationException("body активной версии не является объектом");
var firstWeek = body["weeks"] is JsonArray weeks && weeks.Count > 0
? weeks[0] as JsonObject
: null;
if (firstWeek != null)
foreach (var session in firstWeek["sessions"] is JsonArray sessions
? sessions.OfType().ToList()
: [])
{
session["session_kind"] = kind;
foreach (var exerciseNode in session["exercises"] is JsonArray exerciseNodes
? exerciseNodes.OfType().ToList()
: [])
foreach (var set in exerciseNode["sets"] is JsonArray sets
? sets.OfType().ToList()
: [])
{
if (set["weight_kg"] is JsonValue value && value.TryGetValue(out decimal weight))
set["weight_kg"] = Halves(weight * factor);
// 4–6 дней: первая сессия мягче (−1 подход, RPE 5–6)
if (days <= 6 && set["target_rpe"] != null)
set["target_rpe"] = 5;
}
if (days <= 6) TrimSets(session);
}
var kindNote = open.Kind == BreakKinds.Illness
? "болезнь"
: $"простой {days} дн.";
return programs.CreateDraft(athleteId, body.ToJsonString(),
ProgramVersionAuthors.Rules,
$"Возврат после break ({kindNote}): веса ×{factor}, session_kind={kind}");
}
private Break? OpenBreak(int athleteId) =>
breaks.Select(new BreakFilter { AthleteId = athleteId })
.Where(b => b.EndedOn == null)
.OrderByDescending(b => b.StartedOn)
.FirstOrDefault();
/// Плановые дни недели активной версии (v1 — все недели
/// одинаковы, ротация — v2).
private static HashSet PlannedDays(string body)
{
var result = new HashSet(StringComparer.OrdinalIgnoreCase);
using var doc = JsonDocument.Parse(body);
foreach (var week in doc.RootElement.GetProperty("weeks").EnumerateArray())
foreach (var session in week.GetProperty("sessions").EnumerateArray())
if (session.TryGetProperty("day", out var day) && day.ValueKind == JsonValueKind.String)
result.Add(day.GetString()!);
return result;
}
private static string DayName(DateOnly date) => DayNames[((int)date.DayOfWeek + 6) % 7];
/// Первая сессия мягче: подходы −1 (минимум 1).
private static void TrimSets(JsonObject session)
{
if (session["exercises"] is not JsonArray exerciseNodes) return;
foreach (var exerciseNode in exerciseNodes.OfType())
{
if (exerciseNode["sets"] is not JsonArray sets) continue;
if (sets.Count > 1) sets.RemoveAt(sets.Count - 1);
}
}
private static decimal Halves(decimal value) =>
Math.Round(value * 2m, MidpointRounding.AwayFromZero) / 2m;
/// Rules-draft на активную версию или новее — предложений
/// не создаём (spec 04 §3.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);
}