<?php
/**
 * HLS metadata handler.
 *
 * Fetches and parses .m3u8 playlists. Master playlists give us
 * stream-level info (bandwidth, codecs). Media playlists occasionally
 * embed track info in EXTINF titles, especially on radio simulcasts.
 *
 * @package VMPlayer
 */

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

/**
 * Class VMPlayer_HLS
 */
class VMPlayer_HLS {

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

    /**
     * HTTP timeout for playlist fetches in seconds.
     */
    const TIMEOUT = 5;

    /**
     * Fetch HLS metadata for a stream URL.
     *
     * @param string $stream_url The .m3u8 URL.
     * @return array Partial metadata array. Empty on total failure.
     */
    public static function fetch_metadata( $stream_url ) {
        $manifest = self::fetch_manifest( $stream_url );
        if ( '' === $manifest ) {
            return array();
        }

        // Master playlists reference variant playlists via
        // #EXT-X-STREAM-INF tags. Media playlists have #EXTINF
        // tags for individual segments.
        if ( self::is_master_playlist( $manifest ) ) {
            $meta = self::parse_master( $manifest, $stream_url );
        } else {
            $meta = self::parse_media( $manifest );
        }

        if ( ! empty( $meta ) ) {
            $meta['is_live'] = true;
            $meta['source']  = 'hls';
        }

        return $meta;
    }

    /**
     * Fetch a playlist URL and return its body.
     *
     * @param string $url Playlist URL.
     * @return string Body content, or empty string on failure.
     */
    private static function fetch_manifest( $url ) {
        $response = wp_remote_get(
            $url,
            array(
                'timeout'     => self::TIMEOUT,
                'redirection' => 3,
                'sslverify'   => false,
                'user-agent'  => self::USER_AGENT,
                'headers'     => array(
                    'Accept' => 'application/vnd.apple.mpegurl,application/x-mpegurl,*/*;q=0.5',
                ),
            )
        );

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

        return (string) wp_remote_retrieve_body( $response );
    }

    /**
     * Detect whether a manifest is a master playlist.
     * Master playlists contain EXT-X-STREAM-INF tags that reference
     * variant playlists. Media playlists contain EXTINF segments.
     *
     * @param string $manifest Raw manifest content.
     * @return bool
     */
    private static function is_master_playlist( $manifest ) {
        return false !== stripos( $manifest, '#EXT-X-STREAM-INF' );
    }

    /**
     * Parse a master playlist and extract stream-level info from
     * the highest-bandwidth variant. Optionally recurses into that
     * variant to look for track-level metadata.
     *
     * @param string $manifest   Master playlist body.
     * @param string $stream_url URL the manifest was fetched from.
     * @return array
     */
    private static function parse_master( $manifest, $stream_url ) {
        $variants = self::extract_variants( $manifest );
        if ( empty( $variants ) ) {
            return array();
        }

        // Pick highest-bandwidth variant for the best user experience.
        usort(
            $variants,
            function ( $a, $b ) {
                return $b['bandwidth'] - $a['bandwidth'];
            }
        );
        $best = $variants[0];

        $result = array();
        if ( $best['bandwidth'] > 0 ) {
            // Convert bits per second to kbps.
            $result['bitrate'] = (int) round( $best['bandwidth'] / 1000 );
        }
        if ( '' !== $best['codecs'] ) {
            $result['content_type'] = self::codec_to_mime( $best['codecs'] );
        }

        // Try to recurse into the variant to pull track-level info
        // from EXTINF titles. Resolve relative URLs against the
        // master playlist URL.
        if ( '' !== $best['uri'] ) {
            $variant_url      = self::resolve_url( $best['uri'], $stream_url );
            $variant_manifest = self::fetch_manifest( $variant_url );
            if ( '' !== $variant_manifest ) {
                $variant_meta = self::parse_media( $variant_manifest );
                foreach ( $variant_meta as $key => $value ) {
                    if ( ! isset( $result[ $key ] ) || '' === $result[ $key ] ) {
                        $result[ $key ] = $value;
                    }
                }
            }
        }

        return $result;
    }

    /**
     * Parse a media playlist. Looks for track info embedded in
     * EXTINF titles (some radio HLS streams do this).
     *
     * @param string $manifest Media playlist body.
     * @return array
     */
    private static function parse_media( $manifest ) {
        $result = array();
        $lines  = preg_split( '/\r?\n/', $manifest );
        if ( ! is_array( $lines ) ) {
            return array();
        }

        // The most recent EXTINF title is the current segment info.
        $last_title = '';
        foreach ( $lines as $line ) {
            if ( 0 === stripos( $line, '#EXTINF:' ) ) {
                // Format: #EXTINF:<duration>,<title>
                $rest = substr( $line, strlen( '#EXTINF:' ) );
                $comma_pos = strpos( $rest, ',' );
                if ( false !== $comma_pos ) {
                    $title = trim( substr( $rest, $comma_pos + 1 ) );
                    if ( '' !== $title ) {
                        $last_title = $title;
                    }
                }
            }
        }

        if ( '' !== $last_title ) {
            list( $artist, $title ) = VMPlayer_Metadata::parse_artist_title( $last_title );
            if ( '' !== $title ) {
                $result['title'] = $title;
            }
            if ( '' !== $artist ) {
                $result['artist'] = $artist;
            }
        }

        return $result;
    }

