using TeleWave.Domain.Broadcast.Scheduling;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
using Xunit;
using GridPlanner = TeleWave.Domain.Programming.Planning.SchedulePlanner;

namespace TeleWave.Domain.Tests.Programming;

public class JunctionFillerTests
{
    private static readonly DateTimeOffset T0 = new(2026, 4, 6, 18, 0, 0, TimeSpan.Zero);

    private sealed class FirstAlways : IRandomSource
    {
        public int Next(int maxExclusive) => 0;
    }

    /// <summary>Всегда последний вариант — по нему видно, что жребий вообще участвует в выборе.</summary>
    private sealed class LastAlways : IRandomSource
    {
        public int Next(int maxExclusive) => Math.Max(0, maxExclusive - 1);
    }

    /// <summary>
    /// Считает броски жребия. Жребий — единственный, кто спрашивает сотню, поэтому по этому числу
    /// его и отличаем от выбора внутри развилки, который спрашивает сумму весов.
    /// </summary>
    private sealed class CountingRandom : IRandomSource
    {
        public int Rolls { get; private set; }

        public int Next(int maxExclusive)
        {
            if (maxExclusive == 100)
                Rolls++;
            return 0;
        }
    }

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

    private static PlanningElement Series(int episodes, int minutes, out Guid showId)
    {
        showId = Guid.NewGuid();
        var id = showId;
        var units = Enumerable.Range(0, episodes).Select(i => Unit(minutes, id, i)).ToList();
        return new PlanningElement(GroupElementKind.Show, Guid.NewGuid(), 1, 0, units);
    }

    private static PlanningJunctionElement Ads(
        int count,
        double minutesEach = 0.5,
        bool required = false,
        int minMinutesBetween = 0,
        bool onlyOnElementChange = false,
        int chance = 100,
        PlanningTimeWindow? window = null,
        string? choiceKey = null,
        int choiceWeight = 1,
        int poolSize = 8,
        int nearHourMinutes = 0
    ) =>
        new(
            Guid.NewGuid(),
            JunctionElementKind.Ad,
            Enumerable.Range(0, poolSize).Select(_ => Unit(minutesEach)).ToList(),
            JunctionAmountMode.Count,
            count,
            required,
            onlyOnElementChange,
            minMinutesBetween,
            chance,
            window,
            nearHourMinutes,
            choiceKey,
            choiceWeight
        );

    private static PlanningSlot Slot(
        PlanningElement element,
        int blockValue,
        PlanningJunction? between = null,
        PlanningJunction? after = null
    ) =>
        new(
            Guid.NewGuid(),
            T0,
            240,
            SlotKind.Content,
            IsAnchor: false,
            MaxDriftMinutes: 30,
            SnapToMinutes: null,
            SlotBlockMode.Count,
            blockValue,
            OverflowPolicy.ContinueNext,
            new PlanningStrategy(SlotStrategyKind.Sequential),
            [element],
            Cursor: null,
            RepeatUnits: null,
            JunctionBetween: between,
            JunctionAfter: after
        );

    private static PlanningResult Run(
        PlanningSlot slot,
        IRandomSource? random = null,
        int utcOffsetMinutes = 0
    ) =>
        GridPlanner.Plan(
            new PlanningInput(
                Guid.NewGuid(),
                T0,
                T0.AddHours(5),
                [slot],
                [Unit(1)],
                SegmentSeconds: 2,
                UtcOffsetMinutes: utcOffsetMinutes
            ),
            random ?? new FirstAlways()
        );

    [Fact]
    public void BetweenJunction_PlacesAdsBetweenUnitsButNotBeforeFirst()
    {
        var element = Series(3, 20, out _);
        var result = Run(Slot(element, 3, between: new PlanningJunction(Guid.NewGuid(), [Ads(2)])));

        var ordered = result.Items.OrderBy(i => i.StartsAtUtc).ToList();
        Assert.Equal(PlannedItemKind.Program, ordered[0].Kind);
        // Между тремя сериями два стыка по две врезки.
        Assert.Equal(4, result.Items.Count(i => i.Kind == PlannedItemKind.Ad));
    }

