<?php
defined( 'ABSPATH' ) || exit();

/**
 * Radio_Player_Stream_Data
 *
 * Responsibilities:
 *  - detect stream type (shoutcast / icecast / unknown)
 *  - fetch current title/artist from shoutcast/icecast or directly from stream ICY metadata
 *  - find artwork for track
 *
 * Improvements:
 *  - 30s transient caching of full stream data
 *  - safer WP HTTP usage for header checks
 *  - per-URL instance caching (multiple URLs supported)
 *  - better null/array checks and early returns
 *  - small performance and readability improvements
 */
class Radio_Player_Stream_Data {

	/** @var array<string, self> instances indexed by url */
	private static $instances = [];

	/** @var string Stream url */
	private $url;

	/** Construct with a stream URL */
	public function __construct( string $url ) {
		$this->url = $url;
	}

	/**
	 * Get (and cache for 30 seconds) stream data array.
	 *
	 * @return array{title?:string,artist?:string,art?:string}
	 */
	public function get_stream_data(): array {

		// transient key per-stream URL (hashed to avoid length issues)
		$key    = 'radio_player_stream_data_' . md5( $this->url );
		$cached = get_transient( $key );

		if ( is_array( $cached ) ) {
			/**
			 * Allow external code to alter cached copy too.
			 * Still return early to avoid extra network calls.
			 */
			return (array) apply_filters( 'radio_player/stream_data', $cached, $this->get_stream_type() );
		}

		$stream_type = $this->get_stream_type();
		$stream_data = [];

		// Shoutcast: try /currentsong?sid=N
		if ( 'shoutcast' === $stream_type ) {
			$sid = 1;
			if ( preg_match( '/stream\/(\d+)/', $this->url, $m ) ) {
				$sid = (int) $m[1];
			}
			$shoutcast_url = apply_filters( 'radio_player/shoutcast_metadata_url', $this->get_shoutcast_base_url() . "/currentsong?sid=$sid", $this->url );
			$title         = $this->get_remote_response( $shoutcast_url );
			if ( $title ) {
				$stream_data['title'] = trim( wp_strip_all_tags( $title ) );
			}
		} elseif ( 'icecast' === $stream_type ) {
			$icecast_url = apply_filters( 'radio_player/icecast_metadata_url', $this->get_icecast_base_url() . '/status-json.xsl', $this->url );
			$meta        = $this->fetch_and_decode( $icecast_url );

			if ( is_array( $meta ) && isset( $meta['icestats'] ) ) {
				$source = $meta['icestats']['source'] ?? null;
				// single source => associative; multiple => numeric array
				if ( is_array( $source ) ) {
					// single source structure
					if ( isset( $source['title'] ) && ! empty( $source['title'] ) ) {
						$stream_data['title'] = $source['title'];
					} else {
						// multiple sources: try to match listenurl
						$matched = [];
						foreach ( $source as $s ) {
							if ( isset( $s['listenurl'] ) && str_contains( $s['listenurl'], $this->url ) ) {
								$matched[] = $s;
							}
						}
						if ( ! empty( $matched ) && isset( $matched[0]['title'] ) ) {
							$stream_data['title'] = $matched[0]['title'];
						} else {
							// fallback: any source with a title
							foreach ( $source as $s ) {
								if ( ! empty( $s['title'] ) ) {
									$stream_data['title'] = $s['title'];
									break;
								}
							}
						}
					}
				}
			}
		}

		// If still empty, try ICY metadata / direct stream probing
		if ( empty( $stream_data['title'] ) ) {
			$found = $this->fetch_stream_data( $this->url );
			if ( is_array( $found ) && ! empty( $found['title'] ) ) {
				$stream_data = array_merge( $stream_data, $found );
				// try artwork if we have artist/title
				$art = $this->find_track_artwork( $stream_data['artist'] ?? '', $stream_data['title'] ?? '' );
				if ( $art ) {
					$stream_data['art'] = $art;
				}
			}
		}

		// Fallback: fetch title via separate method (header-based)
		if ( empty( $stream_data['title'] ) ) {
			$maybe = $this->fetch_stream_title( $this->url );
			if ( $maybe ) {
				$stream_data['title'] = $maybe;
			}
		}

		$stream_data = (array) apply_filters( 'radio_player/stream_data', $stream_data, $stream_type );

		// Store a short-lived cache to prevent frequent remote calls (30 sec)
		set_transient( $key, $stream_data, 30 );

		return $stream_data;
	}

