using System.Globalization;
using System.Text.Json;
using Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Metadata;
using TeleWave.Domain.Library;
using static TeleWave.Infrastructure.Metadata.MetadataJson;

namespace TeleWave.Infrastructure.Metadata;

/// <summary>Провайдер метаданных TMDb (themoviedb.org). Требует API-ключ (v3).</summary>
public sealed class TmdbMetadataProvider(
    IHttpClientFactory httpFactory,
    IOptions<MetadataOptions> options
) : IMetadataProvider
{
    private readonly MetadataOptions _options = options.Value;

    public string Key => "tmdb";

    private TmdbOptions Tmdb => _options.Tmdb;

    /// <summary>
    /// TMDb принимает два вида учётных данных, и различаются они формой, а не настройкой: ключ v3 —
    /// 32 hex-символа и едет параметром <c>api_key</c>, токен v4 — JWT (три части через точку) и
    /// едет заголовком <c>Authorization: Bearer</c>. Точка в hex-ключе невозможна, поэтому по ней
    /// и различаем — оператору не нужно заводить ещё одну переменную и гадать, какая к какому ключу.
    /// </summary>
    private bool UsesBearer => Tmdb.ApiKey.Contains('.', StringComparison.Ordinal);

    private string? BearerToken => UsesBearer ? Tmdb.ApiKey : null;

    /// <summary>Начало строки запроса: авторизация (если она в query) и язык — они есть у всех обращений.</summary>
    private string BaseQuery =>
        UsesBearer
            ? $"?language={_options.Language}"
            : $"?api_key={Tmdb.ApiKey}&language={_options.Language}";

    public async Task<IReadOnlyList<MetadataCandidate>> SearchShowsAsync(
        string query,
        ShowKind kind,
        CancellationToken cancellationToken
    )
    {
        var movie = IsMovie(kind);
        var url =
            $"{Tmdb.BaseUrl}/search/{Segment(movie)}{BaseQuery}"
            + $"&include_adult=false&query={Uri.EscapeDataString(query)}";
        using var doc = await GetAsync(httpFactory, url, cancellationToken, BearerToken);
        if (!doc.RootElement.TryGetProperty("results", out var results))
            return [];

        var list = new List<MetadataCandidate>();
        foreach (var item in results.EnumerateArray())
        {
            var id = GetInt(item, "id");
            if (id is null)
                continue;
            list.Add(
                new MetadataCandidate(
                    id.Value.ToString(CultureInfo.InvariantCulture),
                    GetString(item, TitleField(movie)) ?? "—",
                    YearFrom(GetString(item, DateField(movie))),
                    GetString(item, "overview"),
                    PosterUrl(GetString(item, "poster_path")),
                    movie ? ShowKind.Single : ShowKind.Series,
                    GetString(item, OriginalTitleField(movie))
                )
            );
        }
        return list;
    }

    /// <summary>
    /// Среди чего искать. Сериал — это <see cref="ShowKind.Series"/>, всё остальное (полнометражка,
    /// ролик-врезка) ищется среди фильмов: у ролика единственная «серия», и сезонов у него нет.
    /// </summary>
    private static bool IsMovie(ShowKind kind) => kind != ShowKind.Series;

    private static string Segment(bool movie) => movie ? "movie" : "tv";

    // У фильмов и сериалов TMDb называет одни и те же вещи по-разному.
    private static string TitleField(bool movie) => movie ? "title" : "name";

    private static string DateField(bool movie) => movie ? "release_date" : "first_air_date";

    private static string OriginalTitleField(bool movie) =>
        movie ? "original_title" : "original_name";

    public async Task<ShowMetadata?> GetShowAsync(
        string externalId,
        ShowKind kind,
        CancellationToken cancellationToken
    )
    {
        var movie = IsMovie(kind);

        // Сертификацию подвешиваем к тому же запросу — отдельного обращения к API не требуется.
        // У фильмов и сериалов она лежит в разных разделах, поэтому и append, и разбор разные.
        var url =
            $"{Tmdb.BaseUrl}/{Segment(movie)}/{externalId}{BaseQuery}"
            + $"&append_to_response={(movie ? "release_dates" : "content_ratings")}";
        using var doc = await TryGetAsync(httpFactory, url, cancellationToken, BearerToken);
        if (doc is null)
            return null;
        var root = doc.RootElement;

        // Постер TMDb отдаёт под запрошенный язык, и у шоу без локализованной обложки поле пустое —
        // хотя в поиске картинка была: там приходит язык-нейтральная. Без добора «Применить» молча
        // оставляло бы шоу без постера, и это неотличимо от «постера нет вовсе».
        var posterPath =
            GetString(root, "poster_path")
            ?? await NeutralImagePathAsync(
                $"{Segment(movie)}/{externalId}",
                "poster_path",
                cancellationToken
            );

        return new ShowMetadata(
            externalId,
            GetString(root, TitleField(movie)) ?? "—",
            YearFrom(GetString(root, DateField(movie))),
            GetString(root, "overview"),
            PosterUrl(posterPath),
            GenresFrom(root),
            movie ? MovieCertification(root) : TvCertification(root),
            FranchiseFrom(root),
            GetString(root, OriginalTitleField(movie))
        );
    }

    /// <summary>
    /// Картинка без привязки к языку: тот же адрес, но без <c>language</c>. TMDb отдаёт постеры
    /// и кадры под запрошенный язык, и у контента без локализованной картинки поле пустое — хотя
    /// язык-нейтральная у него есть. Второе обращение к API делается только в этом случае.
    /// </summary>
    private async Task<string?> NeutralImagePathAsync(
        string path,
        string field,
        CancellationToken cancellationToken
    )
    {
        var query = UsesBearer ? string.Empty : $"?api_key={Tmdb.ApiKey}";
        var url = $"{Tmdb.BaseUrl}/{path}{query}";
        using var doc = await TryGetAsync(httpFactory, url, cancellationToken, BearerToken);
        return doc is null ? null : GetString(doc.RootElement, field);
    }

    /// <summary>
    /// Франшиза фильма: TMDb отдаёт её прямо в ответе по фильму (<c>belongs_to_collection</c>),
    /// отдельного запроса не нужно. У сериалов этого поля нет.
    /// </summary>
    private static MetadataFranchise? FranchiseFrom(JsonElement root)
    {
        if (
            !root.TryGetProperty("belongs_to_collection", out var block)
            || block.ValueKind != JsonValueKind.Object
        )
            return null;

        var id = GetInt(block, "id");
        var name = GetString(block, "name");
        return id is null || string.IsNullOrWhiteSpace(name)
            ? null
            : new MetadataFranchise(id.Value.ToString(CultureInfo.InvariantCulture), name.Trim());
    }

    /// <summary>Код прокатного релиза в справочнике типов релизов TMDb.</summary>
    private const int TheatricalReleaseType = 3;

    /// <summary>
    /// Сертификация сериала: в <c>content_ratings</c> на страну приходится одна запись, рейтинг
    /// лежит прямо в ней.
    /// </summary>
    private string? TvCertification(JsonElement root) =>
        FirstCertification(root, "content_ratings", item => Trimmed(GetString(item, "rating")));

    /// <summary>
    /// Сертификация фильма: в <c>release_dates</c> на страну приходится список релизов, и
    /// <c>certification</c> проставлена не у всех. Прокатный релиз предпочитаем прочим — цифровые и
    /// телевизионные переиздания иногда несут собственную, более позднюю маркировку.
    /// </summary>
    private string? MovieCertification(JsonElement root) =>
        FirstCertification(
            root,
            "release_dates",
            item =>
            {
                if (
                    !item.TryGetProperty("release_dates", out var releases)
                    || releases.ValueKind != JsonValueKind.Array
                )
                    return null;

                string? anyRelease = null;
                foreach (var release in releases.EnumerateArray())
                {
                    if (Trimmed(GetString(release, "certification")) is not { } certification)
                        continue;
                    if (GetInt(release, "type") == TheatricalReleaseType)
                        return certification;
                    anyRelease ??= certification;
                }
                return anyRelease;
            }
        );

    /// <summary>
    /// Общая часть обеих форм: перебираем страны в порядке приоритета
    /// (<see cref="TmdbOptions.CertificationCountries"/>) и отдаём первое непустое значение,
    /// которое <paramref name="select"/> вытащит из записи страны. Написание источника не трогаем —
    /// приводит его к шкале приложение (ContentRating).
    ///
    /// Пустое значение — обычное дело: страна в списке есть, а сертификации у неё нет, и такую
    /// запись надо пропускать, а не считать ответом.
    /// </summary>
    private string? FirstCertification(
        JsonElement root,
        string section,
        Func<JsonElement, string?> select
    )
    {
        if (
            !root.TryGetProperty(section, out var block)
            || !block.TryGetProperty("results", out var results)
            || results.ValueKind != JsonValueKind.Array
        )
            return null;

        foreach (var country in Tmdb.CertificationCountries)
        {
            foreach (var item in results.EnumerateArray())
            {
                if (
                    !string.Equals(
                        GetString(item, "iso_3166_1"),
                        country,
                        StringComparison.OrdinalIgnoreCase
                    )
                )
                    continue;

                if (select(item) is { } value)
                    return value;
            }
        }

        return null;
    }

    private static string? Trimmed(string? value) =>
        string.IsNullOrWhiteSpace(value) ? null : value.Trim();

    /// <summary>
    /// Жанры из ответа TMDb в порядке источника: на каждый — сначала идентификатор
    /// (<c>tmdb:28</c>), затем локализованное название. Идентификатор точнее, название — запасной
    /// вариант, если справочник его ещё не знает.
    /// </summary>
    private static List<string> GenresFrom(JsonElement root)
    {
        var genres = new List<string>();
        if (!root.TryGetProperty("genres", out var array) || array.ValueKind != JsonValueKind.Array)
            return genres;

        foreach (var item in array.EnumerateArray())
        {
            if (GetInt(item, "id") is { } id)
                genres.Add($"tmdb:{id.ToString(CultureInfo.InvariantCulture)}");
            if (GetString(item, "name") is { Length: > 0 } name)
                genres.Add(name);
        }
        return genres;
    }

    public async Task<EpisodeMetadata?> GetEpisodeAsync(
        string externalId,
        int season,
        int episode,
        CancellationToken cancellationToken
    )
    {
        var path = $"tv/{externalId}/season/{season}/episode/{episode}";
        using var doc = await TryGetAsync(
            httpFactory,
            $"{Tmdb.BaseUrl}/{path}{BaseQuery}",
            cancellationToken,
            BearerToken
        );
        if (doc is null)
            return null;
        var root = doc.RootElement;

        // Кадр серии — та же история, что и с постером шоу: под русский язык его обычно нет,
        // а язык-нейтральный есть. Добор идёт по серии, поэтому у сезона без локализованных
        // кадров запросов вдвое больше — «Обновить серии» жмут руками, это терпимо.
        var stillPath =
            GetString(root, "still_path")
            ?? await NeutralImagePathAsync(path, "still_path", cancellationToken);

        return new EpisodeMetadata(
            GetString(root, "name") ?? "—",
            GetString(root, "overview"),
            StillUrl(stillPath),
            DateFrom(GetString(root, "air_date"))
        );
    }

    public async Task<int?> GetSeasonEpisodeCountAsync(
        string externalId,
        int season,
        CancellationToken cancellationToken
    )
    {
        var url = $"{Tmdb.BaseUrl}/tv/{externalId}/season/{season}{BaseQuery}";
        using var doc = await TryGetAsync(httpFactory, url, cancellationToken, BearerToken);
        if (
            doc is null
            || !doc.RootElement.TryGetProperty("episodes", out var episodes)
            || episodes.ValueKind != JsonValueKind.Array
        )
            return null;
        return episodes.GetArrayLength();
    }

    public async Task<IReadOnlyList<int>> GetSeasonNumbersAsync(
        string externalId,
        CancellationToken cancellationToken
    )
    {
        var url = $"{Tmdb.BaseUrl}/tv/{externalId}{BaseQuery}";
        using var doc = await TryGetAsync(httpFactory, url, cancellationToken, BearerToken);
        if (
            doc is null
            || !doc.RootElement.TryGetProperty("seasons", out var seasons)
            || seasons.ValueKind != JsonValueKind.Array
        )
            return [];

        var numbers = new List<int>();
        foreach (var season in seasons.EnumerateArray())
        {
            // Нулевой сезон у TMDb — спецвыпуски; обычные сезоны нумеруются с единицы.
            if (GetInt(season, "season_number") is { } number && number > 0)
                numbers.Add(number);
        }
        numbers.Sort();
        return numbers;
    }

    private string? PosterUrl(string? path) =>
        string.IsNullOrEmpty(path) ? null : $"{Tmdb.ImageBaseUrl}/{Tmdb.PosterSize}{path}";

    private string? StillUrl(string? path) =>
        string.IsNullOrEmpty(path) ? null : $"{Tmdb.ImageBaseUrl}/{Tmdb.StillSize}{path}";

    private static DateOnly? DateFrom(string? date) =>
        DateOnly.TryParse(date, CultureInfo.InvariantCulture, out var d) ? d : null;
}
