<?php
/**
 * Settings and data access layer for VMPlayer.
 *
 * Single source of truth for reading and writing plugin settings and
 * station presets. All other classes (admin, frontend, API) go through
 * this class for any database access.
 *
 * @package VMPlayer
 */

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

/**
 * Class VMPlayer_Settings
 *
 * Static utility class. Handles get, set, sanitize, and validate
 * operations on global settings and station presets.
 */
class VMPlayer_Settings {

    /**
     * WordPress option key for global settings.
     */
    const OPTION_SETTINGS = 'vmplayer_settings';

    /**
     * WordPress option key for station presets array.
     */
    const OPTION_STATIONS = 'vmplayer_stations';

    /**
     * Hard cap on station presets in the free version.
     */
    const FREE_STATION_LIMIT = 5;

    /**
     * Minimum allowed metadata refresh interval in seconds.
     */
    const MIN_REFRESH_INTERVAL = 5;

    /**
     * Maximum allowed metadata refresh interval in seconds.
     */
    const MAX_REFRESH_INTERVAL = 300;

    /* ---------------------------------------------------------------------
     * Default values
     * ------------------------------------------------------------------ */

    /**
     * Return the default global settings array.
     *
     * @return array
     */
    public static function get_default_settings() {
        return array(
            'version'                   => defined( 'VMPLAYER_VERSION' ) ? VMPLAYER_VERSION : '1.0.0',
            'default_station'           => 0,
            'max_stations'              => self::FREE_STATION_LIMIT,
            'theme_color_primary'       => '#a855f7',
            'theme_color_secondary'     => '#3b82f6',
            'enable_visualizer'         => true,
            'enable_popout'             => true,
            'auto_resume'               => true,
            'metadata_refresh_interval' => 15,
            'itunes_enabled'            => true,
            'itunes_country'            => 'us',
            'deezer_enabled'            => true,
            'musicbrainz_enabled'       => true,
            // Reserved for the future paid version. When true, the
            // sponsor banner on the About tab hides. Not exposed in
            // the UI; will be toggled by a license check later.
            'is_pro'                    => false,
        );
    }

    /**
     * Return the default station template.
     * Used to fill in missing keys when loading stations.
     *
     * @return array
     */
    public static function get_default_station_template() {
        return array(
            'id'                     => '',
            'name'                   => '',
            'tagline'                => '',
            'description'            => '',
            'logo_url'               => '',
            'stream_url'             => '',
            'stream_type'            => 'shoutcast',
            'mounts'                 => array(),
            'genre'                  => '',
            'social'                 => array(),
            // Toggle to detect live broadcasts. When enabled and the
            // stream title matches a live keyword, the player shows
            // "LIVE" instead of the leftover song title from before
            // the live source took over.
            'detect_live'            => false,
            // Comma-separated words that indicate a live source. Only
            // consulted when detect_live is true. Defaults are picked
            // to cover the most common phrases without misfiring on
            // real songs that happen to contain the word "live".
            'live_keywords'          => 'live, on air, live show, live mix, live dj, live stream',
            // Per-station toggle to display listener count when the
            // server reports one. Off by default because not every
            // station operator wants that number visible.
            'show_listener_count'    => false,
            // Per-station toggle for the clock and date display in
            // the main player and popout headers. Default true so
            // existing stations upgrading from 1.1.3 keep the clock
            // visible; admins who want a cleaner header can flip it
            // off per station.
            'show_clock'             => true,
        );
    }

    /**
     * Return the default mount template.
     *
     * @return array
     */
    public static function get_default_mount_template() {
        return array(
            'label'   => '',
            'url'     => '',
            'bitrate' => 0,
            'format'  => 'mp3',
        );
    }

    /**
     * Allowed stream types.
     *
     * @return array
     */
    public static function get_supported_stream_types() {
        return array( 'shoutcast', 'icecast', 'hls', 'direct' );
    }

    /**
     * Allowed audio formats for mounts.
     *
     * @return array
     */
    public static function get_supported_audio_formats() {
        return array( 'mp3', 'aac', 'ogg', 'opus' );
    }

    /**
     * Allowed social platforms.
     *
     * Old plugin versions used 'bluesky' and 'twitter' as keys. Those
     * are quietly migrated to 'bsky' and 'x' during sanitize_social()
     * so previously entered handles keep working after upgrade.
     *
     * @return array
     */
    public static function get_supported_social_platforms() {
        return array( 'facebook', 'bsky', 'instagram', 'youtube', 'whatsapp', 'x' );
    }