    [Fact]
    public void AfterJunction_PlacesAdsAtBlockEnd()
    {
        var element = Series(2, 20, out _);
        var result = Run(Slot(element, 2, after: new PlanningJunction(Guid.NewGuid(), [Ads(3)])));

        var last = result
            .Items.Where(i => i.Kind != PlannedItemKind.Fallback)
            .OrderBy(i => i.StartsAtUtc)
            .Last();
        Assert.Equal(PlannedItemKind.Ad, last.Kind);
        Assert.Equal(3, result.Items.Count(i => i.Kind == PlannedItemKind.Ad));
    }

    [Fact]
    public void DurationMode_IncludesLastUnitWholly()
    {
        var element = Series(2, 20, out _);
        var ads = new PlanningJunctionElement(
            Guid.NewGuid(),
            JunctionElementKind.Ad,
            Enumerable.Range(0, 6).Select(_ => Unit(0.5)).ToList(),
            JunctionAmountMode.Duration,
            1,
            IsRequired: false
        );

        var result = Run(Slot(element, 2, between: new PlanningJunction(Guid.NewGuid(), [ads])));

        // Бюджет 1 минута, ролики по 30 секунд: две штуки добирают ровно минуту, третья не нужна.
        Assert.Equal(2, result.Items.Count(i => i.Kind == PlannedItemKind.Ad));
    }

    [Fact]
    public void MinInterval_SkipsSecondJunction()
    {
        var element = Series(3, 20, out _);
        var result = Run(
            Slot(
                element,
                3,
                between: new PlanningJunction(Guid.NewGuid(), [Ads(1, minMinutesBetween: 60)])
            )
        );

        // Серии по 20 минут: второй стык приходит через 20 минут после первого — раньше разрешённого.
        Assert.Equal(1, result.Items.Count(i => i.Kind == PlannedItemKind.Ad));
    }

    [Fact]
    public void MinInterval_IsPerElement_NotPerKind()
    {
        var element = Series(2, 20, out _);
        var frequent = Ads(1, minMinutesBetween: 0);
        var rare = Ads(1, minMinutesBetween: 600);

        var result = Run(
            Slot(element, 2, between: new PlanningJunction(Guid.NewGuid(), [frequent, rare]))
        );

        // Обе врезки рекламные, но ограничение у каждой своё — общий счётчик на вид склеил бы их.
        Assert.Equal(2, result.Items.Count(i => i.Kind == PlannedItemKind.Ad));
    }

    [Fact]
    public void Units_RotateBetweenJunctions()
    {
        var element = Series(3, 20, out _);
        var result = Run(Slot(element, 3, between: new PlanningJunction(Guid.NewGuid(), [Ads(1)])));

        var ads = result.Items.Where(i => i.Kind == PlannedItemKind.Ad).ToList();
        // Иначе каждый рекламный блок начинался бы с одного и того же ролика.
        Assert.Equal(2, ads.Select(a => a.MediaAssetId).Distinct().Count());
    }

