using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Metadata;
using TeleWave.Domain.Library;

namespace TeleWave.Application.Media.MovieImport;

/// <summary>Что стало с именем после разбора и поиска.</summary>
public enum MovieMatchStatus
{
    /// <summary>Название и год сошлись — можно импортировать не глядя.</summary>
    Confident,

    /// <summary>Кандидаты есть, но точного совпадения нет: выбирать человеку.</summary>
    Uncertain,

    /// <summary>Источник ничего не вернул — нужно вписать название руками.</summary>
    NotFound,

    /// <summary>Такой фильм в библиотеке уже есть — по идентификатору источника.</summary>
    AlreadyInLibrary,
}

/// <summary>Одна строка разбора: что распозналось, что нашлось и что с этим делать.</summary>
public sealed record MovieMatchDto(
    /// <summary>Имя, как его прислал клиент, — по нему строка и опознаётся при импорте.</summary>
    string Name,
    string ParsedTitle,
    int? ParsedYear,
    MovieMatchStatus Status,
    IReadOnlyList<MetadataCandidate> Candidates,
    /// <summary>Кандидат, выбранный автоматически (только при уверенном совпадении).</summary>
    string? PickedExternalId,
    /// <summary>Шоу, которое уже занимает этого кандидата.</summary>
    Guid? ExistingShowId,
    string? ExistingShowName
);

/// <summary>
/// Подбор фильма по имени файла: разбор имени → поиск в источнике → решение, уверенное ли это
/// совпадение. Один и тот же код обслуживает и кнопку разбора, и автоматику inbox: правило
/// «что считать уверенным» обязано быть общим, иначе автоматика заведёт то, что таблица разбора
/// показала бы жёлтым.
///
/// Уверенным считается совпадение, где нормализованное название сошлось дословно, год сошёлся
/// с точностью до года (у релизов год проката и год производства разъезжаются) и такой кандидат
/// ровно один. Всё остальное — человеку: цена ошибки здесь чужой постер и чужой рейтинг у фильма
/// в эфире.
/// </summary>
public sealed class MovieMatcher(IAppDbContext dbContext, IMetadataProviderResolver resolver)
{
    /// <summary>Сколько кандидатов показывать в строке: дальше третьего никто не смотрит.</summary>
    private const int MaxCandidates = 5;

    /// <summary>Допуск по году. Больше единицы брать нельзя: сиквелы выходят с шагом в год.</summary>
    private const int YearTolerance = 1;

    /// <summary>Шоу, уже занявшее кандидата источника.</summary>
    private sealed record TakenShow(Guid Id, string Name);

    /// <summary>Разбор пачки имён: как их прислал клиент, так и вернутся — строка к строке.</summary>
    public async Task<IReadOnlyList<MovieMatchDto>> MatchAsync(
        IReadOnlyList<string> names,
        string providerKey,
        CancellationToken cancellationToken
    )
    {
        if (resolver.Resolve(providerKey) is not { } provider)
            return [];

        var taken = await TakenAsync(providerKey, cancellationToken);
        var rows = new List<MovieMatchDto>(names.Count);

        foreach (var name in names.Distinct(StringComparer.Ordinal))
        {
            var parsed = MovieNameParser.Parse(name);
            rows.Add(await MatchOneAsync(name, parsed, provider, taken, cancellationToken));
        }

        return rows;
    }

    /// <summary>Подбор по названию, которое админ вписал руками вместо распознанного.</summary>
    public async Task<MovieMatchDto> MatchQueryAsync(
        string name,
        string title,
        int? year,
        string providerKey,
        CancellationToken cancellationToken
    )
    {
        if (resolver.Resolve(providerKey) is not { } provider)
            return NothingFound(name, title, year);

        var taken = await TakenAsync(providerKey, cancellationToken);
        return await MatchOneAsync(
            name,
            new ParsedMovieName(title, year),
            provider,
            taken,
            cancellationToken
        );
    }

