<?php
/**
 * Streaming endpoint (template_redirect) with HTTP Range support.
 *
 * @package The Studio Player
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Build stream URL for an attachment ID.
 *
 * @param int $attachment_id Attachment ID.
 * @return string
 */
function thespl_get_stream_url_for_attachment( $attachment_id ) {
	$attachment_id = (int) $attachment_id;
	if ( $attachment_id <= 0 ) {
		return '';
	}

	$url = add_query_arg( 'thespl_stream', (string) $attachment_id, home_url( '/' ) );

	$settings = thespl_get_player_settings();
	$enabled  = ! empty( $settings['streaming_security_enabled'] );

	if ( $enabled ) {
		$lifetime = isset( $settings['streaming_token_lifetime'] ) ? (int) $settings['streaming_token_lifetime'] : 60;
		$expires  = time() + ( $lifetime * 60 );
		$token    = thespl_generate_streaming_token( $attachment_id, $expires );

		if ( $token ) {
			$url = add_query_arg(
				array(
					'thespl_token'   => $token,
					'thespl_expires' => $expires,
				),
				$url
			);
		}
	}

	return $url;
}

/**
 * Generate a security token for a given attachment ID.
 *
 * @param int $attachment_id Attachment ID.
 * @param int $expires       Expiration timestamp.
 * @return string
 */
function thespl_generate_streaming_token( $attachment_id, $expires ) {
	$secret = get_option( 'thespl_streaming_secret_key' );
	if ( ! $secret ) {
		// Fallback: Generate it if missing (e.g. if plugin was active before this feature was added).
		$secret = wp_generate_password( 64, true, true );
		update_option( 'thespl_streaming_secret_key', $secret );
	}
	// Use attachment ID, expiration, and secret to generate a HMAC.
	return hash_hmac( 'sha256', (string) $attachment_id . '|' . (string) $expires, $secret );
}

/**
 * Register rewrite tag and query vars for streaming.
 *
 * @return void
 */
function thespl_add_stream_rewrite_rules() {
	add_rewrite_tag( '%thespl_stream%', '([0-9]+)' );
}
add_action( 'init', 'thespl_add_stream_rewrite_rules' );

/**
 * Add thespl_stream to public query vars.
 *
 * @param array $vars Public query vars.
 * @return array
 */
function thespl_add_streaming_query_vars( $vars ) {
	$vars[] = 'thespl_stream';
	$vars[] = 'thespl_token';
	$vars[] = 'thespl_expires';
	$vars[] = 'thespl_chunk_level';
	return $vars;
}
add_filter( 'query_vars', 'thespl_add_streaming_query_vars' );

/**
 * Get user IP address.
 *
 * @return string
 */
function thespl_get_user_ip() {
	// Best-effort IP signal for throttling only; access control is token-based.
	$ip = '0.0.0.0';
	if ( ! empty( $_SERVER['HTTP_CLIENT_IP'] ) ) {
		$ip = sanitize_text_field( wp_unslash( $_SERVER['HTTP_CLIENT_IP'] ) );
	} elseif ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
		$ips = explode( ',', sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) );
		$ip  = trim( $ips[0] );
	} elseif ( ! empty( $_SERVER['REMOTE_ADDR'] ) ) {
		$ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );
	}
	return $ip;
}

/**
 * Resolve the requested stream attachment ID from the public request.
 *
 * @return int Attachment ID or zero.
 */
function thespl_get_stream_attachment_id_from_request() {
	$attachment_id = absint( get_query_var( 'thespl_stream' ) );

	return $attachment_id > 0 ? $attachment_id : 0;
}

/**
 * Resolve the requested stream security token from the public request.
 *
 * @return string
 */
function thespl_get_stream_token_from_request() {
	$token = get_query_var( 'thespl_token' );
	if ( is_scalar( $token ) && '' !== (string) $token ) {
		return sanitize_text_field( (string) $token );
	}

	return '';
}

/**
 * Resolve the requested stream token expiry timestamp from the public request.
 *
 * @return int Expiration timestamp or zero.
 */