	/**
	 * Probe a stream with cURL and parse ICY metadata (streaming read).
	 *
	 * Returns null if nothing found or not supported, or an array with 'title' and optional 'artist'.
	 *
	 * @param string $streamUrl
	 * @param int $timeout
	 *
	 * @return array|null
	 */
	public function fetch_stream_data( string $streamUrl, int $timeout = 8 ): ?array {
		if ( empty( $streamUrl ) ) {
			return null;
		}

		$metaInt        = null;
		$bytesUntilMeta = null;
		$found          = null;

		$ch = curl_init( $streamUrl );
		if ( ! $ch ) {
			return null;
		}

		// Basic cURL options for streaming
		curl_setopt_array( $ch, [
			CURLOPT_FOLLOWLOCATION => true,
			CURLOPT_SSL_VERIFYHOST => 2,
			CURLOPT_SSL_VERIFYPEER => true,
			CURLOPT_RETURNTRANSFER => false,
			CURLOPT_TIMEOUT        => $timeout,
			CURLOPT_CONNECTTIMEOUT => $timeout,
			CURLOPT_HTTPHEADER     => [ 'Icy-MetaData: 1' ],
			CURLOPT_USERAGENT      => 'Mozilla/5.0 (PHP-ICY-Reader)',
			CURLOPT_NOBODY         => false,
			CURLOPT_HEADER         => false,
		] );

		// Capture headers (icy-metaint)
		curl_setopt( $ch, CURLOPT_HEADERFUNCTION, function ( $ch, $header ) use ( &$metaInt, &$bytesUntilMeta ) {
			$len = strlen( $header );
			$h   = strtolower( $header );
			if ( stripos( $h, 'icy-metaint:' ) === 0 ) {
				$metaInt        = (int) trim( substr( $header, 11 ) );
				$bytesUntilMeta = $metaInt;
			}

			return $len;
		} );

		// Stream the body and parse the first metadata block we can extract
		curl_setopt( $ch, CURLOPT_WRITEFUNCTION, function ( $ch, $chunk ) use ( &$metaInt, &$bytesUntilMeta, &$found ) {
			$len    = strlen( $chunk );
			$offset = 0;

			// If server didn't send metaint, read a limited amount then give up
			if ( $metaInt === null ) {
				static $seen = 0;
				$seen += $len;
				if ( $seen > 65536 ) { // ~64KB
					return 0;
				}

				return $len;
			}

			while ( $offset < $len ) {
				$need  = $bytesUntilMeta;
				$avail = $len - $offset;

				if ( $need > 0 ) {
					$take           = min( $need, $avail );
					$offset         += $take;
					$bytesUntilMeta -= $take;
					if ( $bytesUntilMeta > 0 ) {
						continue;
					}
				}

				// Now at metadata length byte
				if ( $offset >= $len ) {
					break;
				}
				$metaLenByte = ord( $chunk[ $offset ] );
				$offset      += 1;
				$metaBytes   = $metaLenByte * 16;

				if ( $metaBytes > 0 ) {
					// If metadata spans beyond current chunk, abort cURL to allow re-request (we don't want complex buffering)
					if ( $offset + $metaBytes > $len ) {
						return 0;
					}
					$raw    = substr( $chunk, $offset, $metaBytes );
					$offset += $metaBytes;

					if ( preg_match( "/StreamTitle='(.*?)';/i", $raw, $m ) ) {
						$titleFull = trim( $m[1] );
						if ( $titleFull !== '' ) {
							$artist = null;
							$title  = $titleFull;
							if ( strpos( $titleFull, ' - ' ) !== false ) {
								[ $artist, $title ] = array_map( 'trim', explode( ' - ', $titleFull, 2 ) );
							}
							$found = [
								'title'  => $title ?: $titleFull,
								'artist' => $artist,
							];

							// stop cURL immediately
							return 0;
						}
					}
				}

				// reset for next metadata block
				$bytesUntilMeta = $metaInt;
			}

			return $len;
		} );

		curl_exec( $ch );
		curl_close( $ch );

		return is_array( $found ) ? $found : null;
	}

