using System.Collections.Immutable;
using System.Text;
using CliWrap;
using CliWrap.Buffered;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.FFmpeg;
using ErsatzTV.FFmpeg.Environment;
using ErsatzTV.FFmpeg.Format;
using ErsatzTV.FFmpeg.OutputFormat;
using ErsatzTV.FFmpeg.Pipeline;
using ErsatzTV.FFmpeg.Preset;
using ErsatzTV.FFmpeg.State;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using MediaStream = ErsatzTV.Core.Domain.MediaStream;

namespace ErsatzTV.Core.FFmpeg;

public class FFmpegLibraryProcessService : IFFmpegProcessService
{
    private readonly IConfigElementRepository _configElementRepository;
    private readonly IGraphicsElementLoader _graphicsElementLoader;
    private readonly IMemoryCache _memoryCache;
    private readonly IMpegTsScriptService _mpegTsScriptService;
    private readonly ILocalStatisticsProvider _localStatisticsProvider;
    private readonly IMediaItemRepository _mediaItemRepository;
    private readonly ILocalFileSystem _localFileSystem;
    private readonly ICustomStreamSelector _customStreamSelector;
    private readonly FFmpegProcessService _ffmpegProcessService;
    private readonly IFFmpegStreamSelector _ffmpegStreamSelector;
    private readonly ILogger<FFmpegLibraryProcessService> _logger;
    private readonly IPipelineBuilderFactory _pipelineBuilderFactory;
    private readonly ITempFilePool _tempFilePool;

    public FFmpegLibraryProcessService(
        FFmpegProcessService ffmpegProcessService,
        IFFmpegStreamSelector ffmpegStreamSelector,
        ICustomStreamSelector customStreamSelector,
        ITempFilePool tempFilePool,
        IPipelineBuilderFactory pipelineBuilderFactory,
        IConfigElementRepository configElementRepository,
        IGraphicsElementLoader graphicsElementLoader,
        IMemoryCache memoryCache,
        IMpegTsScriptService mpegTsScriptService,
        ILocalStatisticsProvider localStatisticsProvider,
        IMediaItemRepository mediaItemRepository,
        ILocalFileSystem localFileSystem,
        ILogger<FFmpegLibraryProcessService> logger)
    {
        _ffmpegProcessService = ffmpegProcessService;
        _ffmpegStreamSelector = ffmpegStreamSelector;
        _customStreamSelector = customStreamSelector;
        _tempFilePool = tempFilePool;
        _pipelineBuilderFactory = pipelineBuilderFactory;
        _configElementRepository = configElementRepository;
        _graphicsElementLoader = graphicsElementLoader;
        _memoryCache = memoryCache;
        _mpegTsScriptService = mpegTsScriptService;
        _localStatisticsProvider = localStatisticsProvider;
        _mediaItemRepository = mediaItemRepository;
        _localFileSystem = localFileSystem;
        _logger = logger;
    }

    public async Task<PlayoutItemResult> ForPlayoutItem(
        string ffmpegPath,
        string ffprobePath,
        bool saveReports,
        Channel channel,
        MediaItemVideoVersion videoVersion,
        MediaItemAudioVersion audioVersion,
        string videoPath,
        string audioPath,
        Func<FFmpegPlaybackSettings, Task<List<Subtitle>>> getSubtitles,
        string preferredAudioLanguage,
        string preferredAudioTitle,
        string preferredSubtitleLanguage,
        ChannelSubtitleMode subtitleMode,
        DateTimeOffset start,
        DateTimeOffset finish,
        DateTimeOffset now,
        TimeSpan originalContentDuration,
        List<WatermarkOptions> watermarks,
        List<PlayoutItemGraphicsElement> graphicsElements,
        string vaapiDisplay,
        VaapiDriver vaapiDriver,
        string vaapiDevice,
        Option<int> qsvExtraHardwareFrames,
        bool hlsRealtime,
        StreamInputKind streamInputKind,
        FillerKind fillerKind,
        TimeSpan inPoint,
        DateTimeOffset channelStartTime,
        TimeSpan ptsOffset,
        Option<FrameRate> targetFramerate,
        Option<string> customReportsFolder,
        Action<FFmpegPipeline> pipelineAction,
        bool canProxy,
        CancellationToken cancellationToken)
    {
        MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(videoVersion.MediaVersion);

        // we cannot burst live input
        hlsRealtime = hlsRealtime || streamInputKind is StreamInputKind.Live;

        FFmpegPlaybackSettings playbackSettings = FFmpegPlaybackSettingsCalculator.CalculateSettings(
            channel.StreamingMode,
            channel.FFmpegProfile,
            videoVersion.MediaVersion,
            videoStream,
            start,
            now,
            inPoint,
            hlsRealtime,
            streamInputKind,
            targetFramerate);

        List<Subtitle> allSubtitles = await getSubtitles(playbackSettings);

        Option<MediaStream> maybeAudioStream = Option<MediaStream>.None;
        Option<Subtitle> maybeSubtitle = Option<Subtitle>.None;

        if (channel.StreamSelectorMode is ChannelStreamSelectorMode.Custom)
        {
            StreamSelectorResult result = await _customStreamSelector.SelectStreams(
                channel,
                start,
                audioVersion,
                allSubtitles);
            maybeAudioStream = result.AudioStream;
            maybeSubtitle = result.Subtitle;

            if (maybeAudioStream.IsNone)
            {
                _logger.LogWarning(
                    "No audio stream found using custom stream selector {StreamSelector}; will use default stream selection logic",
                    channel.StreamSelector);
            }
        }

        if (channel.StreamSelectorMode is ChannelStreamSelectorMode.Default || maybeAudioStream.IsNone)
        {
            maybeAudioStream =
                await _ffmpegStreamSelector.SelectAudioStream(
                    audioVersion,
                    channel.StreamingMode,
                    channel,
                    preferredAudioLanguage,
                    preferredAudioTitle,
                    cancellationToken);

            maybeSubtitle =
                await _ffmpegStreamSelector.SelectSubtitleStream(
                    allSubtitles.ToImmutableList(),
                    channel,
                    preferredSubtitleLanguage,
                    subtitleMode,
                    cancellationToken);
        }

        if (channel.StreamSelectorMode is ChannelStreamSelectorMode.Troubleshooting && maybeSubtitle.IsNone)
        {
            maybeSubtitle = allSubtitles.HeadOrNone();
        }

        if (canProxy)
        {
            foreach (Subtitle subtitle in maybeSubtitle)
            {
                if (subtitle.SubtitleKind == SubtitleKind.Sidecar || subtitle is
                        { SubtitleKind: SubtitleKind.Embedded, IsImage: false, IsExtracted: true })
                {
                    // proxy to avoid dealing with escaping
                    subtitle.Path = $"http://localhost:{Settings.StreamingPort}/media/subtitle/{subtitle.Id}";

                    foreach (TimeSpan seek in playbackSettings.StreamSeek)
                    {
                        subtitle.Path += $"?seekToMs={(int)seek.TotalMilliseconds}";
                    }
                }
            }
        }

        string audioFormat = playbackSettings.AudioFormat switch
        {
            FFmpegProfileAudioFormat.Aac => AudioFormat.Aac,
            FFmpegProfileAudioFormat.AacLatm => AudioFormat.AacLatm,
            FFmpegProfileAudioFormat.Ac3 => AudioFormat.Ac3,
            FFmpegProfileAudioFormat.Copy => AudioFormat.Copy,
            _ => throw new ArgumentOutOfRangeException($"unexpected audio format {playbackSettings.VideoFormat}")
        };

        var audioState = new AudioState(
            audioFormat,
            playbackSettings.AudioChannels,
            playbackSettings.AudioBitrate,
            playbackSettings.AudioBufferSize,
            playbackSettings.AudioSampleRate,
            audioFormat != AudioFormat.Copy && videoPath == audioPath,
            playbackSettings.NormalizeLoudnessMode switch
            {
                NormalizeLoudnessMode.LoudNorm => AudioFilter.LoudNorm,
                _ => AudioFilter.None
            },
            playbackSettings.TargetLoudness);

        // don't log generated images, or hls direct, which are expected to have unknown format
        bool isUnknownPixelFormatExpected =
            videoPath != audioPath || channel.StreamingMode == StreamingMode.HttpLiveStreamingDirect;
        ILogger<FFmpegLibraryProcessService> pixelFormatLogger = isUnknownPixelFormatExpected ? null : _logger;

        IPixelFormat pixelFormat = await AvailablePixelFormats
            .ForPixelFormat(videoStream.PixelFormat, pixelFormatLogger)
            .IfNoneAsync(() =>
            {
                return videoStream.BitsPerRawSample switch
                {
                    8 => new PixelFormatYuv420P(),
                    10 => new PixelFormatYuv420P10Le(),
                    _ => new PixelFormatUnknown(videoStream.BitsPerRawSample)
                };
            });

        ScanKind scanKind = ScanKind.Progressive;
        if (playbackSettings.Deinterlace)
        {
            scanKind = await ProbeScanKind(ffmpegPath, videoVersion.MediaItem, cancellationToken);
        }

        HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings);

