using System.Text.RegularExpressions;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Library;

namespace TeleWave.Application.Library.Collections.Suggest;

/// <summary>
/// Предложение франшизы: имя и части в предлагаемом порядке. <paramref name="CollectionId"/>
/// задан — франшиза уже собрана, и предлагается дополнить её найденными частями, а не заводить
/// вторую такую же.
/// </summary>
public sealed record CollectionSuggestion(
    string Key,
    CollectionSuggestionSource Source,
    string Name,
    IReadOnlyList<CollectionSuggestionPartDto> Parts,
    Guid? CollectionId = null
);

/// <summary>
/// Ищет, что стоит собрать в коллекцию, среди полнометражек, ещё не входящих ни в одну.
///
/// Два источника, и они не равны по надёжности. Франшиза из метаданных — это факт: TMDb сам считает
/// эти фильмы одним целым. Догадка по названиям — эвристика для библиотеки без метаданных, и она
/// намеренно осторожна: совпадать должно осмысленное начало названия, а не первое слово, иначе
/// «Человек-паук» и «Человек дождя» уедут в одну франшизу.
///
/// Предложений тоже два вида: собрать новую франшизу и **дополнить уже собранную**. Второй важнее,
/// чем кажется: библиотека пополняется по одному фильму, и четвёртая часть, приехавшая после сборки
/// коллекции, сама по себе франшизы не образует — раньше она просто не показывалась нигде.
///
/// Решение всё равно за человеком: предложение показывает состав целиком, а применяется кнопкой.
/// Сериалы сюда не попадают — у них своя форма продолжения, сезоны.
/// </summary>
public sealed class CollectionSuggestionBuilder(IAppDbContext dbContext)
{
    /// <summary>Меньше двух частей — не франшиза.</summary>
    private const int MinParts = 2;

    /// <summary>
    /// Со скольких общих слов начало названия само по себе считается признаком франшизы:
    /// «Harry Potter and the …» случайным совпадением не бывает, а «Человек …» — бывает.
    /// </summary>
    private const int StrongCommonWords = 3;

    /// <summary>
    /// Чем продолжение отличается от однофамильца: номер («Рокки 2», «Рокки III»), слово «часть»
    /// либо подзаголовок через двоеточие или тире («Матрица: Перезагрузка»). У «Человек дождя»
    /// и «Человек-слон» хвосты — обычные слова, и в одну франшизу они не попадут.
    /// </summary>
    private static readonly Regex SequelTail = new(
        @"^(?:\d{1,2}\b|[IVXivx]{1,4}\b|part\b|часть\b|[:–—-]\s*\S)",
        RegexOptions.IgnoreCase | RegexOptions.CultureInvariant,
        TimeSpan.FromSeconds(1)
    );

    public async Task<IReadOnlyList<CollectionSuggestion>> BuildAsync(
        CancellationToken cancellationToken
    )
    {
        var members = await dbContext
            .CollectionItems.AsNoTracking()
            .Select(i => new MemberRow(i.CollectionId, i.ShowId))
            .ToListAsync(cancellationToken);

        var allMovies = await dbContext
            .Shows.AsNoTracking()
            .Where(s => s.Kind == ShowKind.Single)
            .Select(s => new MovieRow(
                s.Id,
                s.Name,
                s.OriginalName,
                s.Year,
                s.FranchiseExternalId,
                s.FranchiseName
            ))
            .ToListAsync(cancellationToken);

        var inCollections = members.Select(m => m.ShowId).ToHashSet();
        var movies = allMovies.Where(m => !inCollections.Contains(m.Id)).ToList();
        if (movies.Count == 0)
            return [];

        var suggestions = new List<CollectionSuggestion>();
        var taken = new HashSet<Guid>();

        // Дополнение существующих идёт первым: если франшиза уже собрана, вторую такую же заводить
        // нельзя (имя занято), и предложить админу «создать» вместо «добавить» значило бы завести
        // его в конфликт.
        var additions = await ToExistingAsync(movies, members, allMovies, cancellationToken);
        foreach (var addition in additions)
        {
            suggestions.Add(addition);
            foreach (var part in addition.Parts)
                taken.Add(part.ShowId);
        }

        var free = movies.Where(m => !taken.Contains(m.Id)).ToList();

        foreach (var franchise in ByMetadata(free))
        {
            suggestions.Add(franchise);
            foreach (var part in franchise.Parts)
                taken.Add(part.ShowId);
        }

        // По названиям добираем только то, что не разобрала метадата: иначе одни и те же фильмы
        // попали бы в два предложения сразу и админ выбирал бы между ними вслепую.
        suggestions.AddRange(ByName(free.Where(m => !taken.Contains(m.Id)).ToList()));

        return
        [
            .. suggestions
                // Дополнения выше новых франшиз: это точечное действие на один-два фильма, и оно
                // не должно теряться под списком крупных предложений.
                .OrderByDescending(s => s.CollectionId is not null)
                .ThenBy(s => s.Source)
                .ThenByDescending(s => s.Parts.Count)
                .ThenBy(s => s.Name, StringComparer.CurrentCultureIgnoreCase),
        ];
    }

