<?php

declare(strict_types=1);

namespace App\Radio\Backend\Liquidsoap;

use App\Entity;
use App\Environment;
use App\Event\Radio\WriteLiquidsoapConfiguration;
use App\Exception;
use App\Flysystem\StationFilesystems;
use App\Message;
use App\Radio\Adapters;
use App\Radio\Backend\Liquidsoap;
use Doctrine\ORM\EntityManagerInterface;
use League\Flysystem\StorageAttributes;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

class ConfigWriter implements EventSubscriberInterface
{
    public const CUSTOM_TOP = 'custom_config_top';
    public const CUSTOM_PRE_PLAYLISTS = 'custom_config_pre_playlists';
    public const CUSTOM_PRE_LIVE = 'custom_config_pre_live';
    public const CUSTOM_PRE_FADE = 'custom_config_pre_fade';
    public const CUSTOM_PRE_BROADCAST = 'custom_config';
    public const CUSTOM_BOTTOM = 'custom_config_bottom';

    public const CROSSFADE_NORMAL = 'normal';
    public const CROSSFADE_DISABLED = 'none';
    public const CROSSFADE_SMART = 'smart';

    public function __construct(
        protected EntityManagerInterface $em,
        protected Entity\Repository\SettingsRepository $settingsRepo,
        protected Liquidsoap $liquidsoap,
        protected Environment $environment,
        protected LoggerInterface $logger
    ) {
    }

    /**
     * Handle event dispatch.
     *
     * @param Message\AbstractMessage $message
     */
    public function __invoke(Message\AbstractMessage $message): void
    {
        if ($message instanceof Message\WritePlaylistFileMessage) {
            $playlist = $this->em->find(Entity\StationPlaylist::class, $message->playlist_id);

            if ($playlist instanceof Entity\StationPlaylist) {
                $this->writePlaylistFile($playlist);
            }
        }
    }

    /**
     * @return mixed[]
     */
    public static function getSubscribedEvents(): array
    {
        return [
            WriteLiquidsoapConfiguration::class => [
                ['writeHeaderFunctions', 35],
                ['writePlaylistConfiguration', 30],
                ['writeCrossfadeConfiguration', 25],
                ['writeHarborConfiguration', 20],
                ['writePreBroadcastConfiguration', 10],
                ['writeLocalBroadcastConfiguration', 5],
                ['writeRemoteBroadcastConfiguration', 0],
                ['writePostBroadcastConfiguration', -5],
            ],
        ];
    }

    public function writeCustomConfigurationSection(WriteLiquidsoapConfiguration $event, string $sectionName): void
    {
        if ($event->isForEditing()) {
            $divider = self::getDividerString();
            $event->appendLines(
                [
                    $divider . $sectionName . $divider,
                ]
            );
            return;
        }

        $settings = $this->settingsRepo->readSettings();
        if (!$settings->getEnableAdvancedFeatures()) {
            return;
        }

        $settings = $event->getStation()->getBackendConfig();
        if (!empty($settings[$sectionName])) {
            $event->appendLines(
                [
                    '# Custom Configuration (Specified in Station Profile)',
                    $settings[$sectionName],
                ]
            );
        }
    }

    public static function getDividerString(): string
    {
        return chr(7);
    }

    public function writeHeaderFunctions(WriteLiquidsoapConfiguration $event): void
    {
        if (!$event->isForEditing()) {
            $event->prependLines(
                [
                    '# WARNING! This file is automatically generated by AzuraCast.',
                    '# Do not update it directly!',
                ]
            );
        }

        $this->writeCustomConfigurationSection($event, self::CUSTOM_TOP);

        $station = $event->getStation();

        $configDir = $station->getRadioConfigDir();
        $pidfile = $configDir . DIRECTORY_SEPARATOR . 'liquidsoap.pid';

        $telnetBindAddr = $this->environment->isDocker() ? '0.0.0.0' : '127.0.0.1';
        $telnetPort = $this->liquidsoap->getTelnetPort($station);

        $stationTz = self::cleanUpString($station->getTimezone());
        $stationApiAuth = self::cleanUpString($station->getAdapterApiKey());

        $event->appendBlock(
            <<<EOF
        init.daemon.set(false)
        init.daemon.pidfile.path.set("${pidfile}")
        log.stdout.set(true)
        log.file.set(false)
        settings.server.telnet.set(true)
        settings.server.telnet.bind_addr.set("${telnetBindAddr}")
        settings.server.telnet.port.set(${telnetPort})
        settings.harbor.bind_addrs.set(["0.0.0.0"])

        settings.tag.encodings.set(["UTF-8","ISO-8859-1"])
        settings.encoder.metadata.export.set(["artist","title","album","song"])

        settings.decoder.priorities.ogg.set(15)
        settings.decoder.priorities.mad.set(15)
        settings.decoder.priorities.flac.set(15)
        settings.decoder.priorities.aac.set(15)
        settings.decoder.priorities.ffmpeg.set(10)

        setenv("TZ", "${stationTz}")

        azuracast_api_auth = ref("${stationApiAuth}")
        ignore(azuracast_api_auth)

        autodj_is_loading = ref(true)
        ignore(autodj_is_loading)

        autodj_ping_attempts = ref(0)
        ignore(autodj_ping_attempts)
        EOF
        );
    }