    /**
     * Per-platform configuration for admin UI and URL building.
     * Central definition so we only have to update one place when
     * a platform changes its URL scheme or public name.
     *
     * @return array Keyed by platform slug. Each entry contains:
     *               label       (string) Human name shown to admin.
     *               base_url    (string) URL prefix appended to the handle.
     *               placeholder (string) Example handle for the admin field.
     *               hint        (string) Short helper text under the field.
     */
    public static function get_social_platform_config() {
        return array(
            'facebook' => array(
                'label'       => 'Facebook',
                'base_url'    => 'https://facebook.com/',
                'placeholder' => 'yourpagename',
                'hint'        => 'Your page name, not the full URL.',
            ),
            'bsky' => array(
                'label'       => 'Bsky',
                'base_url'    => 'https://bsky.app/profile/',
                'placeholder' => 'yourname.bsky.social',
                'hint'        => 'Your Bsky handle or custom domain.',
            ),
            'instagram' => array(
                'label'       => 'Instagram',
                'base_url'    => 'https://instagram.com/',
                'placeholder' => 'yourusername',
                'hint'        => 'Your Instagram username.',
            ),
            'youtube' => array(
                'label'       => 'YouTube',
                'base_url'    => 'https://youtube.com/',
                'placeholder' => '@yourchannel',
                'hint'        => 'Your channel handle, e.g., @yourchannel.',
            ),
            'whatsapp' => array(
                'label'       => 'WhatsApp',
                'base_url'    => 'https://wa.me/',
                'placeholder' => '+15551234567',
                'hint'        => 'Phone number with country code. Digits only get saved.',
            ),
            'x' => array(
                'label'       => 'X (formerly Twitter)',
                'base_url'    => 'https://x.com/',
                'placeholder' => 'yourhandle',
                'hint'        => 'Your X handle without the @.',
            ),
        );
    }

    /**
     * Build the full profile URL for a given platform and handle.
     * Handles are stored bare so we can adapt if a platform changes
     * its URL scheme later.
     *
     * @param string $platform Platform slug.
     * @param string $handle   Stored handle.
     * @return string Full URL, or empty string if inputs are missing.
     */
    public static function build_social_url( $platform, $handle ) {
        $handle = trim( (string) $handle );
        if ( '' === $handle ) {
            return '';
        }

        $config = self::get_social_platform_config();
        if ( ! isset( $config[ $platform ] ) ) {
            return '';
        }

        return $config[ $platform ]['base_url'] . $handle;
    }

    /* ---------------------------------------------------------------------
     * Global settings: read
     * ------------------------------------------------------------------ */

    /**
     * Get all global settings, merged with defaults to fill missing keys.
     *
     * @return array
     */
    public static function get_settings() {
        $saved = get_option( self::OPTION_SETTINGS, array() );
        if ( ! is_array( $saved ) ) {
            $saved = array();
        }

        return wp_parse_args( $saved, self::get_default_settings() );
    }

    /**
     * Get a single setting value.
     *
     * @param string $key     Setting key.
     * @param mixed  $default Optional. Fallback if key is not set.
     * @return mixed
     */
    public static function get_setting( $key, $default = null ) {
        $settings = self::get_settings();
        if ( array_key_exists( $key, $settings ) ) {
            return $settings[ $key ];
        }
        return $default;
    }

    /* ---------------------------------------------------------------------
     * Global settings: write
     * ------------------------------------------------------------------ */

    /**
     * Sanitize and save global settings.
     * Merges incoming partial input with existing values so callers
     * can update one key at a time.
     *
     * @param array $input Raw settings input.
     * @return bool True on success.
     */
    public static function update_settings( $input ) {
        if ( ! is_array( $input ) ) {
            return false;
        }

        $current = self::get_settings();
        $merged  = wp_parse_args( $input, $current );
        $clean   = self::sanitize_settings( $merged );

        return update_option( self::OPTION_SETTINGS, $clean );
    }

    /**
     * Reset all global settings to defaults.
     *
     * @return bool
     */
    public static function reset_settings() {
        return update_option( self::OPTION_SETTINGS, self::get_default_settings() );
    }

    /* ---------------------------------------------------------------------
     * Stations: read
     * ------------------------------------------------------------------ */

