<?php
/**
 * Metadata orchestrator for VMPlayer.
 *
 * Routes metadata fetches to the appropriate protocol handler
 * (Shoutcast, Icecast, HLS, direct) based on the station's
 * configured stream_type. Provides shared HTTP helpers used
 * by every handler (ICY header fetching, base URL derivation,
 * artist/title parsing) and manages transient caching so we
 * do not hammer the stream server.
 *
 * @package VMPlayer
 */

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

/**
 * Class VMPlayer_Metadata
 *
 * Static utility class. Every method is stateless.
 */
class VMPlayer_Metadata {

    /**
     * Cache key prefix for metadata transients.
     */
    const CACHE_PREFIX_META = 'vmplayer_meta_';

    /**
     * Cache key prefix for artwork lookups.
     */
    const CACHE_PREFIX_ART = 'vmplayer_art_';

    /**
     * Artwork cache lifetime in seconds. MusicBrainz results are
     * stable so a full day is safe and polite to their servers.
     */
    const ARTWORK_TTL = DAY_IN_SECONDS;

    /**
     * User-Agent that identifies VMPlayer to upstream servers.
     * MusicBrainz specifically requires an identifying UA per
     * their access policy.
     */
    const USER_AGENT = 'VMPlayer/' . VMPLAYER_VERSION . ' (+https://gandenterprisesinc.com/vmplayer) WordPress-Plugin';

    /**
     * Default HTTP timeout for stats endpoints.
     */
    const HTTP_TIMEOUT = 5;

    /* ------------------------------------------------------------------
     * Main entry point
     * --------------------------------------------------------------- */