    public function writePlaylistConfiguration(WriteLiquidsoapConfiguration $event): void
    {
        $station = $event->getStation();

        $this->writeCustomConfigurationSection($event, self::CUSTOM_PRE_PLAYLISTS);

        // Clear out existing playlists directory.

        $fsPlaylists = (new StationFilesystems($station))->getPlaylistsFilesystem();

        foreach ($fsPlaylists->listContents('', false) as $file) {
            /** @var StorageAttributes $file */
            if ($file->isDir()) {
                $fsPlaylists->deleteDirectory($file->path());
            } else {
                $fsPlaylists->delete($file->path());
            }
        }

        // Set up playlists using older format as a fallback.
        $playlistObjects = [];

        foreach ($station->getPlaylists() as $playlistRaw) {
            /** @var Entity\StationPlaylist $playlistRaw */
            if (!$playlistRaw->getIsEnabled()) {
                continue;
            }

            $playlistObjects[] = $playlistRaw;
        }

        $playlistVarNames = [];
        $genPlaylistWeights = [];
        $genPlaylistVars = [];

        $specialPlaylists = [
            'once_per_x_songs' => [
                '# Once per x Songs Playlists',
            ],
            'once_per_x_minutes' => [
                '# Once per x Minutes Playlists',
            ],
        ];

        $scheduleSwitches = [];
        $scheduleSwitchesInterrupting = [];

        foreach ($playlistObjects as $playlist) {
            /** @var Entity\StationPlaylist $playlist */
            $playlistVarName = self::cleanUpVarName('playlist_' . $playlist->getShortName());

            if (in_array($playlistVarName, $playlistVarNames, true)) {
                $playlistVarName .= '_' . $playlist->getId();
            }

            $playlistVarNames[] = $playlistVarName;

            $playlistConfigLines = [];

            if (Entity\StationPlaylist::SOURCE_SONGS === $playlist->getSource()) {
                $playlistFilePath = $this->writePlaylistFile($playlist, false);
                if (!$playlistFilePath) {
                    continue;
                }

                $playlistParams = [
                    'id="' . self::cleanUpString($playlistVarName) . '"',
                ];

                $playlistModes = [
                    Entity\StationPlaylist::ORDER_SEQUENTIAL => 'normal',
                    Entity\StationPlaylist::ORDER_SHUFFLE => 'randomize',
                    Entity\StationPlaylist::ORDER_RANDOM => 'random',
                ];

                $playlistParams[] = 'mode="' . $playlistModes[$playlist->getOrder()] . '"';

                if ($playlist->backendLoopPlaylistOnce()) {
                    $playlistParams[] = 'reload_mode="never"';
                } else {
                    $playlistParams[] = 'reload_mode="watch"';
                }

                $playlistParams[] = '"' . $playlistFilePath . '"';

                $playlistConfigLines[] = $playlistVarName . ' = playlist('
                    . implode(',', $playlistParams) . ')';

                if ($playlist->backendMerge()) {
                    $playlistConfigLines[] = $playlistVarName . ' = merge_tracks(id="merge_'
                        . self::cleanUpString($playlistVarName) . '", ' . $playlistVarName . ')';
                }

                $playlistConfigLines[] = $playlistVarName . ' = cue_cut(id="cue_'
                    . self::cleanUpString($playlistVarName) . '", ' . $playlistVarName . ')';
            } else {
                switch ($playlist->getRemoteType()) {
                    case Entity\StationPlaylist::REMOTE_TYPE_PLAYLIST:
                        $playlistFunc = 'playlist("'
                            . self::cleanUpString($playlist->getRemoteUrl())
                            . '")';
                        $playlistConfigLines[] = $playlistVarName . ' = ' . $playlistFunc;
                        break;

                    case Entity\StationPlaylist::REMOTE_TYPE_STREAM:
                    default:
                        $remote_url = $playlist->getRemoteUrl();
                        if (null !== $remote_url) {
                            $buffer = $playlist->getRemoteBuffer();
                            $buffer = ($buffer < 1) ? Entity\StationPlaylist::DEFAULT_REMOTE_BUFFER : $buffer;

                            $playlistConfigLines[] = $playlistVarName . ' = mksafe(buffer(buffer=' . $buffer . '., input.http(max_buffer=' . $buffer . '., "' . self::cleanUpString($remote_url) . '")))';
                        }
                        break;
                }
            }

            if ($playlist->getIsJingle()) {
                $playlistConfigLines[] = $playlistVarName . ' = drop_metadata(' . $playlistVarName . ')';
            }

            if (Entity\StationPlaylist::TYPE_ADVANCED === $playlist->getType()) {
                $playlistConfigLines[] = 'ignore(' . $playlistVarName . ')';
            }

            $event->appendLines($playlistConfigLines);

            if ($playlist->backendPlaySingleTrack()) {
                $playlistVarName = 'once(' . $playlistVarName . ')';
            }

            $scheduleItems = $playlist->getScheduleItems();

            switch ($playlist->getType()) {
                case Entity\StationPlaylist::TYPE_DEFAULT:
                    if ($scheduleItems->count() > 0) {
                        foreach ($scheduleItems as $scheduleItem) {
                            $play_time = $this->getScheduledPlaylistPlayTime($scheduleItem);

                            $schedule_timing = '({ ' . $play_time . ' }, ' . $playlistVarName . ')';
                            if ($playlist->backendInterruptOtherSongs()) {
                                $scheduleSwitchesInterrupting[] = $schedule_timing;
                            } else {
                                $scheduleSwitches[] = $schedule_timing;
                            }
                        }
                    } else {
                        $genPlaylistWeights[] = $playlist->getWeight();
                        $genPlaylistVars[] = $playlistVarName;
                    }
                    break;

                case Entity\StationPlaylist::TYPE_ONCE_PER_X_SONGS:
                case Entity\StationPlaylist::TYPE_ONCE_PER_X_MINUTES:
                    if (Entity\StationPlaylist::TYPE_ONCE_PER_X_SONGS === $playlist->getType()) {
                        $playlistScheduleVar = 'rotate(weights=[1,'
                            . $playlist->getPlayPerSongs() . '], [' . $playlistVarName . ', radio])';
                    } else {
                        $delaySeconds = $playlist->getPlayPerMinutes() * 60;
                        $delayTrackSensitive = $playlist->backendInterruptOtherSongs() ? 'false' : 'true';

                        $playlistScheduleVar = 'fallback(track_sensitive=' . $delayTrackSensitive . ', [delay(' . $delaySeconds . '., ' . $playlistVarName . '), radio])';
                    }

                    if ($scheduleItems->count() > 0) {
                        foreach ($scheduleItems as $scheduleItem) {
                            $play_time = $this->getScheduledPlaylistPlayTime($scheduleItem);

                            $schedule_timing = '({ ' . $play_time . ' }, ' . $playlistScheduleVar . ')';
                            if ($playlist->backendInterruptOtherSongs()) {
                                $scheduleSwitchesInterrupting[] = $schedule_timing;
                            } else {
                                $scheduleSwitches[] = $schedule_timing;
                            }
                        }
                    } else {
                        $specialPlaylists[$playlist->getType()][] = 'radio = ' . $playlistScheduleVar;
                    }
                    break;

                case Entity\StationPlaylist::TYPE_ONCE_PER_HOUR:
                    $minutePlayTime = $playlist->getPlayPerHourMinute() . 'm';

                    if ($scheduleItems->count() > 0) {
                        foreach ($scheduleItems as $scheduleItem) {
                            $playTime = '(' . $minutePlayTime . ') and ('
                                . $this->getScheduledPlaylistPlayTime($scheduleItem) . ')';

                            $schedule_timing = '({ ' . $playTime . ' }, ' . $playlistVarName . ')';
                            if ($playlist->backendInterruptOtherSongs()) {
                                $scheduleSwitchesInterrupting[] = $schedule_timing;
                            } else {
                                $scheduleSwitches[] = $schedule_timing;
                            }
                        }
                    } else {
                        $schedule_timing = '({ ' . $minutePlayTime . ' }, ' . $playlistVarName . ')';
                        if ($playlist->backendInterruptOtherSongs()) {
                            $scheduleSwitchesInterrupting[] = $schedule_timing;
                        } else {
                            $scheduleSwitches[] = $schedule_timing;
                        }
                    }
                    break;
            }
        }

        // Build "default" type playlists.
        $event->appendLines(
            [
                '# Standard Playlists',
                sprintf(
                    'radio = random(id="standard_playlists", weights=[%s], [%s])',
                    implode(', ', $genPlaylistWeights),
                    implode(', ', $genPlaylistVars)
                ),
            ]
        );

        if (!empty($scheduleSwitches)) {
            $event->appendLines(['# Standard Schedule Switches']);

            // Chunk scheduled switches to avoid hitting the max amount of playlists in a switch()
            foreach (array_chunk($scheduleSwitches, 168, true) as $scheduleSwitchesChunk) {
                $scheduleSwitchesChunk[] = '({true}, radio)';

                $event->appendLines(
                    [
                        sprintf(
                            'radio = switch(id="schedule_switch", track_sensitive=true, [ %s ])',
                            implode(', ', $scheduleSwitchesChunk)
                        ),
                    ]
                );
            }
        }

        // Add in special playlists if necessary.
        foreach ($specialPlaylists as $playlistConfigLines) {
            if (count($playlistConfigLines) > 1) {
                $event->appendLines($playlistConfigLines);
            }
        }

        if (!$station->useManualAutoDJ()) {
            $nextsongCommand = $this->getApiUrlCommand($station, 'nextsong');

            $event->appendBlock(
                <<< EOF
            # AutoDJ Next Song Script
            def autodj_next_song() =
                uri = {$nextsongCommand}
                log("AzuraCast Raw Response: #{uri}")

                if uri == "" or string.match(pattern="Error", uri) then
                    null()
                else
                    r = request.create(uri)
                    if request.resolve(r) then
                        r
                    else
                        null()
                   end
                end
            end

            # Delayed ping for AutoDJ Next Song
            def wait_for_next_song(autodj)
                autodj_ping_attempts := !autodj_ping_attempts + 1
                delay = ref(0.5)

                if source.is_ready(autodj) then
                    log("AutoDJ is ready!")

                    autodj_is_loading := false
                    delay := -1.0
                elsif !autodj_ping_attempts > 200 then
                    log("AutoDJ could not be initialized within the specified timeout.")

                    autodj_is_loading := false
                    delay := -1.0
                end

                !delay
            end

            dynamic = request.dynamic(id="next_song", timeout=20., retry_delay=2., autodj_next_song)
            dynamic = cue_cut(id="cue_next_song", dynamic)

            dynamic_startup = fallback(
                id = "dynamic_startup",
                track_sensitive = false,
                [
                    dynamic,
                    source.available(
                        blank(id = "autodj_startup_blank", duration = 120.),
                        predicate.activates({!autodj_is_loading})
                    )
                ]
            )
            radio = fallback(id="autodj_fallback", track_sensitive = true, [dynamic_startup, radio])

            ref_dynamic = ref(dynamic);
            thread.run.recurrent(delay=0.25, { wait_for_next_song(!ref_dynamic) })
            EOF
            );
        }

        if (!empty($scheduleSwitchesInterrupting)) {
            $scheduleSwitchesInterrupting[] = '({true}, radio)';

            $event->appendLines(
                [
                    '# Interrupting Schedule Switches',
                    sprintf(
                        'radio = switch(id="interrupt_switch", track_sensitive=false, [ %s ])',
                        implode(', ', $scheduleSwitchesInterrupting)
                    ),
                ]
            );
        }

        $event->appendBlock(
            <<< EOF
        requests = request.queue(id="requests")
        requests = cue_cut(id="cue_requests", requests)

        radio = fallback(id="requests_fallback", track_sensitive = true, [requests, radio])
        add_skip_command(radio)
        EOF
        );
    }