    /**
     * Get all station presets, each merged with the station template
     * to fill in missing keys.
     *
     * @return array Array of station arrays.
     */
    public static function get_stations() {
        $saved = get_option( self::OPTION_STATIONS, array() );
        if ( ! is_array( $saved ) ) {
            return array();
        }

        $template = self::get_default_station_template();
        $result   = array();

        foreach ( $saved as $station ) {
            if ( ! is_array( $station ) ) {
                continue;
            }
            $station = wp_parse_args( $station, $template );

            // Defensive boolean coercion at read time. Older plugin
            // versions could save these fields as the literal string
            // "false" or "true" because FormData stringifies JavaScript
            // booleans on the wire and the pre-1.1.3 sanitizer used
            // ! empty() which mishandles those strings. Any station
            // that has stale string values from that era gets
            // normalized here on every read, so the template and JS
            // both see the real bool the admin intended even when the
            // row has not been re-saved.
            if ( array_key_exists( 'detect_live', $station ) ) {
                $station['detect_live'] = self::sanitize_bool( $station['detect_live'] );
            }
            if ( array_key_exists( 'show_listener_count', $station ) ) {
                $station['show_listener_count'] = self::sanitize_bool( $station['show_listener_count'] );
            }
            if ( array_key_exists( 'show_clock', $station ) ) {
                $station['show_clock'] = self::sanitize_bool( $station['show_clock'] );
            }

            // Migrate legacy social keys on read so old data flows into
            // the new-named fields (bluesky -> bsky, twitter -> x). This
            // keeps admins from seeing empty fields after the plugin
            // renames a platform, and it works even when the row has not
            // been re-saved yet.
            //
            // Also run each value through sanitize_social_handle, because
            // older plugin versions stored full URLs in the social fields.
            // If we handed those raw values to build_social_url, we would
            // concatenate the base URL to another URL and produce broken
            // links like https://bsky.app/profile/https://bsky.app/handle.
            // Normalizing here means the rest of the code never has to
            // worry about which storage generation a station came from.
            if ( isset( $station['social'] ) && is_array( $station['social'] ) ) {
                if ( isset( $station['social']['bluesky'] ) && ! isset( $station['social']['bsky'] ) ) {
                    $station['social']['bsky'] = $station['social']['bluesky'];
                }
                if ( isset( $station['social']['twitter'] ) && ! isset( $station['social']['x'] ) ) {
                    $station['social']['x'] = $station['social']['twitter'];
                }
                unset( $station['social']['bluesky'], $station['social']['twitter'] );

                $normalized = array();
                foreach ( self::get_supported_social_platforms() as $platform ) {
                    if ( empty( $station['social'][ $platform ] ) ) {
                        continue;
                    }
                    $handle = self::sanitize_social_handle( $platform, $station['social'][ $platform ] );
                    if ( '' !== $handle ) {
                        $normalized[ $platform ] = $handle;
                    }
                }
                $station['social'] = $normalized;
            }

            $result[] = $station;
        }

        return $result;
    }

    /**
     * Get a single station by index.
     *
     * @param int $index Zero-based station index.
     * @return array|null Station array or null if index is out of range.
     */
    public static function get_station( $index ) {
        $stations = self::get_stations();
        $index    = absint( $index );

        if ( isset( $stations[ $index ] ) ) {
            return $stations[ $index ];
        }
        return null;
    }

    /**
     * Find a station by its ID slug.
     *
     * @param string $id Station ID slug.
     * @return array|null
     */
    public static function get_station_by_id( $id ) {
        $id       = sanitize_title( $id );
        $stations = self::get_stations();

        foreach ( $stations as $station ) {
            if ( isset( $station['id'] ) && $station['id'] === $id ) {
                return $station;
            }
        }
        return null;
    }

    /**
     * Get the station configured as the default.
     * Falls back to the first station if the configured default
     * is out of range.
     *
     * @return array|null
     */
    public static function get_default_station() {
        $stations = self::get_stations();
        if ( empty( $stations ) ) {
            return null;
        }

        $default_index = absint( self::get_setting( 'default_station', 0 ) );
        if ( isset( $stations[ $default_index ] ) ) {
            return $stations[ $default_index ];
        }

        return $stations[0];
    }

    /**
     * Count configured stations.
     *
     * @return int
     */
    public static function count_stations() {
        return count( self::get_stations() );
    }

    /**
     * Whether the current station count is below the hard cap.
     *
     * @return bool
     */
    public static function can_add_station() {
        return self::count_stations() < self::get_station_limit();
    }