	/**
	 * Fallback header-based attempt to find StreamTitle in a small read.
	 *
	 * @param string $stream_url
	 *
	 * @return string
	 */
	public function fetch_stream_title( string $stream_url ): string {
		$result = '';

		if ( empty( $stream_url ) ) {
			return $result;
		}

		$ua   = 'Mozilla/5.0 (PHP-StreamTitleFetcher)';
		$opts = [
			'http' => [
				'method'  => 'GET',
				'header'  => "Icy-MetaData: 1\r\nUser-Agent: $ua\r\n",
				'timeout' => 6,
			],
			'ssl'  => [
				'verify_peer'      => false,
				'verify_peer_name' => false,
			],
		];

		stream_context_set_default( $opts );

		$stream = @fopen( $stream_url, 'r' );
		if ( ! $stream ) {
			return $result;
		}

		$icy_metaint     = - 1;
		$meta_identifier = 'StreamTitle=';

		$meta_data = stream_get_meta_data( $stream );
		if ( ! empty( $meta_data['wrapper_data'] ) ) {
			foreach ( $meta_data['wrapper_data'] as $header ) {
				if ( stripos( $header, 'icy-metaint' ) !== false ) {
					$tmp = explode( ':', $header, 2 );
					if ( isset( $tmp[1] ) ) {
						$icy_metaint = intval( trim( $tmp[1] ) );
					}
					break;
				}
			}
		}

		if ( $icy_metaint > 0 ) {
			$buffer = stream_get_contents( $stream, 300, $icy_metaint );
			if ( $buffer && ( $pos = stripos( $buffer, $meta_identifier ) ) !== false ) {
				$title = substr( $buffer, $pos + strlen( $meta_identifier ) );
				// find ending semicolon
				$end = strpos( $title, ';' );
				if ( $end !== false ) {
					$title = trim( substr( $title, 0, $end ), " \t\n\r\0\x0B'\"" );
				}
				$result = $title;
			}
		}

		@fclose( $stream );

		return (string) $result;
	}

