using TeleWave.Application.Programming.Groups;
using TeleWave.Application.Tests.Support;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using Xunit;

namespace TeleWave.Application.Tests.Programming;

/// <summary>
/// Правило набора: по нему ищет кандидатов карточка группы и по нему же считается состав
/// динамической группы. Каждое поле проверяем отдельно — ошибка в любом уводит в эфир не то.
/// </summary>
public class GroupFilterMatcherTests
{
    [Fact]
    public async Task ShowKinds_NarrowSelection()
    {
        var fixture = new TestDb();
        var series = Show.Create("Сериал", ShowKind.Series);
        var movie = Show.Create("Фильм", ShowKind.Single);
        await SeedAsync(fixture, series, movie);

        var matched = await MatchAsync(fixture, new GroupFilter(ShowKinds: [ShowKind.Single]));

        Assert.Equal([(GroupElementKind.Show, movie.Id)], matched);
    }

    /// <summary>Группа рекламы собирается тем же правилом — иначе её нечем набрать, кроме как руками.</summary>
    [Fact]
    public async Task ShowKinds_Interstitial_SelectsClips()
    {
        var fixture = new TestDb();
        var movie = Show.Create("Фильм", ShowKind.Single);
        var clip = Show.Create("Ролик", ShowKind.Interstitial);
        await SeedAsync(fixture, movie, clip);

        var matched = await MatchAsync(
            fixture,
            new GroupFilter(ShowKinds: [ShowKind.Interstitial])
        );

        Assert.Equal([(GroupElementKind.Show, clip.Id)], matched);
    }

    /// <summary>
    /// Без указанного типа правило означает «кино и сериалы»: ролики живут отдельной библиотекой,
    /// и сотня рекламных вставок в группе фильмов — это не «широкий отбор», а сломанный эфир.
    /// </summary>
    [Fact]
    public async Task ShowKinds_Empty_LeavesOutClips()
    {
        var fixture = new TestDb();
        var movie = Show.Create("Фильм", ShowKind.Single);
        var clip = Show.Create("Ролик", ShowKind.Interstitial);
        await SeedAsync(fixture, movie, clip);

        var matched = await MatchAsync(fixture, new GroupFilter());

        Assert.Equal([(GroupElementKind.Show, movie.Id)], matched);
    }

    [Fact]
    public async Task MaxAudience_KeepsUnrated()
    {
        // Источники проставляют рейтинг далеко не всему: выбрасывать непроставленное значило бы
        // оставить от «мультики не строже PG» горстку голливудского.
        var fixture = new TestDb();
        var kids = Show.Create("Детское", ShowKind.Series);
        kids.SetAudience(ShowAudience.G);
        var adult = Show.Create("Взрослое", ShowKind.Series);
        adult.SetAudience(ShowAudience.R);
        var unrated = Show.Create("Без рейтинга", ShowKind.Series);
        await SeedAsync(fixture, kids, adult, unrated);

        var matched = await MatchAsync(fixture, new GroupFilter(MaxAudience: ShowAudience.Pg));

        Assert.Contains((GroupElementKind.Show, kids.Id), matched);
        Assert.Contains((GroupElementKind.Show, unrated.Id), matched);
        Assert.DoesNotContain((GroupElementKind.Show, adult.Id), matched);
    }

    [Fact]
    public async Task MinAudience_DropsUnrated()
    {
        // У нижней границы правило обратное: «R и старше» с неизвестным рейтингом означало бы
        // мультики в ночном слоте — ровно ту ошибку, ради которой границу и заводят.
        var fixture = new TestDb();
        var adult = Show.Create("Взрослое", ShowKind.Series);
        adult.SetAudience(ShowAudience.R);
        var unrated = Show.Create("Без рейтинга", ShowKind.Series);
        await SeedAsync(fixture, adult, unrated);

        var matched = await MatchAsync(fixture, new GroupFilter(MinAudience: ShowAudience.R));

        Assert.Equal([(GroupElementKind.Show, adult.Id)], matched);
    }

    [Fact]
    public async Task Year_BoundsAreInclusive_AndDropUnknown()
    {
        var fixture = new TestDb();
        var inRange = WithYear(Show.Create("В диапазоне", ShowKind.Single), 2000);
        var older = WithYear(Show.Create("Старое", ShowKind.Single), 1999);
        var unknown = Show.Create("Без года", ShowKind.Single);
        await SeedAsync(fixture, inRange, older, unknown);

        var matched = await MatchAsync(fixture, new GroupFilter(YearMin: 2000, YearMax: 2000));

        Assert.Equal([(GroupElementKind.Show, inRange.Id)], matched);
    }