    [Fact]
    public void OnlyOnElementChange_SkipsWithinSameShow()
    {
        var element = Series(3, 20, out _);
        var result = Run(
            Slot(
                element,
                3,
                between: new PlanningJunction(Guid.NewGuid(), [Ads(1, onlyOnElementChange: true)])
            )
        );

        // Внутри одного сериала смены нет — врезка не ставится.
        Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Ad);
    }

    [Fact]
    public void Chance_Zero_SkipsElement()
    {
        var element = Series(3, 20, out _);
        var result = Run(
            Slot(element, 3, between: new PlanningJunction(Guid.NewGuid(), [Ads(1, chance: 0)]))
        );

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

    [Fact]
    public void TimeWindow_SkipsOutsideChannelHours()
    {
        var element = Series(3, 20, out _);
        // Стык идёт в 18:20 UTC, канал в UTC+3 — 21:20 по времени канала, окно 06:00–20:00 закрыто.
        var window = new PlanningTimeWindow(new TimeOnly(6, 0), new TimeOnly(20, 0));
        var result = Run(
            Slot(
                element,
                3,
                between: new PlanningJunction(Guid.NewGuid(), [Ads(1, window: window)])
            ),
            utcOffsetMinutes: 180
        );

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

    [Fact]
    public void NearHour_SkipsAwayFromRoundHour()
    {
        // Серии по 20 минут: стыки приходятся на 18:20 и 18:40 — сигналу точного времени там не место.
        var element = Series(3, 20, out _);
        var result = Run(
            Slot(
                element,
                3,
                between: new PlanningJunction(Guid.NewGuid(), [Ads(1, nearHourMinutes: 5)])
            )
        );

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

    [Fact]
    public void NearHour_PlacesOnTheHour()
    {
        // Часовые серии: стыки ровно в 19:00 и 20:00 — врезка с допуском срабатывает на каждом.
        var element = Series(3, 60, out _);
        var result = Run(
            Slot(
                element,
                3,
                between: new PlanningJunction(Guid.NewGuid(), [Ads(1, nearHourMinutes: 5)])
            )
        );

        var ads = result.Items.Where(i => i.Kind == PlannedItemKind.Ad).ToList();
        Assert.Equal(2, ads.Count);
        Assert.All(ads, ad => Assert.Equal(0, ad.StartsAtUtc.Minute));
    }

    [Fact]
    public void NearHour_CountsChannelTime_NotUtc()
    {
        // Канал в UTC+3:30: круглый час канала приходится на :30 по UTC, и считать надо по каналу —
        // «московское время» объявляют в эфире, а не в UTC.
        var element = Series(3, 60, out _);
        var result = Run(
            Slot(
                element,
                3,
                between: new PlanningJunction(Guid.NewGuid(), [Ads(1, nearHourMinutes: 5)])
            ),
            utcOffsetMinutes: 210
        );

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

    [Fact]
    public void Choice_PlacesExactlyOneOfTheFork()
    {
        var element = Series(2, 20, out _);
        var first = Ads(1, choiceKey: "fork");
        var second = new PlanningJunctionElement(
            Guid.NewGuid(),
            JunctionElementKind.Promo,
            [Unit(0.5)],
            JunctionAmountMode.Count,
            1,
            IsRequired: false,
            ChoiceKey: "fork"
        );

        var byFirst = Run(
            Slot(element, 2, between: new PlanningJunction(Guid.NewGuid(), [first, second])),
            new FirstAlways()
        );
        var byLast = Run(
            Slot(element, 2, between: new PlanningJunction(Guid.NewGuid(), [first, second])),
            new LastAlways()
        );

        Assert.Single(byFirst.Items, i => i.Kind == PlannedItemKind.Ad);
        Assert.DoesNotContain(byFirst.Items, i => i.Kind == PlannedItemKind.Promo);
        Assert.Single(byLast.Items, i => i.Kind == PlannedItemKind.Promo);
        Assert.DoesNotContain(byLast.Items, i => i.Kind == PlannedItemKind.Ad);
    }

    [Fact]
    public void Choice_RollsChanceOnce_ForTheWholeFork()
    {
        // Три заставки в одной развилке — это один выбор, а не три независимых. Бросок жребия
        // на каждую превратил бы «показать в 60 % случаев» в «показать хоть какую-то почти всегда».
        var element = Series(2, 20, out _);
        var fork = new PlanningJunction(
            Guid.NewGuid(),
            [
                Ads(1, chance: 60, choiceKey: "fork"),
                Ads(1, chance: 60, choiceKey: "fork"),
                Ads(1, chance: 60, choiceKey: "fork"),
            ]
        );

        var random = new CountingRandom();
        Run(Slot(element, 2, between: fork), random);

        Assert.Equal(1, random.Rolls);
    }

    [Fact]
    public void Choice_TakesChanceOfThePickedVariant()
    {
        // Жребий бросает уже выбранный вариант: у него шанс ноль — значит развилка молчит целиком,
        // а не подставляет вместо него соседа с сотней.
        var element = Series(2, 20, out _);
        var fork = new PlanningJunction(
            Guid.NewGuid(),
            [Ads(1, chance: 0, choiceKey: "fork"), Ads(1, chance: 100, choiceKey: "fork")]
        );

        var result = Run(Slot(element, 2, between: fork), new FirstAlways());

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

    [Fact]
    public void MaxTotal_CapsJunctionLength()
    {
        var element = Series(2, 20, out _);
        var junction = new PlanningJunction(
            Guid.NewGuid(),
            [Ads(6, minutesEach: 1)],
            MaxTotal: TimeSpan.FromMinutes(2)
        );

        var result = Run(Slot(element, 2, between: junction));

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

    [Fact]
    public void Bumper_ReservesDurationWithEmptyAssetAndShowPair()
    {
        var element = Series(2, 20, out var showId);
        var variantId = Guid.NewGuid();
        var bumper = new PlanningJunctionElement(
            Guid.NewGuid(),
            JunctionElementKind.Bumper,
            [],
            JunctionAmountMode.Count,
            1,
            IsRequired: true,
            BumperTemplateId: Guid.NewGuid(),
            BumperVariantId: variantId,
            BumperDuration: TimeSpan.FromSeconds(10)
        );

        var result = Run(Slot(element, 2, between: new PlanningJunction(Guid.NewGuid(), [bumper])));

        var placed = Assert.Single(result.Items, i => i.Kind == PlannedItemKind.Bumper);
        // Ассет подставит рендер после сборки ленты — до неё пара соседей неизвестна.
        Assert.Equal(Guid.Empty, placed.MediaAssetId);
        Assert.Equal(showId, placed.ToShowId);
        Assert.Equal(variantId, placed.BumperVariantId);
        Assert.Equal(TimeSpan.FromSeconds(10), placed.EndsAtUtc - placed.StartsAtUtc);
    }

    [Fact]
    public void OrderFollowsPosition_NotRequiredFlag()
    {
        var element = Series(2, 20, out _);
        var optional = Ads(1, minutesEach: 1);
        var required = new PlanningJunctionElement(
            Guid.NewGuid(),
            JunctionElementKind.Promo,
            [Unit(0.5)],
            JunctionAmountMode.Count,
            1,
            IsRequired: true
        );

        var result = Run(
            Slot(element, 2, between: new PlanningJunction(Guid.NewGuid(), [optional, required]))
        );

        var junctionItems = result
            .Items.Where(i => i.Kind is PlannedItemKind.Ad or PlannedItemKind.Promo)
            .OrderBy(i => i.StartsAtUtc)
            .ToList();
        // Обязательность решает, кого выбросить при нехватке времени, а не кто идёт первым.
        Assert.Equal(PlannedItemKind.Ad, junctionItems[0].Kind);
        Assert.Equal(PlannedItemKind.Promo, junctionItems[1].Kind);
    }

    [Fact]
    public void RequiredSurvives_WhenOptionalDoesNotFit()
    {
        var element = Series(2, 20, out _);
        var optional = Ads(4, minutesEach: 1);
        var required = new PlanningJunctionElement(
            Guid.NewGuid(),
            JunctionElementKind.Promo,
            [Unit(1)],
            JunctionAmountMode.Count,
            1,
            IsRequired: true
        );
        var junction = new PlanningJunction(
            Guid.NewGuid(),
            [optional, required],
            MaxTotal: TimeSpan.FromMinutes(2)
        );

        var result = Run(Slot(element, 2, between: junction));

        Assert.Single(result.Items, i => i.Kind == PlannedItemKind.Promo);
        Assert.Single(result.Items, i => i.Kind == PlannedItemKind.Ad);
    }

    [Fact]
    public void JunctionDoesNotCrossAnchor()
    {
        var element = Series(2, 20, out _);
        var slot = Slot(element, 2, after: new PlanningJunction(Guid.NewGuid(), [Ads(4, 5)])) with
        {
            TargetDurationMinutes = 40,
        };
        var anchor = new PlanningSlot(
            Guid.NewGuid(),
            T0.AddMinutes(45),
            60,
            SlotKind.Content,
            IsAnchor: true,
            MaxDriftMinutes: 5,
            SnapToMinutes: null,
            SlotBlockMode.Count,
            1,
            OverflowPolicy.ContinueNext,
            new PlanningStrategy(SlotStrategyKind.Sequential),
            [Series(1, 30, out _)],
            Cursor: null
        );

        var result = GridPlanner.Plan(
            new PlanningInput(
                Guid.NewGuid(),
                T0,
                T0.AddHours(3),
                [slot, anchor],
                [Unit(1)],
                SegmentSeconds: 2
            ),
            new FirstAlways()
        );

        // До якоря остаётся пять минут: влезает одна пятиминутная врезка, остальные не начинают.
        Assert.All(
            result.Items.Where(i => i.Kind == PlannedItemKind.Ad),
            item => Assert.True(item.EndsAtUtc <= anchor.TargetStartUtc)
        );
        Assert.Single(result.Items, i => i.Kind == PlannedItemKind.Ad);
    }
}