    /**
     * Write a playlist's contents to file so Liquidsoap can process it, and optionally notify
     * Liquidsoap of the change.
     *
     * @param Entity\StationPlaylist $playlist
     * @param bool $notify
     *
     * @return string|null The full path that was written to.
     */
    public function writePlaylistFile(Entity\StationPlaylist $playlist, bool $notify = true): ?string
    {
        $station = $playlist->getStation();

        $mediaStorage = $station->getMediaStorageLocation();
        if (!$mediaStorage->isLocal()) {
            return null;
        }

        $playlistPath = $station->getRadioPlaylistsDir();
        $playlistVarName = 'playlist_' . $playlist->getShortName();

        $this->logger->info(
            'Writing playlist file to disk...',
            [
                'station' => $station->getName(),
                'playlist' => $playlist->getName(),
            ]
        );

        $mediaBaseDir = $mediaStorage->getPath() . '/';
        $playlistFile = [];

        $mediaQuery = $this->em->createQuery(
            <<<'DQL'
                SELECT DISTINCT sm
                FROM App\Entity\StationMedia sm
                JOIN sm.playlists spm
                WHERE spm.playlist = :playlist
                ORDER BY spm.weight ASC
            DQL
        )->setParameter('playlist', $playlist);

        /** @var Entity\StationMedia $mediaFile */
        foreach ($mediaQuery->toIterable() as $mediaFile) {
            $mediaFilePath = $mediaBaseDir . $mediaFile->getPath();
            $mediaAnnotations = $this->liquidsoap->annotateMedia($mediaFile);

            if ($playlist->getIsJingle()) {
                $mediaAnnotations['is_jingle_mode'] = 'true';
                unset($mediaAnnotations['media_id']);
            } else {
                $mediaAnnotations['playlist_id'] = $playlist->getId();
            }

            $annotations_str = [];
            foreach ($mediaAnnotations as $annotation_key => $annotation_val) {
                $annotations_str[] = $annotation_key . '="' . $annotation_val . '"';
            }

            $playlistFile[] = 'annotate:' . implode(',', $annotations_str) . ':' . $mediaFilePath;
        }

        $playlistFilePath = $playlistPath . '/' . $playlistVarName . '.m3u';

        file_put_contents($playlistFilePath, implode("\n", $playlistFile));

        if ($notify) {
            try {
                $this->liquidsoap->command($station, $playlistVarName . '.reload');
            } catch (Exception $e) {
                $this->logger->error(
                    'Could not reload playlist with AutoDJ.',
                    [
                        'message' => $e->getMessage(),
                        'playlist' => $playlistVarName,
                        'station' => $station->getId(),
                    ]
                );
            }
        }

        return $playlistFilePath;
    }

