using TeleWave.Domain.Broadcast.Scheduling;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
using Xunit;

namespace TeleWave.Domain.Tests.Programming;

public class SchedulePlannerTests
{
    private static readonly DateTimeOffset T0 = new(2026, 1, 5, 12, 0, 0, TimeSpan.Zero);

    /// <summary>Всегда первый вариант — жребий в тестах не должен добавлять шума.</summary>
    private sealed class FirstAlways : IRandomSource
    {
        public int Next(int maxExclusive) => 0;
    }

    private static PlanningUnit Unit(int minutes, Guid showId, int index) =>
        new(Guid.NewGuid(), TimeSpan.FromMinutes(minutes), showId, index);

    private static PlanningElement Element(
        int unitCount,
        int minutesEach,
        int weight = 1,
        int position = 0,
        DateTimeOffset? lastPlayed = null
    )
    {
        var showId = Guid.NewGuid();
        var units = Enumerable
            .Range(0, unitCount)
            .Select(i => Unit(minutesEach, showId, i))
            .ToList();
        return new PlanningElement(
            GroupElementKind.Show,
            Guid.NewGuid(),
            weight,
            position,
            units,
            lastPlayed
        );
    }

    private static PlanningSlot Slot(
        DateTimeOffset startUtc,
        int durationMinutes,
        IReadOnlyList<PlanningElement> elements,
        SlotBlockMode blockMode = SlotBlockMode.FillSlot,
        int blockValue = 1,
        OverflowPolicy overflow = OverflowPolicy.ContinueNext,
        bool isAnchor = false,
        int? snapToMinutes = null,
        PlanningCursor? cursor = null,
        SlotStrategyKind strategy = SlotStrategyKind.Sequential,
        int cooldownDays = 0
    ) =>
        new(
            Guid.NewGuid(),
            startUtc,
            durationMinutes,
            SlotKind.Content,
            isAnchor,
            MaxDriftMinutes: 30,
            snapToMinutes,
            blockMode,
            blockValue,
            overflow,
            new PlanningStrategy(strategy, CooldownDays: cooldownDays),
            elements,
            cursor
        );

    private static PlanningInput Input(
        IReadOnlyList<PlanningSlot> slots,
        DateTimeOffset? start = null,
        int horizonHours = 6,
        IReadOnlyList<PlanningUnit>? fallback = null
    ) =>
        new(
            Guid.NewGuid(),
            start ?? T0,
            (start ?? T0).AddHours(horizonHours),
            slots,
            fallback ?? [Unit(1, Guid.NewGuid(), 0)],
            SegmentSeconds: 2
        );

    private static PlanningResult Run(PlanningInput input) =>
        TeleWave.Domain.Programming.Planning.SchedulePlanner.Plan(input, new FirstAlways());

    [Fact]
    public void ContinuousTape_HasNoGapsBetweenItems()
    {
        var element = Element(4, 30);
        var result = Run(Input([Slot(T0, 120, [element])]));

        var ordered = result.Items.OrderBy(i => i.StartsAtUtc).ToList();
        Assert.NotEmpty(ordered);
        for (var i = 1; i < ordered.Count; i++)
            Assert.Equal(ordered[i - 1].EndsAtUtc, ordered[i].StartsAtUtc);
    }

    [Fact]
    public void BlockModeCount_PlacesExactlyRequestedUnits()
    {
        var element = Element(10, 20);
        var result = Run(
            Input([Slot(T0, 300, [element], SlotBlockMode.Count, blockValue: 3)], horizonHours: 1)
        );

        Assert.Equal(3, result.Items.Count(i => i.Kind == PlannedItemKind.Program));
    }

    [Fact]
    public void BlockModeDuration_IncludesLastUnitWholly()
    {
        var element = Element(10, 20);
        var result = Run(
            Input(
                [Slot(T0, 300, [element], SlotBlockMode.Duration, blockValue: 50)],
                horizonHours: 2
            )
        );

        // 20+20 < 50, третья добирает до 60 — обрезать видеофайл нельзя.
        Assert.Equal(3, result.Items.Count(i => i.Kind == PlannedItemKind.Program));
    }

