using Microsoft.Extensions.Options;
using NSubstitute;
using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Programming.Groups;
using TeleWave.Application.Programming.Planning;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Streaming;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using TeleWave.Infrastructure.Persistence;
using Xunit;

namespace TeleWave.Integration.Tests;

/// <summary>
/// Конвейер генерации против настоящей БД: разворот групп, наполнение слотов, запись ленты,
/// продвижение курсоров. Юнит-тесты покрывают чистую математику планировщика, а здесь проверяется
/// то, чего они не видят, — реальные запросы EF, транзакция, advisory-lock и ExecuteDelete.
/// </summary>
[Collection("postgres")]
public sealed class GridScheduleGeneratorIntegrationTests(PostgresFixture fixture)
{
    private static readonly DateTimeOffset Now = new(2026, 3, 17, 12, 0, 0, TimeSpan.Zero);

    [SkippableFact]
    public async Task Generate_FillsSlotFromGroup_AndAdvancesCursor()
    {
        Skip.IfNot(fixture.Available, "Docker недоступен");

        await using var seedDb = fixture.CreateContext();
        var world = await SeedAsync(seedDb, episodes: 3);

        await using var db = fixture.CreateContext();
        var report = await Generator(db).GenerateAsync(world.ChannelId, Now, false, default);

        Assert.False(report.ChannelSkipped);
        Assert.True(report.Added > 0);

        await using var verify = fixture.CreateContext();
        var entries = verify
            .ScheduleEntries.Where(e => e.ChannelId == world.ChannelId)
            .OrderBy(e => e.StartsAtUtc)
            .ToList();

        Assert.NotEmpty(entries);
        // Лента обязана быть непрерывной: живой край иначе упрётся в дыру.
        for (var i = 1; i < entries.Count; i++)
            Assert.Equal(entries[i - 1].EndsAtUtc, entries[i].StartsAtUtc);

        var programs = entries.Where(e => e.Kind == ScheduleEntryKind.Program).ToList();
        Assert.NotEmpty(programs);
        Assert.All(programs, e => Assert.Equal(world.SlotId, e.SlotId));
        Assert.All(programs, e => Assert.NotNull(e.TraceJson));

        // Курсор слота сдвинулся — следующий прогон продолжит с той же серии, а не с первой.
        var state = verify.SlotStates.Single(s => s.SlotId == world.SlotId);
        Assert.Equal(world.ShowId, state.CurrentElementId);
        Assert.True(state.NextUnitIndex > 0);

        // И шаблон помечен применённым: баннер «правила изменены» должен погаснуть.
        Assert.False(
            verify.ScheduleTemplates.Single(t => t.Id == world.TemplateId).HasPendingChanges
        );
    }

    [SkippableFact]
    public async Task Generate_IsIdempotentWithinHorizon()
    {
        Skip.IfNot(fixture.Available, "Docker недоступен");

        await using var seedDb = fixture.CreateContext();
        var world = await SeedAsync(seedDb, episodes: 3);

        await using var first = fixture.CreateContext();
        await Generator(first).GenerateAsync(world.ChannelId, Now, false, default);

        await using var second = fixture.CreateContext();
        var again = await Generator(second).GenerateAsync(world.ChannelId, Now, false, default);

        // Горизонт уже заполнен — второй прогон не должен дописывать хвост поверх существующего.
        Assert.Equal(0, again.Added);
    }

