<?php
/**
 * Icecast metadata handler.
 *
 * Fetches Icecast's /status-json.xsl endpoint which returns
 * structured JSON with per-mount info. Handles multi-mount servers
 * by finding the source whose listenurl matches the requested
 * stream URL, then falls back to first-source or ICY headers.
 *
 * @package VMPlayer
 */

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

/**
 * Class VMPlayer_Icecast
 */
class VMPlayer_Icecast {

    /**
     * User-Agent for stats requests.
     */
    const USER_AGENT = 'Mozilla/5.0 (compatible; VMPlayer/' . VMPLAYER_VERSION . ')';

    /**
     * HTTP timeout for stats endpoints in seconds.
     */
    const TIMEOUT = 5;

    /**
     * Fetch Icecast metadata for a stream URL.
     *
     * @param string $stream_url The stream URL.
     * @return array Partial metadata array. Empty on total failure.
     */
    public static function fetch_metadata( $stream_url ) {
        $base = VMPlayer_Metadata::derive_base_url( $stream_url );
        if ( '' === $base ) {
            return VMPlayer_Metadata::fetch_icy_headers( $stream_url );
        }

        $meta = self::fetch_status_json( $base, $stream_url );
        if ( ! empty( $meta['title'] ) || ! empty( $meta['station_name'] ) ) {
            $meta['source'] = 'icecast-json';
            return $meta;
        }

        // Some Icecast installs expose the endpoint at the server
        // root instead of under a subpath, so try the host root
        // as a second attempt for path-routed setups.
        $parsed = wp_parse_url( $stream_url );
        if ( is_array( $parsed ) && ! empty( $parsed['host'] ) ) {
            $scheme    = isset( $parsed['scheme'] ) ? $parsed['scheme'] : 'http';
            $host_root = $scheme . '://' . $parsed['host']
                . ( isset( $parsed['port'] ) ? ':' . (int) $parsed['port'] : '' )
                . '/';

            if ( $host_root !== $base ) {
                $meta = self::fetch_status_json( $host_root, $stream_url );
                if ( ! empty( $meta['title'] ) || ! empty( $meta['station_name'] ) ) {
                    $meta['source'] = 'icecast-json-root';
                    return $meta;
                }
            }
        }

        // Universal fallback: ICY headers.
        $icy = VMPlayer_Metadata::fetch_icy_headers( $stream_url );
        if ( ! empty( $icy ) ) {
            $icy['source'] = 'icy-headers';
        }
        return $icy;
    }

    /* ------------------------------------------------------------------
     * Endpoint fetcher
     * --------------------------------------------------------------- */

    /**
     * Fetch and parse /status-json.xsl from a given base URL.
     *
     * @param string $base_url   Base URL with trailing slash.
     * @param string $stream_url Original stream URL, used to match the mount.
     * @return array Partial metadata or empty array.
     */
    private static function fetch_status_json( $base_url, $stream_url ) {
        $response = wp_remote_get(
            $base_url . 'status-json.xsl',
            array(
                'timeout'     => self::TIMEOUT,
                'redirection' => 3,
                'sslverify'   => false,
                'user-agent'  => self::USER_AGENT,
                'headers'     => array(
                    'Accept' => 'application/json,text/json,*/*;q=0.5',
                ),
            )
        );

        if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
            return array();
        }

        $body = wp_remote_retrieve_body( $response );
        if ( '' === $body ) {
            return array();
        }

        // Icecast sometimes emits invalid JSON when a value contains
        // an unescaped quote or newline. Try a cleanup pass if strict
        // decoding fails.
        $data = json_decode( $body, true );
        if ( null === $data ) {
            $cleaned = self::clean_broken_json( $body );
            $data    = json_decode( $cleaned, true );
        }

        if ( ! is_array( $data ) || ! isset( $data['icestats'] ) ) {
            return array();
        }

        // Sources may be missing (no active mounts), a single object
        // (one mount), or an array of objects (multi-mount).
        if ( ! isset( $data['icestats']['source'] ) ) {
            return array();
        }

        $sources = $data['icestats']['source'];
        if ( isset( $sources['listenurl'] ) || isset( $sources['title'] ) ) {
            $sources = array( $sources );
        }
        if ( ! is_array( $sources ) ) {
            return array();
        }

        $matched = self::find_matching_source( $sources, $stream_url );
        if ( null === $matched ) {
            return array();
        }