        // QSV may have sync issues with h264 files that have multiple profiles
        // check and flag here so software decoding can be used if needed
        bool hasMultipleProfiles = false;
        if (hwAccel is HardwareAccelerationMode.Qsv && videoStream.Codec is VideoFormat.H264)
        {
            hasMultipleProfiles = await ProbeHasMultipleProfiles(ffmpegPath, videoVersion.MediaItem, cancellationToken);
        }

        var ffmpegVideoStream = new VideoStream(
            videoStream.Index,
            videoStream.Codec,
            videoStream.Profile,
            Some(pixelFormat),
            new ColorParams(
                videoStream.ColorRange,
                videoStream.ColorSpace,
                videoStream.ColorTransfer,
                videoStream.ColorPrimaries),
            new FrameSize(videoVersion.MediaVersion.Width, videoVersion.MediaVersion.Height),
            videoVersion.MediaVersion.SampleAspectRatio,
            videoVersion.MediaVersion.DisplayAspectRatio,
            new FrameRate(videoVersion.MediaVersion.RFrameRate),
            videoPath != audioPath, // still image when paths are different
            scanKind)
        {
            HasMultipleProfiles = hasMultipleProfiles
        };

        var videoInputFile = new VideoInputFile(
            videoPath,
            new List<VideoStream> { ffmpegVideoStream },
            streamInputKind);

        Option<AudioInputFile> audioInputFile = maybeAudioStream.Map(audioStream =>
        {
            var ffmpegAudioStream = new AudioStream(audioStream.Index, audioStream.Codec, audioStream.Channels);
            return new AudioInputFile(audioPath, new List<AudioStream> { ffmpegAudioStream }, audioState);
        });

        // when no audio streams are available, use null audio source
        if (!audioVersion.MediaVersion.Streams.Any(s => s.MediaStreamKind is MediaStreamKind.Audio))
        {
            audioInputFile = new NullAudioInputFile(audioState with { PadAudio = playbackSettings.PadAudio });
        }

        OutputFormatKind outputFormat = OutputFormatKind.MpegTs;
        switch (channel.StreamingMode)
        {
            case StreamingMode.HttpLiveStreamingSegmenter:
                outputFormat = OutputFormatKind.Hls;
                break;
            case StreamingMode.HttpLiveStreamingDirect:
            {
                // use mpeg-ts by default
                outputFormat = OutputFormatKind.MpegTs;

                // override with setting if applicable
                Option<OutputFormatKind> maybeOutputFormat = await _configElementRepository
                    .GetValue<OutputFormatKind>(ConfigElementKey.FFmpegHlsDirectOutputFormat, cancellationToken);
                foreach (OutputFormatKind of in maybeOutputFormat)
                {
                    outputFormat = of;
                }

                break;
            }
        }

        Option<string> subtitleLanguage = Option<string>.None;
        Option<string> subtitleTitle = Option<string>.None;

        Option<SubtitleInputFile> subtitleInputFile = maybeSubtitle.Map<Option<SubtitleInputFile>>(subtitle =>
        {
            if (channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect && !subtitle.IsImage &&
                subtitle.SubtitleKind == SubtitleKind.Embedded &&
                (!subtitle.IsExtracted || string.IsNullOrWhiteSpace(subtitle.Path)))
            {
                _logger.LogWarning("Subtitles are not yet available for this item");
                return None;
            }

            var ffmpegSubtitleStream = new ErsatzTV.FFmpeg.MediaStream(
                subtitle.IsImage ? subtitle.StreamIndex : 0,
                subtitle.Codec,
                StreamKind.Video);

            string subtitlePath = subtitle.Path;
            if (!canProxy && !subtitle.IsImage && subtitle.IsExtracted)
            {
                subtitlePath = Path.Combine(FileSystemLayout.SubtitleCacheFolder, subtitlePath);
            }

            string path = subtitle.IsImage ? videoPath : subtitlePath;

            SubtitleMethod method = SubtitleMethod.Burn;
            if (channel.StreamingMode == StreamingMode.HttpLiveStreamingDirect)
            {
                method = (outputFormat, subtitle.SubtitleKind, subtitle.Codec) switch
                {
                    // mkv supports all subtitle codecs, maybe?
                    (OutputFormatKind.Mkv, SubtitleKind.Embedded, _) => SubtitleMethod.Copy,

                    // MP4 supports vobsub
                    (OutputFormatKind.Mp4, SubtitleKind.Embedded, "dvdsub" or "dvd_subtitle" or "vobsub") =>
                        SubtitleMethod.Copy,

                    // MP4 does not support PGS
                    (OutputFormatKind.Mp4, SubtitleKind.Embedded, "pgs" or "pgssub" or "hdmv_pgs_subtitle") =>
                        SubtitleMethod.None,

                    // ignore text subtitles for now
                    _ => SubtitleMethod.None
                };

                if (method == SubtitleMethod.None)
                {
                    return None;
                }

                // hls direct won't use extracted embedded subtitles
                if (subtitle.SubtitleKind == SubtitleKind.Embedded)
                {
                    path = videoPath;
                    ffmpegSubtitleStream = ffmpegSubtitleStream with { Index = subtitle.StreamIndex };
                }
            }

            if (method == SubtitleMethod.Copy)
            {
                subtitleLanguage = Optional(subtitle.Language);
                subtitleTitle = Optional(subtitle.Title);
            }

            return new SubtitleInputFile(
                path,
                new List<ErsatzTV.FFmpeg.MediaStream> { ffmpegSubtitleStream },
                method);
        }).Flatten();

