<?php

declare(strict_types=1);

namespace App\Radio\Backend\Liquidsoap;

use App\Container\EnvironmentAwareTrait;
use App\Container\SettingsAwareTrait;
use App\Entity\Enums\PlaylistOrders;
use App\Entity\Enums\PlaylistRemoteTypes;
use App\Entity\Enums\PlaylistSources;
use App\Entity\Enums\PlaylistTypes;
use App\Entity\Enums\StationBackendPerformanceModes;
use App\Entity\StationBackendConfiguration;
use App\Entity\StationPlaylist;
use App\Entity\StationSchedule;
use App\Entity\StationStreamerBroadcast;
use App\Event\Radio\AnnotateNextSong;
use App\Event\Radio\WriteLiquidsoapConfiguration;
use App\Radio\Backend\Liquidsoap;
use App\Radio\Enums\AudioProcessingMethods;
use App\Radio\Enums\CrossfadeModes;
use App\Radio\Enums\FrontendAdapters;
use App\Radio\Enums\HlsStreamProfiles;
use App\Radio\Enums\LiquidsoapQueues;
use App\Radio\Enums\StreamFormats;
use App\Radio\Enums\StreamProtocols;
use App\Radio\FallbackFile;
use App\Radio\StereoTool;
use App\Utilities\Types;
use Carbon\CarbonImmutable;
use RuntimeException;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

final class ConfigWriter implements EventSubscriberInterface
{
    use EnvironmentAwareTrait;
    use SettingsAwareTrait;