    /**
     * Get the active station limit.
     * Currently always the free cap. A premium build will override this.
     *
     * @return int
     */
    public static function get_station_limit() {
        /**
         * Filter the station preset limit.
         *
         * @param int $limit Default station limit.
         */
        return (int) apply_filters( 'vmplayer_station_limit', self::FREE_STATION_LIMIT );
    }

    /* ---------------------------------------------------------------------
     * Stations: write
     * ------------------------------------------------------------------ */

    /**
     * Add a new station to the end of the list.
     *
     * @param array $station Raw station input.
     * @return int|WP_Error New station index on success, WP_Error on failure.
     */
    public static function add_station( $station ) {
        if ( ! self::can_add_station() ) {
            return new WP_Error(
                'vmplayer_station_limit_reached',
                sprintf(
                    /* translators: %d: station limit */
                    esc_html__( 'Station preset limit of %d reached. Upgrade to add more.', 'vmplayer' ),
                    self::get_station_limit()
                )
            );
        }

        $clean = self::sanitize_station( $station );

        $valid = self::validate_station( $clean );
        if ( is_wp_error( $valid ) ) {
            return $valid;
        }

        // Auto-generate ID if missing or collide-check existing ID.
        if ( empty( $clean['id'] ) ) {
            $clean['id'] = self::generate_station_id( $clean['name'] );
        } else {
            $clean['id'] = self::ensure_unique_id( $clean['id'] );
        }

        $stations   = self::get_stations();
        $stations[] = $clean;
        update_option( self::OPTION_STATIONS, $stations );

        return count( $stations ) - 1;
    }

    /**
     * Update a station at a specific index.
     *
     * @param int   $index   Station index.
     * @param array $station Raw station input.
     * @return bool|WP_Error True on success, WP_Error on failure.
     */
    public static function update_station( $index, $station ) {
        $stations = self::get_stations();
        $index    = absint( $index );

        if ( ! isset( $stations[ $index ] ) ) {
            return new WP_Error(
                'vmplayer_station_not_found',
                esc_html__( 'Station not found at that index.', 'vmplayer' )
            );
        }

        $clean = self::sanitize_station( $station );

        $valid = self::validate_station( $clean );
        if ( is_wp_error( $valid ) ) {
            return $valid;
        }

        // Preserve existing ID unless explicitly changed.
        if ( empty( $clean['id'] ) ) {
            $clean['id'] = $stations[ $index ]['id'];
        }

        $stations[ $index ] = $clean;
        return update_option( self::OPTION_STATIONS, $stations );
    }

    /**
     * Delete a station by index.
     * Re-indexes the array so subsequent stations shift up.
     *
     * @param int $index Station index.
     * @return bool|WP_Error
     */
    public static function delete_station( $index ) {
        $stations = self::get_stations();
        $index    = absint( $index );

        if ( ! isset( $stations[ $index ] ) ) {
            return new WP_Error(
                'vmplayer_station_not_found',
                esc_html__( 'Station not found at that index.', 'vmplayer' )
            );
        }

        array_splice( $stations, $index, 1 );

        // Repair default_station if it referenced the deleted index.
        $default = absint( self::get_setting( 'default_station', 0 ) );
        if ( $default === $index ) {
            self::update_settings( array( 'default_station' => 0 ) );
        } elseif ( $default > $index ) {
            self::update_settings( array( 'default_station' => $default - 1 ) );
        }

        return update_option( self::OPTION_STATIONS, $stations );
    }

    /**
     * Reorder stations.
     *
     * @param array $new_order Array of old indexes in their new positions.
     *                         Example: array(2,0,1) moves station 2 to position 0.
     * @return bool|WP_Error
     */
    public static function reorder_stations( $new_order ) {
        if ( ! is_array( $new_order ) ) {
            return new WP_Error(
                'vmplayer_invalid_order',
                esc_html__( 'Order data must be an array.', 'vmplayer' )
            );
        }

        $stations = self::get_stations();

        if ( count( $new_order ) !== count( $stations ) ) {
            return new WP_Error(
                'vmplayer_invalid_order',
                esc_html__( 'Order array length does not match station count.', 'vmplayer' )
            );
        }

        $reordered = array();
        foreach ( $new_order as $old_index ) {
            $old_index = absint( $old_index );
            if ( ! isset( $stations[ $old_index ] ) ) {
                return new WP_Error(
                    'vmplayer_invalid_order',
                    esc_html__( 'Order array contains an invalid index.', 'vmplayer' )
                );
            }
            $reordered[] = $stations[ $old_index ];
        }

        return update_option( self::OPTION_STATIONS, $reordered );
    }