    /**
     * Given a scheduled playlist, return the time criteria that Liquidsoap can use to determine when to play it.
     *
     * @param Entity\StationSchedule $playlistSchedule
     */
    protected function getScheduledPlaylistPlayTime(Entity\StationSchedule $playlistSchedule): string
    {
        $start_time = $playlistSchedule->getStartTime();
        $end_time = $playlistSchedule->getEndTime();

        // Handle multi-day playlists.
        if ($start_time > $end_time) {
            $play_times = [
                self::formatTimeCode($start_time) . '-23h59m59s',
                '00h00m-' . self::formatTimeCode($end_time),
            ];

            $playlist_schedule_days = $playlistSchedule->getDays();
            if (!empty($playlist_schedule_days) && count($playlist_schedule_days) < 7) {
                $current_play_days = [];
                $next_play_days = [];

                foreach ($playlist_schedule_days as $day) {
                    $current_play_days[] = (($day === 7) ? '0' : $day) . 'w';

                    $day++;
                    if ($day > 7) {
                        $day = 1;
                    }
                    $next_play_days[] = (($day === 7) ? '0' : $day) . 'w';
                }

                $play_times[0] = '(' . implode(' or ', $current_play_days) . ') and ' . $play_times[0];
                $play_times[1] = '(' . implode(' or ', $next_play_days) . ') and ' . $play_times[1];
            }

            return '(' . implode(') or (', $play_times) . ')';
        }

        // Handle once-per-day playlists.
        $play_time = ($start_time === $end_time)
            ? self::formatTimeCode($start_time)
            : self::formatTimeCode($start_time) . '-' . self::formatTimeCode($end_time);

        $playlist_schedule_days = $playlistSchedule->getDays();
        if (!empty($playlist_schedule_days) && count($playlist_schedule_days) < 7) {
            $play_days = [];

            foreach ($playlist_schedule_days as $day) {
                $play_days[] = (($day === 7) ? '0' : $day) . 'w';
            }

            $play_time = '(' . implode(' or ', $play_days) . ') and ' . $play_time;
        }

        return $play_time;
    }