    /**
     * Fetch normalized metadata for a station.
     * Uses transient cache when fresh. Routes to protocol handler
     * on cache miss. Enhances with MusicBrainz artwork when
     * enabled and the handler returned artist plus title.
     *
     * @param array $station Station array from VMPlayer_Settings.
     * @return array Normalized metadata.
     */
    public static function get_metadata( $station ) {
        if ( empty( $station ) || empty( $station['stream_url'] ) ) {
            return self::normalize( array(), $station );
        }

        $cache_key = self::CACHE_PREFIX_META . md5( $station['stream_url'] );
        $cached    = get_transient( $cache_key );
        if ( false !== $cached && is_array( $cached ) ) {
            // Cache hit. Re-run live detection so admin toggle changes
            // take effect immediately and stale-title timing keeps
            // advancing across polls. Detection is cheap (a title
            // comparison, one keyword loop, one transient read) so
            // running it on every read is fine, and skipping it would
            // freeze the LIVE state at whatever it was when the cache
            // was populated.
            return self::apply_live_detection( $cached, $station );
        }

        // Route by stream type. Every handler returns a partial
        // metadata array. Empty array on total failure.
        $raw = array();
        switch ( $station['stream_type'] ) {
            case 'shoutcast':
                $raw = VMPlayer_Shoutcast::fetch_metadata( $station['stream_url'] );
                break;
            case 'icecast':
                $raw = VMPlayer_Icecast::fetch_metadata( $station['stream_url'] );
                break;
            case 'hls':
                $raw = VMPlayer_HLS::fetch_metadata( $station['stream_url'] );
                break;
            case 'direct':
            default:
                $raw = self::fetch_icy_headers( $station['stream_url'] );
                break;
        }

        $metadata = self::normalize( $raw, $station );

        // Priority for artwork_url:
        //   1. Stream metadata itself (set by handler, artwork_source=stream)
        //   2. iTunes Search API (widest catalog including indie via aggregators)
        //   3. Deezer public API (best coverage for international and Caribbean)
        //   4. MusicBrainz + Cover Art Archive (classical, older releases)
        //   5. Station logo (set by normalize as default)
        //
        // External lookups only run when the stream did not provide
        // artwork, because a stream that ships a specific image knows
        // the song better than any external match ever will. Each
        // source runs only when its own admin toggle is enabled, and
        // the chain stops at the first hit.
        $settings = VMPlayer_Settings::get_settings();
        $has_stream_artwork = ! empty( $metadata['artwork_url'] )
            && isset( $metadata['artwork_source'] )
            && 'stream' === $metadata['artwork_source'];

        if ( ! $has_stream_artwork
            && ! empty( $metadata['artist'] )
            && ! empty( $metadata['title'] )
        ) {
            $artwork = '';
            $source  = '';

            if ( ! empty( $settings['itunes_enabled'] ) ) {
                $country = isset( $settings['itunes_country'] ) ? $settings['itunes_country'] : 'us';
                $artwork = VMPlayer_iTunes::lookup_artwork(
                    $metadata['artist'],
                    $metadata['title'],
                    $country
                );
                if ( '' !== $artwork ) {
                    $source = 'itunes';
                }
            }

            if ( '' === $artwork && ! empty( $settings['deezer_enabled'] ) ) {
                $artwork = VMPlayer_Deezer::lookup_artwork(
                    $metadata['artist'],
                    $metadata['title']
                );
                if ( '' !== $artwork ) {
                    $source = 'deezer';
                }
            }

            if ( '' === $artwork && ! empty( $settings['musicbrainz_enabled'] ) ) {
                $artwork = VMPlayer_MusicBrainz::lookup_artwork(
                    $metadata['artist'],
                    $metadata['title']
                );
                if ( '' !== $artwork ) {
                    $source = 'musicbrainz';
                }
            }

            if ( '' !== $artwork ) {
                $metadata['artwork_url']    = $artwork;
                $metadata['artwork_source'] = $source;
            }
        }

        // Supplement missing sample_rate and channels from the stream's
        // ICY headers. These are stream properties, not per-song ones,
        // so we cache the probe result separately with a long TTL to
        // avoid re-probing on every metadata poll. Applies to any stream
        // type that did not already fill these in (v1 SHOUTcast without
        // stats XML, some Icecast configs without audio_info).
        if ( empty( $metadata['sample_rate'] ) || empty( $metadata['channels'] ) ) {
            $icy_supplement = self::get_icy_stream_properties( $station['stream_url'] );
            if ( empty( $metadata['sample_rate'] ) && ! empty( $icy_supplement['sample_rate'] ) ) {
                $metadata['sample_rate'] = (int) $icy_supplement['sample_rate'];
            }
            if ( empty( $metadata['channels'] ) && ! empty( $icy_supplement['channels'] ) ) {
                $metadata['channels'] = (int) $icy_supplement['channels'];
            }
            if ( empty( $metadata['bitrate'] ) && ! empty( $icy_supplement['bitrate'] ) ) {
                $metadata['bitrate'] = (int) $icy_supplement['bitrate'];
            }
        }

        // Cache the result. TTL comes from admin settings.
        $ttl = isset( $settings['metadata_refresh_interval'] )
            ? (int) $settings['metadata_refresh_interval']
            : 15;
        set_transient( $cache_key, $metadata, $ttl );

        return $metadata;
    }

    /**
     * Force a metadata refresh, ignoring cache.
     *
     * @param array $station Station array.
     * @return array
     */
    public static function refresh_metadata( $station ) {
        if ( ! empty( $station['stream_url'] ) ) {
            delete_transient( self::CACHE_PREFIX_META . md5( $station['stream_url'] ) );
        }
        return self::get_metadata( $station );
    }