    /* ---------------------------------------------------------------------
     * Sanitization
     * ------------------------------------------------------------------ */

    /**
     * Sanitize a global settings array.
     *
     * @param array $input Raw input.
     * @return array Sanitized settings.
     */
    public static function sanitize_settings( $input ) {
        $defaults = self::get_default_settings();
        $clean    = array();

        $clean['version']               = isset( $input['version'] ) ? sanitize_text_field( $input['version'] ) : $defaults['version'];
        $clean['default_station']       = isset( $input['default_station'] ) ? absint( $input['default_station'] ) : $defaults['default_station'];
        $clean['max_stations']          = isset( $input['max_stations'] ) ? absint( $input['max_stations'] ) : $defaults['max_stations'];
        $clean['theme_color_primary']   = isset( $input['theme_color_primary'] ) ? self::sanitize_hex_color( $input['theme_color_primary'], $defaults['theme_color_primary'] ) : $defaults['theme_color_primary'];
        $clean['theme_color_secondary'] = isset( $input['theme_color_secondary'] ) ? self::sanitize_hex_color( $input['theme_color_secondary'], $defaults['theme_color_secondary'] ) : $defaults['theme_color_secondary'];
        $clean['enable_visualizer']     = isset( $input['enable_visualizer'] ) ? self::sanitize_bool( $input['enable_visualizer'] ) : $defaults['enable_visualizer'];
        $clean['enable_popout']         = isset( $input['enable_popout'] ) ? self::sanitize_bool( $input['enable_popout'] ) : $defaults['enable_popout'];
        $clean['auto_resume']           = isset( $input['auto_resume'] ) ? self::sanitize_bool( $input['auto_resume'] ) : $defaults['auto_resume'];
        $clean['itunes_enabled']        = isset( $input['itunes_enabled'] ) ? self::sanitize_bool( $input['itunes_enabled'] ) : $defaults['itunes_enabled'];
        $clean['itunes_country']        = isset( $input['itunes_country'] ) ? self::sanitize_country_code( $input['itunes_country'] ) : $defaults['itunes_country'];
        $clean['deezer_enabled']        = isset( $input['deezer_enabled'] ) ? self::sanitize_bool( $input['deezer_enabled'] ) : $defaults['deezer_enabled'];
        $clean['musicbrainz_enabled']   = isset( $input['musicbrainz_enabled'] ) ? self::sanitize_bool( $input['musicbrainz_enabled'] ) : $defaults['musicbrainz_enabled'];

        // Clamp metadata refresh interval to allowed range.
        $interval = isset( $input['metadata_refresh_interval'] )
            ? absint( $input['metadata_refresh_interval'] )
            : $defaults['metadata_refresh_interval'];

        if ( $interval < self::MIN_REFRESH_INTERVAL ) {
            $interval = self::MIN_REFRESH_INTERVAL;
        }
        if ( $interval > self::MAX_REFRESH_INTERVAL ) {
            $interval = self::MAX_REFRESH_INTERVAL;
        }
        $clean['metadata_refresh_interval'] = $interval;

        // Cap max_stations at the hard limit.
        if ( $clean['max_stations'] > self::get_station_limit() ) {
            $clean['max_stations'] = self::get_station_limit();
        }

        return $clean;
    }