    [Fact]
    public void Anchor_DoesNotStartUnitThatCrossesIt()
    {
        var first = Element(10, 25);
        var second = Element(1, 30);

        var slots = new[]
        {
            Slot(T0, 60, [first]),
            Slot(T0.AddMinutes(60), 60, [second], isAnchor: true),
        };
        var result = Run(Input(slots));

        var anchored = result.Items.First(i => i.SlotId == slots[1].SlotId);
        // Третья 25-минутная серия перелезла бы через якорь — её не начинают.
        Assert.True(anchored.StartsAtUtc >= slots[1].TargetStartUtc);
    }

    [Fact]
    public void Snap_MovesStartToRoundTime()
    {
        // Первый слот кончается в 12:47 — второй с округлением до 15 минут стартует в 13:00.
        var first = Element(1, 47);
        var second = Element(1, 30);

        var slots = new[]
        {
            Slot(T0, 47, [first]),
            Slot(T0.AddMinutes(40), 60, [second], snapToMinutes: 15),
        };
        var result = Run(Input(slots));

        var secondItem = result.Items.First(i =>
            i.SlotId == slots[1].SlotId && i.Kind == PlannedItemKind.Program
        );
        Assert.Equal(0, secondItem.StartsAtUtc.Minute % 15);
    }

    [Fact]
    public void Snap_SkippedWhenDriftWouldExceedAllowance()
    {
        var element = Element(1, 30);
        var slot = Slot(T0.AddMinutes(-1), 60, [element], snapToMinutes: 30) with
        {
            MaxDriftMinutes = 2,
        };

        var result = Run(Input([slot]));

        // Округление потребовало бы почти 30 минут фона — лучше начать не в круглое время.
        var program = result.Items.First(i => i.Kind == PlannedItemKind.Program);
        Assert.Equal(T0, program.StartsAtUtc);
    }

    [Fact]
    public void GapBeforeSlot_IsFilledWithFallback()
    {
        var element = Element(1, 30);
        var result = Run(Input([Slot(T0.AddMinutes(10), 30, [element])]));

        Assert.Contains(result.Items, i => i.Kind == PlannedItemKind.Fallback);
        Assert.Equal(T0, result.Items.Min(i => i.StartsAtUtc));
    }

    [Fact]
    public void EmptySlot_WarnsAndFallsBack()
    {
        var result = Run(Input([Slot(T0, 30, [])]));

        Assert.Contains(result.Warnings, w => w.Kind == PlanningWarningKind.SlotEmpty);
        Assert.All(result.Items, i => Assert.NotEqual(PlannedItemKind.Program, i.Kind));
    }

    [Fact]
    public void Cursor_AdvancesToNextUnit()
    {
        var element = Element(5, 20);
        var slot = Slot(T0, 60, [element], SlotBlockMode.Count, blockValue: 2);

        var result = Run(Input([slot], horizonHours: 1));

        var cursor = Assert.Single(result.Cursors);
        Assert.Equal(element.ElementId, cursor.ElementId);
        Assert.Equal(2, cursor.NextUnitIndex);
    }

    [Fact]
    public void Cursor_ResumesMidElement()
    {
        var element = Element(5, 20);
        var slot = Slot(
            T0,
            60,
            [element],
            SlotBlockMode.Count,
            blockValue: 1,
            cursor: new PlanningCursor(GroupElementKind.Show, element.ElementId, 3)
        );

        var result = Run(Input([slot], horizonHours: 1));

        var program = result.Items.First(i => i.Kind == PlannedItemKind.Program);
        Assert.Equal(3, program.UnitIndex);
    }