    /**
     * Probe a stream URL for the admin test button.
     * Combines the fast reachability check (already in the admin
     * class) with a real metadata fetch attempt so the admin sees
     * "Reachable, current track: X by Y" instead of just "Reachable".
     *
     * @param string $stream_url The stream URL.
     * @param string $stream_type One of shoutcast, icecast, hls, direct.
     * @return array Probe result with reachable, is_audio, and metadata keys.
     */
    public static function probe_stream( $stream_url, $stream_type ) {
        // A minimal fake station lets us reuse the routing logic.
        $fake_station = array(
            'name'        => '',
            'stream_url'  => $stream_url,
            'stream_type' => $stream_type,
        );

        $raw = array();
        switch ( $stream_type ) {
            case 'shoutcast':
                $raw = VMPlayer_Shoutcast::fetch_metadata( $stream_url );
                break;
            case 'icecast':
                $raw = VMPlayer_Icecast::fetch_metadata( $stream_url );
                break;
            case 'hls':
                $raw = VMPlayer_HLS::fetch_metadata( $stream_url );
                break;
            case 'direct':
            default:
                $raw = self::fetch_icy_headers( $stream_url );
                break;
        }

        $has_data = ! empty( $raw['title'] )
            || ! empty( $raw['station_name'] )
            || ! empty( $raw['bitrate'] );

        return array(
            'has_metadata' => $has_data,
            'metadata'     => $raw,
        );
    }

    /* ------------------------------------------------------------------
     * Shared HTTP helpers
     * --------------------------------------------------------------- */

    /**
     * Fetch cached ICY stream properties (sample rate, channels, bitrate).
     * These are stream configuration values that rarely change during
     * a broadcast, so we cache them for an hour rather than probing on
     * every metadata poll. Backfills the fields that some SHOUTcast and
     * Icecast handlers do not populate on their own.
     *
     * @param string $stream_url Stream URL.
     * @return array With keys sample_rate, channels, bitrate. Values are
     *               integers, or 0 if unknown.
     */
    public static function get_icy_stream_properties( $stream_url ) {
        if ( empty( $stream_url ) ) {
            return array( 'sample_rate' => 0, 'channels' => 0, 'bitrate' => 0 );
        }

        $cache_key = 'vmp_icy_props_' . md5( $stream_url );
        $cached    = get_transient( $cache_key );
        if ( is_array( $cached ) ) {
            return $cached;
        }

        $icy = self::fetch_icy_headers( $stream_url );
        $out = array(
            'sample_rate' => isset( $icy['sample_rate'] ) ? (int) $icy['sample_rate'] : 0,
            'channels'    => isset( $icy['channels'] ) ? (int) $icy['channels'] : 0,
            'bitrate'     => isset( $icy['bitrate'] ) ? (int) $icy['bitrate'] : 0,
        );

        // Cache for an hour. If any value came back non-zero, cache the
        // full result. If everything is zero (stream did not expose
        // these headers at all), still cache briefly to avoid hammering
        // the endpoint on every poll.
        $any = $out['sample_rate'] || $out['channels'] || $out['bitrate'];
        set_transient( $cache_key, $out, $any ? HOUR_IN_SECONDS : ( 5 * MINUTE_IN_SECONDS ) );

        return $out;
    }

    /**
     * Fetch ICY headers directly from a stream URL.
     * Every Shoutcast/Icecast server (and most direct MP3/AAC streams)
     * returns icy-name, icy-genre, icy-br, icy-sr headers when asked
     * with Icy-MetaData: 1. This is the universal fallback when
     * stats endpoints are unreachable or absent.
     *
     * @param string $stream_url The stream URL.
     * @return array Partial metadata array. Empty on failure.
     */
    public static function fetch_icy_headers( $stream_url ) {
        $response = wp_remote_get(
            $stream_url,
            array(
                'timeout'             => 4,
                'redirection'         => 3,
                'sslverify'           => false,
                'user-agent'          => 'Mozilla/5.0 (compatible; VMPlayer/' . VMPLAYER_VERSION . ')',
                'limit_response_size' => 2048,
                'headers'             => array(
                    'Range'        => 'bytes=0-1',
                    'Icy-MetaData' => '1',
                    'Accept'       => 'audio/*,application/ogg,*/*;q=0.5',
                ),
            )
        );

        if ( is_wp_error( $response ) ) {
            return array();
        }

        $icy_name  = trim( (string) wp_remote_retrieve_header( $response, 'icy-name' ) );
        $icy_genre = trim( (string) wp_remote_retrieve_header( $response, 'icy-genre' ) );
        $icy_br    = trim( (string) wp_remote_retrieve_header( $response, 'icy-br' ) );
        $icy_sr    = trim( (string) wp_remote_retrieve_header( $response, 'icy-sr' ) );
        $icy_ch    = trim( (string) wp_remote_retrieve_header( $response, 'icy-channels' ) );
        $ct        = trim( (string) wp_remote_retrieve_header( $response, 'content-type' ) );

        $result = array();
        if ( '' !== $icy_name ) {
            $result['station_name'] = $icy_name;
        }
        if ( '' !== $icy_genre ) {
            $result['genre'] = $icy_genre;
        }
        if ( '' !== $icy_br ) {
            $result['bitrate'] = (int) $icy_br;
        }
        if ( '' !== $icy_sr ) {
            $result['sample_rate'] = (int) $icy_sr;
        }
        if ( '' !== $icy_ch ) {
            $result['channels'] = (int) $icy_ch;
        }
        if ( '' !== $ct ) {
            $result['content_type'] = $ct;
        }

        return $result;
    }