    /**
     * Sanitize a station preset array.
     *
     * @param array $input Raw station input.
     * @return array
     */
    public static function sanitize_station( $input ) {
        if ( ! is_array( $input ) ) {
            $input = array();
        }

        $template = self::get_default_station_template();
        $clean    = array();

        $clean['id']          = isset( $input['id'] ) ? sanitize_title( $input['id'] ) : '';
        $clean['name']        = isset( $input['name'] ) ? sanitize_text_field( $input['name'] ) : '';
        $clean['tagline']     = isset( $input['tagline'] ) ? sanitize_text_field( $input['tagline'] ) : '';
        $clean['description'] = isset( $input['description'] ) ? wp_kses_post( $input['description'] ) : '';
        $clean['logo_url']    = isset( $input['logo_url'] ) ? esc_url_raw( $input['logo_url'] ) : '';
        $clean['stream_url']  = isset( $input['stream_url'] ) ? esc_url_raw( $input['stream_url'] ) : '';
        $clean['genre']       = isset( $input['genre'] ) ? sanitize_text_field( $input['genre'] ) : '';

        // Stream type with fallback to template default.
        $stream_type = isset( $input['stream_type'] ) ? sanitize_key( $input['stream_type'] ) : $template['stream_type'];
        if ( ! in_array( $stream_type, self::get_supported_stream_types(), true ) ) {
            $stream_type = $template['stream_type'];
        }
        $clean['stream_type'] = $stream_type;

        // Sanitize mounts array.
        $clean['mounts'] = array();
        if ( isset( $input['mounts'] ) && is_array( $input['mounts'] ) ) {
            foreach ( $input['mounts'] as $mount ) {
                $clean_mount = self::sanitize_mount( $mount );
                if ( ! empty( $clean_mount['url'] ) ) {
                    $clean['mounts'][] = $clean_mount;
                }
            }
        }

        // Sanitize social URLs.
        $clean['social'] = self::sanitize_social( isset( $input['social'] ) ? $input['social'] : array() );

        // The boolean toggles below arrive through FormData, which
        // coerces JavaScript booleans to strings on the wire: true
        // becomes the string "true", false becomes the string "false".
        // PHP's ! empty() treats any non-empty string as truthy, so a
        // "false" string would silently save as true. self::sanitize_bool
        // handles both real booleans and the "false" string case
        // correctly, so we route every checkbox value through it.
        //
        // Missing key means the field was never sent (a legacy save
        // or a partial API call), in which case we fall back to false
        // so behavior stays predictable across upgrades.
        if ( array_key_exists( 'detect_live', $input ) ) {
            $clean['detect_live'] = self::sanitize_bool( $input['detect_live'] );
        } else {
            $clean['detect_live'] = false;
        }

        // Live keywords list. Trim, drop empty entries, cap length at
        // a sensible ceiling so a runaway paste does not blow up the
        // option row. Keep as a raw comma-separated string for the
        // admin field; the metadata layer splits it at read time.
        $keywords_raw = isset( $input['live_keywords'] ) ? (string) $input['live_keywords'] : '';
        $keywords_raw = sanitize_text_field( $keywords_raw );
        if ( strlen( $keywords_raw ) > 500 ) {
            $keywords_raw = substr( $keywords_raw, 0, 500 );
        }
        if ( '' === trim( $keywords_raw ) ) {
            $keywords_raw = $template['live_keywords'];
        }
        $clean['live_keywords'] = $keywords_raw;

        // Listener count display toggle. Same FormData caveat applies.
        if ( array_key_exists( 'show_listener_count', $input ) ) {
            $clean['show_listener_count'] = self::sanitize_bool( $input['show_listener_count'] );
        } else {
            $clean['show_listener_count'] = false;
        }

        // Clock display toggle. Missing field defaults to the template
        // default (true) so a legacy save that never sent the field
        // upgrades with the clock still on. Explicit false through
        // sanitize_bool honors an unchecked checkbox even when
        // FormData converts JS false into the string "false".
        if ( array_key_exists( 'show_clock', $input ) ) {
            $clean['show_clock'] = self::sanitize_bool( $input['show_clock'] );
        } else {
            $clean['show_clock'] = ! empty( $template['show_clock'] );
        }

        return $clean;
    }

    /**
     * Sanitize a single mount entry.
     *
     * @param array $input Raw mount input.
     * @return array
     */
    public static function sanitize_mount( $input ) {
        if ( ! is_array( $input ) ) {
            $input = array();
        }

        $clean = array();
        $clean['label']   = isset( $input['label'] ) ? sanitize_text_field( $input['label'] ) : '';
        $clean['url']     = isset( $input['url'] ) ? esc_url_raw( $input['url'] ) : '';
        $clean['bitrate'] = isset( $input['bitrate'] ) ? absint( $input['bitrate'] ) : 0;

        $format = isset( $input['format'] ) ? sanitize_key( $input['format'] ) : 'mp3';
        if ( ! in_array( $format, self::get_supported_audio_formats(), true ) ) {
            $format = 'mp3';
        }
        $clean['format'] = $format;

        return $clean;
    }

