using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;

namespace TeleWave.Application.Broadcast.Bumpers;

/// <summary>
/// Чем подставляются плейсхолдеры одной заставки. Собирает контекст планировщик — только он знает
/// и пару соседей, и точное время показа; редактор подставляет те же поля образцами.
/// </summary>
public sealed record BumperContext(
    string ChannelName,
    int? ChannelNumber,
    /// <summary>Момент показа заставки во времени канала.</summary>
    DateTimeOffset LocalMoment,
    string? NowTitle = null,
    string? NextTitle = null,
    string? NowEpisode = null,
    string? NextEpisode = null,
    int? NextYear = null,
    string? NextGenre = null,
    /// <summary>Во сколько начнётся следующая программа (время канала).</summary>
    TimeOnly? NextTime = null,
    string? SlotTitle = null,
    /// <summary>Что и когда идёт вечером этих вещательных суток — для анонсов.</summary>
    BumperAnnouncement? Tonight = null,
    BumperAnnouncement? Tomorrow = null
);

/// <summary>Анонсируемая программа: что и во сколько. Null — анонсировать нечего, строка схлопнется.</summary>
public sealed record BumperAnnouncement(string Title, TimeOnly Time);

/// <summary>
/// Плейсхолдеры текста заставки: «ДАЛЕЕ В {next.time}» → «ДАЛЕЕ В 21:30».
///
/// Список закрытый и проверяется при сохранении: незнакомый плейсхолдер — ошибка ввода, а не
/// сюрприз в эфире, где его уже не увидит никто, кроме зрителя.
/// </summary>
public static partial class BumperPlaceholders
{
    /// <summary>Все допустимые имена. Описания и образцы живут в локалях редактора, не здесь.</summary>
    public static readonly IReadOnlySet<string> Tokens = new HashSet<string>(StringComparer.Ordinal)
    {
        "channel",
        "channel.number",
        "now.title",
        "next.title",
        "now.episode",
        "next.episode",
        "next.year",
        "next.genre",
        "next.time",
        "tonight.title",
        "tonight.time",
        "tomorrow.title",
        "tomorrow.time",
        "time",
        "date",
        "weekday",
        "slot",
    };

    /// <summary>
    /// Плейсхолдеры, привязанные к моменту показа. Каждое их значение уникально, поэтому кэш
    /// отрендеренных заставок с ними перестаёт работать — редактор обязан об этом предупредить.
    /// </summary>
    public static readonly IReadOnlySet<string> VolatileTokens = new HashSet<string>(
        StringComparer.Ordinal
    )
    {
        "time",
        "date",
        "weekday",
    };

    // Русская культура фиксирована: заставка рендерится один раз в видео, локали зрителя у неё нет.
    private static readonly CultureInfo Culture = CultureInfo.GetCultureInfo("ru-RU");

    /// <summary>Время в кадре — только часы и минуты: секунды в заставке не читаются.</summary>
    private const string TimeFormat = "HH:mm";

    [GeneratedRegex(@"\{([a-zA-Z][a-zA-Z.]*)\}", RegexOptions.CultureInvariant)]
    private static partial Regex TokenPattern();

    [GeneratedRegex(@"[ \t]{2,}", RegexOptions.CultureInvariant)]
    private static partial Regex ExtraSpaces();

    /// <summary>Разделители, которые остаются висеть, когда подставлять оказалось нечего.</summary>
    private static readonly char[] DanglingSeparators = ['—', '–', '-', ':', '·', ',', ';', '/'];

