using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Programming.Groups;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Programming.Templates.Generate;
using TeleWave.Application.Programming.Templates.Transfer;
using TeleWave.Application.Tests.Support;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using Xunit;

namespace TeleWave.Application.Tests.Programming;

/// <summary>
/// Обмен конфигурацией сетки: выгрузка файлом, загрузка чужого файла и сборка запроса к ИИ.
/// Главное свойство — ссылки в файле именные: он должен переноситься между каналами и установками,
/// где тех же идентификаторов нет.
/// </summary>
public class GridTransferTests
{
    private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);

    private static (Show Show, MediaAsset Asset) Playable(string name, int minutes)
    {
        var show = Show.Create(name, ShowKind.Series);
        var asset = MediaAsset.Register($"{name}.mkv", ".mkv", MediaSource.Upload);
        asset.MarkProcessing();
        asset.MarkReady(
            new MediaReadyInfo(
                TimeSpan.FromMinutes(minutes),
                6,
                minutes * 10,
                1920,
                1080,
                "h264",
                "aac",
                $"assets/{name}"
            )
        );
        show.AddEpisode(asset.Id);
        return (show, asset);
    }

    /// <summary>Канал с одной группой, одним стыком и слоем «Основная сетка» из одного слота.</summary>
    private static async Task<Guid> SeedAsync(TestDb fixture, bool withSlot = true)
    {
        var (show, asset) = Playable("Сериал", 25);
        var group = Group.Create("Сериалы");
        group.AddElement(GroupElementKind.Show, show.Id);

        var junction = JunctionTemplate.Create("Рекламный шов");

        var channel = Channel.Create("Первый", "first", T0);
        var template = ScheduleTemplate.Create(channel.Id, "Сетка");
        channel.SetTemplate(template.Id);
        template.SetFallbackGroup(group.Id);
        template.SetDefaultJunction(junction.Id);

        var layer = template.AddLayer("Основная сетка", 10);
        if (withSlot)
        {
            var slot = layer.AddSlot("Вечерний блок", new TimeOnly(20, 0), 120, Daypart.Prime);
            slot.UpdateTiming(null, new TimeOnly(20, 0), 120, Daypart.Prime, true, 5, 15);
            slot.UpdateContent(
                new SlotContent(
                    "Вечерний блок",
                    SlotKind.Content,
                    group.Id,
                    new SlotStrategy(SlotStrategyType.Sequential).ToJson(),
                    null,
                    SlotBlockMode.Count,
                    4,
                    OverflowPolicy.ContinueNext,
                    null,
                    junction.Id
                )
            );
        }

        await using var seed = fixture.New();
        seed.MediaAssets.Add(asset);
        seed.Shows.Add(show);
        seed.Groups.Add(group);
        seed.JunctionTemplates.Add(junction);
        seed.Channels.Add(channel);
        seed.ScheduleTemplates.Add(template);
        await seed.SaveChangesAsync(CancellationToken.None);

        return channel.Id;
    }

    private static async Task<GridConfig> ExportAsync(TestDb fixture, Guid channelId)
    {
        await using var db = fixture.New();
        var result = await new ExportGridQueryHandler(db, GroupServices.Dynamic(db)).Handle(
            new ExportGridQuery(channelId),
            CancellationToken.None
        );
        Assert.True(result.IsSuccess);
        return result.Value;
    }

    private static async Task<GridImportResultDto> ImportAsync(
        TestDb fixture,
        Guid channelId,
        GridConfig config,
        bool replace = true
    )
    {
        await using var db = fixture.New();
        var result = await new ImportGridCommandHandler(
            db,
            GroupServices.Slots(db),
            Stats(db),
            GroupServices.Interstitials(db)
        ).Handle(new ImportGridCommand(channelId, config, replace), CancellationToken.None);
        Assert.True(result.IsSuccess);
        await db.SaveChangesAsync(CancellationToken.None);
        return result.Value;
    }

    [Fact]
    public async Task Export_ReferencesGroupsAndJunctionsByName()
    {
        var fixture = new TestDb();
        var channelId = await SeedAsync(fixture);

        var config = await ExportAsync(fixture, channelId);

        Assert.Equal(GridConfig.CurrentFormat, config.Format);
        Assert.Equal(GridConfig.CurrentVersion, config.Version);
        Assert.Equal("Сериалы", config.FallbackGroup);
        Assert.Equal("Рекламный шов", config.DefaultJunction);
        Assert.Equal("Первый", config.Channel?.Name);

        var layer = Assert.Single(config.Layers, l => !l.IsBackground);
        var slot = Assert.Single(layer.Slots);
        Assert.Equal("Сериалы", slot.Group);
        Assert.Equal("Рекламный шов", slot.JunctionAfter);
        Assert.True(slot.IsAnchor);
        Assert.Equal(15, slot.SnapToMinutes);

        // Файл должен быть читаемым JSON — его правят руками и отдают модели.
        Assert.Contains("\"format\": \"telewave.grid\"", config.ToJson(), StringComparison.Ordinal);
    }

    [Fact]
    public async Task Import_RestoresTheSameGrid_AfterExport()
    {
        var fixture = new TestDb();
        var channelId = await SeedAsync(fixture);
        var config = await ExportAsync(fixture, channelId);

        // Через сериализацию — так файл и приходит обратно, а не объектом в памяти.
        var roundTripped = JsonSerializer.Deserialize<GridConfig>(
            config.ToJson(),
            GridConfig.Options
        );
        Assert.NotNull(roundTripped);

        var imported = await ImportAsync(fixture, channelId, roundTripped);

        Assert.Equal(1, imported.Slots);
        Assert.Equal(0, imported.Skipped);
        Assert.Equal(1, imported.Removed);
        Assert.Empty(imported.Warnings);

        var restored = await ExportAsync(fixture, channelId);
        Assert.Equal(config.Layers.Count, restored.Layers.Count);
        Assert.Equal(
            config.Layers.SelectMany(l => l.Slots),
            restored.Layers.SelectMany(l => l.Slots)
        );
    }

    [Fact]
    public async Task Import_ReusesLayerByName_AndDoesNotDuplicateIt()
    {
        var fixture = new TestDb();
        var channelId = await SeedAsync(fixture);
        var config = await ExportAsync(fixture, channelId);

        await ImportAsync(fixture, channelId, config);
        await ImportAsync(fixture, channelId, config);

        await using var db = fixture.New();
        var template = await db
            .ScheduleTemplates.Include(t => t.Layers)
                .ThenInclude(l => l.Slots)
            .FirstAsync(CancellationToken.None);

        Assert.Single(template.Layers, l => l.Name == "Основная сетка");
        Assert.Equal(1, template.Layers.Sum(l => l.Slots.Count));
    }

    [Fact]
    public async Task Import_SkipsUnknownGroup_AndSaysSo()
    {
        var fixture = new TestDb();
        var channelId = await SeedAsync(fixture, withSlot: false);

        var config = new GridConfig([
            new GridConfigLayer(
                "Основная сетка",
                10,
                [
                    new GridConfigSlot(
                        "Чужой блок",
                        new TimeOnly(20, 0),
                        120,
                        Daypart.Prime,
                        Group: "Сериалы с другого канала"
                    ),
                    new GridConfigSlot(
                        "Свой блок",
                        new TimeOnly(22, 0),
                        60,
                        Daypart.Night,
                        Group: "Сериалы"
                    ),
                ]
            ),
        ]);

        var imported = await ImportAsync(fixture, channelId, config);

        // Одна незнакомая ссылка не должна ронять весь файл — иначе админ чинит JSON руками.
        Assert.Equal(1, imported.Slots);
        Assert.Equal(1, imported.Skipped);
        Assert.Contains(
            imported.Warnings,
            w => w.Contains("Сериалы с другого канала", StringComparison.Ordinal)
        );
    }

    [Fact]
    public async Task Import_SkipsOverlappingSlot_AndKeepsTheGridValid()
    {
        var fixture = new TestDb();
        var channelId = await SeedAsync(fixture, withSlot: false);

        var config = new GridConfig([
            new GridConfigLayer(
                "Основная сетка",
                10,
                [
                    new GridConfigSlot(
                        "Первый",
                        new TimeOnly(20, 0),
                        120,
                        Daypart.Prime,
                        Group: "Сериалы"
                    ),
                    new GridConfigSlot(
                        "Наложился",
                        new TimeOnly(21, 0),
                        60,
                        Daypart.Prime,
                        Group: "Сериалы"
                    ),
                ]
            ),
        ]);

        var imported = await ImportAsync(fixture, channelId, config);

        Assert.Equal(1, imported.Slots);
        Assert.Equal(1, imported.Skipped);
        Assert.NotEmpty(imported.Warnings);
    }

    [Fact]
    public async Task Import_MarksTemplateChanged_ButDoesNotTouchAir()
    {
        var fixture = new TestDb();
        var channelId = await SeedAsync(fixture);
        var config = await ExportAsync(fixture, channelId);

        await using (var applied = fixture.New())
        {
            var template = await applied.ScheduleTemplates.FirstAsync(CancellationToken.None);
            template.MarkApplied();
            await applied.SaveChangesAsync(CancellationToken.None);
        }

        await ImportAsync(fixture, channelId, config);

        await using var db = fixture.New();
        var stored = await db.ScheduleTemplates.FirstAsync(CancellationToken.None);
        Assert.True(stored.HasPendingChanges);
    }

    [Fact]
    public async Task Import_CreatesGroupsFromShows()
    {
        // Сетка, собранная ИИ под ещё не разобранную библиотеку: групп нет, и положить слоты
        // некуда — значит файл приносит группы с собой.
        var fixture = new TestDb();
        var channelId = await SeedAsync(fixture, withSlot: false);

        var config = new GridConfig(
            [
                new GridConfigLayer(
                    "Основная сетка",
                    10,
                    [
                        new GridConfigSlot(
                            "Вечерний блок",
                            new TimeOnly(20, 0),
                            120,
                            Daypart.Prime,
                            Group: "Вечерние сериалы"
                        ),
                    ]
                ),
            ],
            Groups: [new GridConfigGroup("Вечерние сериалы", Shows: ["Сериал"])]
        );

        var imported = await ImportAsync(fixture, channelId, config);

        Assert.Equal(1, imported.Groups);
        Assert.Equal(1, imported.Slots);
        Assert.Empty(imported.Warnings);

        await using var db = fixture.New();
        var group = await db
            .Groups.Include(g => g.Items)
            .FirstAsync(g => g.Name == "Вечерние сериалы", CancellationToken.None);
        Assert.Single(group.Items);
        // Статистика — кэш карточки группы, и пустой после создания она быть не должна.
        Assert.Equal(1, group.UnitCount);
    }

    [Fact]
    public async Task Import_CreatesCollections_AndPutsThemIntoGroups()
    {
        var fixture = new TestDb();
        var channelId = await SeedAsync(fixture, withSlot: false);

        var config = new GridConfig(
            [
                new GridConfigLayer(
                    "Основная сетка",
                    10,
                    [
                        new GridConfigSlot(
                            "Кино",
                            new TimeOnly(20, 0),
                            120,
                            Daypart.Prime,
                            Group: "Циклы"
                        ),
                    ]
                ),
            ],
            Collections: [new GridConfigCollection("Франшиза", ["Сериал"])],
            Groups: [new GridConfigGroup("Циклы", Collections: ["Франшиза"])]
        );

        var imported = await ImportAsync(fixture, channelId, config);

        Assert.Equal(1, imported.Collections);
        Assert.Equal(1, imported.Groups);
        Assert.Empty(imported.Warnings);

        await using var db = fixture.New();
        var collection = await db
            .Collections.Include(c => c.Items)
            .FirstAsync(c => c.Name == "Франшиза", CancellationToken.None);
        Assert.Single(collection.Items);

        var group = await db
            .Groups.Include(g => g.Items)
            .FirstAsync(g => g.Name == "Циклы", CancellationToken.None);
        var item = Assert.Single(group.Items);
        Assert.Equal(GroupElementKind.Collection, item.ElementKind);
        Assert.Equal(collection.Id, item.ElementId);
    }

    [Fact]
    public async Task Import_DoesNotTouchExistingGroup()
    {
        // Группа с этим именем уже стоит в слотах соседнего канала: переписать её состав чужим
        // файлом значит молча перекроить чужой эфир.
        var fixture = new TestDb();
        var channelId = await SeedAsync(fixture, withSlot: false);

        var config = new GridConfig(
            [new GridConfigLayer("Основная сетка", 10, [])],
            Groups: [new GridConfigGroup("Сериалы", Shows: ["Сериал", "Сериал"])]
        );

        var imported = await ImportAsync(fixture, channelId, config);

        Assert.Equal(0, imported.Groups);

        await using var db = fixture.New();
        var group = await db
            .Groups.Include(g => g.Items)
            .FirstAsync(g => g.Name == "Сериалы", CancellationToken.None);
        Assert.Single(group.Items);
    }

    [Fact]
    public async Task Import_WarnsAboutUnknownShow_AndEmptyGroup()
    {
        var fixture = new TestDb();
        var channelId = await SeedAsync(fixture, withSlot: false);

        var config = new GridConfig(
            [new GridConfigLayer("Основная сетка", 10, [])],
            Groups: [new GridConfigGroup("Придуманное", Shows: ["Шоу, которого нет"])]
        );

        var imported = await ImportAsync(fixture, channelId, config);

        Assert.Equal(1, imported.Groups);
        Assert.Contains(
            imported.Warnings,
            w => w.Contains("Шоу, которого нет", StringComparison.Ordinal)
        );
        // Пустая группа — это дыра в эфире, и сказать об этом надо на импорте, а не после эфира.
        Assert.Contains(
            imported.Warnings,
            w => w.Contains("создана пустой", StringComparison.Ordinal)
        );
    }

    [Fact]
    public async Task Export_CarriesCompositionOfUsedGroups()
    {
        var fixture = new TestDb();
        var channelId = await SeedAsync(fixture);

        var config = await ExportAsync(fixture, channelId);

        // Файл должен быть самодостаточным: на другой установке этих групп нет, а слот без группы
        // положить некуда.
        var group = Assert.Single(config.Groups ?? []);
        Assert.Equal("Сериалы", group.Name);
        Assert.Equal(["Сериал"], group.Shows);
    }

    [Fact]
    public async Task Prompt_ExplainsHowToBuildGroups()
    {
        var fixture = new TestDb();
        var channelId = await SeedAsync(fixture);

        await using var db = fixture.New();
        var result = await new BuildGridPromptQueryHandler(db, Catalog(db)).Handle(
            new BuildGridPromptQuery(channelId, [], null),
            CancellationToken.None
        );

        Assert.True(result.IsSuccess);
        var prompt = result.Value.Prompt;

        // Модель должна знать и что группы можно собрать самой, и что названия шоу берутся дословно.
        Assert.Contains("\"groups\"", prompt, StringComparison.Ordinal);
        Assert.Contains("\"collections\"", prompt, StringComparison.Ordinal);
        Assert.Contains("дословно", prompt, StringComparison.Ordinal);
    }

    [Fact]
    public async Task Prompt_ListsGroupsAndSchema()
    {
        var fixture = new TestDb();
        var channelId = await SeedAsync(fixture);

        await using var db = fixture.New();
        var result = await new BuildGridPromptQueryHandler(db, Catalog(db)).Handle(
            new BuildGridPromptQuery(
                channelId,
                ["2×2", "Paramount Comedy"],
                "Хочу ситкомы вечером"
            ),
            CancellationToken.None
        );

        Assert.True(result.IsSuccess);
        var prompt = result.Value.Prompt;

        // Модель обязана видеть, чем канал располагает и в каком виде ждут ответ.
        Assert.Contains("Сериалы", prompt, StringComparison.Ordinal);
        Assert.Contains("2×2", prompt, StringComparison.Ordinal);
        Assert.Contains("Хочу ситкомы вечером", prompt, StringComparison.Ordinal);
        Assert.Contains("Рекламный шов", prompt, StringComparison.Ordinal);
        Assert.Contains(GridConfig.CurrentFormat, prompt, StringComparison.Ordinal);
        Assert.Equal(1, result.Value.Groups);
        Assert.Equal(1, result.Value.Shows);
        Assert.Equal(prompt.Length, result.Value.Characters);
    }

    [Fact]
    public async Task Prompt_SchemaSurvivesImport()
    {
        // Схема в запросе и импорт обязаны быть одним форматом: пример из запроса должен читаться
        // импортом без правок, иначе ответ модели придётся чинить руками.
        var fixture = new TestDb();
        var channelId = await SeedAsync(fixture, withSlot: false);

        var answer = """
            {
              "format": "telewave.grid",
              "version": 1,
              "fallbackGroup": "Сериалы",
              "layers": [
                {
                  "name": "Основная сетка",
                  "priority": 10,
                  "slots": [
                    {
                      "title": "Вечерний блок",
                      "start": "20:00",
                      "durationMinutes": 120,
                      "daypart": "Prime",
                      "kind": "Content",
                      "group": "Сериалы",
                      "blockMode": "Count",
                      "blockValue": 4,
                      "strategy": { "type": "Sequential", "restartOnEnd": true },
                      "isAnchor": true,
                      "snapToMinutes": 15
                    }
                  ]
                }
              ]
            }
            """;

        var config = JsonSerializer.Deserialize<GridConfig>(answer, GridConfig.Options);
        Assert.NotNull(config);

        var imported = await ImportAsync(fixture, channelId, config);

        Assert.Equal(1, imported.Slots);
        Assert.Empty(imported.Warnings);

        var stored = await ExportAsync(fixture, channelId);
        var slot = Assert.Single(stored.Layers.SelectMany(l => l.Slots));
        Assert.Equal("Вечерний блок", slot.Title);
        Assert.Equal(SlotBlockMode.Count, slot.BlockMode);
        Assert.True(slot.IsAnchor);
    }

    private static GroupCatalog Catalog(IAppDbContext db) =>
        new(db, GroupServices.Dynamic(db), new GroupElementResolver(db));

    private static GroupStatsService Stats(IAppDbContext db) =>
        new(db, new GroupElementResolver(db), GroupServices.Dynamic(db));
}