    /**
     * Sanitize a social handles array. Stores just the handle for each
     * platform, not the full URL. On the frontend, build_social_url()
     * composes the link from the base URL and the handle.
     *
     * Legacy migration: old data keyed as 'bluesky' or 'twitter' is
     * quietly moved to 'bsky' and 'x'. If both old and new keys are
     * present, the new one wins.
     *
     * @param array $input Raw social input keyed by platform.
     * @return array
     */
    public static function sanitize_social( $input ) {
        if ( ! is_array( $input ) ) {
            return array();
        }

        // Legacy key migration. Runs before validation so old data
        // flows through the same sanitize path as fresh input.
        if ( isset( $input['bluesky'] ) && ! isset( $input['bsky'] ) ) {
            $input['bsky'] = $input['bluesky'];
        }
        if ( isset( $input['twitter'] ) && ! isset( $input['x'] ) ) {
            $input['x'] = $input['twitter'];
        }
        unset( $input['bluesky'], $input['twitter'] );

        $allowed = self::get_supported_social_platforms();
        $clean   = array();

        foreach ( $allowed as $platform ) {
            if ( isset( $input[ $platform ] ) ) {
                $handle = self::sanitize_social_handle( $platform, $input[ $platform ] );
                if ( '' !== $handle ) {
                    $clean[ $platform ] = $handle;
                }
            }
        }

        return $clean;
    }

    /**
     * Normalize a single social handle. Accepts a raw handle, a handle
     * with a leading @, or a full URL (in which case the handle is
     * pulled from the URL path). Each platform has its own rules:
     * WhatsApp gets stripped to digits, YouTube keeps its leading @,
     * everything else drops the @.
     *
     * @param string $platform Platform slug.
     * @param string $value    Raw input from admin form.
     * @return string Canonical handle, or empty string if unusable.
     */
    public static function sanitize_social_handle( $platform, $value ) {
        $value = trim( sanitize_text_field( (string) $value ) );
        if ( '' === $value ) {
            return '';
        }

        // If they pasted a full URL, keep only the path portion.
        if ( 0 === stripos( $value, 'http://' ) || 0 === stripos( $value, 'https://' ) ) {
            $parsed = wp_parse_url( $value );
            if ( ! empty( $parsed['path'] ) ) {
                $value = trim( $parsed['path'], '/' );
                // Bsky profile URLs look like /profile/handle.
                if ( 'bsky' === $platform && 0 === stripos( $value, 'profile/' ) ) {
                    $value = substr( $value, 8 );
                }
            } else {
                return '';
            }
        }

        switch ( $platform ) {
            case 'whatsapp':
                // Phone number: digits only. Country code stays, plus sign drops.
                return preg_replace( '/\D/', '', $value );

            case 'youtube':
                // YouTube channel handles include the leading @. Add one if
                // the value looks like a handle (no slashes, no @ yet).
                if ( '' !== $value
                    && 0 !== strpos( $value, '@' )
                    && false === strpos( $value, '/' )
                ) {
                    $value = '@' . $value;
                }
                return $value;

            case 'facebook':
            case 'instagram':
            case 'bsky':
            case 'x':
                // Strip a leading @ if present.
                if ( 0 === strpos( $value, '@' ) ) {
                    $value = substr( $value, 1 );
                }
                // Strip any residual trailing slash.
                return rtrim( $value, '/' );
        }

        return $value;
    }

    /**
     * Sanitize a hex color value.
     *
     * @param string $color    Raw color value.
     * @param string $fallback Value to return on invalid input.
     * @return string
     */
    public static function sanitize_hex_color( $color, $fallback = '' ) {
        $color = trim( (string) $color );
        if ( '' === $color ) {
            return $fallback;
        }

        // Accept #RGB or #RRGGBB.
        if ( preg_match( '/^#([A-Fa-f0-9]{3}){1,2}$/', $color ) ) {
            return $color;
        }

        return $fallback;
    }

    /**
     * Sanitize a boolean-ish value.
     *
     * @param mixed $value Raw input.
     * @return bool
     */
    public static function sanitize_bool( $value ) {
        if ( is_bool( $value ) ) {
            return $value;
        }
        if ( is_string( $value ) ) {
            $lower = strtolower( trim( $value ) );
            if ( in_array( $lower, array( 'true', '1', 'on', 'yes' ), true ) ) {
                return true;
            }
            if ( in_array( $lower, array( 'false', '0', 'off', 'no', '' ), true ) ) {
                return false;
            }
        }
        return (bool) $value;
    }

    /**
     * Validate and normalize an iTunes country code. Accepts any two
     * character ISO code that iTunes supports, lowercased. Falls back
     * to 'us' if the input is unrecognized so the API call never fails
     * with a garbage country parameter.
     *
     * @param string $value Raw country code.
     * @return string Two-letter lowercase code, always valid.
     */
    public static function sanitize_country_code( $value ) {
        $value = strtolower( trim( (string) $value ) );
        if ( '' === $value ) {
            return 'us';
        }
        $supported = self::get_supported_itunes_countries();
        return isset( $supported[ $value ] ) ? $value : 'us';
    }