        Option<WatermarkInputFile> watermarkInputFile = Option<WatermarkInputFile>.None;
        Option<GraphicsEngineInput> graphicsEngineInput = Option<GraphicsEngineInput>.None;
        Option<GraphicsEngineContext> graphicsEngineContext = Option<GraphicsEngineContext>.None;
        List<GraphicsElementContext> graphicsElementContexts = [];

        // use ffmpeg for single permanent watermark, graphics engine for all others
        if (graphicsElements.Count == 0 && watermarks.Count == 1 && watermarks.All(wm => wm.Watermark.Mode is ChannelWatermarkMode.Permanent))
        {
            foreach (var wm in watermarks)
            {
                List<VideoStream> videoStreams =
                [
                    new(
                        await wm.ImageStreamIndex.IfNoneAsync(0),
                        "unknown",
                        string.Empty,
                        new PixelFormatUnknown(),
                        ColorParams.Default,
                        new FrameSize(1, 1),
                        string.Empty,
                        string.Empty,
                        Option<FrameRate>.None,
                        !await IsWatermarkAnimated(ffprobePath, wm.ImagePath),
                        ScanKind.Progressive)
                ];

                var state = new WatermarkState(
                    None,
                    wm.Watermark.Location,
                    wm.Watermark.Size,
                    wm.Watermark.WidthPercent,
                    wm.Watermark.HorizontalMarginPercent,
                    wm.Watermark.VerticalMarginPercent,
                    wm.Watermark.Opacity,
                    wm.Watermark.PlaceWithinSourceContent);

                watermarkInputFile = new WatermarkInputFile(wm.ImagePath, videoStreams, state);
            }
        }
        else
        {
            graphicsElementContexts.AddRange(watermarks.Map(wm => new WatermarkElementContext(wm)));
        }

        string videoFormat = GetVideoFormat(playbackSettings);
        Option<string> maybeVideoProfile = GetVideoProfile(videoFormat, channel.FFmpegProfile.VideoProfile);
        Option<string> maybeVideoPreset = GetVideoPreset(
            hwAccel,
            videoFormat,
            channel.FFmpegProfile.VideoPreset,
            FFmpegLibraryHelper.MapBitDepth(channel.FFmpegProfile.BitDepth));

        Option<string> hlsPlaylistPath = outputFormat is OutputFormatKind.Hls or OutputFormatKind.HlsMp4
            ? Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live.m3u8")
            : Option<string>.None;

        long nowSeconds = now.ToUnixTimeSeconds();

        Option<string> hlsSegmentTemplate = outputFormat switch
        {
            OutputFormatKind.Hls => Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live%06d.ts"),
            OutputFormatKind.HlsMp4 => Path.Combine(
                FileSystemLayout.TranscodeFolder,
                channel.Number,
                $"live_{nowSeconds}_%06d.m4s"),
            _ => Option<string>.None
        };

        Option<string> hlsInitTemplate = outputFormat switch
        {
            OutputFormatKind.HlsMp4 => $"{nowSeconds}_init.mp4",
            _ =>  Option<string>.None
        };

        Option<string> hlsSegmentOptions = Option<string>.None;
        if (outputFormat is OutputFormatKind.Hls)
        {
            string options = string.Empty;

            if (ptsOffset == TimeSpan.Zero)
            {
                options += "+initial_discontinuity";
            }

            if (audioFormat == AudioFormat.AacLatm)
            {
                options += "+latm";
            }

            if (!string.IsNullOrWhiteSpace(options))
            {
                hlsSegmentOptions = $"mpegts_flags={options}";
            }
        }