    /// <summary>
    /// Чего не хватает уже собранным франшизам. Фильм признаётся частью коллекции по тем же двум
    /// признакам, что и при сборке новой: франшиза из метаданных совпала с франшизой любого её
    /// участника либо название подошло по правилам догадки.
    ///
    /// Метадата проверяется по всем коллекциям раньше догадки: точное совпадение не должно
    /// проигрывать похожему названию из соседней франшизы.
    /// </summary>
    private async Task<IReadOnlyList<CollectionSuggestion>> ToExistingAsync(
        List<MovieRow> orphans,
        List<MemberRow> members,
        List<MovieRow> allMovies,
        CancellationToken cancellationToken
    )
    {
        var collections = await dbContext
            .Collections.AsNoTracking()
            .Select(c => new { c.Id, c.Name })
            .ToListAsync(cancellationToken);
        if (collections.Count == 0)
            return [];

        var byId = allMovies.ToDictionary(m => m.Id);
        var membersByCollection = members
            .GroupBy(m => m.CollectionId)
            .ToDictionary(
                g => g.Key,
                // Сериал в коллекции — не ориентир для франшизы полнометражек: в справочнике
                // фильмов его просто нет, поэтому он и отсеивается по словарю.
                g => g.Where(m => byId.ContainsKey(m.ShowId)).Select(m => byId[m.ShowId]).ToList()
            );

        var hits = new Dictionary<Guid, List<MovieRow>>();

        foreach (var orphan in orphans)
        {
            var target = collections.FirstOrDefault(c =>
                Members(membersByCollection, c.Id).Any(m => SameMetadataFranchise(orphan, m))
            );
            var byMetadata = target is not null;

            target ??= collections.FirstOrDefault(c =>
                Members(membersByCollection, c.Id)
                    .Any(m => SameFranchise(Words(Title(orphan)), Words(Title(m))))
            );
            if (target is null)
                continue;

            if (!hits.TryGetValue(target.Id, out var found))
                hits[target.Id] = found = [];
            found.Add(orphan with { MatchedByMetadata = byMetadata });
        }

        return
        [
            .. hits.Select(pair => new CollectionSuggestion(
                $"existing:{pair.Key:N}",
                // Источник — по самому слабому попаданию: одна догадка в наборе означает, что
                // весь набор надо проверить глазами.
                pair.Value.All(m => m.MatchedByMetadata)
                    ? CollectionSuggestionSource.Metadata
                    : CollectionSuggestionSource.NameGuess,
                collections.First(c => c.Id == pair.Key).Name,
                Parts(pair.Value),
                pair.Key
            )),
        ];
    }

    private static List<MovieRow> Members(
        Dictionary<Guid, List<MovieRow>> membersByCollection,
        Guid collectionId
    ) => membersByCollection.TryGetValue(collectionId, out var list) ? list : [];

    /// <summary>Обе части знают свою франшизу из метаданных, и это одна и та же франшиза.</summary>
    private static bool SameMetadataFranchise(MovieRow first, MovieRow second) =>
        first.FranchiseExternalId is not null
        && first.FranchiseExternalId == second.FranchiseExternalId;

    private static IEnumerable<CollectionSuggestion> ByMetadata(List<MovieRow> movies)
    {
        var groups = movies
            .Where(m => m.FranchiseExternalId is not null && m.FranchiseName is not null)
            .GroupBy(m => m.FranchiseExternalId!)
            .Where(g => g.Count() >= MinParts);

        foreach (var group in groups)
            yield return new CollectionSuggestion(
                $"franchise:{group.Key}",
                CollectionSuggestionSource.Metadata,
                group.First().FranchiseName!,
                Parts(group)
            );
    }