    /**
     * iTunes Store countries the Search API accepts. Keyed by ISO code
     * with a human-readable label so admin UIs can render a dropdown
     * without a separate labels list. This is the set of storefronts
     * Apple documents publicly; adding more here is safe as long as
     * the code matches a real iTunes storefront.
     *
     * @return array
     */
    public static function get_supported_itunes_countries() {
        return array(
            'us' => 'United States',
            'ca' => 'Canada',
            'mx' => 'Mexico',
            'br' => 'Brazil',
            'ar' => 'Argentina',
            'cl' => 'Chile',
            'co' => 'Colombia',
            'gb' => 'United Kingdom',
            'ie' => 'Ireland',
            'fr' => 'France',
            'de' => 'Germany',
            'it' => 'Italy',
            'es' => 'Spain',
            'pt' => 'Portugal',
            'nl' => 'Netherlands',
            'be' => 'Belgium',
            'ch' => 'Switzerland',
            'at' => 'Austria',
            'se' => 'Sweden',
            'no' => 'Norway',
            'dk' => 'Denmark',
            'fi' => 'Finland',
            'pl' => 'Poland',
            'ru' => 'Russia',
            'tr' => 'Turkey',
            'gr' => 'Greece',
            'au' => 'Australia',
            'nz' => 'New Zealand',
            'jp' => 'Japan',
            'kr' => 'South Korea',
            'cn' => 'China',
            'hk' => 'Hong Kong',
            'tw' => 'Taiwan',
            'sg' => 'Singapore',
            'my' => 'Malaysia',
            'th' => 'Thailand',
            'ph' => 'Philippines',
            'id' => 'Indonesia',
            'vn' => 'Vietnam',
            'in' => 'India',
            'ae' => 'United Arab Emirates',
            'sa' => 'Saudi Arabia',
            'il' => 'Israel',
            'za' => 'South Africa',
            'eg' => 'Egypt',
            'ng' => 'Nigeria',
            'ke' => 'Kenya',
            'jm' => 'Jamaica',
            'tt' => 'Trinidad and Tobago',
            'bb' => 'Barbados',
            'bs' => 'Bahamas',
            'do' => 'Dominican Republic',
        );
    }

    /* ---------------------------------------------------------------------
     * Validation
     * ------------------------------------------------------------------ */

    /**
     * Validate a sanitized station array. Returns true if usable,
     * WP_Error otherwise.
     *
     * @param array $station Already-sanitized station.
     * @return true|WP_Error
     */
    public static function validate_station( $station ) {
        if ( empty( $station['name'] ) ) {
            return new WP_Error(
                'vmplayer_missing_name',
                esc_html__( 'Station name is required.', 'vmplayer' )
            );
        }

        if ( empty( $station['stream_url'] ) ) {
            return new WP_Error(
                'vmplayer_missing_stream_url',
                esc_html__( 'Stream URL is required.', 'vmplayer' )
            );
        }

        if ( ! self::validate_stream_type( $station['stream_type'] ) ) {
            return new WP_Error(
                'vmplayer_invalid_stream_type',
                esc_html__( 'Invalid stream type.', 'vmplayer' )
            );
        }

        return true;
    }

    /**
     * Validate a stream type string.
     *
     * @param string $type Stream type.
     * @return bool
     */
    public static function validate_stream_type( $type ) {
        return in_array( $type, self::get_supported_stream_types(), true );
    }

    /* ---------------------------------------------------------------------
     * Internal helpers
     * ------------------------------------------------------------------ */

    /**
     * Generate a unique station ID from a name.
     *
     * @param string $name Station name.
     * @return string
     */
    public static function generate_station_id( $name ) {
        $base = sanitize_title( $name );
        if ( empty( $base ) ) {
            $base = 'station';
        }
        return self::ensure_unique_id( $base );
    }

    /**
     * Ensure an ID is unique across existing stations. Appends a numeric
     * suffix if needed.
     *
     * @param string $id Proposed ID.
     * @return string
     */
    private static function ensure_unique_id( $id ) {
        $existing = array();
        foreach ( self::get_stations() as $station ) {
            if ( ! empty( $station['id'] ) ) {
                $existing[] = $station['id'];
            }
        }

        if ( ! in_array( $id, $existing, true ) ) {
            return $id;
        }

        $i = 2;
        while ( in_array( $id . '-' . $i, $existing, true ) ) {
            $i++;
        }
        return $id . '-' . $i;
    }
}