    [SkippableFact]
    public async Task Generate_Rebuild_KeepsPastAndReplacesFuture()
    {
        Skip.IfNot(fixture.Available, "Docker недоступен");

        await using var seedDb = fixture.CreateContext();
        var world = await SeedAsync(seedDb, episodes: 3);

        await using var first = fixture.CreateContext();
        await Generator(first).GenerateAsync(world.ChannelId, Now.AddHours(-2), false, default);

        await using var beforeDb = fixture.CreateContext();
        var past = beforeDb
            .ScheduleEntries.Where(e => e.ChannelId == world.ChannelId && e.EndsAtUtc <= Now)
            .Select(e => e.Id)
            .ToHashSet();
        Assert.NotEmpty(past);

        await using var rebuild = fixture.CreateContext();
        await Generator(rebuild).GenerateAsync(world.ChannelId, Now, true, default);

        await using var verify = fixture.CreateContext();
        var survived = verify
            .ScheduleEntries.Where(e => e.ChannelId == world.ChannelId && past.Contains(e.Id))
            .Select(e => e.Id)
            .ToList();

        // Прошлое неприкосновенно: зритель не должен обнаружить, что у него вырезали программу.
        Assert.Equal(past.Count, survived.Count);
    }

    /// <summary>
    /// Пересборка выбрасывает хвост, который курсор уже прошёл. Без отмотки к отыгранному каждое
    /// применение проматывало бы библиотеку на горизонт вперёд, теряя невышедшие серии.
    /// </summary>
    [SkippableFact]
    public async Task Generate_Rebuild_RewindsCursorToWhatActuallyAired()
    {
        Skip.IfNot(fixture.Available, "Docker недоступен");

        await using var seedDb = fixture.CreateContext();
        // Серий заведомо больше, чем влезет в горизонт: иначе сериал успеет пойти по кругу,
        // и «продолжает с той же серии» проверять станет нечем.
        var world = await SeedAsync(seedDb, episodes: 100);

        // Первый прогон строит сутки вперёд от момента двухчасовой давности.
        await using var first = fixture.CreateContext();
        await Generator(first).GenerateAsync(world.ChannelId, Now.AddHours(-2), false, default);

        await using var beforeDb = fixture.CreateContext();
        var aired = beforeDb
            .ScheduleEntries.Where(e =>
                e.ChannelId == world.ChannelId
                && e.Kind == ScheduleEntryKind.Program
                && e.StartsAtUtc < Now
            )
            .OrderByDescending(e => e.StartsAtUtc)
            .First();
        var beforeRebuild = beforeDb.SlotStates.Single(s => s.SlotId == world.SlotId);
        // Курсор ушёл за эфир: он прошёл весь построенный хвост, а не только вышедшее.
        Assert.True(beforeRebuild.NextUnitIndex > aired.EpisodeIndex + 1);

        await using var rebuild = fixture.CreateContext();
        await Generator(rebuild).GenerateAsync(world.ChannelId, Now, true, default);

        await using var verify = fixture.CreateContext();
        var firstAfterRebuild = verify
            .ScheduleEntries.Where(e =>
                e.ChannelId == world.ChannelId
                && e.Kind == ScheduleEntryKind.Program
                && e.StartsAtUtc >= Now
            )
            .OrderBy(e => e.StartsAtUtc)
            .First();

        // Пересобранный эфир продолжает ровно с той серии, что шла следующей по отыгранному.
        Assert.Equal(aired.EpisodeIndex + 1, firstAfterRebuild.EpisodeIndex);
    }

    /// <summary>
    /// Канал, который ещё не вышел в эфир: применять сетку можно сколько угодно раз, и каждый раз
    /// эфир обязан начинаться с начала. Отматывать здесь не к чему — отыгранного нет вовсе, — и без
    /// сброса курсора второе применение начинало бы сериал с той серии, до которой курсор домотал
    /// по только что снесённому хвосту.
    /// </summary>
    [SkippableFact]
    public async Task Generate_Rebuild_StartsOver_WhenNothingHasAiredYet()
    {
        Skip.IfNot(fixture.Available, "Docker недоступен");

        await using var seedDb = fixture.CreateContext();
        var world = await SeedAsync(seedDb, episodes: 100);

        await using var first = fixture.CreateContext();
        await Generator(first).GenerateAsync(world.ChannelId, Now, false, default);

        await using var beforeDb = fixture.CreateContext();
        // Ничего не отыграно: вся построенная лента — будущее.
        Assert.Empty(
            beforeDb.ScheduleEntries.Where(e =>
                e.ChannelId == world.ChannelId && e.StartsAtUtc < Now
            )
        );
        Assert.True(beforeDb.SlotStates.Single(s => s.SlotId == world.SlotId).NextUnitIndex > 0);

        await using var rebuild = fixture.CreateContext();
        await Generator(rebuild).GenerateAsync(world.ChannelId, Now, true, default);

        await using var verify = fixture.CreateContext();
        var firstProgram = verify
            .ScheduleEntries.Where(e =>
                e.ChannelId == world.ChannelId && e.Kind == ScheduleEntryKind.Program
            )
            .OrderBy(e => e.StartsAtUtc)
            .First();

        Assert.Equal(0, firstProgram.EpisodeIndex);
    }