    /// <summary>
    /// Догадка по названиям для библиотеки без метаданных. Сравниваются целые слова, а не символы:
    /// посимвольное совпадение слишком легко ловит однофамильцев («Человек дождя» и «Человек-слон»).
    /// Пара считается франшизой, если общее начало длинное либо хвосты обеих частей выглядят
    /// продолжением. Имя предложения — общее начало, очищенное от хвостовых предлогов и знаков.
    /// </summary>
    private static IEnumerable<CollectionSuggestion> ByName(List<MovieRow> movies)
    {
        var used = new HashSet<Guid>();

        // От длинных названий к коротким: так более специфичная франшиза собирается первой.
        foreach (var movie in movies.OrderByDescending(m => Title(m).Length))
        {
            if (used.Contains(movie.Id))
                continue;

            var words = Words(Title(movie));
            var family = movies
                .Where(other =>
                    other.Id != movie.Id
                    && !used.Contains(other.Id)
                    && SameFranchise(words, Words(Title(other)))
                )
                .ToList();
            if (family.Count == 0)
                continue;

            var common = family.Aggregate(
                words,
                (acc, other) => acc[..CommonWordCount(acc, Words(Title(other)))]
            );
            var name = Clean(string.Join(' ', common));
            if (name.Length < 3)
                continue;

            family.Add(movie);
            foreach (var part in family)
                used.Add(part.Id);

            yield return new CollectionSuggestion(
                $"name:{name.ToLowerInvariant()}",
                CollectionSuggestionSource.NameGuess,
                name,
                Parts(family)
            );
        }
    }

    /// <summary>Части идут по году: у франшизы это и есть порядок просмотра.</summary>
    private static IReadOnlyList<CollectionSuggestionPartDto> Parts(IEnumerable<MovieRow> movies) =>
        [
            .. movies
                .OrderBy(m => m.Year is null)
                .ThenBy(m => m.Year)
                .ThenBy(m => m.Name, StringComparer.CurrentCultureIgnoreCase)
                .Select(m => new CollectionSuggestionPartDto(m.Id, m.Name, m.Year)),
        ];

    /// <summary>Сравниваем по оригинальному названию, если оно есть: у релизов оно стабильнее.</summary>
    private static string Title(MovieRow movie) =>
        string.IsNullOrWhiteSpace(movie.OriginalName) ? movie.Name : movie.OriginalName;

    private static bool SameFranchise(string[] first, string[] second)
    {
        var common = CommonWordCount(first, second);
        if (common == 0)
            return false;
        if (common >= StrongCommonWords)
            return true;
        return IsSequelTail(first, common) && IsSequelTail(second, common);
    }

    /// <summary>Хвост после общего начала: пусто — это первая часть, иначе должен выглядеть продолжением.</summary>
    private static bool IsSequelTail(string[] words, int common)
    {
        var tail = string.Join(' ', words.Skip(common)).Trim();
        return tail.Length == 0 || SequelTail.IsMatch(tail);
    }

    private static int CommonWordCount(string[] first, string[] second)
    {
        var count = 0;
        while (
            count < first.Length
            && count < second.Length
            && string.Equals(first[count], second[count], StringComparison.CurrentCultureIgnoreCase)
        )
            count++;
        return count;
    }

    private static string[] Words(string title) =>
        title.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);

    /// <summary>
    /// Общее начало обрывается на союзе или знаке: «Harry Potter and the» — это ещё не имя франшизы.
    /// Чистим, пока есть что убирать: за один проход «and the» не снять.
    /// </summary>
    private static string Clean(string prefix)
    {
        var trimmed = prefix.Trim();
        bool changed;
        do
        {
            changed = false;
            trimmed = trimmed.TrimEnd(':', '-', '–', '—', '.', ',', ' ');
            foreach (var tail in (string[])[" и", " and", " the", " part", " часть"])
            {
                if (!trimmed.EndsWith(tail, StringComparison.CurrentCultureIgnoreCase))
                    continue;
                trimmed = trimmed[..^tail.Length];
                changed = true;
            }
        } while (changed);

        return trimmed.Trim();
    }

    /// <param name="MatchedByMetadata">
    /// Как фильм попал в дополнение существующей коллекции: по франшизе из метаданных или догадкой
    /// по названию. Живёт здесь, а не отдельным списком, чтобы признак не разъезжался с самим
    /// фильмом при группировке.
    /// </param>
    private sealed record MovieRow(
        Guid Id,
        string Name,
        string? OriginalName,
        int? Year,
        string? FranchiseExternalId,
        string? FranchiseName,
        bool MatchedByMetadata = false
    );

    private sealed record MemberRow(Guid CollectionId, Guid ShowId);
}