    [Fact]
    public async Task Genres_MatchAnyOfSelected()
    {
        var fixture = new TestDb();
        var action = Genre.Create("Боевик", "action");
        var drama = Genre.Create("Драма", "drama");
        var first = Show.Create("Боевик", ShowKind.Single);
        first.SetGenres([action.Id]);
        var second = Show.Create("Драма", ShowKind.Single);
        second.SetGenres([drama.Id]);

        await using (var seed = fixture.New())
        {
            seed.Genres.AddRange(action, drama);
            seed.Shows.AddRange(first, second);
            await seed.SaveChangesAsync(CancellationToken.None);
        }

        var matched = await MatchAsync(fixture, new GroupFilter(GenreIds: [action.Id]));

        Assert.Equal([(GroupElementKind.Show, first.Id)], matched);
    }

    [Fact]
    public async Task Collection_MatchesOnlyWhenEveryPartDoes()
    {
        // Иначе во «взрослую ночь» через франшизу просочилось бы детское.
        var fixture = new TestDb();
        var kids = Show.Create("Детская часть", ShowKind.Single);
        kids.SetAudience(ShowAudience.G);
        var adult = Show.Create("Взрослая часть", ShowKind.Single);
        adult.SetAudience(ShowAudience.R);

        var pure = Collection.Create("Только детское");
        pure.AddShow(kids.Id);
        var mixed = Collection.Create("Смешанная");
        mixed.AddShow(kids.Id);
        mixed.AddShow(adult.Id);

        await using (var seed = fixture.New())
        {
            seed.Shows.AddRange(kids, adult);
            seed.Collections.AddRange(pure, mixed);
            await seed.SaveChangesAsync(CancellationToken.None);
        }

        var matched = await MatchAsync(fixture, new GroupFilter(MaxAudience: ShowAudience.Pg));

        Assert.Contains((GroupElementKind.Collection, pure.Id), matched);
        Assert.DoesNotContain((GroupElementKind.Collection, mixed.Id), matched);
    }

    [Fact]
    public async Task ElementKinds_LimitWhatIsSearched()
    {
        var fixture = new TestDb();
        var show = Show.Create("Фильм", ShowKind.Single);
        var collection = Collection.Create("Франшиза");
        collection.AddShow(show.Id);

        await using (var seed = fixture.New())
        {
            seed.Shows.Add(show);
            seed.Collections.Add(collection);
            await seed.SaveChangesAsync(CancellationToken.None);
        }

        var onlyCollections = await MatchAsync(
            fixture,
            new GroupFilter(ElementKinds: [GroupElementKind.Collection])
        );

        Assert.Equal([(GroupElementKind.Collection, collection.Id)], onlyCollections);
    }

    [Fact]
    public void UnitDuration_ChecksAverageUnit_AndKeepsUnprocessed()
    {
        var filter = new GroupFilter(UnitMinutesMin: 20, UnitMinutesMax: 30);

        // Сериал из двух серий по 22 минуты — подходит по средней длине единицы, а не по сумме.
        Assert.True(
            GroupFilterMatcher.MatchesUnitDuration(
                filter,
                Element(units: 2, total: TimeSpan.FromMinutes(44))
            )
        );
        Assert.False(
            GroupFilterMatcher.MatchesUnitDuration(
                filter,
                Element(units: 1, total: TimeSpan.FromMinutes(120))
            )
        );
        Assert.False(
            GroupFilterMatcher.MatchesUnitDuration(
                filter,
                Element(units: 1, total: TimeSpan.FromMinutes(5))
            )
        );
        // Ещё не обработанное длительности не имеет — такое фильтр не отбрасывает.
        Assert.True(
            GroupFilterMatcher.MatchesUnitDuration(filter, Element(units: 0, total: TimeSpan.Zero))
        );
        // Без границ проверять нечего.
        Assert.True(
            GroupFilterMatcher.MatchesUnitDuration(
                new GroupFilter(),
                Element(units: 1, total: TimeSpan.FromMinutes(120))
            )
        );
    }

    private static GroupElementInfo Element(int units, TimeSpan total) =>
        new("Элемент", units, total, ShowKind.Series, null, null, null);

    private static Show WithYear(Show show, int year)
    {
        show.ApplyMetadata("manual", Guid.NewGuid().ToString("N"), null, year, null);
        return show;
    }

    private static async Task SeedAsync(TestDb fixture, params Show[] shows)
    {
        await using var seed = fixture.New();
        seed.Shows.AddRange(shows);
        await seed.SaveChangesAsync(CancellationToken.None);
    }

    private static async Task<IReadOnlyList<(GroupElementKind Kind, Guid Id)>> MatchAsync(
        TestDb fixture,
        GroupFilter filter
    )
    {
        await using var db = fixture.New();
        return await new GroupFilterMatcher(db).MatchAsync(filter, CancellationToken.None);
    }
}