    [SkippableFact]
    public async Task Generate_CollectionInGroup_StampsCollectionOnEntries()
    {
        Skip.IfNot(fixture.Available, "Docker недоступен");

        await using var seedDb = fixture.CreateContext();
        var world = await SeedAsync(seedDb, episodes: 2, asCollection: true);

        await using var db = fixture.CreateContext();
        await Generator(db).GenerateAsync(world.ChannelId, Now, false, default);

        await using var verify = fixture.CreateContext();
        var programs = verify
            .ScheduleEntries.Where(e =>
                e.ChannelId == world.ChannelId && e.Kind == ScheduleEntryKind.Program
            )
            .ToList();

        Assert.NotEmpty(programs);
        // Из шоу коллекцию не вывести — она должна прийти из плана и осесть в ленте.
        Assert.All(programs, e => Assert.Equal(world.CollectionId, e.CollectionId));
    }

    [SkippableFact]
    public async Task Preview_WritesNothing()
    {
        Skip.IfNot(fixture.Available, "Docker недоступен");

        await using var seedDb = fixture.CreateContext();
        var world = await SeedAsync(seedDb, episodes: 3);

        await using var db = fixture.CreateContext();
        var result = await Generator(db).PreviewAsync(world.ChannelId, Now, 1, default);

        Assert.NotNull(result);
        Assert.NotEmpty(result.Items);

        await using var verify = fixture.CreateContext();
        // Сухой прогон: ни ленты, ни курсоров, ни отметки о применении.
        Assert.Empty(verify.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId));
        Assert.Empty(verify.SlotStates.Where(s => s.SlotId == world.SlotId));
        // Отметку о применении сухой прогон тоже не ставит: применять по-прежнему есть что.
        Assert.True(
            verify.ScheduleTemplates.Single(t => t.Id == world.TemplateId).HasPendingChanges
        );
    }

    [SkippableFact]
    public async Task Generate_EmptyGroup_FallsBackAndWarns()
    {
        Skip.IfNot(fixture.Available, "Docker недоступен");

        await using var seedDb = fixture.CreateContext();
        var world = await SeedAsync(seedDb, episodes: 0);

        await using var db = fixture.CreateContext();
        var report = await Generator(db).GenerateAsync(world.ChannelId, Now, false, default);

        // Пустая группа — не ошибка генерации: слот закрывает фон, а админ получает предупреждение.
        Assert.Contains(
            report.Warnings,
            w => w.Kind == Domain.Programming.Planning.PlanningWarningKind.SlotEmpty
        );

        await using var verify = fixture.CreateContext();
        Assert.All(
            verify.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId).ToList(),
            e => Assert.NotEqual(ScheduleEntryKind.Program, e.Kind)
        );
    }

    /// <summary>
    /// Пересборка посреди длинного фильма не открывает его слот заново. Кино кончается в 23:14,
    /// окно слота идёт до 23:15 — и без проверки «выход уже состоялся» планировщик честно ставил
    /// вторую полнометражку подряд: слот-то по времени ещё не истёк.
    /// </summary>
    [SkippableFact]
    public async Task Generate_Rebuild_DoesNotReopenSlotThatAlreadyAired()
    {
        Skip.IfNot(fixture.Available, "Docker недоступен");

        await using var seedDb = fixture.CreateContext();
        var world = await SeedFilmsAsync(seedDb);

        await using (var first = fixture.CreateContext())
            await Generator(first).GenerateAsync(world.ChannelId, Now, false, default);

        await using var read = fixture.CreateContext();
        var film = read
            .ScheduleEntries.Where(e =>
                e.ChannelId == world.ChannelId
                && e.SlotId == world.SlotId
                && e.Kind == ScheduleEntryKind.Program
            )
            .OrderBy(e => e.StartsAtUtc)
            .First();

        var during = film.StartsAtUtc.AddMinutes(30);
        await using (var rebuild = fixture.CreateContext())
            await Generator(rebuild).GenerateAsync(world.ChannelId, during, true, default);

        await using var verify = fixture.CreateContext();
        // Второго кино из того же слота быть не должно; следующее окно этого слота — только завтра.
        var extra = verify
            .ScheduleEntries.Where(e =>
                e.ChannelId == world.ChannelId
                && e.SlotId == world.SlotId
                && e.Kind == ScheduleEntryKind.Program
                && e.StartsAtUtc >= during
                && e.StartsAtUtc < film.StartsAtUtc.AddHours(4)
            )
            .ToList();

        Assert.Empty(extra);
        // Само кино при этом не тронуто: прошлое и идущая запись неприкосновенны.
        Assert.NotNull(verify.ScheduleEntries.FirstOrDefault(e => e.Id == film.Id));
    }

    // ── Обвязка ─────────────────────────────────────────────────────────────

    private static GridScheduleGenerator Generator(AppDbContext db)
    {
        var random = new SequenceRandom(0);
        return new GridScheduleGenerator(
            db,
            new GroupExpander(
                db,
                new DynamicGroupResolver(new GroupFilterMatcher(db), new GroupElementResolver(db))
            ),
            new BumperResolver(db, Substitute.For<IBumperRenderQueue>(), random),
            new PostCheckRunner(db),
            random,
            Options.Create(new SchedulerOptions { HorizonDays = 1, RetentionDays = 90 }),
            Options.Create(new StreamingOptions())
        );
    }

    private sealed record World(
        Guid ChannelId,
        Guid TemplateId,
        Guid SlotId,
        Guid ShowId,
        Guid? CollectionId
    );

    /// <summary>
    /// Минимальный работающий канал: шоу с готовыми сериями, группа, шаблон со слотом на весь день
    /// и филлер для пауз. <paramref name="asCollection"/> кладёт в группу коллекцию, а не шоу.
    /// </summary>
    private static async Task<World> SeedAsync(
        AppDbContext db,
        int episodes,
        bool asCollection = false
    )
    {
        var suffix = Guid.NewGuid().ToString("N")[..8];

        var show = Show.Create($"Шоу {suffix}", ShowKind.Series);
        for (var i = 0; i < episodes; i++)
        {
            var asset = ReadyAsset(db, $"ep{i}-{suffix}.mkv", TimeSpan.FromMinutes(30));
            show.AddEpisode(asset.Id);
        }
        db.Shows.Add(show);

        Collection? collection = null;
        if (asCollection)
        {
            collection = Collection.Create($"Франшиза {suffix}");
            collection.AddShow(show.Id);
            db.Collections.Add(collection);
        }

        var group = Group.Create($"Группа {suffix}");
        group.AddElement(
            asCollection ? GroupElementKind.Collection : GroupElementKind.Show,
            asCollection ? collection!.Id : show.Id
        );
        db.Groups.Add(group);

        var filler = ReadyAsset(db, $"filler-{suffix}.mkv", TimeSpan.FromMinutes(1));

        var channel = Channel.Create($"Канал {suffix}", $"ch-{suffix}", Now.AddDays(-7));
        channel.UpdateSettings(channel.Name, isEnabled: true, filler.Id);

        var template = ScheduleTemplate.Create(channel.Id, "Сетка");
        var layer = template.AddLayer("Базовый", 10);
        var slot = layer.AddSlot("Дневной блок", new TimeOnly(6, 0), 24 * 60);
        slot.UpdateContent(
            new SlotContent(
                slot.Title,
                SlotKind.Content,
                group.Id,
                new SlotStrategy(SlotStrategyType.Sequential).ToJson(),
                null,
                SlotBlockMode.FillSlot,
                1,
                OverflowPolicy.ContinueNext
            )
        );
        channel.SetTemplate(template.Id);
        // Правка правил помечает шаблон изменённым — воспроизводим состояние «есть что применить».
        template.MarkChanged();

        db.Channels.Add(channel);
        db.ScheduleTemplates.Add(template);

        await db.SaveChangesAsync();
        return new World(channel.Id, template.Id, slot.Id, show.Id, collection?.Id);
    }

    /// <summary>
    /// Канал с одним вечерним слотом кино: фильм длиннее слота и доигрывает целиком
    /// (<see cref="OverflowPolicy.ExtendSlot"/>) — на этом и ловится повторное открытие слота.
    /// </summary>
    private static async Task<World> SeedFilmsAsync(AppDbContext db)
    {
        var suffix = Guid.NewGuid().ToString("N")[..8];

        var group = Group.Create($"Полнометражки {suffix}");
        for (var i = 0; i < 3; i++)
        {
            var film = Show.Create($"Фильм {i} {suffix}", ShowKind.Single);
            film.AddEpisode(ReadyAsset(db, $"film{i}-{suffix}.mkv", TimeSpan.FromMinutes(139)).Id);
            db.Shows.Add(film);
            group.AddElement(GroupElementKind.Show, film.Id);
        }
        db.Groups.Add(group);

        // Филлер подлиннее: паузу до вечера он закрывает по кругу, и минутный дал бы сотни записей.
        var filler = ReadyAsset(db, $"filler-{suffix}.mkv", TimeSpan.FromMinutes(30));

        var channel = Channel.Create($"Канал {suffix}", $"ch-{suffix}", Now.AddDays(-7));
        channel.UpdateSettings(channel.Name, isEnabled: true, filler.Id);

        var template = ScheduleTemplate.Create(channel.Id, "Сетка");
        var layer = template.AddLayer("Базовый", 10);
        var slot = layer.AddSlot("Кино в 21:00", new TimeOnly(21, 0), 135);
        slot.UpdateContent(
            new SlotContent(
                slot.Title,
                SlotKind.Content,
                group.Id,
                new SlotStrategy(SlotStrategyType.Sequential).ToJson(),
                null,
                SlotBlockMode.Count,
                1,
                OverflowPolicy.ExtendSlot
            )
        );
        channel.SetTemplate(template.Id);
        template.MarkChanged();

        db.Channels.Add(channel);
        db.ScheduleTemplates.Add(template);

        await db.SaveChangesAsync();
        return new World(channel.Id, template.Id, slot.Id, Guid.Empty, null);
    }

    /// <summary>Ассет, доведённый до Ready: в эфир попадают только такие.</summary>
    private static MediaAsset ReadyAsset(AppDbContext db, string fileName, TimeSpan duration)
    {
        var asset = MediaAsset.Register(fileName, ".mkv", MediaSource.Upload);
        asset.MarkProcessing();
        asset.MarkReady(
            new MediaReadyInfo(
                duration,
                SegmentSeconds: 2,
                SegmentCount: (int)(duration.TotalSeconds / 2),
                Width: 1920,
                Height: 1080,
                VideoCodec: "h264",
                AudioCodec: "aac",
                RelativePath: $"segments/{asset.Id}"
            )
        );
        db.MediaAssets.Add(asset);
        return asset;
    }
}