    /**
     * Returns the URL that LiquidSoap should call when attempting to execute AzuraCast API commands.
     *
     * @param Entity\Station $station
     * @param string $endpoint
     * @param array $params
     */
    protected function getApiUrlCommand(Entity\Station $station, string $endpoint, array $params = []): string
    {
        // Docker cURL-based API URL call with API authentication.
        if ($this->environment->isDocker()) {
            $params = (array)$params;
            $params['api_auth'] = '!azuracast_api_auth';

            $service_uri = ($this->environment->isDockerRevisionAtLeast(5)) ? 'web' : 'nginx';

            /** @noinspection HttpUrlsUsage */
            $api_url = 'http://' . $service_uri . '/api/internal/' . $station->getId() . '/' . $endpoint;
            $command = 'curl -s --request POST --url ' . $api_url;
            foreach ($params as $paramKey => $paramVal) {
                $envVarKey = strtoupper(str_replace('-', '_', $paramKey));
                $command .= ' --form ' . $paramKey . '="$' . $envVarKey . '"';
            }
        } else {
            // Ansible shell-script call.
            $shell_path = '/usr/bin/php ' . $this->environment->getBaseDirectory() . '/bin/console';

            $shell_args = [];
            $shell_args[] = 'azuracast:internal:' . $endpoint;
            $shell_args[] = $station->getId();

            foreach ((array)$params as $paramKey => $paramVal) {
                $envVarKey = strtoupper(str_replace('-', '_', $paramKey));
                $shell_args [] = '--' . $paramKey . '="$' . $envVarKey . '"';
            }

            $command = $shell_path . ' ' . implode(' ', $shell_args);
        }

        $envVarsParts = [];
        foreach ($params as $envVarName => $envVarValue) {
            $envVarKey = strtoupper(str_replace('-', '_', $envVarName));
            $envVarsParts[] = '("' . $envVarKey . '", ' . $envVarValue . ')';
        }
        $envVarsStr = 'env=[' . implode(', ', $envVarsParts) . ']';

        return 'list.hd(process.read.lines(' . $envVarsStr . ', \'' . $command . '\'), default="")';
    }

    public function writeCrossfadeConfiguration(WriteLiquidsoapConfiguration $event): void
    {
        $settings = $event->getStation()->getBackendConfig();

        // Write pre-crossfade section.
        $this->writeCustomConfigurationSection($event, self::CUSTOM_PRE_FADE);

        // Crossfading happens before the live broadcast is mixed in, because of buffer issues.
        $crossfade_type = $settings->getCrossfadeType();
        $crossfade = $settings->getCrossfade();

        $crossDuration = $settings->getCrossfadeDuration();
        if ($crossDuration > 0) {
            $crossfadeIsSmart = (Entity\StationBackendConfiguration::CROSSFADE_SMART === $crossfade_type) ? 'true' : 'false';
            $event->appendLines([
                sprintf(
                    'radio = crossfade(smart=%s, duration=%s, fade_out=%s, fade_in=%s, radio)',
                    $crossfadeIsSmart,
                    self::toFloat($crossDuration),
                    self::toFloat($crossfade),
                    self::toFloat($crossfade)
                ),
            ]);
        }
    }

