<?php

namespace ProtoneMedia\LaravelFFMpeg\Exporters;

use Closure;
use FFMpeg\Format\Audio\DefaultAudio;
use FFMpeg\Format\AudioInterface;
use FFMpeg\Format\FormatInterface;
use FFMpeg\Format\Video\DefaultVideo;
use Illuminate\Support\Collection;
use ProtoneMedia\LaravelFFMpeg\Filesystem\Disk;
use ProtoneMedia\LaravelFFMpeg\Filesystem\Media;
use ProtoneMedia\LaravelFFMpeg\MediaOpener;

class HLSExporter extends MediaExporter
{
    use EncryptsHLSSegments;

    public const HLS_KEY_INFO_FILENAME = 'hls_encryption.keyinfo';

    public const ENCRYPTION_LISTENER = 'listen-encryption-key';

    /**
     * @var int
     */
    private $segmentLength = 10;

    /**
     * @var int
     */
    private $keyFrameInterval = 48;

    /**
     * @var \Illuminate\Support\Collection
     */
    private $pendingFormats;

    /**
     * @var \ProtoneMedia\LaravelFFMpeg\Exporters\PlaylistGenerator
     */
    private $playlistGenerator;

    /**
     * @var \Closure
     */
    private $segmentFilenameGenerator = null;

    /**
     * Setter for the segment length
     */
    public function setSegmentLength(int $length): self
    {
        $this->segmentLength = max(2, $length);

        return $this;
    }

    /**
     * Setter for the Key Frame interval
     */
    public function setKeyFrameInterval(int $interval): self
    {
        $this->keyFrameInterval = max(2, $interval);

        return $this;
    }

    /**
     * Method to set a different playlist generator than
     * the default HLSPlaylistGenerator.
     */
    public function withPlaylistGenerator(PlaylistGenerator $playlistGenerator): self
    {
        $this->playlistGenerator = $playlistGenerator;

        return $this;
    }

    private function getPlaylistGenerator(): PlaylistGenerator
    {
        return $this->playlistGenerator ??= new HLSPlaylistGenerator;
    }

    /**
     * Method to not add the #EXT-X-ENDLIST line to the playlist.
     */
    public function withoutPlaylistEndLine(): self
    {
        $playlistGenerator = $this->getPlaylistGenerator();

        if ($playlistGenerator instanceof HLSPlaylistGenerator) {
            $playlistGenerator->withoutEndLine();
        }

        return $this;
    }

    /**
     * Setter for a callback that generates a segment filename.
     */
    public function useSegmentFilenameGenerator(Closure $callback): self
    {
        $this->segmentFilenameGenerator = $callback;

        return $this;
    }

    /**
     * Returns a default generator if none is set.
     */
    private function getSegmentFilenameGenerator(): callable
    {
        return $this->segmentFilenameGenerator ?: function ($name, $format, $key, $segments, $playlist) {
            $bitrate = $this->driver->getVideoStream()
                ? $format->getKiloBitrate()
                : $format->getAudioKiloBitrate();

            $segments("{$name}_{$key}_{$bitrate}_%05d.ts");
            $playlist("{$name}_{$key}_{$bitrate}.m3u8");
        };
    }

    /**
     * Calls the generator with the path (without extension), format and key.
     */
    private function getSegmentPatternAndFormatPlaylistPath(string $baseName, AudioInterface $format, int $key): array
    {
        $segmentsPattern = null;
        $formatPlaylistPath = null;

        call_user_func(
            $this->getSegmentFilenameGenerator(),
            $baseName,
            $format,
            $key,
            function ($path) use (&$segmentsPattern) {
                $segmentsPattern = $path;
            },
            function ($path) use (&$formatPlaylistPath) {
                $formatPlaylistPath = $path;
            }
        );

        return [$segmentsPattern, $formatPlaylistPath];
    }

    /**
     * Merges the HLS parameters to the given format.
     *
     * @param  \FFMpeg\Format\Video\DefaultAudio  $format
     */
    private function addHLSParametersToFormat(DefaultAudio $format, string $segmentsPattern, Disk $disk, int $key): array
    {
        $format->setAdditionalParameters(array_merge(
            $format->getAdditionalParameters() ?: [],
            $hlsParameters = [
                '-sc_threshold',
                '0',
                '-g',
                $this->keyFrameInterval,
                '-hls_playlist_type',
                'vod',
                '-hls_time',
                $this->segmentLength,
                '-hls_segment_filename',
                $disk->makeMedia($segmentsPattern)->getLocalPath(),
                '-master_pl_name',
                $this->generateTemporarySegmentPlaylistFilename($key),
            ],
            $this->getEncrypedHLSParameters()
        ));

        return $hlsParameters;
    }

    /**
     * Gives the callback an HLSVideoFilters object that provides addFilter(),
     * addLegacyFilter(), addWatermark() and resize() helper methods. It
     * returns a mapping for the video and (optional) audio stream.
     */
    private function applyFiltersCallback(callable $filtersCallback, int $formatKey): array
    {
        $filtersCallback(
            $hlsVideoFilters = new HLSVideoFilters($this->driver, $formatKey)
        );

        $filterCount = $hlsVideoFilters->count();

        $outs = [$filterCount ? HLSVideoFilters::glue($formatKey, $filterCount) : '0:v'];

        if ($this->getAudioStream()) {
            $outs[] = '0:a';
        }

        return $outs;
    }