	/**
	 * Try to find artwork for a track (iTunes -> MusicBrainz/CAA fallback).
	 *
	 * @param string $artist
	 * @param string $title
	 * @param int $timeout
	 *
	 * @return string|null
	 */
	public function find_track_artwork( $artist, $title, int $timeout = 6 ): ?string {
		$artist = trim( (string) $artist );
		$title  = trim( (string) $title );

		if ( $title === '' && $artist === '' ) {
			return null;
		}

		$term = trim( $artist !== '' ? "$artist $title" : $title );

		// iTunes
		$url = 'https://itunes.apple.com/search?' . http_build_query( [
				'term'    => $term,
				'entity'  => 'song',
				'limit'   => 1,
				'country' => 'US',
				'media'   => 'music',
			] );

		$json = $this->curl_get_json( $url, $timeout );
		if ( ! empty( $json['results'][0]['artworkUrl100'] ) ) {
			$art = $json['results'][0]['artworkUrl100'];
			$hi  = preg_replace( '/\/\d+x\d+bb(\.[a-z]+)(\?.*)?$/i', '/1200x1200bb$1$2', $art );

			return $hi ?: $art;
		}

		// MusicBrainz + Cover Art Archive fallback
		$mbQuery = sprintf(
			'recording:(%s)%s',
			mb_strtolower( $this->_mbq( $title ) ),
			$artist !== '' ? ' AND artist:(' . mb_strtolower( $this->_mbq( $artist ) ) . ')' : ''
		);

		$mbUrl = 'https://musicbrainz.org/ws/2/recording/?' . http_build_query( [
				'query' => $mbQuery,
				'fmt'   => 'json',
				'limit' => 1,
			] );

		$mb = $this->curl_get_json( $mbUrl, $timeout, [
			'User-Agent: SoftLab-ArtworkFetcher/1.0 (contact: your-email@example.com)',
		] );

		if ( ! empty( $mb['recordings'][0]['releases'][0]['id'] ) ) {
			$releaseId = $mb['recordings'][0]['releases'][0]['id'];
			$caa       = $this->curl_get_json( "https://coverartarchive.org/release/$releaseId", $timeout, [
				'User-Agent: SoftLab-ArtworkFetcher/1.0 (contact: your-email@example.com)',
			] );
			if ( ! empty( $caa['images'] ) && is_array( $caa['images'] ) ) {
				foreach ( $caa['images'] as $img ) {
					if ( ! empty( $img['front'] ) && ! empty( $img['image'] ) ) {
						return $img['image'];
					}
				}
				if ( ! empty( $caa['images'][0]['image'] ) ) {
					return $caa['images'][0]['image'];
				}
			}
		}

		return null;
	}

	/**
	 * Minimal cURL JSON helper.
	 *
	 * @return array
	 */
	public function curl_get_json( string $url, int $timeout = 6, array $headers = [] ): array {
		$ch = curl_init( $url );
		if ( ! $ch ) {
			return [];
		}
		curl_setopt_array( $ch, [
			CURLOPT_RETURNTRANSFER => true,
			CURLOPT_FOLLOWLOCATION => true,
			CURLOPT_TIMEOUT        => $timeout,
			CURLOPT_CONNECTTIMEOUT => max( 3, (int) $timeout ),
			CURLOPT_SSL_VERIFYHOST => 2,
			CURLOPT_SSL_VERIFYPEER => true,
			CURLOPT_HTTPHEADER     => array_merge( [ 'Accept: application/json' ], $headers ),
			CURLOPT_USERAGENT      => 'Mozilla/5.0 (PHP-cURL)',
		] );
		$res = curl_exec( $ch );
		curl_close( $ch );
		if ( ! $res ) {
			return [];
		}
		$data = json_decode( $res, true );

		return is_array( $data ) ? $data : [];
	}

	/**
	 * Escape for MusicBrainz Lucene query
	 */
	public function _mbq( string $s ): string {
		return preg_replace( '/([+\-!(){}\[\]^"~*?:\/\\\\])/', '\\\\$1', $s );
	}

	/**
	 * Fetch JSON and decode or return null.
	 *
	 * @param string $url
	 *
	 * @return array|null
	 */
	public function fetch_and_decode( string $url ): ?array {
		$response = $this->get_remote_response( $url );
		if ( $response ) {
			$decoded = json_decode( $response, true );

			return is_array( $decoded ) ? $decoded : null;
		}

		return null;
	}

	/**
	 * Use WP HTTP API to get a response body (string) or false.
	 *
	 * @param string $url
	 *
	 * @return string|false
	 */
	public function get_remote_response( string $url ) {
		$response = wp_remote_get( $url, [
			'timeout' => 8,
			'headers' => [
				'Accept' => 'application/json',
			],
		] );
		if ( is_wp_error( $response ) ) {
			return false;
		}
		if ( wp_remote_retrieve_response_code( $response ) !== 200 ) {
			return false;
		}

		return wp_remote_retrieve_body( $response );
	}