    public function writeHarborConfiguration(WriteLiquidsoapConfiguration $event): void
    {
        $station = $event->getStation();

        if (!$station->getEnableStreamers()) {
            return;
        }

        $this->writeCustomConfigurationSection($event, self::CUSTOM_PRE_LIVE);

        $settings = $station->getBackendConfig();
        $charset = $settings->getCharset();
        $dj_mount = $settings->getDjMountPoint();
        $recordLiveStreams = $settings->recordStreams();

        $authCommand = $this->getApiUrlCommand($station, 'auth', ['dj-user' => '!user', 'dj-password' => '!password']);
        $djonCommand = $this->getApiUrlCommand($station, 'djon', ['dj-user' => 'dj']);
        $djoffCommand = $this->getApiUrlCommand($station, 'djoff', ['dj-user' => 'dj']);

        $event->appendBlock(
            <<< EOF
        # DJ Authentication
        live_enabled = ref(false)
        last_authenticated_dj = ref("")
        live_dj = ref("")

        live_record_path = ref("")

        def dj_auth(login) =
            user = ref("")
            password = ref("")

            if (login.user == "source" or login.user == "") and (string.match(pattern="(:|,)+", login.password)) then
                auth_string = string.split(separator="(:|,)", login.password)

                user := list.nth(default="", auth_string, 0)
                password := list.nth(default="", auth_string, 2)
            else
                user := login.user
                password := login.password
            end

            log("Authenticating DJ: #{!user}")

            ret = {$authCommand}
            log("AzuraCast DJ Auth Response: #{ret}")

            authed = bool_of_string(ret)
            if (authed) then
                last_authenticated_dj := !user
            end

            authed
        end

        def live_connected(header) =
            dj = !last_authenticated_dj
            log("DJ Source connected! Last authenticated DJ: #{dj} - #{header}")

            live_enabled := true
            live_dj := dj

            ret = {$djonCommand}
            log("AzuraCast Live Connected Response: #{ret}")

            if (string.contains(prefix="/", ret)) then
                live_record_path := ret
            end
        end

        def live_disconnected() =
            dj = !live_dj

            log("DJ Source disconnected! Current live DJ: #{dj}")

            ret = {$djoffCommand}
            log("AzuraCast Live Disconnected Response: #{ret}")

            live_enabled := false
            last_authenticated_dj := ""
            live_dj := ""

            live_record_path := ""
        end
        EOF
        );

        $harbor_params = [
            '"' . self::cleanUpString($dj_mount) . '"',
            'id = "input_streamer"',
            'port = ' . $this->liquidsoap->getStreamPort($station),
            'auth = dj_auth',
            'icy = true',
            'icy_metadata_charset = "' . $charset . '"',
            'metadata_charset = "' . $charset . '"',
            'on_connect = live_connected',
            'on_disconnect = live_disconnected',
        ];

        $djBuffer = (int)($settings['dj_buffer'] ?? 5);
        if (0 !== $djBuffer) {
            $harbor_params[] = 'buffer = ' . self::toFloat($djBuffer);
            $harbor_params[] = 'max = ' . self::toFloat(max($djBuffer + 5, 10));
        }

        $harborParams = implode(', ', $harbor_params);

        $event->appendBlock(
            <<<EOF
        # A Pre-DJ source of radio that can be broadcast if needed',
        radio_without_live = radio
        ignore(radio_without_live)

        # Live Broadcasting
        live = input.harbor(${harborParams})
        ignore(output.dummy(live, fallible=true))

        radio = fallback(id="live_fallback", replay_metadata=false, track_sensitive=false, [live, radio])
        EOF
        );

        if ($recordLiveStreams) {
            $recordLiveStreamsFormat = $settings['record_streams_format'] ?? Entity\Interfaces\StationMountInterface::FORMAT_MP3;
            $recordLiveStreamsBitrate = (int)($settings['record_streams_bitrate'] ?? 128);

            $formatString = $this->getOutputFormatString($recordLiveStreamsFormat, $recordLiveStreamsBitrate);

            $event->appendBlock(
                <<< EOF
            # Record Live Broadcasts
            output.file(
                {$formatString}, 
                fun () -> begin
                    path = !live_record_path
                    if (path != "") then
                        "#{path}.tmp"
                    else
                        ""
                    end
                end, 
                live, 
                fallible=true, 
                on_close=fun (tempPath) -> begin
                    path = string.replace(pattern=".tmp$", (fun(_) -> ""), tempPath)

                    log("Recording stopped: Switching from #{tempPath} to #{path}")

                    process.run("mv #{tempPath} #{path}")
                    ()
                end
            )
            EOF
            );
        }
    }