    /**
     * Derive a base URL from a stream URL by stripping the mount point.
     * The mount point is the last path segment (commonly /stream, /listen,
     * /live). The result includes a trailing slash so callers can append
     * stats endpoint paths directly.
     *
     * Examples:
     *   https://radio.example.com:7006/stream -> https://radio.example.com:7006/
     *   https://radio.example.com/8014/stream -> https://radio.example.com/8014/
     *   https://radio.example.com/0/live      -> https://radio.example.com/0/
     *
     * @param string $stream_url The stream URL.
     * @return string Base URL with trailing slash, or empty string on parse failure.
     */
    public static function derive_base_url( $stream_url ) {
        $parsed = wp_parse_url( $stream_url );
        if ( ! is_array( $parsed ) || empty( $parsed['host'] ) ) {
            return '';
        }

        $scheme = isset( $parsed['scheme'] ) ? $parsed['scheme'] : 'http';
        $host   = $parsed['host'];
        $port   = isset( $parsed['port'] ) ? ':' . (int) $parsed['port'] : '';
        $path   = isset( $parsed['path'] ) ? $parsed['path'] : '/';

        $trimmed = trim( $path, '/' );
        if ( '' === $trimmed ) {
            return "{$scheme}://{$host}{$port}/";
        }

        $parts = explode( '/', $trimmed );
        array_pop( $parts );

        $base_path = empty( $parts ) ? '' : '/' . implode( '/', $parts );
        return "{$scheme}://{$host}{$port}{$base_path}/";
    }

    /**
     * Parse an "Artist - Title" song string into its parts.
     * Handles the three common separator styles:
     *   Artist - Title  (space-dash-space, most common)
     *   Artist: Title   (colon-space, some Icecast setups)
     *   Title only      (no separator, fall through)
     *
     * @param string $song The raw song string from stream metadata.
     * @return array Two-element array of [artist, title]. Either may be empty.
     */
    public static function parse_artist_title( $song ) {
        $song = trim( (string) $song );
        if ( '' === $song ) {
            return array( '', '' );
        }

        // " - " is the standard Shoutcast/Icecast format.
        if ( false !== strpos( $song, ' - ' ) ) {
            $parts = explode( ' - ', $song, 2 );
            return array( trim( $parts[0] ), trim( $parts[1] ) );
        }

        // ": " is used by some server setups.
        if ( false !== strpos( $song, ': ' ) ) {
            $parts = explode( ': ', $song, 2 );
            return array( trim( $parts[0] ), trim( $parts[1] ) );
        }

        // No separator found. Assume the whole string is the title.
        return array( '', $song );
    }

    /* ------------------------------------------------------------------
     * Normalization
     * --------------------------------------------------------------- */