        return self::extract_source_metadata( $matched );
    }

    /**
     * Pick the source whose listenurl matches the requested stream URL.
     * Falls back to the first source if none match, so the display
     * still works on single-mount setups where paths might differ.
     *
     * @param array  $sources    Array of source objects.
     * @param string $stream_url The stream URL being played.
     * @return array|null
     */
    private static function find_matching_source( $sources, $stream_url ) {
        $stream_path = '';
        $parsed      = wp_parse_url( $stream_url );
        if ( is_array( $parsed ) && ! empty( $parsed['path'] ) ) {
            $stream_path = $parsed['path'];
        }

        foreach ( $sources as $source ) {
            if ( ! is_array( $source ) ) {
                continue;
            }
            $listenurl = isset( $source['listenurl'] ) ? (string) $source['listenurl'] : '';
            if ( '' === $listenurl ) {
                continue;
            }

            // Exact match on the full URL.
            if ( $listenurl === $stream_url ) {
                return $source;
            }

            // Path suffix match. Reverse proxies often rewrite the
            // host in listenurl to the internal name, but the path
            // stays the same.
            if ( '' !== $stream_path ) {
                $lu_parsed = wp_parse_url( $listenurl );
                if ( is_array( $lu_parsed ) && ! empty( $lu_parsed['path'] ) ) {
                    if ( $lu_parsed['path'] === $stream_path ) {
                        return $source;
                    }
                }
            }
        }

        // No match found. Return the first source as a best guess.
        return isset( $sources[0] ) && is_array( $sources[0] ) ? $sources[0] : null;
    }

    /**
     * Extract normalized fields from an Icecast source object.
     *
     * @param array $source Source object from status-json.xsl.
     * @return array
     */
    private static function extract_source_metadata( $source ) {
        $result = array();

        if ( ! empty( $source['title'] ) ) {
            $song                    = trim( (string) $source['title'] );
            list( $artist, $title )  = VMPlayer_Metadata::parse_artist_title( $song );
            $result['title']         = $title;
            $result['artist']        = $artist;
        }
        if ( ! empty( $source['server_name'] ) ) {
            $result['station_name'] = trim( (string) $source['server_name'] );
        }
        if ( ! empty( $source['genre'] ) ) {
            $result['genre'] = trim( (string) $source['genre'] );
        }
        if ( isset( $source['bitrate'] ) ) {
            $result['bitrate'] = (int) $source['bitrate'];
        }
        // Listener count. Icecast's status-json.xsl reports the number
        // of clients actively connected to this mount in the
        // "listeners" field. Some minimal Icecast configs omit it, in
        // which case we leave the field unset so the frontend can hide
        // the display rather than showing a misleading zero.
        if ( isset( $source['listeners'] ) ) {
            $result['listener_count'] = (int) $source['listeners'];
        }
        if ( ! empty( $source['server_type'] ) ) {
            $result['content_type'] = trim( (string) $source['server_type'] );
        }

        // Look for artwork provided by the stream itself. Icecast's
        // core schema does not define an artwork field, but many source
        // clients (RadioBoss, RadioDJ, StreamerBot, custom scripts)
        // push an image URL under one of these common field names.
        // If any is present and looks like an image URL, prefer it
        // over external artwork lookups because the stream knows
        // exactly what song it is playing.
        $artwork = VMPlayer_Metadata::extract_stream_artwork( $source );
        if ( '' !== $artwork ) {
            $result['artwork_url']    = $artwork;
            $result['artwork_source'] = 'stream';
        }

        // Icecast bundles sample rate and channels into audio_info,
        // formatted as: "channels=2;samplerate=44100;bitrate=128".
        if ( ! empty( $source['audio_info'] ) ) {
            $audio = self::parse_audio_info( (string) $source['audio_info'] );
            if ( isset( $audio['samplerate'] ) ) {
                $result['sample_rate'] = (int) $audio['samplerate'];
            }
            if ( isset( $audio['channels'] ) ) {
                $result['channels'] = (int) $audio['channels'];
            }
            // audio_info bitrate wins over server-level bitrate.
            if ( isset( $audio['bitrate'] ) && empty( $result['bitrate'] ) ) {
                $result['bitrate'] = (int) $audio['bitrate'];
            }
        }

        // Everything Icecast reports is live audio.
        $result['is_live'] = true;

        return $result;
    }

    /**
     * Parse the semicolon-delimited audio_info string.
     *
     * @param string $audio_info Raw audio_info value.
     * @return array Parsed key-value pairs.
     */
    private static function parse_audio_info( $audio_info ) {
        $result = array();
        $parts  = explode( ';', $audio_info );
        foreach ( $parts as $part ) {
            $part = trim( $part );
            if ( '' === $part || false === strpos( $part, '=' ) ) {
                continue;
            }
            list( $key, $val )      = explode( '=', $part, 2 );
            $result[ trim( $key ) ] = trim( $val );
        }
        return $result;
    }

    /**
     * Attempt to fix common Icecast JSON output issues that break
     * strict decoders. Currently handles unescaped forward slashes
     * and stray control characters.
     *
     * @param string $body Raw response body.
     * @return string Cleaned JSON string.
     */
    private static function clean_broken_json( $body ) {
        // Strip control chars except tab, newline, carriage return.
        $body = preg_replace( '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $body );
        return (string) $body;
    }
}