    public function writePreBroadcastConfiguration(WriteLiquidsoapConfiguration $event): void
    {
        $station = $event->getStation();
        $settings = $station->getBackendConfig();

        $event->appendLines(
            [
                '# Allow for Telnet-driven insertion of custom metadata.',
                'radio = server.insert_metadata(id="custom_metadata", radio)',
                '',
                '# Apply amplification metadata (if supplied)',
                'radio = amplify(override="liq_amplify", 1., radio)',
            ]
        );

        // NRJ normalization
        if ($settings->useNormalizer()) {
            $event->appendLines(
                [
                    '# Normalization and Compression',
                    'radio = normalize(target = 0., window = 0.03, gain_min = -16., gain_max = 0., radio)',
                    'radio = compress.exponential(radio, mu = 1.0)',
                ]
            );
        }

        // Replaygain metadata
        if ($settings->useReplayGain()) {
            $event->appendLines(
                [
                    '# Replaygain Metadata',
                    'enable_replaygain_metadata()',
                ]
            );
        }

        // Write fallback to safety file to ensure infallible source for the broadcast outputs.
        $error_file = $this->environment->isDocker()
            ? '/usr/local/share/icecast/web/error.mp3'
            : $this->environment->getBaseDirectory() . '/resources/error.mp3';

        $event->appendLines(
            [
                sprintf(
                    'radio = fallback(id="safe_fallback", track_sensitive = false, [radio, single(id="error_jingle", "%s")])',
                    $error_file
                ),
            ]
        );

        // Custom configuration
        $this->writeCustomConfigurationSection($event, self::CUSTOM_PRE_BROADCAST);

        $feedbackCommand = $this->getApiUrlCommand(
            $station,
            'feedback',
            ['song' => 'm["song_id"]', 'media' => 'm["media_id"]', 'playlist' => 'm["playlist_id"]']
        );

        $event->appendBlock(
            <<<EOF
        # Send metadata changes back to AzuraCast
        def metadata_updated(m) =
            def f() =
                if (m["song_id"] != "") then
                    ret = {$feedbackCommand}
                    log("AzuraCast Feedback Response: #{ret}")
                end
                (-1.)
            end

            thread.run.recurrent(fast=false, delay=0., f)
        end

        radio.on_metadata(metadata_updated)
        EOF
        );
    }

    public function writeLocalBroadcastConfiguration(WriteLiquidsoapConfiguration $event): void
    {
        $station = $event->getStation();

        if (Adapters::FRONTEND_REMOTE === $station->getFrontendType()) {
            return;
        }

        $ls_config = [
            '# Local Broadcasts',
        ];

        // Configure the outbound broadcast.
        $i = 0;
        foreach ($station->getMounts() as $mount_row) {
            $i++;

            /** @var Entity\StationMount $mount_row */
            if (!$mount_row->getEnableAutodj()) {
                continue;
            }

            $ls_config[] = $this->getOutputString($station, $mount_row, 'local_', $i);
        }

        $event->appendLines($ls_config);
    }

    /**
     * Given outbound broadcast information, produce a suitable LiquidSoap configuration line for the stream.
     */
    protected function getOutputString(
        Entity\Station $station,
        Entity\Interfaces\StationMountInterface $mount,
        string $idPrefix,
        int $id
    ): string {
        $charset = $station->getBackendConfig()->getCharset();

        $output_format = $this->getOutputFormatString(
            $mount->getAutodjFormat() ?? $mount::FORMAT_MP3,
            $mount->getAutodjBitrate() ?? 128
        );

        $output_params = [];
        $output_params[] = $output_format;
        $output_params[] = 'id="' . $idPrefix . $id . '"';

        $output_params[] = 'host = "' . self::cleanUpString($mount->getAutodjHost()) . '"';
        $output_params[] = 'port = ' . (int)$mount->getAutodjPort();

        $username = $mount->getAutodjUsername();
        if (!empty($username)) {
            $output_params[] = 'user = "' . self::cleanUpString($username) . '"';
        }

        $password = self::cleanUpString($mount->getAutodjPassword());
        if (Adapters::REMOTE_SHOUTCAST2 === $mount->getAutodjAdapterType()) {
            $password .= ':#' . $id;
        }
        $output_params[] = 'password = "' . $password . '"';

        $protocol = $mount->getAutodjProtocol();

        if (!empty($mount->getAutodjMount())) {
            if ($mount::PROTOCOL_ICY === $protocol) {
                $output_params[] = 'icy_id = ' . $id;
            } else {
                $output_params[] = 'mount = "' . self::cleanUpString($mount->getAutodjMount()) . '"';
            }
        }

        $output_params[] = 'name = "' . self::cleanUpString($station->getName()) . '"';
        $output_params[] = 'description = "' . self::cleanUpString($station->getDescription()) . '"';
        $output_params[] = 'genre = "' . self::cleanUpString($station->getGenre()) . '"';

        if (!empty($station->getUrl())) {
            $output_params[] = 'url = "' . self::cleanUpString($station->getUrl()) . '"';
        }

        $output_params[] = 'public = ' . ($mount->getIsPublic() ? 'true' : 'false');
        $output_params[] = 'encoding = "' . $charset . '"';

        if (null !== $protocol) {
            $output_params[] = 'protocol="' . $protocol . '"';
        }

        if (
            Entity\Interfaces\StationMountInterface::FORMAT_OPUS === $mount->getAutodjFormat()
            || Entity\Interfaces\StationMountInterface::FORMAT_FLAC === $mount->getAutodjFormat()
        ) {
            $output_params[] = 'icy_metadata="true"';
        }

        $output_params[] = 'radio';

        return 'output.icecast(' . implode(', ', $output_params) . ')';
    }