function thespl_get_stream_expiry_from_request() {
	$expires = absint( get_query_var( 'thespl_expires' ) );

	return $expires > 0 ? $expires : 0;
}

/**
 * Validate a signed stream access token for a public read request.
 *
 * @param int    $attachment_id Attachment ID.
 * @param string $token         Raw token value.
 * @param int    $expires       Expiration timestamp.
 * @return bool
 */
function thespl_validate_stream_access_token( $attachment_id, $token, $expires ) {
	$attachment_id = (int) $attachment_id;
	$token         = is_string( $token ) ? $token : '';
	$expires       = (int) $expires;

	if ( '' !== $token && ( 64 !== strlen( $token ) || ! ctype_xdigit( $token ) ) ) {
		return false;
	}

	if ( $attachment_id <= 0 || '' === $token || $expires <= 0 || $expires < time() ) {
		return false;
	}

	$expected = thespl_generate_streaming_token( $attachment_id, $expires );

	return hash_equals( $expected, $token );
}

/**
 * Normalize a configured or requested stream chunk level.
 *
 * @param int $chunk_level Raw chunk level.
 * @return int
 */
function thespl_normalize_stream_chunk_level( $chunk_level ) {
	$chunk_level = (int) $chunk_level;
	if ( $chunk_level < 0 ) {
		return 0;
	}
	if ( $chunk_level > 9 ) {
		return 9;
	}

	return $chunk_level;
}

/**
 * Resolve the runtime stream chunk level override from request or cookie.
 *
 * Request overrides keep the current product behavior and take precedence over
 * the cookie value because the player appends the active runtime chunk level to
 * individual stream and probe requests.
 *
 * @return int Override level or -1 when no valid override exists.
 */
function thespl_get_stream_chunk_level_override() {
	$chunk_level  = get_query_var( 'thespl_chunk_level' );
	$req_level    = -1;
	$cookie_level = -1;

	if ( is_scalar( $chunk_level ) && '' !== (string) $chunk_level ) {
		$req_level = thespl_normalize_stream_chunk_level( $chunk_level );
	}

	if ( isset( $_COOKIE['thespl_chunk_level'] ) && is_scalar( $_COOKIE['thespl_chunk_level'] ) ) {
		$cookie_level = absint( wp_unslash( (string) $_COOKIE['thespl_chunk_level'] ) );
	}

	if ( $req_level >= 0 && $req_level <= 9 ) {
		return $req_level;
	}

	if ( $cookie_level >= 0 && $cookie_level <= 9 ) {
		return $cookie_level;
	}

	return -1;
}

/**
 * Maybe handle streaming for requests containing thespl_stream.
 *
 * @return void
 */