	/**
	 * Detect stream type using cached per-URL transient (7d).
	 *
	 * @return string 'shoutcast'|'icecast'|'unknown'
	 */
	public function get_stream_type(): string {
		$urls = (array) get_transient( 'radio_player_stream_urls' );

		if ( isset( $urls[ $this->url ] ) ) {
			return $urls[ $this->url ];
		}

		$type = 'unknown';
		if ( $this->is_shoutcast_url() ) {
			$type = 'shoutcast';
		} elseif ( $this->is_icecast_url() ) {
			$type = 'icecast';
		}

		$urls[ $this->url ] = $type;
		set_transient( 'radio_player_stream_urls', $urls, 7 * DAY_IN_SECONDS );

		return $type;
	}

	/**
	 * Determine Shoutcast by checking server headers (uses WP HTTP HEAD).
	 *
	 * @return bool
	 */
	public function is_shoutcast_url(): bool {
		try {
			$res = wp_remote_head( $this->url, [ 'timeout' => 3 ] );
			if ( is_wp_error( $res ) ) {
				return false;
			}
			$headers = wp_remote_retrieve_headers( $res );
			if ( ! $headers ) {
				return false;
			}
			// iterate through header values looking for 'shoutcast'
			foreach ( (array) $headers as $k => $v ) {
				$values = is_array( $v ) ? $v : [ $v ];
				foreach ( $values as $val ) {
					if ( stripos( (string) $val, 'shoutcast' ) !== false ) {
						return true;
					}
				}
			}
		} catch ( Exception $e ) {
			// ignore and return false
		}

		return false;
	}

	/**
	 * Determine Icecast by checking server headers (uses WP HTTP HEAD).
	 *
	 * @return bool
	 */
	public function is_icecast_url(): bool {
		try {
			$res = wp_remote_head( $this->url, [ 'timeout' => 3 ] );
			if ( is_wp_error( $res ) ) {
				return false;
			}
			$headers = wp_remote_retrieve_headers( $res );
			if ( ! $headers ) {
				return false;
			}
			foreach ( (array) $headers as $k => $v ) {
				$values = is_array( $v ) ? $v : [ $v ];
				foreach ( $values as $val ) {
					if ( stripos( (string) $val, 'ice-audio-info' ) !== false || stripos( (string) $val, 'icecast' ) !== false ) {
						return true;
					}
				}
			}
		} catch ( Exception $e ) {
			// ignore
		}

		return false;
	}

	/**
	 * Derive shoutcast base from the URL (scheme + host + optional port).
	 *
	 * @return string|false
	 */
	public function get_shoutcast_base_url() {
		if ( preg_match( '/^(https?:\/\/[^\/:]+)(?::(\d+))?/', $this->url, $matches ) ) {
			$base_url = $matches[1];
			if ( ! empty( $matches[2] ) ) {
				$base_url .= ':' . $matches[2];
			} else {
				// heuristic: if path contains '/radio' we guess :8000 (existing behavior preserved)
				if ( strpos( $this->url, '/radio' ) !== false ) {
					$base_url .= ':8000';
				}
			}

			return $base_url;
		}

		return false;
	}

	/**
	 * Derive icecast base from URL.
	 *
	 * @return string
	 */
	public function get_icecast_base_url(): string {
		$parsed = wp_parse_url( $this->url );
		$scheme = $parsed['scheme'] ?? 'http';
		$host   = $parsed['host'] ?? '';
		$port   = isset( $parsed['port'] ) ? ':' . $parsed['port'] : '';

		return $scheme . '://' . $host . $port;
	}

	/**
	 * Instance accessor (per-URL cached instance).
	 *
	 * @param string $url
	 *
	 * @return self
	 */
	public static function instance( string $url ): self {
		$key = (string) $url;
		if ( empty( self::$instances[ $key ] ) ) {
			self::$instances[ $key ] = new self( $url );
		}

		return self::$instances[ $key ];
	}
}