    /**
     * Fill in a raw metadata array with defaults from station config.
     * The result is the exact shape that the frontend player will
     * consume, so every key it might need is guaranteed to be present.
     *
     * @param array $raw     Partial metadata from a handler.
     * @param array $station Station config array.
     * @return array Fully populated metadata.
     */
    public static function normalize( $raw, $station ) {
        if ( ! is_array( $raw ) ) {
            $raw = array();
        }
        if ( ! is_array( $station ) ) {
            $station = array();
        }

        $defaults = array(
            'title'              => '',
            'artist'             => '',
            'genre'              => isset( $station['genre'] ) ? $station['genre'] : '',
            'bitrate'            => 0,
            'sample_rate'        => 0,
            'channels'           => 0,
            // Set to true by the detection pass below when the current
            // source appears to be a live broadcast rather than a song
            // from an automation playlist. Consumers of this field
            // should treat true as "show LIVE instead of track title".
            'is_live_broadcast'  => false,
            // Set to true when the stream server reports an active
            // source connection. Distinct from is_live_broadcast: a
            // stream can be online (source connected) and playing
            // automation, or online and hosting a live DJ, or offline
            // entirely. Populated by handlers that expose this field.
            'stream_online'      => true,
            // Listener count when the server reports it. Null means no
            // data was provided; zero means the server explicitly said
            // zero listeners. Frontend hides the display when null and
            // shows the number otherwise.
            'listener_count'     => null,
            'artwork_url'        => isset( $station['logo_url'] ) ? $station['logo_url'] : '',
            'artwork_source'     => 'station',
            'station_name'       => isset( $station['name'] ) ? $station['name'] : '',
            'stream_type'        => isset( $station['stream_type'] ) ? $station['stream_type'] : 'direct',
            'source_url'         => isset( $station['stream_url'] ) ? $station['stream_url'] : '',
            'content_type'       => '',
            'timestamp'          => time(),
            'source'             => 'unknown',
        );

        // Merge, letting handler-provided values win over defaults.
        $result = wp_parse_args( $raw, $defaults );

        // If handler did not supply station_name but ICY headers did,
        // prefer the station config name for display consistency.
        if ( empty( $result['station_name'] ) && ! empty( $station['name'] ) ) {
            $result['station_name'] = $station['name'];
        }

        // Some Shoutcast v2 hosting panels HTML-encode SONGTITLE before
        // wrapping it in XML, which means simplexml only unwinds one
        // level of encoding and leaves entities like &apos; and &amp;
        // sitting in the output as literal text. Icecast setups running
        // behind panels sometimes do the same in status-json.xsl. We
        // decode any residual entities on the way out so text-only
        // fields display as their real characters in the frontend
        // (which uses textContent and does not decode entities on its
        // side). Numeric fields skip this pass because they never carry
        // entities and running decode on them is wasteful.
        $text_fields = array( 'title', 'artist', 'genre', 'station_name' );
        foreach ( $text_fields as $field ) {
            if ( isset( $result[ $field ] ) && is_string( $result[ $field ] ) && '' !== $result[ $field ] ) {
                $result[ $field ] = html_entity_decode(
                    $result[ $field ],
                    ENT_QUOTES | ENT_HTML5,
                    'UTF-8'
                );
            }
        }

        // Live broadcast detection runs on every read (both fresh and
        // cached paths) so it stays responsive to admin toggles and
        // stale-title timing without waiting for cache expiry. Full
        // detection logic lives in apply_live_detection below and
        // covers four patterns: empty title, station-name-only title,
        // keyword-matched title, and stale-metadata detection.
        $result = self::apply_live_detection( $result, $station );

        // If no title came back, fall back to the station name so the
        // player never renders an empty display. This runs AFTER the
        // live detection so an empty raw title still gets flagged as
        // a live source before we mask it.
        if ( '' === trim( $result['title'] ) ) {
            $result['title'] = ! empty( $station['name'] ) ? $station['name'] : 'Live Stream';
        }

        return $result;
    }

