using System.Globalization;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Media;
using static TeleWave.Infrastructure.Media.FfmpegText;

namespace TeleWave.Infrastructure.Media;

/// <summary>
/// Нормализация (H.264/AAC 1080p) и нарезка исходника на HLS-сегменты через ffmpeg. Длительность
/// добивается чёрным кадром + тишиной до кратности длине сегмента — ключевой инвариант эфирной
/// математики (см. docs/media-storage-and-streaming.md).
/// </summary>
public sealed class FfmpegMediaProcessor(
    MediaPathResolver paths,
    IServiceScopeFactory scopeFactory,
    IOptions<StorageOptions> storageOptions,
    IOptions<MediaOptions> mediaOptions
) : IMediaProcessor
{
    private const int MaxWidth = 1920;
    private readonly StorageOptions _storage = storageOptions.Value;
    private readonly MediaOptions _media = mediaOptions.Value;

    public async Task<MediaProcessingResult> ProcessAsync(
        Guid assetId,
        string extension,
        CancellationToken cancellationToken
    )
    {
        var originalPath = paths.OriginalPath(assetId, extension);
        if (!File.Exists(originalPath))
            throw new FileNotFoundException("Исходник для обработки не найден.", originalPath);

        var probe = await ProbeAsync(originalPath, cancellationToken);
        var segmentSeconds = _storage.SegmentSeconds;

        // Целевая длительность — вверх до кратности длине сегмента.
        var target = Math.Ceiling(probe.Duration / segmentSeconds) * segmentSeconds;
        var pad = target - probe.Duration;

        var assetDir = paths.AssetDir(assetId);
        if (Directory.Exists(assetDir))
            Directory.Delete(assetDir, recursive: true);
        Directory.CreateDirectory(assetDir);

        // Выбор аудиодорожки по предпочитаемым языкам (настройка сайта, иначе дефолт из конфига).
        var preferred = await GetPreferredLanguagesAsync(cancellationToken);
        var audioTrack = AudioTrackSelector.Select(probe.AudioLanguages, preferred);

        var (outWidth, outHeight) = ScaleDown(probe.Width, probe.Height);
        var args = BuildFfmpegArgs(originalPath, assetDir, segmentSeconds, target, pad, audioTrack);

        var result = await ProcessRunner.RunAsync(
            _media.FfmpegPath,
            args,
            lowPriority: true,
            TimeSpan.FromSeconds(_media.TranscodeTimeoutSeconds),
            cancellationToken
        );
        if (result.ExitCode != 0)
            throw new InvalidOperationException(
                $"ffmpeg завершился с кодом {result.ExitCode}: {Tail(result.StdErr)}"
            );

        var playlist = Path.Combine(assetDir, "index.m3u8");
        if (!File.Exists(playlist))
            throw new InvalidOperationException("ffmpeg не создал плейлист index.m3u8.");

        var segmentCount = Directory.GetFiles(assetDir, "seg*.ts").Length;
        if (segmentCount == 0)
            throw new InvalidOperationException("ffmpeg не создал ни одного сегмента.");

        return new MediaProcessingResult(
            TimeSpan.FromSeconds(target),
            segmentSeconds,
            segmentCount,
            outWidth,
            outHeight,
            "h264",
            "aac",
            MediaPathResolver.AssetRelativePath(assetId)
        );
    }

    private List<string> BuildFfmpegArgs(
        string input,
        string assetDir,
        int segmentSeconds,
        double target,
        double pad,
        int? audioTrack
    )
    {
        var padding = pad > 0.001;
        var vfilter = $"scale='min({MaxWidth},iw)':-2";
        if (padding)
            vfilter += $",tpad=stop_duration={Fmt(pad)}:stop_mode=add:color=black";

        var args = new List<string> { "-hide_banner", "-nostdin", "-y", "-i", input };

        // Явный выбор дорожки по предпочитаемому языку: маппим видео + конкретную аудиодорожку.
        // Без выбора (audioTrack == null) — не маппим, оставляя дефолтную эвристику ffmpeg (как раньше).
        if (audioTrack is { } track)
        {
            args.AddRange([
                "-map",
                "0:v:0",
                "-map",
                $"0:a:{track.ToString(CultureInfo.InvariantCulture)}",
            ]);
        }

        args.AddRange([
            "-threads",
            _media.TranscodeThreads.ToString(CultureInfo.InvariantCulture),
            "-c:v",
            "libx264",
            "-preset",
            "veryfast",
            "-crf",
            "21",
            "-maxrate",
            "4500k",
            "-bufsize",
            "9000k",
            "-vf",
            vfilter,
            "-force_key_frames",
            $"expr:gte(t,n_forced*{segmentSeconds.ToString(CultureInfo.InvariantCulture)})",
            "-sc_threshold",
            "0",
            "-c:a",
            "aac",
            "-b:a",
            "128k",
            "-ac",
            "2",
            "-ar",
            "48000",
        ]);

        // Аудиофильтры: нормализация громкости (EBU R128, чтобы все ролики звучали одинаково) и,
        // при добивке хвоста, тишина apad. Порядок: сначала loudnorm по реальному звуку, потом apad.
        var afilters = new List<string>();
        if (_media.NormalizeLoudness)
            afilters.Add(
                $"loudnorm=I={_media.LoudnessTargetLufs.ToString(CultureInfo.InvariantCulture)}:TP=-1.5:LRA=11"
            );
        if (padding)
            afilters.Add($"apad=pad_dur={Fmt(pad)}");

        if (afilters.Count > 0)
        {
            args.Add("-af");
            args.Add(string.Join(",", afilters));
        }

        if (padding)
        {
            args.Add("-t");
            args.Add(Fmt(target));
        }

        args.AddRange([
            "-f",
            "hls",
            "-hls_time",
            segmentSeconds.ToString(CultureInfo.InvariantCulture),
            "-hls_playlist_type",
            "vod",
            "-hls_list_size",
            "0",
            "-hls_segment_filename",
            Path.Combine(assetDir, "seg%05d.ts"),
            Path.Combine(assetDir, "index.m3u8"),
        ]);

        return args;
    }

    private async Task<ProbeInfo> ProbeAsync(string path, CancellationToken cancellationToken)
    {
        var result = await ProcessRunner.RunAsync(
            _media.FfprobePath,
            ["-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", path],
            lowPriority: false,
            TimeSpan.FromSeconds(_media.ProbeTimeoutSeconds),
            cancellationToken
        );
        if (result.ExitCode != 0)
            throw new InvalidOperationException(
                $"ffprobe завершился с кодом {result.ExitCode}: {Tail(result.StdErr)}"
            );

        using var doc = JsonDocument.Parse(result.StdOut);
        var root = doc.RootElement;

        var duration = ParseDuration(root);
        var (width, height, audioLanguages) = ParseStreams(root);

        if (duration <= 0)
            throw new InvalidOperationException("Не удалось определить длительность файла.");
        if (width <= 0 || height <= 0)
            throw new InvalidOperationException("В файле не найдена видеодорожка.");

        return new ProbeInfo(duration, width, height, audioLanguages);
    }

    /// <summary>Длительность из секции format; 0 — поля нет либо оно не разбирается.</summary>
    private static double ParseDuration(JsonElement root) =>
        root.TryGetProperty("format", out var format)
        && format.TryGetProperty("duration", out var durationElement)
        && double.TryParse(
            durationElement.GetString(),
            NumberStyles.Float,
            CultureInfo.InvariantCulture,
            out var parsed
        )
            ? parsed
            : 0;

    /// <summary>
    /// Размер кадра по первой видеодорожке и языки аудиодорожек в порядке их следования — индекс
    /// в списке равен порядковому номеру дорожки в <c>0:a:N</c>. Нулевой размер означает, что видео
    /// в файле не нашлось.
    /// </summary>
    private static (int Width, int Height, List<string?> AudioLanguages) ParseStreams(
        JsonElement root
    )
    {
        int width = 0,
            height = 0;
        // Размер берём у первой видеодорожки и больше не переписываем — даже если у неё нулевые
        // размеры: так вело себя прежнее условие, и «починить» его здесь значило бы поменять
        // поведение под видом рефакторинга.
        var hasVideo = false;
        var audioLanguages = new List<string?>();

        if (!root.TryGetProperty("streams", out var streams))
            return (width, height, audioLanguages);

        foreach (var stream in streams.EnumerateArray())
        {
            var codecType = stream.TryGetProperty("codec_type", out var typeElement)
                ? typeElement.GetString()
                : null;

            if (codecType == "video" && !hasVideo)
            {
                hasVideo = true;
                width = stream.TryGetProperty("width", out var w) ? w.GetInt32() : 0;
                height = stream.TryGetProperty("height", out var h) ? h.GetInt32() : 0;
            }
            else if (codecType == "audio")
                audioLanguages.Add(LanguageOf(stream));
        }

        return (width, height, audioLanguages);
    }

    private static string? LanguageOf(JsonElement stream) =>
        stream.TryGetProperty("tags", out var tags)
        && tags.TryGetProperty("language", out var language)
            ? language.GetString()
            : null;

    private async Task<IReadOnlyList<string>> GetPreferredLanguagesAsync(
        CancellationToken cancellationToken
    )
    {
        await using var scope = scopeFactory.CreateAsyncScope();
        var settings = scope.ServiceProvider.GetRequiredService<ISiteSettings>();
        var configured = await settings.GetPreferredAudioLanguagesAsync(cancellationToken);
        var raw = string.IsNullOrWhiteSpace(configured)
            ? _media.PreferredAudioLanguages
            : configured;
        return raw.Split(
                ',',
                StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries
            )
            .Select(x => x.ToLowerInvariant())
            .ToList();
    }

    private static (int Width, int Height) ScaleDown(int width, int height)
    {
        if (width <= MaxWidth)
            return (width, height);
        var scaledHeight = (int)Math.Round((double)height * MaxWidth / width);
        if (scaledHeight % 2 != 0)
            scaledHeight++;
        return (MaxWidth, scaledHeight);
    }

    private sealed record ProbeInfo(
        double Duration,
        int Width,
        int Height,
        IReadOnlyList<string?> AudioLanguages
    );
}