        FrameSize scaledSize = ffmpegVideoStream.SquarePixelFrameSize(
            new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height));

        var paddedSize = new FrameSize(
            channel.FFmpegProfile.Resolution.Width,
            channel.FFmpegProfile.Resolution.Height);

        Option<FrameSize> cropSize = Option<FrameSize>.None;

        if (channel.FFmpegProfile.ScalingBehavior is ScalingBehavior.Stretch)
        {
            scaledSize = paddedSize;
        }

        if (channel.FFmpegProfile.ScalingBehavior is ScalingBehavior.Crop)
        {
            bool isTooSmallToCrop = videoVersion.MediaVersion.Height < channel.FFmpegProfile.Resolution.Height ||
                                    videoVersion.MediaVersion.Width < channel.FFmpegProfile.Resolution.Width;

            // if any dimension is smaller than the crop, scale beyond the crop (beyond the target resolution)
            if (isTooSmallToCrop)
            {
                foreach (IDisplaySize size in playbackSettings.ScaledSize)
                {
                    scaledSize = new FrameSize(size.Width, size.Height);
                }

                paddedSize = scaledSize;
            }
            else
            {
                paddedSize = ffmpegVideoStream.SquarePixelFrameSizeForCrop(
                    new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height));
            }

            cropSize = new FrameSize(
                channel.FFmpegProfile.Resolution.Width,
                channel.FFmpegProfile.Resolution.Height);
        }

        var desiredState = new FrameState(
            playbackSettings.RealtimeOutput,
            InfiniteLoop: false,
            videoFormat,
            maybeVideoProfile,
            maybeVideoPreset,
            channel.FFmpegProfile.AllowBFrames,
            Optional(playbackSettings.PixelFormat),
            scaledSize,
            paddedSize,
            cropSize,
            channel.FFmpegProfile.PadMode is FilterMode.HardwareIfPossible
                ? FFmpegFilterMode.HardwareIfPossible
                : FFmpegFilterMode.Software,
            IsAnamorphic: false,
            playbackSettings.FrameRate,
            playbackSettings.VideoBitrate,
            playbackSettings.VideoBufferSize,
            playbackSettings.VideoTrackTimeScale,
            playbackSettings.NormalizeColors,
            playbackSettings.Deinterlace);

        // only use graphics engine when we have elements, and are normalizing video
        if (videoFormat != VideoFormat.Copy && (graphicsElementContexts.Count > 0 || graphicsElements.Count > 0))
        {
            FrameSize targetSize = await desiredState.CroppedSize.IfNoneAsync(desiredState.ScaledSize);

            FrameRate frameRate = await playbackSettings.FrameRate
                .IfNoneAsync(new FrameRate(videoVersion.MediaVersion.RFrameRate));

            var context = new GraphicsEngineContext(
                channel.Number,
                audioVersion.MediaItem,
                graphicsElementContexts,
                TemplateVariables: [],
                new Resolution { Width = targetSize.Width, Height = targetSize.Height },
                channel.FFmpegProfile.Resolution,
                frameRate,
                channelStartTime,
                start,
                now > start ? now - start : TimeSpan.Zero,
                finish - now,
                originalContentDuration);

            context = await _graphicsElementLoader.LoadAll(context, graphicsElements, cancellationToken);

            if (context?.Elements?.Count > 0)
            {
                graphicsEngineInput = new GraphicsEngineInput();
                graphicsEngineContext = context;
            }
        }

        var ffmpegState = new FFmpegState(
            saveReports,
            hwAccel,
            hwAccel,
            VaapiDriverName(hwAccel, vaapiDriver),
            VaapiDeviceName(hwAccel, vaapiDevice),
            playbackSettings.StreamSeek,
            finish - now,
            channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect,
            "ErsatzTV",
            channel.Name,
            maybeAudioStream.Map(s => Optional(s.Language)).Flatten(),
            subtitleLanguage,
            subtitleTitle,
            outputFormat,
            hlsPlaylistPath,
            hlsSegmentTemplate,
            hlsInitTemplate,
            hlsSegmentOptions,
            ptsOffset,
            playbackSettings.ThreadCount,
            qsvExtraHardwareFrames,
            videoVersion.MediaVersion is BackgroundImageMediaVersion { IsSongWithProgress: true },
            false,
            GetTonemapAlgorithm(playbackSettings),
            channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel);

        _logger.LogDebug("FFmpeg desired state {FrameState}", desiredState);

        IPipelineBuilder pipelineBuilder = await _pipelineBuilderFactory.GetBuilder(
            hwAccel,
            videoInputFile,
            audioInputFile,
            watermarkInputFile,
            subtitleInputFile,
            Option<ConcatInputFile>.None,
            graphicsEngineInput,
            VaapiDisplayName(hwAccel, vaapiDisplay),
            VaapiDriverName(hwAccel, vaapiDriver),
            VaapiDeviceName(hwAccel, vaapiDevice),
            await customReportsFolder.IfNoneAsync(FileSystemLayout.FFmpegReportsFolder),
            FileSystemLayout.FontsCacheFolder,
            ffmpegPath,
            cancellationToken);

        FFmpegPipeline pipeline = pipelineBuilder.Build(ffmpegState, desiredState);

        pipelineAction?.Invoke(pipeline);

        Command command = GetCommand(
            ffmpegPath,
            videoInputFile,
            audioInputFile,
            watermarkInputFile,
            Option<ConcatInputFile>.None,
            graphicsEngineInput,
            pipeline);

        return new PlayoutItemResult(command, graphicsEngineContext, videoVersion.MediaItem.Id);
    }

    private async Task<ScanKind> ProbeScanKind(
        string ffmpegPath,
        MediaItem mediaItem,
        CancellationToken cancellationToken)
    {
        var headVersion = mediaItem.GetHeadVersion();
        if (headVersion.VideoScanKind is VideoScanKind.Interlaced)
        {
            _logger.LogDebug("Container is marked {ScanKind}", headVersion.VideoScanKind);
            return ScanKind.Interlaced;
        }

        // skip probe if disabled
        if (!await _configElementRepository.GetValue<bool>(
                ConfigElementKey.FFmpegProbeForInterlacedFrames,
                cancellationToken).IfNoneAsync(false))
        {
            _logger.LogDebug("Probe for interlaced frames is disabled");
            return ScanKind.Progressive;
        }

        if (headVersion.InterlacedRatio is null)
        {
            _logger.LogDebug("Will probe for interlaced frames");

            Option<double> maybeInterlacedRatio =
                await _localStatisticsProvider.GetInterlacedRatio(ffmpegPath, mediaItem, cancellationToken);
            foreach (double ratio in maybeInterlacedRatio)
            {
                await _mediaItemRepository.SetInterlacedRatio(mediaItem, ratio);
            }
        }

        var result = headVersion.InterlacedRatio > 0.05 ? ScanKind.Interlaced : ScanKind.Progressive;
        _logger.LogDebug(
            "Content has interlaced ratio of {Ratio} - will consider as {ScanKind}",
            headVersion.InterlacedRatio,
            result);
        return result;
    }

    private async Task<bool> ProbeHasMultipleProfiles(
        string ffmpegPath,
        MediaItem mediaItem,
        CancellationToken cancellationToken)
    {
        _logger.LogDebug("Will probe for h264 profile count");

        Option<int> profileCount =
            await _localStatisticsProvider.GetProfileCount(ffmpegPath, mediaItem, cancellationToken);

        return await profileCount.IfNoneAsync(1) > 1;
    }

    public async Task<Command> ForError(
        string ffmpegPath,
        Channel channel,
        DateTimeOffset now,
        Option<TimeSpan> duration,
        string errorMessage,
        bool hlsRealtime,
        TimeSpan ptsOffset,
        string vaapiDisplay,
        VaapiDriver vaapiDriver,
        string vaapiDevice,
        Option<int> qsvExtraHardwareFrames,
        CancellationToken cancellationToken)
    {
        FFmpegPlaybackSettings playbackSettings = FFmpegPlaybackSettingsCalculator.CalculateGeneratedImageSettings(
            channel.StreamingMode,
            channel.FFmpegProfile,
            hlsRealtime);

        Resolution desiredResolution = channel.FFmpegProfile.Resolution;

        var fontSize = (int)Math.Round(channel.FFmpegProfile.Resolution.Height / 20.0);
        var margin = (int)Math.Round(channel.FFmpegProfile.Resolution.Height * 0.05);

        string subtitleFile = await new SubtitleBuilder(_tempFilePool)
            .WithResolution(desiredResolution)
            .WithFontName("Roboto")
            .WithFontSize(fontSize)
            .WithAlignment(2)
            .WithMarginV(margin)
            .WithPrimaryColor("&HFFFFFF")
            .WithFormattedContent(errorMessage.Replace(Environment.NewLine, "\\N"))
            .BuildFile();

        string audioFormat = playbackSettings.AudioFormat switch
        {
            FFmpegProfileAudioFormat.Ac3 => AudioFormat.Ac3,
            FFmpegProfileAudioFormat.AacLatm => AudioFormat.AacLatm,
            _ => AudioFormat.Aac
        };

        var audioState = new AudioState(
            audioFormat,
            playbackSettings.AudioChannels,
            playbackSettings.AudioBitrate,
            playbackSettings.AudioBufferSize,
            playbackSettings.AudioSampleRate,
            false,
            AudioFilter.None,
            playbackSettings.TargetLoudness);

        string videoFormat = GetVideoFormat(playbackSettings);

        var desiredState = new FrameState(
            playbackSettings.RealtimeOutput,
            InfiniteLoop: false,
            videoFormat,
            GetVideoProfile(videoFormat, channel.FFmpegProfile.VideoProfile),
            VideoPreset.Unset,
            channel.FFmpegProfile.AllowBFrames,
            new PixelFormatYuv420P(),
            new FrameSize(desiredResolution.Width, desiredResolution.Height),
            new FrameSize(desiredResolution.Width, desiredResolution.Height),
            Option<FrameSize>.None,
            channel.FFmpegProfile.PadMode is FilterMode.HardwareIfPossible
                ? FFmpegFilterMode.HardwareIfPossible
                : FFmpegFilterMode.Software,
            IsAnamorphic: false,
            playbackSettings.FrameRate,
            playbackSettings.VideoBitrate,
            playbackSettings.VideoBufferSize,
            playbackSettings.VideoTrackTimeScale,
            playbackSettings.NormalizeColors,
            playbackSettings.Deinterlace);

        string videoPath = _localFileSystem.GetCustomOrDefaultFile(
            FileSystemLayout.ResourcesCacheFolder,
            "background.png");

        var videoVersion = BackgroundImageMediaVersion.ForPath(videoPath, desiredResolution);

        var ffmpegVideoStream = new VideoStream(
            0,
            VideoFormat.GeneratedImage,
            string.Empty,
            new PixelFormatUnknown(), // leave this unknown so we convert to desired yuv420p
            ColorParams.Default,
            new FrameSize(videoVersion.Width, videoVersion.Height),
            videoVersion.SampleAspectRatio,
            videoVersion.DisplayAspectRatio,
            None,
            true,
            ScanKind.Progressive);

        var videoInputFile = new VideoInputFile(videoPath, new List<VideoStream> { ffmpegVideoStream });

        // TODO: ignore accel if this already failed once
        HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings);
        _logger.LogDebug("HW accel mode: {HwAccel}", hwAccel);

        var hlsOptions = GetHlsOptions(channel, now, ptsOffset, audioFormat);

        var ffmpegState = new FFmpegState(
            channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel,
            HardwareAccelerationMode.None, // no hw accel decode since errors loop
            hwAccel,
            VaapiDriverName(hwAccel, vaapiDriver),
            VaapiDeviceName(hwAccel, vaapiDevice),
            playbackSettings.StreamSeek,
            duration,
            channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect,
            "ErsatzTV",
            channel.Name,
            None,
            None,
            None,
            hlsOptions.OutputFormat,
            hlsOptions.HlsPlaylistPath,
            hlsOptions.HlsSegmentTemplate,
            hlsOptions.HlsInitTemplate,
            hlsOptions.HlsSegmentOptions,
            ptsOffset,
            Option<int>.None,
            qsvExtraHardwareFrames,
            false,
            false,
            GetTonemapAlgorithm(playbackSettings),
            channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel);

        var ffmpegSubtitleStream = new ErsatzTV.FFmpeg.MediaStream(0, "ass", StreamKind.Video);

        var audioInputFile = new NullAudioInputFile(audioState);

        var subtitleInputFile = new SubtitleInputFile(
            subtitleFile,
            new List<ErsatzTV.FFmpeg.MediaStream> { ffmpegSubtitleStream },
            SubtitleMethod.Burn);

        _logger.LogDebug("FFmpeg desired error state {FrameState}", desiredState);

        IPipelineBuilder pipelineBuilder = await _pipelineBuilderFactory.GetBuilder(
            hwAccel,
            videoInputFile,
            audioInputFile,
            Option<WatermarkInputFile>.None,
            subtitleInputFile,
            Option<ConcatInputFile>.None,
            Option<GraphicsEngineInput>.None,
            VaapiDisplayName(hwAccel, vaapiDisplay),
            VaapiDriverName(hwAccel, vaapiDriver),
            VaapiDeviceName(hwAccel, vaapiDevice),
            channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel
                ? FileSystemLayout.TranscodeTroubleshootingFolder
                : FileSystemLayout.FFmpegReportsFolder,
            FileSystemLayout.FontsCacheFolder,
            ffmpegPath,
            cancellationToken);

        FFmpegPipeline pipeline = pipelineBuilder.Build(ffmpegState, desiredState);

        return GetCommand(ffmpegPath, videoInputFile, audioInputFile, None, None, None, pipeline);
    }

    public async Task<Command> Slug(
        string ffmpegPath,
        Channel channel,
        DateTimeOffset now,
        TimeSpan duration,
        bool hlsRealtime,
        TimeSpan ptsOffset,
        CancellationToken cancellationToken)
    {
        FFmpegPlaybackSettings playbackSettings = FFmpegPlaybackSettingsCalculator.CalculateGeneratedImageSettings(
            channel.StreamingMode,
            channel.FFmpegProfile,
            hlsRealtime);

        Resolution desiredResolution = channel.FFmpegProfile.Resolution;

        string audioFormat = playbackSettings.AudioFormat switch
        {
            FFmpegProfileAudioFormat.Ac3 => AudioFormat.Ac3,
            FFmpegProfileAudioFormat.AacLatm => AudioFormat.AacLatm,
            _ => AudioFormat.Aac
        };

        var audioState = new AudioState(
            audioFormat,
            playbackSettings.AudioChannels,
            playbackSettings.AudioBitrate,
            playbackSettings.AudioBufferSize,
            playbackSettings.AudioSampleRate,
            false,
            AudioFilter.None,
            playbackSettings.TargetLoudness);

        string videoFormat = GetVideoFormat(playbackSettings);

        var desiredState = new FrameState(
            playbackSettings.RealtimeOutput,
            InfiniteLoop: false,
            videoFormat,
            GetVideoProfile(videoFormat, channel.FFmpegProfile.VideoProfile),
            VideoPreset.Unset,
            channel.FFmpegProfile.AllowBFrames,
            new PixelFormatYuv420P(),
            new FrameSize(desiredResolution.Width, desiredResolution.Height),
            new FrameSize(desiredResolution.Width, desiredResolution.Height),
            Option<FrameSize>.None,
            channel.FFmpegProfile.PadMode is FilterMode.HardwareIfPossible
                ? FFmpegFilterMode.HardwareIfPossible
                : FFmpegFilterMode.Software,
            IsAnamorphic: false,
            Option<FrameRate>.None,
            playbackSettings.VideoBitrate,
            playbackSettings.VideoBufferSize,
            playbackSettings.VideoTrackTimeScale,
            playbackSettings.NormalizeColors,
            playbackSettings.Deinterlace);

        var frameRate = await playbackSettings.FrameRate.IfNoneAsync(new FrameRate("24"));

        var ffmpegVideoStream = new VideoStream(
            0,
            VideoFormat.GeneratedImage,
            string.Empty,
            new PixelFormatUnknown(), // leave this unknown so we convert to desired yuv420p
            ColorParams.Default,
            desiredState.PaddedSize,
            MaybeSampleAspectRatio: "1:1",
            DisplayAspectRatio: string.Empty,
            frameRate,
            true,
            ScanKind.Progressive);

        var videoInputFile = new LavfiInputFile(
            $"color=c=black:s={desiredState.PaddedSize.Width}x{desiredState.PaddedSize.Height}:r={frameRate.FrameRateString}:d={duration.TotalSeconds}",
            ffmpegVideoStream);

        HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings);

        var hlsOptions = GetHlsOptions(channel, now, ptsOffset, audioFormat);

        var ffmpegState = new FFmpegState(
            channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel,
            HardwareAccelerationMode.None, // no hw accel decode since errors loop
            hwAccel,
            VaapiDriverName(hwAccel, channel.FFmpegProfile.VaapiDriver),
            VaapiDeviceName(hwAccel, channel.FFmpegProfile.VaapiDevice),
            playbackSettings.StreamSeek,
            duration,
            channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect,
            "ErsatzTV",
            channel.Name,
            None,
            None,
            None,
            hlsOptions.OutputFormat,
            hlsOptions.HlsPlaylistPath,
            hlsOptions.HlsSegmentTemplate,
            hlsOptions.HlsInitTemplate,
            hlsOptions.HlsSegmentOptions,
            ptsOffset,
            Option<int>.None,
            Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
            false,
            false,
            GetTonemapAlgorithm(playbackSettings),
            channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel);

        var audioInputFile = new NullAudioInputFile(audioState);

        IPipelineBuilder pipelineBuilder = await _pipelineBuilderFactory.GetBuilder(
            hwAccel,
            videoInputFile,
            audioInputFile,
            Option<WatermarkInputFile>.None,
            Option<SubtitleInputFile>.None,
            Option<ConcatInputFile>.None,
            Option<GraphicsEngineInput>.None,
            VaapiDisplayName(hwAccel, channel.FFmpegProfile.VaapiDisplay),
            VaapiDriverName(hwAccel, channel.FFmpegProfile.VaapiDriver),
            VaapiDeviceName(hwAccel, channel.FFmpegProfile.VaapiDevice),
            channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel
                ? FileSystemLayout.TranscodeTroubleshootingFolder
                : FileSystemLayout.FFmpegReportsFolder,
            FileSystemLayout.FontsCacheFolder,
            ffmpegPath,
            cancellationToken);

        FFmpegPipeline pipeline = pipelineBuilder.Build(ffmpegState, desiredState);

        return GetCommand(ffmpegPath, videoInputFile, audioInputFile, None, None, None, pipeline);
    }

    public async Task<Command> ConcatChannel(
        string ffmpegPath,
        bool saveReports,
        Channel channel,
        string scheme,
        string host,
        CancellationToken cancellationToken)
    {
        var resolution = new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height);

        var concatInputFile = new ConcatInputFile(
            $"http://localhost:{Settings.StreamingPort}/ffmpeg/concat/{channel.Number}?mode=ts-legacy",
            resolution);

        IPipelineBuilder pipelineBuilder = await _pipelineBuilderFactory.GetBuilder(
            HardwareAccelerationMode.None,
            Option<VideoInputFile>.None,
            Option<AudioInputFile>.None,
            Option<WatermarkInputFile>.None,
            Option<SubtitleInputFile>.None,
            concatInputFile,
            Option<GraphicsEngineInput>.None,
            Option<string>.None,
            Option<string>.None,
            Option<string>.None,
            FileSystemLayout.FFmpegReportsFolder,
            FileSystemLayout.FontsCacheFolder,
            ffmpegPath,
            cancellationToken);

        FFmpegPipeline pipeline = pipelineBuilder.Concat(
            concatInputFile,
            FFmpegState.Concat(saveReports, channel.Name));

        return GetCommand(ffmpegPath, None, None, None, concatInputFile, None, pipeline);
    }

    public async Task<Command> WrapSegmenter(
        string ffmpegPath,
        bool saveReports,
        Channel channel,
        string scheme,
        string host,
        string accessToken,
        CancellationToken cancellationToken)
    {
        var resolution = new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height);

        string accessTokenQuery = string.IsNullOrWhiteSpace(accessToken)
            ? string.Empty
            : $"&access_token={accessToken}";

        var concatInputFile = new ConcatInputFile(
            $"http://localhost:{Settings.StreamingPort}/iptv/channel/{channel.Number}.m3u8?mode=segmenter{accessTokenQuery}",
            resolution);

        if (channel.FFmpegProfile.AudioFormat is FFmpegProfileAudioFormat.AacLatm)
        {
            concatInputFile.AudioFormat = AudioFormat.AacLatm;
        }

        // TODO: save reports?
        string defaultScript = await _configElementRepository
            .GetValue<string>(ConfigElementKey.FFmpegDefaultMpegTsScript, cancellationToken)
            .IfNoneAsync("Default");
        List<MpegTsScript> allScripts = _mpegTsScriptService.GetScripts();
        Option<MpegTsScript> maybeScript = Optional(allScripts.Find(s => s.Id == defaultScript));
        foreach (var script in maybeScript)
        {
            Option<Command> maybeCommand = await _mpegTsScriptService.Execute(
                script,
                channel,
                concatInputFile.Url,
                ffmpegPath);
            foreach (var command in maybeCommand)
            {
                return command;
            }
        }

        if (maybeScript.IsNone)
        {
            _logger.LogWarning("Unable to locate MPEG-TS Script in folder {Id}", defaultScript);
        }

        IPipelineBuilder pipelineBuilder = await _pipelineBuilderFactory.GetBuilder(
            HardwareAccelerationMode.None,
            Option<VideoInputFile>.None,
            Option<AudioInputFile>.None,
            Option<WatermarkInputFile>.None,
            Option<SubtitleInputFile>.None,
            concatInputFile,
            Option<GraphicsEngineInput>.None,
            Option<string>.None,
            Option<string>.None,
            Option<string>.None,
            FileSystemLayout.FFmpegReportsFolder,
            FileSystemLayout.FontsCacheFolder,
            ffmpegPath,
            cancellationToken);

        FFmpegPipeline pipeline = pipelineBuilder.WrapSegmenter(
            concatInputFile,
            FFmpegState.Concat(saveReports, channel.Name));

        return GetCommand(ffmpegPath, None, None, None, concatInputFile, None, pipeline);
    }

    public async Task<Command> ResizeImage(
        string ffmpegPath,
        string inputFile,
        string outputFile,
        int height,
        CancellationToken cancellationToken)
    {
        var videoInputFile = new VideoInputFile(
            inputFile,
            new List<VideoStream>
            {
                new(
                    0,
                    string.Empty,
                    string.Empty,
                    None,
                    ColorParams.Default,
                    FrameSize.Unknown,
                    string.Empty,
                    string.Empty,
                    None,
                    true,
                    ScanKind.Progressive)
            });

        IPipelineBuilder pipelineBuilder = await _pipelineBuilderFactory.GetBuilder(
            HardwareAccelerationMode.None,
            videoInputFile,
            Option<AudioInputFile>.None,
            Option<WatermarkInputFile>.None,
            Option<SubtitleInputFile>.None,
            Option<ConcatInputFile>.None,
            Option<GraphicsEngineInput>.None,
            Option<string>.None,
            Option<string>.None,
            Option<string>.None,
            FileSystemLayout.FFmpegReportsFolder,
            FileSystemLayout.FontsCacheFolder,
            ffmpegPath,
            cancellationToken);

        FFmpegPipeline pipeline = pipelineBuilder.Resize(outputFile, new FrameSize(-1, height));

        return GetCommand(ffmpegPath, videoInputFile, None, None, None, None, pipeline, false);
    }

    public Task<Either<BaseError, string>> GenerateSongImage(
        string ffmpegPath,
        string ffprobePath,
        Option<string> subtitleFile,
        Channel channel,
        MediaVersion videoVersion,
        string videoPath,
        bool boxBlur,
        Option<string> watermarkPath,
        WatermarkLocation watermarkLocation,
        int horizontalMarginPercent,
        int verticalMarginPercent,
        int watermarkWidthPercent,
        CancellationToken cancellationToken) =>
        _ffmpegProcessService.GenerateSongImage(
            ffmpegPath,
            ffprobePath,
            subtitleFile,
            channel,
            videoVersion,
            videoPath,
            boxBlur,
            watermarkPath,
            watermarkLocation,
            horizontalMarginPercent,
            verticalMarginPercent,
            watermarkWidthPercent,
            cancellationToken);

    public async Task<Command> SeekTextSubtitle(
        string ffmpegPath,
        string inputFile,
        string codec,
        TimeSpan seek,
        CancellationToken cancellationToken)
    {
        var videoInputFile = new VideoInputFile(
            inputFile,
            new List<VideoStream>
            {
                new(
                    0,
                    codec,
                    string.Empty,
                    None,
                    ColorParams.Default,
                    FrameSize.Unknown,
                    string.Empty,
                    string.Empty,
                    None,
                    true,
                    ScanKind.Progressive)
            });

        IPipelineBuilder pipelineBuilder = await _pipelineBuilderFactory.GetBuilder(
            HardwareAccelerationMode.None,
            videoInputFile,
            Option<AudioInputFile>.None,
            Option<WatermarkInputFile>.None,
            Option<SubtitleInputFile>.None,
            Option<ConcatInputFile>.None,
            Option<GraphicsEngineInput>.None,
            Option<string>.None,
            Option<string>.None,
            Option<string>.None,
            FileSystemLayout.FFmpegReportsFolder,
            FileSystemLayout.FontsCacheFolder,
            ffmpegPath,
            cancellationToken);

        FFmpegPipeline pipeline = pipelineBuilder.Seek(inputFile, codec, seek);

        return GetCommand(ffmpegPath, videoInputFile, None, None, None, None, pipeline, false);
    }

    private Command GetCommand(
        string ffmpegPath,
        Option<VideoInputFile> videoInputFile,
        Option<AudioInputFile> audioInputFile,
        Option<WatermarkInputFile> watermarkInputFile,
        Option<ConcatInputFile> concatInputFile,
        Option<GraphicsEngineInput> graphicsEngineInput,
        FFmpegPipeline pipeline,
        bool log = true)
    {
        IEnumerable<string> loggedSteps = pipeline.PipelineSteps.Map(ps => ps.GetType().Name);
        IEnumerable<string> loggedAudioFilters =
            audioInputFile.Map(f => f.FilterSteps.Map(af => af.GetType().Name)).Flatten();
        IEnumerable<string> loggedVideoFilters =
            videoInputFile.Map(f => f.FilterSteps.Map(vf => vf.GetType().Name)).Flatten();

        if (log)
        {
            _logger.LogDebug(
                "FFmpeg pipeline {PipelineSteps}, {AudioFilters}, {VideoFilters}",
                loggedSteps,
                loggedAudioFilters,
                loggedVideoFilters
            );
        }

        IList<EnvironmentVariable> environmentVariables =
            CommandGenerator.GenerateEnvironmentVariables(pipeline.PipelineSteps);
        IList<string> arguments = CommandGenerator.GenerateArguments(
            videoInputFile,
            audioInputFile,
            watermarkInputFile,
            concatInputFile,
            graphicsEngineInput,
            pipeline.PipelineSteps,
            pipeline.IsIntelVaapiOrQsv);

        if (environmentVariables.Any())
        {
            _logger.LogDebug("FFmpeg environment variables {EnvVars}", environmentVariables);
        }

        return Cli.Wrap(ffmpegPath)
            .WithArguments(arguments)
            .WithValidation(CommandResultValidation.None)
            .WithStandardErrorPipe(PipeTarget.ToStream(Stream.Null))
            .WithEnvironmentVariables(environmentVariables.ToDictionary(e => e.Key, e => e.Value));
    }

    private static Option<string> VaapiDisplayName(HardwareAccelerationMode accelerationMode, string vaapiDisplay) =>
        accelerationMode == HardwareAccelerationMode.Vaapi ? vaapiDisplay : Option<string>.None;

    private static Option<string> VaapiDriverName(HardwareAccelerationMode accelerationMode, VaapiDriver driver)
    {
        if (accelerationMode == HardwareAccelerationMode.Vaapi)
        {
            switch (driver)
            {
                case VaapiDriver.i965:
                    return "i965";
                case VaapiDriver.iHD:
                    return "iHD";
                case VaapiDriver.RadeonSI:
                    return "radeonsi";
                case VaapiDriver.Nouveau:
                    return "nouveau";
            }
        }

        return Option<string>.None;
    }

    private static Option<string> VaapiDeviceName(HardwareAccelerationMode accelerationMode, string vaapiDevice) =>
        accelerationMode == HardwareAccelerationMode.Vaapi ||
        OperatingSystem.IsLinux() && accelerationMode == HardwareAccelerationMode.Qsv
            ? string.IsNullOrWhiteSpace(vaapiDevice) ? "/dev/dri/renderD128" : vaapiDevice
            : Option<string>.None;

    private static string GetVideoFormat(FFmpegPlaybackSettings playbackSettings) =>
        playbackSettings.VideoFormat switch
        {
            FFmpegProfileVideoFormat.Av1 => VideoFormat.Av1,
            FFmpegProfileVideoFormat.Hevc => VideoFormat.Hevc,
            FFmpegProfileVideoFormat.H264 => VideoFormat.H264,
            FFmpegProfileVideoFormat.Mpeg2Video => VideoFormat.Mpeg2Video,
            FFmpegProfileVideoFormat.Copy => VideoFormat.Copy,
            _ => throw new ArgumentOutOfRangeException($"unexpected video format {playbackSettings.VideoFormat}")
        };

    private static string GetTonemapAlgorithm(FFmpegPlaybackSettings playbackSettings) =>
        playbackSettings.TonemapAlgorithm switch
        {
            FFmpegProfileTonemapAlgorithm.Linear => TonemapAlgorithm.Linear,
            FFmpegProfileTonemapAlgorithm.Clip => TonemapAlgorithm.Clip,
            FFmpegProfileTonemapAlgorithm.Gamma => TonemapAlgorithm.Gamma,
            FFmpegProfileTonemapAlgorithm.Reinhard => TonemapAlgorithm.Reinhard,
            FFmpegProfileTonemapAlgorithm.Mobius => TonemapAlgorithm.Mobius,
            FFmpegProfileTonemapAlgorithm.Hable => TonemapAlgorithm.Hable,
            _ => throw new ArgumentOutOfRangeException(
                $"unexpected tonemap algorithm {playbackSettings.TonemapAlgorithm}")
        };

    private static Option<string> GetVideoProfile(string videoFormat, string videoProfile) =>
        (videoFormat, (videoProfile ?? string.Empty).ToLowerInvariant()) switch
        {
            (VideoFormat.H264, VideoProfile.Main) => VideoProfile.Main,
            (VideoFormat.H264, VideoProfile.High) => VideoProfile.High,
            (VideoFormat.H264, VideoProfile.High10) => VideoProfile.High10,
            (VideoFormat.H264, VideoProfile.High444p) => VideoProfile.High444p,
            _ => Option<string>.None
        };

    private static Option<string> GetVideoPreset(
        HardwareAccelerationMode hardwareAccelerationMode,
        string videoFormat,
        string videoPreset,
        int bitDepth) =>
        AvailablePresets
            .ForAccelAndFormat(hardwareAccelerationMode, videoFormat, bitDepth)
            .Find(p => string.Equals(p, videoPreset, StringComparison.OrdinalIgnoreCase));

    private static HardwareAccelerationMode GetHardwareAccelerationMode(FFmpegPlaybackSettings playbackSettings) =>
        playbackSettings.HardwareAcceleration switch
        {
            //_ when fillerKind == FillerKind.Fallback => HardwareAccelerationMode.None,
            HardwareAccelerationKind.Nvenc => HardwareAccelerationMode.Nvenc,
            HardwareAccelerationKind.Qsv => HardwareAccelerationMode.Qsv,
            HardwareAccelerationKind.Vaapi => HardwareAccelerationMode.Vaapi,
            HardwareAccelerationKind.VideoToolbox => HardwareAccelerationMode.VideoToolbox,
            HardwareAccelerationKind.Amf => HardwareAccelerationMode.Amf,
            HardwareAccelerationKind.V4l2m2m => HardwareAccelerationMode.V4l2m2m,
            HardwareAccelerationKind.Rkmpp => HardwareAccelerationMode.Rkmpp,
            _ => HardwareAccelerationMode.None
        };

    private async Task<bool> IsWatermarkAnimated(string ffprobePath, string path)
    {
        try
        {
            var cacheKey = $"image.animated.{Path.GetFileName(path)}";
            if (_memoryCache.TryGetValue(cacheKey, out bool animated))
            {
                return animated;
            }

            BufferedCommandResult result = await Cli.Wrap(ffprobePath)
                .WithArguments(
                [
                    "-loglevel", "error",
                    "-select_streams", "v:0",
                    "-count_frames",
                    "-show_entries", "stream=nb_read_frames",
                    "-print_format", "csv",
                    path
                ])
                .WithValidation(CommandResultValidation.None)
                .ExecuteBufferedAsync(Encoding.UTF8);

            if (result.ExitCode == 0)
            {
                string output = result.StandardOutput;
                output = output.Replace("stream,", string.Empty);
                if (int.TryParse(output, out int frameCount))
                {
                    bool isAnimated = frameCount > 1;
                    _memoryCache.Set(cacheKey, isAnimated, TimeSpan.FromDays(1));
                    return isAnimated;
                }
            }
            else
            {
                _logger.LogWarning(
                    "Error checking frame count for file {File} exit code {ExitCode}",
                    path,
                    result.ExitCode);
            }
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Error checking frame count for file {File}", path);
        }

        return false;
    }

    private static HlsOptions GetHlsOptions(Channel channel, DateTimeOffset now, TimeSpan ptsOffset, string audioFormat)
    {
        OutputFormatKind outputFormat = OutputFormatKind.MpegTs;
        switch (channel.StreamingMode)
        {
            case StreamingMode.HttpLiveStreamingSegmenter:
                outputFormat = OutputFormatKind.Hls;
                break;
        }

        Option<string> hlsPlaylistPath = outputFormat is OutputFormatKind.Hls or OutputFormatKind.HlsMp4
            ? Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live.m3u8")
            : Option<string>.None;

        long nowSeconds = now.ToUnixTimeSeconds();

        Option<string> hlsSegmentTemplate = outputFormat switch
        {
            OutputFormatKind.Hls => Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live%06d.ts"),
            OutputFormatKind.HlsMp4 => Path.Combine(
                FileSystemLayout.TranscodeFolder,
                channel.Number,
                $"live_{nowSeconds}_%06d.m4s"),
            _ => Option<string>.None
        };

        Option<string> hlsInitTemplate = outputFormat switch
        {
            OutputFormatKind.HlsMp4 => $"{nowSeconds}_init.mp4",
            _ => Option<string>.None
        };

        Option<string> hlsSegmentOptions = Option<string>.None;
        if (outputFormat is OutputFormatKind.Hls)
        {
            string options = string.Empty;

            if (ptsOffset == TimeSpan.Zero)
            {
                options += "+initial_discontinuity";
            }

            if (audioFormat == AudioFormat.AacLatm)
            {
                options += "+latm";
            }

            if (!string.IsNullOrWhiteSpace(options))
            {
                hlsSegmentOptions = $"mpegts_flags={options}";
            }
        }

        return new HlsOptions(outputFormat, hlsPlaylistPath, hlsSegmentTemplate, hlsInitTemplate, hlsSegmentOptions);
    }

    private sealed record HlsOptions(
        OutputFormatKind OutputFormat,
        Option<string> HlsPlaylistPath,
        Option<string> HlsSegmentTemplate,
        Option<string> HlsInitTemplate,
        Option<string> HlsSegmentOptions);
}