    /**
     * Run live-broadcast detection on a metadata payload against the
     * current station settings. Separated from normalize so it can
     * also run on cached metadata: without that, toggling detect_live
     * or reaching the stale-title threshold would not take effect
     * until cache expired, which defeats the whole point of the
     * feature.
     *
     * Only runs when the station admin opted in with the detect_live
     * toggle. Covers four patterns real stations run into:
     *
     *   1. Encoder has no metadata bridge (BUTT, physical console
     *      through a hardware encoder, and similar). The stream title
     *      comes back empty because nothing is telling the server
     *      what is playing.
     *
     *   2. Encoder sends only the station name as the title. Same
     *      root cause as pattern 1, just a different default.
     *
     *   3. DJ types a live indicator into their encoder software, or
     *      the automation software sends a specific title when
     *      switching to a live source. We match those against a
     *      per-station keyword list. Real songs almost always contain
     *      " - " between artist and title, so we require the match to
     *      be on a title that does not have that separator to avoid
     *      firing on songs that happen to contain a word like "live"
     *      in their name.
     *
     *   4. Stale metadata. Encoder does not update the title after
     *      the last automation song ended, so the server keeps
     *      reporting the same track long after it stopped playing. We
     *      track the last-seen title in a transient and flag the
     *      source as live when the same title has been on air longer
     *      than a typical song.
     *
     * @param array $metadata Metadata payload to inspect and mutate.
     * @param array $station  Station config with detect_live settings.
     * @return array Metadata with is_live_broadcast set as needed.
     */
    public static function apply_live_detection( $metadata, $station ) {
        if ( ! is_array( $metadata ) ) {
            return $metadata;
        }

        // Reset the flag before running detection so a cached true
        // from a previous poll does not survive when the current
        // conditions no longer justify it. A station that switches
        // back to automation should drop the LIVE badge on the very
        // next poll, not on cache expiry.
        $metadata['is_live_broadcast'] = false;

        if ( empty( $station['detect_live'] ) ) {
            return $metadata;
        }

        $raw_title      = isset( $metadata['title'] ) ? trim( (string) $metadata['title'] ) : '';
        $station_name   = isset( $station['name'] ) ? (string) $station['name'] : '';

        $raw_title_lc   = function_exists( 'mb_strtolower' )
            ? mb_strtolower( $raw_title, 'UTF-8' )
            : strtolower( $raw_title );
        $station_lc     = function_exists( 'mb_strtolower' )
            ? mb_strtolower( $station_name, 'UTF-8' )
            : strtolower( $station_name );

        // The station-name check has to handle the case where the raw
        // title was already replaced by the fallback in normalize. On
        // a fresh fetch normalize replaces an empty title with the
        // station name before caching. Later runs on the cached data
        // see title === station name and would incorrectly treat that
        // as "encoder set title to station name" rather than "encoder
        // sent nothing." Distinguishing the two matters because a
        // real station-name title is a valid live indicator too, so
        // we accept the match either way and just make sure both
        // paths flow through the same code.
        $matches_empty         = '' === $raw_title;
        $matches_station_name  = '' !== $station_lc && $raw_title_lc === $station_lc;
        $matches_keyword       = false;
        $matches_stale         = false;

        // Keyword pass. Only runs when the title looks like it could
        // be a status message rather than an artist-title pair. The
        // " - " check keeps us from flagging real songs like "Live
        // at the Apollo - Aretha Franklin" as live.
        if ( ! $matches_empty && false === strpos( $raw_title, ' - ' ) ) {
            $keywords_csv = isset( $station['live_keywords'] ) ? (string) $station['live_keywords'] : '';
            $keywords     = array_filter( array_map( 'trim', explode( ',', $keywords_csv ) ) );
            foreach ( $keywords as $keyword ) {
                $keyword_lc = function_exists( 'mb_strtolower' )
                    ? mb_strtolower( $keyword, 'UTF-8' )
                    : strtolower( $keyword );
                if ( '' === $keyword_lc ) {
                    continue;
                }
                if ( false !== strpos( $raw_title_lc, $keyword_lc ) ) {
                    $matches_keyword = true;
                    break;
                }
            }
        }

        // Stale metadata pass. Uses a WordPress transient keyed to a
        // hash of the station stream URL for a stable per-station
        // bucket. Threshold defaults to 8 minutes, longer than a
        // typical song but shorter than a typical live DJ set. Only
        // runs when the title looks like a song (contains " - ") so
        // a literal "LIVE" title never gets incorrectly aged into
        // a stale flag.
        if ( function_exists( 'get_transient' ) && ! $matches_empty && false !== strpos( $raw_title, ' - ' ) ) {
            $stream_url  = isset( $station['stream_url'] ) ? (string) $station['stream_url'] : '';
            $bucket_key  = 'vmplayer_title_seen_' . md5( $stream_url );
            $threshold   = (int) apply_filters( 'vmplayer_stale_title_threshold', 480 );
            $now         = time();
            $cached      = get_transient( $bucket_key );

            if ( is_array( $cached ) && isset( $cached['title'], $cached['seen_at'] ) && $cached['title'] === $raw_title ) {
                if ( ( $now - (int) $cached['seen_at'] ) >= $threshold ) {
                    $matches_stale = true;
                }
            } else {
                set_transient(
                    $bucket_key,
                    array(
                        'title'   => $raw_title,
                        'seen_at' => $now,
                    ),
                    max( 3600, $threshold * 2 )
                );
            }
        }

        if ( $matches_empty || $matches_station_name || $matches_keyword || $matches_stale ) {
            $metadata['is_live_broadcast'] = true;
        }

        return $metadata;
    }