    public function __construct(
        private readonly Liquidsoap $liquidsoap,
        private readonly FallbackFile $fallbackFile
    ) {
    }

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

    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, StationBackendConfiguration::CUSTOM_TOP);

        $station = $event->getStation();

        $configDir = $station->getRadioConfigDir();
        $pidfile = $configDir . '/liquidsoap.pid';
        $httpApiPort = $this->liquidsoap->getHttpApiPort($station);

        $stationTz = self::cleanUpString($station->timezone ?? 'UTC');

        $stationApiAuth = self::cleanUpString($station->adapter_api_key);
        $stationApiUrl = self::cleanUpString(
            (string)$this->environment->getInternalUri()
                ->withPath('/api/internal/' . $station->id . '/liquidsoap')
        );

        $commonLibPath = $this->environment->getParentDirectory() . '/liquidsoap/azuracast.liq';

        $mediaStorageLocation = $station->media_storage_location;
        $stationMediaDir = $mediaStorageLocation->adapter->isLocal()
            ? $mediaStorageLocation->getFilteredPath()
            : 'api';

        $logLevel = $this->environment->isProduction() ? 3 : 4;

        $backendConfig = $event->getBackendConfig();
        $useComputeAutocue = $backendConfig->enable_auto_cue ? 'true' : 'false';

        $enableCrossfade = $backendConfig->isCrossfadeEnabled() ? 'true' : 'false';
        $crossfadeType = (CrossfadeModes::Smart === $backendConfig->getCrossfadeTypeEnum())
            ? 'smart'
            : 'normal';
        $defaultFade = self::toFloat($backendConfig->crossfade);
        $defaultCross = self::toFloat($backendConfig->getCrossfadeDuration());

        $liveBroadcastText = self::cleanUpString(
            $backendConfig->live_broadcast_text
        );

        $fallbackPath = self::cleanUpString(
            $this->fallbackFile->getFallbackPathForStation($station)
        );

        $tempPath = self::cleanUpString(
            $station->getRadioTempDir()
        );

        $event->appendBlock(
            <<<LIQ
            # AzuraCast Common Runtime Functions
            %include "{$commonLibPath}"
            
            log.level := {$logLevel}
            init.daemon.pidfile.path := "{$pidfile}"
            
            settings.init.compact_before_start := true
            
            environment.set("TZ", "{$stationTz}") 
            
            settings.azuracast.liquidsoap_api_port := {$httpApiPort}
            settings.azuracast.api_url := "{$stationApiUrl}"
            settings.azuracast.api_key := "{$stationApiAuth}"
            settings.azuracast.media_path := "{$stationMediaDir}"
            settings.azuracast.fallback_path := "{$fallbackPath}"
            settings.azuracast.temp_path := "{$tempPath}"
            
            settings.azuracast.compute_autocue := {$useComputeAutocue}
            
            settings.azuracast.default_fade := {$defaultFade}
            settings.azuracast.default_cross := {$defaultCross}
            settings.azuracast.enable_crossfade := {$enableCrossfade}
            settings.azuracast.crossfade_type := "{$crossfadeType}"
            
            settings.azuracast.live_broadcast_text := "{$liveBroadcastText}"
            
            # Start HTTP API Server
            azuracast.start_http_api()
            
            LIQ
        );

        $perfMode = $backendConfig->getPerformanceModeEnum();
        if ($perfMode !== StationBackendPerformanceModes::Disabled) {
            $gcSpaceOverhead = match ($backendConfig->getPerformanceModeEnum()) {
                StationBackendPerformanceModes::LessMemory => 20,
                StationBackendPerformanceModes::LessCpu => 140,
                StationBackendPerformanceModes::Balanced => 80,
                StationBackendPerformanceModes::Disabled => 0,
            };

            $event->appendBlock(
                <<<LIQ
                # Optimize Performance
                runtime.gc.set(runtime.gc.get().{
                  space_overhead = {$gcSpaceOverhead},
                  allocation_policy = 2
                })
                LIQ
            );
        }
    }

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

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

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

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

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

        $fallbackRemoteUrl = null;

        foreach ($station->playlists as $playlist) {
            if (!$playlist->is_enabled) {
                continue;
            }

            if (!self::shouldWritePlaylist($event, $playlist)) {
                continue;
            }

            $playlistVarName = self::getPlaylistVariableName($playlist);

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

            $scheduleItems = $playlist->schedule_items;

            $playlistVarNames[] = $playlistVarName;
            $playlistConfigLines = [];

            if (PlaylistSources::Songs === $playlist->source) {
                $playlistFilePath = PlaylistFileWriter::getPlaylistFilePath($playlist);

                $playlistParams = [
                    'id="' . self::cleanUpString($playlistVarName) . '"',
                    'mime_type="audio/x-mpegurl"',
                ];

                $playlistMode = match ($playlist->order) {
                    PlaylistOrders::Sequential => 'normal',
                    PlaylistOrders::Shuffle => 'randomize',
                    PlaylistOrders::Random => 'random'
                };
                $playlistParams[] = 'mode="' . $playlistMode . '"';
                $playlistParams[] = 'reload_mode="watch"';
                $playlistParams[] = '"' . $playlistFilePath . '"';

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

                if ($playlist->backendMerge()) {
                    $playlistConfigLines[] = $playlistVarName . ' = merge_tracks(id="merge_'
                        . self::cleanUpString($playlistVarName) . '", ' . $playlistVarName . ')';
                }
            } elseif (PlaylistRemoteTypes::Playlist === $playlist->remote_type) {
                $playlistFunc = 'playlist("'
                    . self::cleanUpString($playlist->remote_url)
                    . '")';
                $playlistConfigLines[] = $playlistVarName . ' = ' . $playlistFunc;
            } else {
                // Special handling for Remote Stream URLs.
                $remoteUrl = $playlist->remote_url;
                if (null === $remoteUrl) {
                    continue;
                }

                $buffer = $playlist->remote_buffer;
                $buffer = ($buffer < 1) ? StationPlaylist::DEFAULT_REMOTE_BUFFER : $buffer;

                $inputFunc = match ($playlist->remote_type) {
                    PlaylistRemoteTypes::Stream => 'input.http',
                    default => 'input.ffmpeg'
                };

                $remoteUrlFunc = 'mksafe(buffer(buffer=' . $buffer . '., '
                    . $inputFunc . '("' . self::cleanUpString($remoteUrl) . '")))';

                if (0 === $scheduleItems->count()) {
                    $fallbackRemoteUrl = $remoteUrlFunc;
                    continue;
                }

                $playlistConfigLines[] = $playlistVarName . ' = ' . $remoteUrlFunc;
                $event->appendLines($playlistConfigLines);

                foreach ($scheduleItems as $scheduleItem) {
                    $playTime = $this->getScheduledPlaylistPlayTime($event, $scheduleItem);

                    $scheduleTiming = '({ ' . $playTime . ' }, ' . $playlistVarName . ')';
                    $scheduleSwitchesRemoteUrl[] = $scheduleTiming;
                }
                continue;
            }

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

            if (PlaylistTypes::Advanced === $playlist->type) {
                $playlistConfigLines[] = 'ignore(' . $playlistVarName . ')';
            }

            $event->appendLines($playlistConfigLines);

            switch ($playlist->type) {
                case PlaylistTypes::Standard:
                    if ($scheduleItems->count() > 0) {
                        foreach ($scheduleItems as $scheduleItem) {
                            $playTime = $this->getScheduledPlaylistPlayTime($event, $scheduleItem);

                            $scheduleTiming = $playlist->backendPlaySingleTrack()
                                ? '(predicate.at_most(1, {' . $playTime . '}), ' . $playlistVarName . ')'
                                : '({ ' . $playTime . ' }, ' . $playlistVarName . ')';

                            if ($playlist->backendInterruptOtherSongs()) {
                                $scheduleSwitchesInterrupting[] = $scheduleTiming;
                            } else {
                                $scheduleSwitches[] = $scheduleTiming;
                            }
                        }
                    } else {
                        $genPlaylistWeights[] = $playlist->weight;
                        $genPlaylistVars[] = $playlistVarName;
                    }
                    break;

                case PlaylistTypes::OncePerXSongs:
                case PlaylistTypes::OncePerXMinutes:
                    if (PlaylistTypes::OncePerXSongs === $playlist->type) {
                        $playlistScheduleVar = 'rotate(weights=[1,'
                            . $playlist->play_per_songs . '], [' . $playlistVarName . ', radio])';
                    } else {
                        $delaySeconds = $playlist->play_per_minutes * 60;
                        $delayTrackSensitive = $playlist->backendInterruptOtherSongs() ? 'false' : 'true';

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

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

                            $scheduleTiming = $playlist->backendPlaySingleTrack()
                                ? '(predicate.at_most(1, {' . $playTime . '}), ' . $playlistScheduleVar . ')'
                                : '({ ' . $playTime . ' }, ' . $playlistScheduleVar . ')';

                            if ($playlist->backendInterruptOtherSongs()) {
                                $scheduleSwitchesInterrupting[] = $scheduleTiming;
                            } else {
                                $scheduleSwitches[] = $scheduleTiming;
                            }
                        }
                    } else {
                        $specialPlaylists[$playlist->type->value][] = 'radio = ' . $playlistScheduleVar;
                    }
                    break;

                case PlaylistTypes::OncePerHour:
                    $minutePlayTime = $playlist->play_per_hour_minute . 'm';

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

                            $scheduleTiming = $playlist->backendPlaySingleTrack()
                                ? '(predicate.at_most(1, {' . $playTime . '}), ' . $playlistVarName . ')'
                                : '({ ' . $playTime . ' }, ' . $playlistVarName . ')';

                            if ($playlist->backendInterruptOtherSongs()) {
                                $scheduleSwitchesInterrupting[] = $scheduleTiming;
                            } else {
                                $scheduleSwitches[] = $scheduleTiming;
                            }
                        }
                    } else {
                        $scheduleTiming = $playlist->backendPlaySingleTrack()
                            ? '(predicate.at_most(1, {' . $minutePlayTime . '}), ' . $playlistVarName . ')'
                            : '({ ' . $minutePlayTime . ' }, ' . $playlistVarName . ')';

                        if ($playlist->backendInterruptOtherSongs()) {
                            $scheduleSwitchesInterrupting[] = $scheduleTiming;
                        } else {
                            $scheduleSwitches[] = $scheduleTiming;
                        }
                    }
                    break;

                case PlaylistTypes::Advanced:
                    // NOOP
            }
        }

        // 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 (!empty($scheduleSwitchesInterrupting)) {
            $event->appendLines(['# Interrupting Schedule Switches']);

            foreach (array_chunk($scheduleSwitchesInterrupting, 168, true) as $scheduleSwitchesChunk) {
                $scheduleSwitchesChunk[] = '({true}, radio)';

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

        if (!$station->backend_config->use_manual_autodj) {
            $event->appendBlock(
                <<< LIQ
                radio = azuracast.enable_autodj(radio)
                LIQ
            );
        }

        // Handle remote URL fallbacks.
        if (null !== $fallbackRemoteUrl) {
            $event->appendBlock(
                <<< LIQ
                remote_url = {$fallbackRemoteUrl}
                radio = fallback(id="fallback_remote_url", track_sensitive = false, [remote_url, radio])
                LIQ
            );
        }

        $requestsQueueName = LiquidsoapQueues::Requests->value;
        $interruptingQueueName = LiquidsoapQueues::Interrupting->value;

        $event->appendBlock(
            <<< LIQ
            requests = request.queue(id="{$requestsQueueName}", timeout=settings.azuracast.request_timeout())
            radio = fallback(id="requests_fallback", track_sensitive = true, [requests, radio])

            interrupting_queue = request.queue(id="{$interruptingQueueName}", timeout=settings.azuracast.request_timeout())
            radio = fallback(id="interrupting_fallback", track_sensitive = false, [interrupting_queue, radio])
            LIQ
        );

        if (!empty($scheduleSwitchesRemoteUrl)) {
            $event->appendLines(['# Remote URL Schedule Switches']);

            foreach (array_chunk($scheduleSwitchesRemoteUrl, 168, true) as $scheduleSwitchesChunk) {
                $scheduleSwitchesChunk[] = '({true}, radio)';
                $event->appendLines(
                    [
                        sprintf(
                            'radio = switch(id="schedule_switch", track_sensitive=false, [ %s ])',
                            implode(', ', $scheduleSwitchesChunk)
                        ),
                    ]
                );
            }
        }
    }

    public function writeCrossfadeConfiguration(WriteLiquidsoapConfiguration $event): void
    {
        // Write pre-crossfade section.
        $this->writeCustomConfigurationSection($event, StationBackendConfiguration::CUSTOM_PRE_FADE);

        // Amplify and Skip
        $event->appendBlock(
            <<<LIQ
            # Allow Telnet to skip the current track.
            azuracast.utilities.add_skip_command(radio)
            
            # Apply amplification metadata (if supplied)
            # This can be disabled by setting:
            #   settings.azuracast.apply_amplify := false
            radio = azuracast.apply_amplify(radio)
            LIQ
        );

        // Replaygain metadata
        $settings = $event->getBackendConfig();

        if ($settings->enable_replaygain_metadata) {
            $event->appendBlock(
                <<<LIQ
                # Replaygain Metadata
                enable_replaygain_metadata()
                radio = replaygain(radio)
                LIQ
            );
        }

        // Add debug logging for metadata.
        $event->appendBlock(
            <<<LS
            # Log current metadata for debugging.
            source.methods(radio).on_metadata(synchronous=false, azuracast.log_meta)
            
            # Apply crossfade.
            radio = azuracast.apply_crossfade(radio)
            LS
        );

        if ($settings->isAudioProcessingEnabled() && !$settings->post_processing_include_live) {
            $this->writePostProcessingSection($event);
        }
    }

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

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

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

        $settings = $event->getBackendConfig();
        $charset = $settings->charset;
        $djMount = $settings->dj_mount_point;
        $recordLiveStreams = $settings->record_streams;

        $harborParams = [
            '"' . self::cleanUpString($djMount) . '"',
            'id = "input_streamer"',
            'port = ' . $this->liquidsoap->getStreamPort($station),
            'auth = azuracast.dj_auth',
            'icy = true',
            'icy_metadata_charset = "' . $charset . '"',
            'metadata_charset = "' . $charset . '"',
        ];

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

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

        $event->appendBlock(
            <<<LIQ
            # 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})
            live.on_connect(synchronous=false, azuracast.live_connected)
            live.on_disconnect(synchronous=false, azuracast.live_disconnected)

            def insert_missing(m) =
                def updates =
                    if m == [] then
                        [("title", "#{settings.azuracast.live_broadcast_text()}"), ("is_live", "true")]
                    else
                        [("is_live", "true")]
                    end
                end
                updates
            end
            
            # Temporarily disabled for testing.
            # live = metadata.map(insert_missing, live)
            
            radio = fallback(
                id="live_fallback",
                track_sensitive=true,
                replay_metadata=true,
                transitions=[
                    fun (_, s) -> begin
                        log("executing transition to live")
                        s
                    end, 
                    fun (_, s) -> begin
                        s
                    end
                ],
                [live, radio]
            )

            # Skip non-live track when live DJ goes live.
            def check_live() =
                if live.is_ready() then
                    if not azuracast.to_live() then
                        azuracast.to_live := true
                        radio_without_live.skip()
                    end
                else
                    azuracast.to_live := false
                end
            end

            # Continuously check on live.
            source.methods(radio).on_frame(synchronous=true, check_live)
            LIQ
        );

        if ($recordLiveStreams) {
            $recordEncoding = $settings->getRecordStreamsEncoding();
            assert(null !== $recordEncoding);

            $formatString = $this->getFullFfmpegString($recordEncoding);
            $recordExtension = $recordEncoding->format->getExtension();
            $recordPathPrefix = StationStreamerBroadcast::PATH_PREFIX;

            $event->appendBlock(
                <<< LIQ
                # Record Live Broadcasts
                output.file(
                    {$formatString},
                    fun () -> begin
                        if (azuracast.live_enabled()) then
                            time.string("#{settings.azuracast.temp_path()}/#{azuracast.live_dj()}/{$recordPathPrefix}_%Y%m%d-%H%M%S.{$recordExtension}.tmp")
                        else
                            ""
                        end
                    end,
                    live,
                    fallible=true,
                    on_close=fun (tempPath) -> begin
                        newPath = string.replace(pattern=".tmp$", (fun(_) -> ""), tempPath)

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

                        process.run("mv #{tempPath} #{newPath}")
                        ()
                    end
                )
                LIQ
            );
        }
    }

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

        $event->appendBlock(
            <<<LIQ
            # Allow for Telnet-driven insertion of custom metadata.
            azuracast.utilities.add_custom_metadata_command(id="custom_metadata", radio)
            LIQ
        );

        if ($settings->isAudioProcessingEnabled() && $settings->post_processing_include_live) {
            $this->writePostProcessingSection($event);
        }

        $event->appendBlock(
            <<<LIQ
            # Add Fallback
            radio = azuracast.add_fallback(radio)
            
            # Send metadata changes back to AzuraCast
            source.methods(radio).on_metadata(synchronous=false, azuracast.send_feedback)

            # Handle "Jingle Mode" tracks by replaying the previous metadata.
            radio = azuracast.handle_jingle_mode(radio)
            LIQ
        );

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

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

        if (!$event->getBackendConfig()->share_encoders) {
            return;
        }

        // @var Collection<EncodableInterface> $encodables
        $encodables = [
            $station->mounts,
            $station->remotes,
        ];

        if ($station->enable_hls) {
            $encodables[] = $station->hls_streams;
        }

        $encoders = [];
        foreach ($encodables as $collection) {
            foreach ($collection as $encodable) {
                $encoder = $encodable->getEncodingFormat();

                if (null !== $encoder) {
                    $varName = $encoder->getVariableName('radio');
                    $encoders[$varName] = $encoder;
                }
            }
        }

        foreach ($encoders as $varName => $encoder) {
            $varName = self::cleanUpVarName($varName);
            $audioString = $this->getFfmpegAudioString($encoder);

            $event->appendBlock(
                <<<LIQ
                {$varName} = ffmpeg.encode.audio(
                    %ffmpeg({$audioString}),
                    radio
                )
                LIQ
            );
        }
    }

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

        if (FrontendAdapters::Remote === $station->frontend_type) {
            return;
        }

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

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

            $outputtableSource = $mountRow->getOutputtableSource();
            if (null !== $outputtableSource) {
                $lsConfig[] = $this->getOutputString($event, $outputtableSource, 'local_', $i);
            }
        }

        $event->appendLines($lsConfig);
    }

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

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

        $shareEncoders = $event->getBackendConfig()->share_encoders;

        $lsConfig = [
            '# HLS Broadcasting',
        ];

        // Configure the outbound broadcast.
        $hlsStreams = [];

        // Build the HLS stream encoder destinations.
        foreach ($station->hls_streams as $hlsStream) {
            $streamVarName = self::cleanUpVarName($hlsStream->name);

            $ffmpegStreams = [];

            if ($shareEncoders) {
                foreach ($station->hls_streams as $hlsInnerStream) {
                    $innerStreamVarName = self::cleanUpVarName(
                        $hlsInnerStream->getEncodingFormat()->getVariableName('hls')
                    );

                    $ffmpegStreams[] = ($hlsInnerStream->id === $hlsStream->id)
                        ? '%' . $innerStreamVarName . '.copy'
                        : '%' . $innerStreamVarName . '.drop';
                }
            } else {
                $ffmpegStreams[] = $this->getFfmpegAudioString(
                    $hlsStream->getEncodingFormat()
                );
            }

            $hlsStreams[] = sprintf(
                '("%s", %%ffmpeg(format="mpegts", %s))',
                $streamVarName,
                implode(', ', $ffmpegStreams)
            );
        }

        if (empty($hlsStreams)) {
            return;
        }

        $lsConfig[] = 'hls_streams = [' . "\n" . '    ' . implode(
            ',' . "\n" . '    ',
            $hlsStreams
        ) . "\n" . ']';

        // Build an aggregate source composed of the various encoders.
        if ($shareEncoders) {
            $i = 0;
            $hlsSourceTracks = [];

            foreach ($station->hls_streams as $hlsStream) {
                $i++;

                $encoding = $hlsStream->getEncodingFormat();
                $encoderVarName = $encoding->getVariableName('radio');
                $hlsVarName = $encoding->getVariableName('hls');

                $hlsSourceTracks[] = $hlsVarName . ' = ' . $hlsVarName;

                if ($i === 1) {
                    $hlsSourceTracks[] = 'metadata = hls_m';
                    $hlsSourceTracks[] = 'track_marks = hls_tm';

                    $lsConfig[] = sprintf(
                        'let {audio = %s, metadata = hls_m, track_marks = hls_tm} = source.tracks(%s)',
                        $hlsVarName,
                        $encoderVarName
                    );
                } else {
                    $lsConfig[] = sprintf(
                        'let {audio = %s} = source.tracks(%s)',
                        $hlsVarName,
                        $encoderVarName
                    );
                }
            }

            $lsConfig[] = 'hls_radio = source({' . implode(', ', $hlsSourceTracks) . '})';

            $radioVarName = 'hls_radio';
        } else {
            $radioVarName = 'radio';
        }

        $event->appendLines($lsConfig);

        $configDir = $station->getRadioConfigDir();
        $hlsBaseDir = $station->getRadioHlsDir();

        $backendConfig = $event->getBackendConfig();
        $hlsSegmentLength = $backendConfig->hls_segment_length;
        $hlsSegmentsInPlaylist = $backendConfig->hls_segments_in_playlist;
        $hlsSegmentsOverhead = $backendConfig->hls_segments_overhead;

        $event->appendBlock(
            <<<LIQ
            def hls_segment_name(seg_meta) =
                seg_timestamp = int_of_float(time())
                seg_duration = {$hlsSegmentLength}
                "#{seg_meta.stream_name}_#{seg_duration}_#{seg_timestamp}_#{seg_meta.position}.#{seg_meta.extname}"
            end

            output.file.hls(playlist="live.m3u8",
                segment_duration={$hlsSegmentLength}.0,
                segments={$hlsSegmentsInPlaylist},
                segments_overhead={$hlsSegmentsOverhead},
                segment_name=hls_segment_name,
                persist_at="{$configDir}/hls.config",
                temp_dir="#{settings.azuracast.temp_path()}",
                "{$hlsBaseDir}",
                hls_streams,
                {$radioVarName}
            )
            LIQ
        );
    }

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

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

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

            $encoderDefinition = $remoteRow->getOutputtableSource();
            if (null !== $encoderDefinition) {
                $lsConfig[] = $this->getOutputString($event, $encoderDefinition, 'relay_', $i);
            }
        }

        $event->appendLines($lsConfig);
    }

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

        $settings = $event->getStation()->backend_config;
        $customConfig = $settings->getCustomConfigurationSection($sectionName);

        if (!empty($customConfig)) {
            $event->appendLines(
                [
                    '# Custom Configuration (Specified in Station Profile)',
                    '# startcustomconfig(' . $sectionName . ')',
                    $customConfig,
                    '# endcustomconfig(' . $sectionName . ')',
                ]
            );
        }
    }

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

        switch ($settings->getAudioProcessingMethodEnum()) {
            case AudioProcessingMethods::Liquidsoap:
                // NRJ normalization
                $event->appendBlock(
                    <<<LIQ
                    # Normalization and Compression
                    radio = normalize(target = 0., window = 0.03, gain_min = -16., gain_max = 0., radio)
                    radio = compress.exponential(radio, mu = 1.0)
                    LIQ
                );
                break;

            case AudioProcessingMethods::MasterMe:
                // MasterMe Presets

                $lines = [
                    'radio = ladspa.master_me(',
                ];

                $preset = $settings->getMasterMePresetEnum();
                $presetOptions = $preset->getOptions();

                if (0 !== ($loudnessTarget = $settings->master_me_loudness_target)) {
                    $presetOptions['target'] = $loudnessTarget;
                }

                foreach ($presetOptions as $presetKey => $presetVal) {
                    $presetVal = match (true) {
                        is_int($presetVal) => self::toFloat($presetVal, 0),
                        is_float($presetVal) => self::toFloat($presetVal),
                        is_bool($presetVal) => ($presetVal) ? 'true' : 'false',
                        default => $presetVal
                    };

                    $lines[] = '    ' . $presetKey . ' = ' . $presetVal . ',';
                }

                $lines[] = '    radio';
                $lines[] = ')';

                $event->appendLines($lines);
                break;

            case AudioProcessingMethods::StereoTool:
                // Stereo Tool processing
                if (!StereoTool::isReady($station)) {
                    return;
                }

                $stereoToolLibraryPath = StereoTool::getLibraryPath();
                $stereoToolBinary = $stereoToolLibraryPath . '/stereo_tool';

                $stereoToolConfiguration = $station->getRadioConfigDir()
                    . DIRECTORY_SEPARATOR . $settings->stereo_tool_configuration_path;

                $stereoToolLicenseKey = $settings->stereo_tool_license_key;

                if (is_file($stereoToolBinary)) {
                    $stereoToolProcess = $stereoToolBinary . ' --silent - - -s ' . $stereoToolConfiguration;

                    if (!empty($stereoToolLicenseKey)) {
                        $stereoToolProcess .= ' -k "' . $stereoToolLicenseKey . '"';
                    }

                    $event->appendBlock(
                        <<<LIQ
                        # Stereo Tool Pipe
                        radio = pipe(replay_delay=1.0, process='{$stereoToolProcess}', radio)
                        LIQ
                    );
                } else {
                    $serverArch = php_uname('m');
                    $stereoToolLibrary = match ($serverArch) {
                        'x86' => $stereoToolLibraryPath . '/libStereoTool_intel32.so',
                        'aarch64', 'arm64' => $stereoToolLibraryPath . '/libStereoTool_arm64.so',
                        default => $stereoToolLibraryPath . '/libStereoTool_intel64.so',
                    };

                    if (!file_exists($stereoToolLibrary)) {
                        // Stereo Tool 10.0 uploaded using a different format.
                        $is64Bit = in_array($serverArch, ['x86_64', 'arm64'], true);
                        if ($is64Bit && file_exists($stereoToolLibraryPath . '/libStereoTool_64.so')) {
                            $stereoToolLibrary = $stereoToolLibraryPath . '/libStereoTool_64.so';
                        } elseif (file_exists(($stereoToolLibraryPath . '/libStereoTool.so'))) {
                            $stereoToolLibrary = $stereoToolLibraryPath . '/libStereoTool.so';
                        } else {
                            break;
                        }
                    }

                    $event->appendBlock(
                        <<<LIQ
                        # Stereo Tool Pipe
                        radio = stereotool(
                            library_file="{$stereoToolLibrary}",
                            license_key="{$stereoToolLicenseKey}",
                            preset="{$stereoToolConfiguration}",
                            radio
                        )
                        LIQ
                    );
                }
                break;

            case AudioProcessingMethods::None:
                // Noop
                break;
        }
    }

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

    /**
     * Given a scheduled playlist, return the time criteria that Liquidsoap can use to determine when to play it.
     */
    private function getScheduledPlaylistPlayTime(
        WriteLiquidsoapConfiguration $event,
        StationSchedule $playlistSchedule
    ): string {
        $startTime = $playlistSchedule->start_time;
        $endTime = $playlistSchedule->end_time;

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

            $playlistScheduleDays = $playlistSchedule->days;
            if (!empty($playlistScheduleDays) && count($playlistScheduleDays) < 7) {
                $currentPlayDays = [];
                $nextPlayDays = [];

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

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

                $playTimes[0] = '(' . implode(' or ', $currentPlayDays) . ') and ' . $playTimes[0];
                $playTimes[1] = '(' . implode(' or ', $nextPlayDays) . ') and ' . $playTimes[1];
            }

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

        // Handle once-per-day playlists.
        $playTime = ($startTime === $endTime)
            ? self::formatTimeCode($startTime)
            : self::formatTimeCode($startTime) . '-' . self::formatTimeCode($endTime);

        $playlistScheduleDays = $playlistSchedule->days;
        if (!empty($playlistScheduleDays) && count($playlistScheduleDays) < 7) {
            $playDays = [];

            foreach ($playlistScheduleDays as $day) {
                $playDays[] = (($day === 7) ? '0' : $day) . 'w';
            }
            $playTime = '(' . implode(' or ', $playDays) . ') and ' . $playTime;
        }

        // Handle start-date and end-date boundaries.
        $startDate = $playlistSchedule->start_date;
        $endDate = $playlistSchedule->end_date;

        if (!empty($startDate) || !empty($endDate)) {
            $tzObject = $event->getStation()->getTimezoneObject();

            $customFunctionBody = [];

            $scheduleMethod = 'schedule_' . $playlistSchedule->id . '_date_range';
            $customFunctionBody[] = 'def ' . $scheduleMethod . '() =';

            $conditions = [];

            if (!empty($startDate)) {
                $startDateObj = CarbonImmutable::createFromFormat('Y-m-d', $startDate, $tzObject);

                if (null !== $startDateObj) {
                    $startDateObj = $startDateObj->setTime(0, 0);

                    $customFunctionBody[] = '    # ' . $startDateObj->__toString();
                    $customFunctionBody[] = '    range_start = ' . $startDateObj->getTimestamp() . '.';
                    $conditions[] = 'range_start <= current_time';
                }
            }

            if (!empty($endDate)) {
                $endDateObj = CarbonImmutable::createFromFormat('Y-m-d', $endDate, $tzObject);

                if (null !== $endDateObj) {
                    $endDateObj = $endDateObj->setTime(23, 59, 59);

                    $customFunctionBody[] = '    # ' . $endDateObj->__toString();
                    $customFunctionBody[] = '    range_end = ' . $endDateObj->getTimestamp() . '.';

                    $conditions[] = 'current_time <= range_end';
                }
            }

            $customFunctionBody[] = '    current_time = time()';
            $customFunctionBody[] = '    result = (' . implode(' and ', $conditions) . ')';
            $customFunctionBody[] = '    result';
            $customFunctionBody[] = 'end';
            $event->appendLines($customFunctionBody);

            $playTime = $scheduleMethod . '() and ' . $playTime;
        }

        return $playTime;
    }

    /**
     * Given outbound broadcast information, produce a suitable LiquidSoap configuration line for the stream.
     */
    private function getOutputString(
        WriteLiquidsoapConfiguration $event,
        OutputtableSource $source,
        string $idPrefix,
        int $id
    ): string {
        $station = $event->getStation();
        $charset = $event->getBackendConfig()->charset;
        $shareEncoders = $event->getBackendConfig()->share_encoders;

        $encoding = $source->encoding;

        $outputParams = [];

        $container = $encoding->format->getFfmpegContainer();

        if ($shareEncoders) {
            $outputParams[] = '%ffmpeg(format="' . $container . '", %audio.copy)';
        } else {
            $audioString = $this->getFfmpegAudioString($encoding);
            $outputParams[] = '%ffmpeg(format="' . $container . '", ' . $audioString . ')';
        }

        $outputParams[] = 'id="' . $idPrefix . $id . '"';

        $outputParams[] = 'host = "' . self::cleanUpString($source->host) . '"';
        $outputParams[] = 'port = ' . (int)$source->port;

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

        $password = self::cleanUpString($source->password);

        $adapterType = $source->adapterType;
        if (FrontendAdapters::Shoutcast === $adapterType) {
            $password .= ':#' . $id;
        }

        $outputParams[] = 'password = "' . $password . '"';

        $mountPoint = $source->mount;
        if (StreamProtocols::Icy === $source->protocol) {
            if (!empty($mountPoint)) {
                $outputParams[] = 'icy_id = ' . $id;
            }
        } else {
            if (empty($mountPoint)) {
                $mountPoint = '/';
            }

            $outputParams[] = 'mount = "' . self::cleanUpString($mountPoint) . '"';
        }

        $outputParams[] = 'name = "' . self::cleanUpString($station->name) . '"';

        if (!$source->isShoutcast) {
            $outputParams[] = 'description = "' . self::cleanUpString($station->description) . '"';
        }
        $outputParams[] = 'genre = "' . self::cleanUpString($station->genre) . '"';

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

        $outputParams[] = 'public = ' . ($source->isPublic ? 'true' : 'false');
        $outputParams[] = 'encoding = "' . $charset . '"';

        if (StreamProtocols::Https === $source->protocol) {
            $outputParams[] = 'transport = https_transport';
        }

        $sendIcyMetadata = $encoding->format->sendIcyMetadata();
        if (null !== $sendIcyMetadata) {
            $outputParams[] = 'send_icy_metadata = ' . ($sendIcyMetadata ? 'true' : 'false');
        }

        if ($shareEncoders) {
            $outputParams[] = self::cleanUpVarName($encoding->getVariableName('radio'));
        } else {
            $outputParams[] = 'radio';
        }

        $outputCommand = ($source->isShoutcast)
            ? 'output.shoutcast'
            : 'output.icecast';

        return $outputCommand . '(' . implode(', ', $outputParams) . ')';
    }

    private function getFullFfmpegString(
        EncodingFormat $encoding,
        ?string $container = null,
    ): string {
        $audioString = $this->getFfmpegAudioString($encoding);
        $container ??= $encoding->format->getFfmpegContainer();

        return <<<LIQ
        %ffmpeg(format="{$container}", {$audioString})
        LIQ;
    }

    private function getFfmpegAudioString(
        EncodingFormat $encoding,
    ): string {
        $bitrate = $encoding->bitrate;

        switch ($encoding->format) {
            case StreamFormats::Aac:
                $afterburner = ($encoding->bitrate >= 160) ? '1' : '0';
                $profile = $encoding->subProfile?->getProfileName()
                    ?? HlsStreamProfiles::default()->getProfileName();

                return <<<LIQ
                %audio(codec="libfdk_aac", samplerate=44100, channels=2, b="{$bitrate}k", profile="{$profile}", afterburner={$afterburner})
                LIQ;

            case StreamFormats::Ogg:
                return <<<LIQ
                %audio(codec="libvorbis", samplerate=48000, b="{$bitrate}k", channels=2)
                LIQ;

            case StreamFormats::Opus:
                return <<<LIQ
                %audio(codec="libopus", samplerate=48000, b="{$bitrate}k", vbr="constrained", application="audio", channels=2, compression_level=10, cutoff=20000)
                LIQ;

            case StreamFormats::Flac:
                return <<<LIQ
                %audio(codec="flac", channels=2, ar=48000)
                LIQ;

            case StreamFormats::Mp3:
                return <<<LIQ
                %audio(codec="libmp3lame", ac=2, ar=44100, b="{$bitrate}k")
                LIQ;

            default:
                throw new RuntimeException(
                    sprintf('Unsupported stream format: %s', $encoding->format->value)
                );
        }
    }

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

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

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

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

    /**
     * Filter a user-supplied string to be a valid LiquidSoap config entry.
     */
    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.
     * @return string The cleaned up, variable-name-friendly string.
     */
    public static function cleanUpVarName(string $str): string
    {
        $str = strtolower(
            preg_replace(
                ['/[\r\n\t ]+/', '/[\"*\/:<>?\'|]+/'],
                ' ',
                strip_tags($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 = rawurlencode(str_replace(' ', '_', $str));
        return str_replace(['%', '-', '.'], ['', '_', '_'], $str);
    }

    public static function getPlaylistVariableName(StationPlaylist $playlist): string
    {
        return self::cleanUpVarName('playlist_' . StationPlaylist::generateShortName($playlist->name));
    }

    /**
     * Given a value, convert it into an annotation-friendly quoted string.
     */
    public static function annotateValue(string|int|float|bool $dataVal, bool $preserveType = false): string
    {
        if ($preserveType) {
            $strVal = Types::string($dataVal);
        } else {
            $strVal = match (true) {
                'true' === $dataVal || 'false' === $dataVal => $dataVal,
                is_bool($dataVal) => Types::bool($dataVal, false, true) ? 'true' : 'false',
                is_numeric($dataVal) && !is_int($dataVal) => self::toFloat($dataVal),
                default => Types::string($dataVal)
            };
        }

        $strVal = mb_convert_encoding($strVal, 'UTF-8');

        return str_replace(['"', "\n", "\t", "\r"], ['\"', '', '', ''], $strVal);
    }

    /**
     * Given an array of values, convert them into a Liquidsoap annotation format.
     *
     * @param array<string, string|int|float|bool|null> $values
     */
    public static function annotateArray(array $values): string
    {
        $values = array_filter(
            $values,
            fn(string|int|float|bool|null $val, string $key): bool => $val !== null
                && in_array($key, AnnotateNextSong::ALLOWED_ANNOTATIONS, true),
            ARRAY_FILTER_USE_BOTH
        );

        $annotations = [];
        foreach ($values as $key => $val) {
            $annotatedVal = self::annotateValue(
                $val,
                in_array($key, AnnotateNextSong::ALWAYS_STRING_ANNOTATIONS, true)
            );

            $annotations[] = $key . '="' . $annotatedVal . '"';
        }

        return implode(',', $annotations);
    }

    public static function shouldWritePlaylist(
        WriteLiquidsoapConfiguration $event,
        StationPlaylist $playlist
    ): bool {
        $station = $event->getStation();
        $backendConfig = $event->getBackendConfig();

        if ($backendConfig->write_playlists_to_liquidsoap) {
            return true;
        }

        if ($station->backend_config->use_manual_autodj) {
            return true;
        }

        if (PlaylistSources::Songs !== $playlist->source) {
            return true;
        }

        if (
            $playlist->backendInterruptOtherSongs()
            || $playlist->backendPlaySingleTrack()
            || $playlist->backendMerge()
            || PlaylistTypes::Advanced === $playlist->type
        ) {
            return true;
        }

        return false;
    }
}