    [Fact]
    public void SkipIfNotFits_LeavesSlotToFallback()
    {
        // Элемент из трёх часовых частей в часовой слот целиком не влезает.
        var element = Element(3, 60);
        var slot = Slot(T0, 60, [element], overflow: OverflowPolicy.SkipIfNotFits);

        var result = Run(Input([slot], horizonHours: 2));

        Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Program);
    }

    [Fact]
    public void ExtendSlot_PlaysElementBeyondBudget()
    {
        var element = Element(3, 60);
        var slot = Slot(T0, 60, [element], overflow: OverflowPolicy.ExtendSlot);

        var result = Run(Input([slot], horizonHours: 5));

        // Трилогия доигрывается целиком, слот залезает на соседей — так и задумано.
        Assert.Equal(3, result.Items.Count(i => i.Kind == PlannedItemKind.Program));
    }

    [Fact]
    public void Cooldown_PrefersElementOutsideWindow()
    {
        var hot = Element(1, 30, position: 0, lastPlayed: T0.AddDays(-1));
        var cold = Element(1, 30, position: 1, lastPlayed: T0.AddDays(-40));

        var slot = Slot(
            T0,
            30,
            [hot, cold],
            SlotBlockMode.Count,
            blockValue: 1,
            strategy: SlotStrategyKind.RandomWithCooldown,
            cooldownDays: 28
        );

        var result = Run(Input([slot], horizonHours: 1));

        var program = result.Items.First(i => i.Kind == PlannedItemKind.Program);
        Assert.Equal(cold.Units[0].ShowId, program.ShowId);
    }

    [Fact]
    public void Cooldown_Exhausted_WarnsAndTakesOldest()
    {
        var recentA = Element(1, 30, position: 0, lastPlayed: T0.AddDays(-2));
        var recentB = Element(1, 30, position: 1, lastPlayed: T0.AddDays(-5));

        var slot = Slot(
            T0,
            30,
            [recentA, recentB],
            SlotBlockMode.Count,
            blockValue: 1,
            strategy: SlotStrategyKind.RandomWithCooldown,
            cooldownDays: 28
        );

        var result = Run(Input([slot], horizonHours: 1));

        Assert.Contains(result.Warnings, w => w.Kind == PlanningWarningKind.CooldownExhausted);
        var program = result.Items.First(i => i.Kind == PlannedItemKind.Program);
        Assert.Equal(recentB.Units[0].ShowId, program.ShowId);
    }

    [Fact]
    public void SignOffSlot_MarksItemsAccordingly()
    {
        var slot = Slot(T0, 60, []) with { SlotKind = SlotKind.SignOff };

        var result = Run(Input([slot], horizonHours: 2));

        Assert.Contains(result.Items, i => i.Kind == PlannedItemKind.SignOff);
    }

    [Fact]
    public void RepeatSlot_ReplaysSourceUnits()
    {
        var showId = Guid.NewGuid();
        var repeat = new[] { Unit(30, showId, 0), Unit(30, showId, 1) };
        var slot = Slot(T0, 60, []) with { SlotKind = SlotKind.Repeat, RepeatUnits = repeat };

        var result = Run(Input([slot], horizonHours: 2));

        Assert.Equal(2, result.Items.Count(i => i.Kind == PlannedItemKind.Program));
    }

    [Fact]
    public void RepeatSlot_WithEmptySource_WarnsAndFallsBack()
    {
        var slot = Slot(T0, 60, []) with { SlotKind = SlotKind.Repeat, RepeatUnits = [] };

        var result = Run(Input([slot], horizonHours: 2));

        Assert.Contains(result.Warnings, w => w.Kind == PlanningWarningKind.RepeatSourceEmpty);
    }

    [Fact]
    public void Trace_IsWrittenWithSlotAndStrategy()
    {
        var element = Element(1, 30);
        var slot = Slot(T0, 30, [element], SlotBlockMode.Count, blockValue: 1);

        var result = Run(Input([slot], horizonHours: 1));

        var program = result.Items.First(i => i.Kind == PlannedItemKind.Program);
        Assert.NotNull(program.Trace);
        Assert.Equal(slot.SlotId, program.Trace!.SlotId);
        Assert.Equal(SlotStrategyKind.Sequential, program.Trace.Strategy);
        Assert.Equal(element.ElementId, program.Trace.ElementId);
    }

    [Fact]
    public void NoFallbackUnits_ProducesWarning()
    {
        var input = Input([], fallback: []);

        var result = Run(input);

        Assert.Contains(result.Warnings, w => w.Kind == PlanningWarningKind.FallbackEmpty);
    }

    [Fact]
    public void SequentialStrategy_MovesToNextElementWhenCurrentExhausted()
    {
        var first = Element(1, 30, position: 0);
        var second = Element(1, 30, position: 1);

        var slot = Slot(
            T0,
            30,
            [first, second],
            SlotBlockMode.Count,
            blockValue: 1,
            cursor: new PlanningCursor(GroupElementKind.Show, first.ElementId, 1)
        );

        var result = Run(Input([slot], horizonHours: 1));

        var program = result.Items.First(i => i.Kind == PlannedItemKind.Program);
        Assert.Equal(second.Units[0].ShowId, program.ShowId);
    }
}