    /**
     * Look through a stream metadata blob for an artwork URL. Icecast
     * and Shoutcast schemas do not define a standard field for this,
     * but source clients like RadioBoss, RadioDJ, StreamerBot, and
     * numerous custom scripts push image URLs under various names.
     * We walk the well-known keys and return the first one that
     * validates as a plausible image URL.
     *
     * The list is ordered by how commonly each name shows up in the
     * wild, so the common cases short-circuit early.
     *
     * @param array $source Raw source or stats data.
     * @return string Valid image URL or empty string.
     */
    public static function extract_stream_artwork( $source ) {
        if ( ! is_array( $source ) ) {
            return '';
        }

        $candidate_keys = array(
            'song_url',
            'song_art',
            'songurl',
            'songart',
            'artwork',
            'artwork_url',
            'artworkurl',
            'image',
            'image_url',
            'imageurl',
            'albumart',
            'album_art',
            'album_art_url',
            'cover',
            'cover_url',
            'coverart',
            'cover_art',
            'now_playing_art',
            'nowplayingart',
            'stream_art',
            'streamart',
            'picture',
            'thumbnail',
        );

        foreach ( $candidate_keys as $key ) {
            if ( empty( $source[ $key ] ) ) {
                continue;
            }
            $value = self::validate_image_url( (string) $source[ $key ] );
            if ( '' !== $value ) {
                return $value;
            }
        }

        return '';
    }

    /**
     * Validate a candidate artwork URL. Rejects data URIs, javascript:
     * URLs, and non-image extensions when an extension is present.
     * Preserves URLs that have query strings or lack an extension,
     * since many CDNs serve images from extensionless paths.
     *
     * @param string $url Candidate URL.
     * @return string Sanitized URL or empty string.
     */
    public static function validate_image_url( $url ) {
        $url = trim( $url );
        if ( '' === $url ) {
            return '';
        }
        // Only http(s). Blocks javascript:, data:, ftp:, etc.
        if ( 0 !== stripos( $url, 'http://' ) && 0 !== stripos( $url, 'https://' ) ) {
            return '';
        }

        $clean = esc_url_raw( $url );
        if ( '' === $clean ) {
            return '';
        }

        // If the URL has an extension in its path, make sure it is
        // one that browsers can render as an image. Extension-less
        // URLs pass through because CDNs often use path-only IDs.
        $parsed = wp_parse_url( $clean );
        if ( ! empty( $parsed['path'] ) ) {
            $path = $parsed['path'];
            $ext  = strtolower( pathinfo( $path, PATHINFO_EXTENSION ) );
            if ( '' !== $ext ) {
                $ok = array( 'jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'bmp' );
                if ( ! in_array( $ext, $ok, true ) ) {
                    return '';
                }
            }
        }

        return $clean;
    }
}