    /**
     * Returns the filename of a segment playlist by its key. We let FFmpeg generate a playlist
     * for each added format so we don't have to detect the bitrate and codec ourselves.
     * We use this as a reference so when can generate our own main playlist.
     */
    public static function generateTemporarySegmentPlaylistFilename(int $key): string
    {
        return "temporary_segment_playlist_{$key}.m3u8";
    }

    /**
     * Loops through each added format and then deletes the temporary
     * segment playlist, which we generate manually using the
     * HLSPlaylistGenerator.
     */
    private function cleanupSegmentPlaylistGuides(Media $media): self
    {
        $disk = $media->getDisk();
        $directory = $media->getDirectory();

        $this->pendingFormats->map(function ($formatAndCallback, $key) use ($disk, $directory) {
            $disk->delete($directory.static::generateTemporarySegmentPlaylistFilename($key));
        });

        return $this;
    }

    /**
     * Adds a mapping for each added format and automatically handles the mapping
     * for filters. Adds a handler to rotate the encryption key (optional).
     * Returns a media collection of all segment playlists.
     *
     * @throws \ProtoneMedia\LaravelFFMpeg\Exporters\NoFormatException
     */
    private function prepareSaving(?string $path = null): Collection
    {
        if (! $this->pendingFormats) {
            throw new NoFormatException;
        }

        $media = $this->getDisk()->makeMedia($path);

        $baseName = $media->getDirectory().$media->getFilenameWithoutExtension();

        return $this->pendingFormats->map(function (array $formatAndCallback, $key) use ($baseName) {
            [$format, $filtersCallback] = $formatAndCallback;

            [$segmentsPattern, $formatPlaylistPath] = $this->getSegmentPatternAndFormatPlaylistPath(
                $baseName,
                $format,
                $key
            );

            $disk = $this->getDisk()->clone();

            $this->addHLSParametersToFormat($format, $segmentsPattern, $disk, $key);

            if ($filtersCallback) {
                $outs = $this->applyFiltersCallback($filtersCallback, $key);
            }
            $formatPlaylistOutput = $disk->makeMedia($formatPlaylistPath);
            $this->addFormatOutputMapping($format, $formatPlaylistOutput, $outs ?? ['0']);

            return $formatPlaylistOutput;
        })->tap(function () {
            $this->addHandlerToRotateEncryptionKey();
        });
    }

    /**
     * Prepares the saves command but returns the command instead.
     *
     * @return mixed
     */
    public function getCommand(?string $path = null)
    {
        $this->prepareSaving($path);

        return parent::getCommand(null);
    }

    /**
     * Runs the export, generates the main playlist, and cleans up the
     * segment playlist guides and temporary HLS encryption keys.
     *
     * @param  string  $path
     */
    public function save(?string $mainPlaylistPath = null): MediaOpener
    {
        return $this->prepareSaving($mainPlaylistPath)->pipe(function ($segmentPlaylists) use ($mainPlaylistPath) {
            $result = parent::save();

            $playlist = $this->getPlaylistGenerator()->get(
                $segmentPlaylists->all(),
                $this->driver->fresh()
            );

            $this->getDisk()->put($mainPlaylistPath, $playlist);

            $this->replaceAbsolutePathsHLSEncryption($segmentPlaylists)
                ->cleanupSegmentPlaylistGuides($segmentPlaylists->first())
                ->cleanupHLSEncryption()
                ->removeHandlerThatRotatesEncryptionKey();

            return $result;
        });
    }

    /**
     * Initializes the $pendingFormats property when needed and adds the format
     * with the optional callback to add filters.
     */
    public function addFormat(FormatInterface $format, ?callable $filtersCallback = null): self
    {
        if (! $this->pendingFormats) {
            $this->pendingFormats = new Collection;
        }

        if (! $format instanceof DefaultVideo && $format instanceof DefaultAudio) {
            $originalFormat = clone $format;

            $format = new class extends DefaultVideo
            {
                private array $audioCodecs = [];

                public function setAvailableAudioCodecs(array $audioCodecs)
                {
                    $this->audioCodecs = $audioCodecs;
                }

                public function getAvailableAudioCodecs(): array
                {
                    return $this->audioCodecs;
                }

                public function supportBFrames()
                {
                    return false;
                }

                public function getAvailableVideoCodecs()
                {
                    return [];
                }
            };

            $format->setAvailableAudioCodecs($originalFormat->getAvailableAudioCodecs());
            $format->setAudioCodec($originalFormat->getAudioCodec());
            $format->setAudioKiloBitrate($originalFormat->getAudioKiloBitrate());

            if ($originalFormat->getAudioChannels()) {
                $format->setAudioChannels($originalFormat->getAudioChannels());
            }
        }

        $this->pendingFormats->push([$format, $filtersCallback]);

        return $this;
    }
}