    /// <summary>
    /// Подставляет значения. Неизвестное значение даёт пустую строку, а осиротевшие разделители
    /// по краям убираются: «СЕЙЧАС — {now.title}» без текущей программы должно стать «СЕЙЧАС»,
    /// а не «СЕЙЧАС —».
    ///
    /// Строка, в которой не подставился ни один плейсхолдер, исчезает целиком: она и писалась ради
    /// данных, а «ДАЛЕЕ В» без времени — это не подпись, а мусор в кадре. Строки без плейсхолдеров
    /// это не касается: они и есть постоянный текст.
    /// </summary>
    public static string Resolve(string text, BumperContext context)
    {
        if (string.IsNullOrWhiteSpace(text))
            return string.Empty;

        var tokens = TokenPattern().Matches(text);
        if (tokens.Count == 0)
            return Clean(text);

        // Собираем строку сами, а не через Replace с лямбдой: подстановку надо не только сделать,
        // но и сосчитать, а счётчик в замыкании читается хуже и не виден анализаторам.
        var builder = new StringBuilder();
        var cursor = 0;
        var filled = 0;

        foreach (Match token in tokens)
        {
            builder.Append(text, cursor, token.Index - cursor);
            cursor = token.Index + token.Length;

            var value = Value(token.Groups[1].Value, context);
            if (string.IsNullOrWhiteSpace(value))
                continue;

            builder.Append(value);
            filled++;
        }

        if (filled == 0)
            return string.Empty;

        builder.Append(text, cursor, text.Length - cursor);
        return Clean(builder.ToString());
    }

    /// <summary>Схлопывает лишние пробелы и снимает осиротевшие разделители по краям.</summary>
    private static string Clean(string text) =>
        ExtraSpaces().Replace(text, " ").Trim().Trim(DanglingSeparators).Trim();

    /// <summary>Какие плейсхолдеры встречаются в текстах — по ним планировщик решает, что грузить.</summary>
    public static IReadOnlySet<string> TokensIn(IEnumerable<string> texts)
    {
        var matches = texts
            .Where(t => !string.IsNullOrWhiteSpace(t))
            .SelectMany(text => TokenPattern().Matches(text))
            .Select(match => match.Groups[1].Value);

        return new HashSet<string>(matches, StringComparer.Ordinal);
    }

    /// <summary>Плейсхолдеры текста, которых нет в списке допустимых.</summary>
    public static IReadOnlyList<string> UnknownTokens(string? text)
    {
        if (string.IsNullOrWhiteSpace(text))
            return [];

        return TokenPattern()
            .Matches(text)
            .Select(m => m.Groups[1].Value)
            .Where(token => !Tokens.Contains(token))
            .Distinct(StringComparer.Ordinal)
            .ToList();
    }

    /// <summary>Есть ли в тексте плейсхолдер, привязанный к моменту показа (ломает кэш рендера).</summary>
    public static bool IsVolatile(string? text) =>
        !string.IsNullOrWhiteSpace(text)
        && TokenPattern().Matches(text).Any(m => VolatileTokens.Contains(m.Groups[1].Value));

    private static string? Value(string token, BumperContext context) =>
        token switch
        {
            "channel" => context.ChannelName,
            "channel.number" => context.ChannelNumber?.ToString(Culture),
            "now.title" => context.NowTitle,
            "next.title" => context.NextTitle,
            "now.episode" => context.NowEpisode,
            "next.episode" => context.NextEpisode,
            "next.year" => context.NextYear?.ToString(Culture),
            "next.genre" => context.NextGenre,
            "next.time" => context.NextTime?.ToString(TimeFormat, Culture),
            "tonight.title" => context.Tonight?.Title,
            "tonight.time" => context.Tonight?.Time.ToString(TimeFormat, Culture),
            "tomorrow.title" => context.Tomorrow?.Title,
            "tomorrow.time" => context.Tomorrow?.Time.ToString(TimeFormat, Culture),
            "time" => TimeOnly
                .FromDateTime(context.LocalMoment.DateTime)
                .ToString(TimeFormat, Culture),
            "date" => context.LocalMoment.ToString("d MMMM", Culture),
            "weekday" => context.LocalMoment.ToString("dddd", Culture),
            "slot" => context.SlotTitle,
            _ => null,
        };

    /// <summary>Подпись серии в эфирном виде: «с5э12», либо просто номер, если сезон не распознан.</summary>
    public static string? Episode(int? season, int? episode)
    {
        if (season is { } s and > 0 && episode is { } e and > 0)
            return string.Create(Culture, $"с{s}э{e}");
        return episode is { } only and > 0 ? string.Create(Culture, $"э{only}") : null;
    }
}