    /// <summary>
    /// Уверенный кандидат для автоматики inbox. Возвращает null всегда, когда решение неочевидно, —
    /// молчаливый пропуск здесь лучше молчаливой ошибки: файл останется ассетом без шоу и попадёт
    /// в тот же разбор, что и всё непонятное.
    /// </summary>
    public async Task<MetadataCandidate?> PickConfidentAsync(
        string fileName,
        string providerKey,
        CancellationToken cancellationToken
    )
    {
        if (resolver.Resolve(providerKey) is not { } provider)
            return null;

        var parsed = MovieNameParser.Parse(fileName);
        if (string.IsNullOrWhiteSpace(parsed.Title))
            return null;

        var candidates = await provider.SearchShowsAsync(
            parsed.Title,
            ShowKind.Single,
            cancellationToken
        );

        return Pick([.. candidates.Take(MaxCandidates)], parsed);
    }

    private async Task<Dictionary<string, TakenShow>> TakenAsync(
        string providerKey,
        CancellationToken cancellationToken
    )
    {
        // Один запрос на весь разбор, а не запрос на строку: имён в пачке сотни.
        var shows = await dbContext
            .Shows.AsNoTracking()
            .Where(s => s.MetadataProvider == providerKey && s.MetadataExternalId != null)
            .Select(s => new
            {
                s.Id,
                s.Name,
                ExternalId = s.MetadataExternalId!,
            })
            .ToListAsync(cancellationToken);

        var taken = new Dictionary<string, TakenShow>(StringComparer.Ordinal);
        foreach (var show in shows)
            taken.TryAdd(show.ExternalId, new TakenShow(show.Id, show.Name));

        return taken;
    }

    private static async Task<MovieMatchDto> MatchOneAsync(
        string name,
        ParsedMovieName parsed,
        IMetadataProvider provider,
        IReadOnlyDictionary<string, TakenShow> taken,
        CancellationToken cancellationToken
    )
    {
        if (string.IsNullOrWhiteSpace(parsed.Title))
            return NothingFound(name, parsed.Title, parsed.Year);

        var found = await provider.SearchShowsAsync(
            parsed.Title,
            ShowKind.Single,
            cancellationToken
        );
        var candidates = found.Take(MaxCandidates).ToList();
        if (candidates.Count == 0)
            return NothingFound(name, parsed.Title, parsed.Year);

        var picked = Pick(candidates, parsed);
        var existing = picked is null ? null : taken.GetValueOrDefault(picked.ExternalId);

        return new MovieMatchDto(
            name,
            parsed.Title,
            parsed.Year,
            StatusOf(existing, picked),
            candidates,
            picked?.ExternalId,
            existing?.Id,
            existing?.Name
        );
    }

    private static MovieMatchDto NothingFound(string name, string title, int? year) =>
        new(name, title, year, MovieMatchStatus.NotFound, [], null, null, null);

    /// <summary>
    /// Что делать со строкой. Занятый кандидат важнее уверенности: второй файл того же фильма —
    /// это не второй фильм, и заводить его нельзя, каким бы точным ни было совпадение.
    /// </summary>
    private static MovieMatchStatus StatusOf(TakenShow? existing, MetadataCandidate? picked)
    {
        if (existing is not null)
            return MovieMatchStatus.AlreadyInLibrary;

        return picked is not null ? MovieMatchStatus.Confident : MovieMatchStatus.Uncertain;
    }

    /// <summary>
    /// Единственный кандидат, у которого название совпало дословно, а год сошёлся. Несколько таких —
    /// значит выбирать не из чего: два «Мстителя» одного года различит только человек.
    /// </summary>
    private static MetadataCandidate? Pick(
        IReadOnlyList<MetadataCandidate> candidates,
        ParsedMovieName parsed
    )
    {
        var matches = candidates
            .Where(c => Same(c.Title, parsed.Title) && YearFits(c.Year, parsed.Year))
            .ToList();

        return matches.Count == 1 ? matches[0] : null;
    }

    private static bool Same(string left, string right) =>
        Normalize(left).Length > 0 && Normalize(left) == Normalize(right);

    /// <summary>Сравниваем по буквам и цифрам: «Spider-Man» и «Spider Man» — одно название.</summary>
    private static string Normalize(string value) =>
        string.Concat(value.Where(char.IsLetterOrDigit)).ToLowerInvariant();

    /// <summary>Год без года не проверяется: имя без года — обычное дело, и отбрасывать его нельзя.</summary>
    private static bool YearFits(int? candidate, int? parsed) =>
        parsed is not { } year
        || candidate is not { } found
        || Math.Abs(found - year) <= YearTolerance;
}