function thespl_maybe_stream() {
	// 1. Resolve Attachment ID.
	$attachment_id = thespl_get_stream_attachment_id_from_request();
	if ( $attachment_id <= 0 ) {
		return;
	}

	// 2. Load Settings.
	$player_settings = thespl_get_player_settings();

	// 3. ENFORCE SECURITY if enabled.
	$security_enabled = ! empty( $player_settings['streaming_security_enabled'] );

	if ( $security_enabled ) {
		$token   = thespl_get_stream_token_from_request();
		$expires = thespl_get_stream_expiry_from_request();

		if ( ! thespl_validate_stream_access_token( $attachment_id, $token, $expires ) ) {
			status_header( 403 );
			nocache_headers();
			exit;
		}
	}

	// 4. Rate Limiting.
	if ( ! empty( $player_settings['streaming_rate_limit_enabled'] ) ) {
		// IP hash is a secondary abuse signal, not a primary security boundary.
		$ip          = thespl_get_user_ip();
		$ip_hash     = md5( $ip );
		$rpm_limit   = isset( $player_settings['streaming_rate_limit_rpm'] ) ? (int) $player_settings['streaming_rate_limit_rpm'] : 300;
		$burst_limit = isset( $player_settings['streaming_rate_limit_burst'] ) ? (int) $player_settings['streaming_rate_limit_burst'] : 80;

		$transient_rpm   = 'thespl_rl_rpm_' . $ip_hash;
		$transient_burst = 'thespl_rl_burst_' . $ip_hash;

		$current_rpm   = (int) get_transient( $transient_rpm );
		$current_burst = (int) get_transient( $transient_burst );

		if ( $current_rpm >= $rpm_limit || $current_burst >= $burst_limit ) {
			status_header( 429 );
			nocache_headers();
			header( 'Retry-After: 30' );
			header( 'X-THESPL-Rate-Limit: exceeded' );
			if ( ! empty( $player_settings['debug_console_enabled'] ) ) {
				header( 'X-THESPL-Debug-RL-RPM: ' . $current_rpm . '/' . $rpm_limit );
				header( 'X-THESPL-Debug-RL-Burst: ' . $current_burst . '/' . $burst_limit );
				header( 'X-THESPL-Debug-IP: ' . $ip );
			}
			exit;
		}

		// Increment with a slightly better approach to minimize race conditions:
		// We set it immediately.
		set_transient( $transient_rpm, $current_rpm + 1, 60 );
		set_transient( $transient_burst, $current_burst + 1, 1 ); // 1 second window for burst.

		if ( ! empty( $player_settings['debug_console_enabled'] ) ) {
			header( 'X-THESPL-Debug-RL-RPM: ' . ( $current_rpm + 1 ) . '/' . $rpm_limit );
			header( 'X-THESPL-Debug-RL-Burst: ' . ( $current_burst + 1 ) . '/' . $burst_limit );
			header( 'X-THESPL-Debug-IP: ' . $ip );
		}
	}

	// 5. Verify Attachment.
	$attachment = get_post( $attachment_id );
	if ( ! $attachment || 'attachment' !== $attachment->post_type ) {
		status_header( 404 );
		exit;
	}

	$file_path = get_attached_file( $attachment_id );
	if ( ! is_string( $file_path ) || '' === $file_path || ! file_exists( $file_path ) ) {
		status_header( 404 );
		exit;
	}

	$uploads     = wp_get_upload_dir();
	$uploads_dir = isset( $uploads['basedir'] ) ? realpath( $uploads['basedir'] ) : false;
	$real_file   = realpath( $file_path );

	$uploads_dir_norm = $uploads_dir ? trailingslashit( wp_normalize_path( $uploads_dir ) ) : '';
	$real_file_norm   = $real_file ? wp_normalize_path( $real_file ) : '';

	if ( '' === $uploads_dir_norm || '' === $real_file_norm || 0 !== strpos( $real_file_norm, $uploads_dir_norm ) ) {
		status_header( 403 );
		exit;
	}

	if ( is_link( $real_file ) || ! is_readable( $real_file ) || ! is_file( $real_file ) ) {
		status_header( 404 );
		exit;
	}

	$size = filesize( $real_file );
	if ( ! is_int( $size ) || $size <= 0 ) {
		status_header( 404 );
		exit;
	}

	$mime     = get_post_mime_type( $attachment_id );
	$mime     = is_string( $mime ) ? $mime : '';
	$filetype = wp_check_filetype( $real_file_norm );
	$ext      = isset( $filetype['ext'] ) && is_string( $filetype['ext'] ) ? strtolower( $filetype['ext'] ) : '';

	$allowed_ext  = array( 'flac', 'wav' );
	$allowed_mime = array( 'audio/flac', 'audio/x-flac', 'audio/wav', 'audio/x-wav', 'audio/wave', 'audio/vnd.wave' );

	if ( ! in_array( $ext, $allowed_ext, true ) && ! in_array( $mime, $allowed_mime, true ) ) {
		status_header( 403 );
		exit;
	}

	if ( '' === $mime ) {
		$mime = in_array( $ext, array( 'flac' ), true ) ? 'audio/flac' : 'application/octet-stream';
	}

	$start  = 0;
	$end    = $size - 1;
	$status = 200;

	$debug_enabled = ! empty( $player_settings['debug_console_enabled'] ) ? 1 : 0;
	$chunk_level   = thespl_normalize_stream_chunk_level( isset( $player_settings['streaming_chunk_level'] ) ? (int) $player_settings['streaming_chunk_level'] : 4 );
	$override      = thespl_get_stream_chunk_level_override();
	if ( $override >= 0 ) {
		$chunk_level = $override;
	}
	$levels     = array( 128 * 1024, 256 * 1024, 512 * 1024, 768 * 1024, 1024 * 1024, 1536 * 1024, 2048 * 1024, 3072 * 1024, 4096 * 1024, 6144 * 1024 );
	$chunk_size = isset( $levels[ $chunk_level ] ) ? (int) $levels[ $chunk_level ] : (int) end( $levels );
	$enforce    = true;

	$range = isset( $_SERVER['HTTP_RANGE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_RANGE'] ) ) : '';
	$range = is_string( $range ) ? trim( $range ) : '';
	if ( $range && preg_match( '/bytes=(\d*)-(\d*)/i', $range, $m ) ) {
		if ( '' !== $m[1] ) {
			$start = (int) $m[1];
		}
		if ( '' !== $m[2] ) {
			$end = (int) $m[2];
		}

		if ( '' === $m[1] && '' !== $m[2] ) {
			$suffix = (int) $m[2];
			if ( $suffix > 0 ) {
				$start = max( 0, $size - $suffix );
				$end   = $size - 1;
			}
		}

		if ( $start > $end || $start < 0 || $end >= $size ) {
			header( 'Content-Range: bytes */' . $size );
			status_header( 416 );
			exit;
		}

		$requested_len = ( $end - $start ) + 1;
		if ( $requested_len > $chunk_size ) {
			$end = min( $start + $chunk_size - 1, $size - 1 );
		}

		$status = 206;
	}

	nocache_headers();
	status_header( $status );
	header( 'Content-Type: ' . $mime );
	header( 'Accept-Ranges: bytes' );
	header( 'Content-Disposition: inline; filename="' . wp_basename( $real_file ) . '"' );
	header( 'X-Content-Type-Options: nosniff' );

	$length = ( $end - $start ) + 1;
	if ( 206 === $status ) {
		header( 'Content-Range: bytes ' . $start . '-' . $end . '/' . $size );
	}
	header( 'Content-Length: ' . $length );
	if ( ! headers_sent() ) {
		if ( 1 === (int) $debug_enabled ) {
			header( 'X-THESPL-Chunk-Level: ' . $chunk_level );
			header( 'X-THESPL-Chunk-Size: ' . $chunk_size );
			header( 'X-THESPL-Range-Start: ' . $start );
			header( 'X-THESPL-Range-End: ' . $end );
			header( 'X-THESPL-Response-Length: ' . $length );
			header( 'X-THESPL-Enforce-Chunk: ' . ( $enforce ? '1' : '0' ) );
		}
	}

	if ( isset( $_SERVER['REQUEST_METHOD'] ) && 'HEAD' === sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) {
		exit;
	}

	while ( ob_get_level() ) {
		ob_end_clean();
	}

	try {
		$fp = new SplFileObject( $real_file, 'rb' );
	} catch ( Exception $e ) {
		status_header( 500 );
		exit;
	}

	$fp->fseek( $start );
	try {
		$out = new SplFileObject( 'php://output', 'wb' );
	} catch ( Exception $e ) {
		status_header( 500 );
		exit;
	}
	$bytes_left = $length;
	while ( $bytes_left > 0 && ! $fp->eof() ) {
		$read_len = $bytes_left > $chunk_size ? $chunk_size : $bytes_left;
		$buf      = $fp->fread( $read_len );
		if ( false === $buf || '' === $buf ) {
			break;
		}
		$out->fwrite( $buf );
		flush();
		$bytes_left -= strlen( $buf );
	}

	unset( $out );
	unset( $fp );
	exit;
}
add_action( 'template_redirect', 'thespl_maybe_stream', 1 );