    /**
     * Extract variant stream entries from a master playlist.
     *
     * @param string $manifest Master playlist body.
     * @return array Array of variants with bandwidth, codecs, uri.
     */
    private static function extract_variants( $manifest ) {
        $variants = array();
        $lines    = preg_split( '/\r?\n/', $manifest );
        if ( ! is_array( $lines ) ) {
            return $variants;
        }

        $count = count( $lines );
        for ( $i = 0; $i < $count; $i++ ) {
            $line = $lines[ $i ];
            if ( 0 !== stripos( $line, '#EXT-X-STREAM-INF' ) ) {
                continue;
            }

            $attrs     = self::parse_attributes( substr( $line, strpos( $line, ':' ) + 1 ) );
            $bandwidth = isset( $attrs['BANDWIDTH'] ) ? (int) $attrs['BANDWIDTH'] : 0;
            $codecs    = isset( $attrs['CODECS'] ) ? trim( $attrs['CODECS'], '"' ) : '';

            // The URI is on the next non-blank, non-comment line.
            $uri = '';
            for ( $j = $i + 1; $j < $count; $j++ ) {
                $next = trim( $lines[ $j ] );
                if ( '' === $next || 0 === strpos( $next, '#' ) ) {
                    continue;
                }
                $uri = $next;
                break;
            }

            $variants[] = array(
                'bandwidth' => $bandwidth,
                'codecs'    => $codecs,
                'uri'       => $uri,
            );
        }

        return $variants;
    }

    /**
     * Parse HLS attribute list (comma-separated key=value pairs,
     * with quoted string values allowed to contain commas).
     *
     * @param string $attr_string The attribute list, e.g. BANDWIDTH=128000,CODECS="mp4a.40.2".
     * @return array
     */
    private static function parse_attributes( $attr_string ) {
        $result  = array();
        $length  = strlen( $attr_string );
        $key     = '';
        $value   = '';
        $in_key  = true;
        $in_quot = false;

        for ( $i = 0; $i < $length; $i++ ) {
            $ch = $attr_string[ $i ];

            if ( $in_key ) {
                if ( '=' === $ch ) {
                    $in_key = false;
                    continue;
                }
                $key .= $ch;
                continue;
            }

            if ( '"' === $ch ) {
                $in_quot = ! $in_quot;
                $value  .= $ch;
                continue;
            }

            if ( ',' === $ch && ! $in_quot ) {
                $result[ trim( $key ) ] = trim( $value );
                $key    = '';
                $value  = '';
                $in_key = true;
                continue;
            }

            $value .= $ch;
        }

        if ( '' !== $key ) {
            $result[ trim( $key ) ] = trim( $value );
        }

        return $result;
    }

    /**
     * Resolve a possibly-relative URL against a base URL.
     *
     * @param string $url  URL to resolve.
     * @param string $base Base URL.
     * @return string
     */
    private static function resolve_url( $url, $base ) {
        // Absolute already.
        if ( preg_match( '#^[a-z][a-z0-9+.\-]*://#i', $url ) ) {
            return $url;
        }

        $parsed = wp_parse_url( $base );
        if ( ! is_array( $parsed ) || empty( $parsed['host'] ) ) {
            return $url;
        }

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

        // Absolute path.
        if ( 0 === strpos( $url, '/' ) ) {
            return "{$scheme}://{$host}{$port}{$url}";
        }

        // Relative path. Strip filename from base path.
        $path = isset( $parsed['path'] ) ? $parsed['path'] : '/';
        $dir  = rtrim( dirname( $path ), '/' );
        return "{$scheme}://{$host}{$port}{$dir}/{$url}";
    }

    /**
     * Map a codec string to a MIME type for display.
     *
     * @param string $codecs Codec string from CODECS attribute.
     * @return string
     */
    private static function codec_to_mime( $codecs ) {
        $lower = strtolower( $codecs );
        if ( false !== strpos( $lower, 'mp4a' ) ) {
            return 'audio/aac';
        }
        if ( false !== strpos( $lower, 'mp3' ) ) {
            return 'audio/mpeg';
        }
        return 'audio/*';
    }
}