    protected function getOutputFormatString(string $format, int $bitrate = 128): string
    {
        switch (strtolower($format)) {
            case Entity\Interfaces\StationMountInterface::FORMAT_AAC:
                $afterburner = ($bitrate >= 160) ? 'true' : 'false';
                $aot = ($bitrate >= 96) ? 'mpeg4_aac_lc' : 'mpeg4_he_aac_v2';

                return '%fdkaac(channels=2, samplerate=44100, bitrate=' . $bitrate . ', afterburner=' . $afterburner . ', aot="' . $aot . '", sbr_mode=true)';

            case Entity\Interfaces\StationMountInterface::FORMAT_OGG:
                return '%vorbis.cbr(samplerate=44100, channels=2, bitrate=' . $bitrate . ')';

            case Entity\Interfaces\StationMountInterface::FORMAT_OPUS:
                return '%opus(samplerate=48000, bitrate=' . $bitrate . ', vbr="constrained", application="audio", channels=2, signal="music", complexity=10, max_bandwidth="full_band")';

            case Entity\Interfaces\StationMountInterface::FORMAT_FLAC:
                return '%ogg(%flac(samplerate=48000, channels=2, compression=4, bits_per_sample=24))';

            case Entity\Interfaces\StationMountInterface::FORMAT_MP3:
            default:
                return '%mp3(samplerate=44100, stereo=true, bitrate=' . $bitrate . ', id3v2=true)';
        }
    }

    public function writeRemoteBroadcastConfiguration(WriteLiquidsoapConfiguration $event): void
    {
        $station = $event->getStation();

        $ls_config = [
            '# Remote Relays',
        ];

        // Set up broadcast to remote relays.
        $i = 0;
        foreach ($station->getRemotes() as $remote_row) {
            $i++;

            /** @var Entity\StationRemote $remote_row */
            if (!$remote_row->getEnableAutodj()) {
                continue;
            }

            $ls_config[] = $this->getOutputString($station, $remote_row, 'relay_', $i);
        }

        $event->appendLines($ls_config);
    }

    public function writePostBroadcastConfiguration(WriteLiquidsoapConfiguration $event): void
    {
        $this->writeCustomConfigurationSection($event, self::CUSTOM_BOTTOM);
    }

    /**
     * @return string[]
     */
    public static function getCustomConfigurationSections(): array
    {
        return [
            self::CUSTOM_TOP,
            self::CUSTOM_PRE_PLAYLISTS,
            self::CUSTOM_PRE_FADE,
            self::CUSTOM_PRE_LIVE,
            self::CUSTOM_PRE_BROADCAST,
            self::CUSTOM_BOTTOM,
        ];
    }

    /**
     * Convert an integer or float into a Liquidsoap configuration compatible float.
     *
     * @param float|int|string $number
     * @param int $decimals
     */
    public static function toFloat(float|int|string $number, int $decimals = 2): string
    {
        return number_format((float)$number, $decimals, '.', '');
    }

    public static function formatTimeCode(int $time_code): string
    {
        $hours = floor($time_code / 100);
        $mins = $time_code % 100;

        return $hours . 'h' . $mins . 'm';
    }

    /**
     * Filter a user-supplied string to be a valid LiquidSoap config entry.
     *
     * @param string|null $string
     *
     */
    public static function cleanUpString(?string $string): string
    {
        return str_replace(['"', "\n", "\r"], ['\'', '', ''], $string ?? '');
    }

    /**
     * Apply a more aggressive string filtering to variable names used in Liquidsoap.
     *
     * @param string $str
     *
     * @return string The cleaned up, variable-name-friendly string.
     */
    public static function cleanUpVarName(string $str): string
    {
        $str = strip_tags($str);
        $str = preg_replace(['/[\r\n\t ]+/', '/[\"\*\/\:\<\>\?\'\|]+/'], ' ', $str) ?? '';
        $str = strtolower($str);
        $str = html_entity_decode($str, ENT_QUOTES, "utf-8");
        $str = htmlentities($str, ENT_QUOTES, "utf-8");
        $str = preg_replace("/(&)([a-z])([a-z]+;)/i", '$2', $str) ?? '';
        $str = str_replace(' ', '_', $str);
        $str = rawurlencode($str);
        $str = str_replace(['%', '-', '.'], ['', '_', '_'], $str);

        return $str;
    }
}
