<?php

namespace SeriouslySimplePodcasting\Controllers;

use SeriouslySimplePodcasting\Entities\Castos_File_Data;
use SeriouslySimplePodcasting\Handlers\Archive_Page_Handler;
use SeriouslySimplePodcasting\Repositories\Episode_Repository;
use SeriouslySimplePodcasting\Traits\Logger;
use SeriouslySimplePodcasting\Traits\Useful_Variables;

use WP_Term;

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

/**
 * Frontend Controller
 *
 * @author      Hugh Lashbrooke
 * @category    Class
 * @package     SeriouslySimplePodcasting/Controllers
 * @since       1.0
 */
class Frontend_Controller {

	use Logger;

	use Useful_Variables;

	/**
	 * @var Players_Controller
	 * */
	public $players_controller;

	/**
	 * @var Episode_Repository
	 * */
	public $episode_repository;

	/**
	 * @var Archive_Page_Handler
	 */
	protected $archive_page_handler;

	/**
	 * @var array
	 * */
	protected $removed_filters;

	/**
	 * Cache TTL for valid URL validation results (15 minutes).
	 */
	const CACHE_TTL_VALID = 900; // 15 * MINUTE_IN_SECONDS

	/**
	 * Cache TTL for invalid URL validation results (5 minutes).
	 */
	const CACHE_TTL_INVALID = 300; // 5 * MINUTE_IN_SECONDS

	/**
	 * Cache TTL for trusted domain validation results (1 hour).
	 */
	const CACHE_TTL_TRUSTED = 3600; // HOUR_IN_SECONDS

	/**
	 * Allowed URL schemes for file downloads.
	 */
	const ALLOWED_SCHEMES = array( 'http', 'https' );

	/**
	 * Frontend_Controller constructor.
	 *
	 * @param Players_Controller  $players_controller
	 * @param Episode_Repository  $episode_repository
	 * @param Archive_Page_Handler $archive_page_handler
	 */
	public function __construct( $players_controller, $episode_repository, $archive_page_handler ) {
		$this->init_useful_variables();

		$this->players_controller   = $players_controller;
		$this->episode_repository   = $episode_repository;
		$this->archive_page_handler = $archive_page_handler;

		$this->register_hooks_and_filters();
		$this->register_ajax_actions();
		$this->protect_private_podcast_episodes();
	}

	/**
	 * Register all relevant front end hooks and filters
	 */
	public function register_hooks_and_filters() {

		// Add meta data to start of podcast content
		$locations = get_option( 'ss_podcasting_player_locations', array( 'content' ) );

		if ( in_array( 'content', (array) $locations, true ) ) {
			add_filter( 'the_content', array( $this, 'content_meta_data' ), 10, 1 );
		}

		if ( in_array( 'excerpt', (array) $locations, true ) ) {
			add_filter( 'the_excerpt', array( $this, 'get_excerpt_meta_data' ), 10, 1 );
		}

		if ( in_array( 'excerpt_embed', (array) $locations, true ) ) {
			add_filter( 'the_excerpt_embed', array( $this, 'get_embed_meta_data' ), 10, 1 );
		}

		// Add SSP label and version to generator tags
		add_action( 'get_the_generator_html', array( $this, 'generator_tag' ), 10, 2 );
		add_action( 'get_the_generator_xhtml', array( $this, 'generator_tag' ), 10, 2 );

		// Add RSS meta tag to site header
		add_action( 'wp_head', array( $this, 'rss_meta_tag' ) );

		// Disable default RSS feed link for podcast archive page (/podcast)
		add_filter( 'post_type_archive_feed_link', array( $this, 'disable_default_podcast_rss_feed_link' ) );

		// Add podcast episode to main query loop if setting is activated
		add_action( 'pre_get_posts', array( $this, 'add_to_home_query' ) );

		// Make sure to fetch all relevant post types when viewing series archive
		add_action( 'pre_get_posts', array( $this, 'add_all_post_types' ) );

		// Make sure to fetch all relevant post types when viewing a tag archive
		add_action( 'pre_get_posts', array( $this, 'add_all_post_types_for_tag_archive' ) );

		// Download podcast episode
		add_action( 'wp', array( $this, 'download_file' ), 1 );

		add_filter( 'feed_content_type', array( $this, 'feed_content_type' ), 10, 2 );

		// Redirect the archive page's own URL to the podcast archive URL.
		add_action( 'template_redirect', array( $this, 'maybe_redirect_archive_page_to_podcast_url' ) );

		// When a podcast archive page is assigned, swap the main query so WP's
		// natural template dispatch picks the page template instead of the CPT archive.
		add_action( 'template_redirect', array( $this, 'maybe_serve_archive_page' ) );

		// Flush rewrite rules when the podcast archive page setting changes.
		add_action(
			'update_option_' . Archive_Page_Handler::OPTION_PODCAST_PAGE_ID,
			function () {
				flush_rewrite_rules( false );
			}
		);

		// Handle localisation
		add_action( 'plugins_loaded', array( $this, 'load_localisation' ) );

		add_filter( 'archive_template_hierarchy', array( $this, 'fix_template_hierarchy' ) );
	}

	/**
	 * Remove class filters.
	 *
	 * @since 2.11.0
	 * @param string $filter_name Filter name.
	 * @param string $function_name Function name.
	 * @param int    $priority Filter priority.
	 *
	 * @return bool
	 */
	public function remove_filter( $filter_name, $function_name, $priority = 10 ) {
		$removed = remove_filter( $filter_name, array( $this, $function_name ), $priority );

		if ( $removed ) {
			$this->removed_filters[] = array(
				'filter_name'   => $filter_name,
				'function_name' => $function_name,
				'priority'      => $priority,
			);
		}

		return true;
	}

	/**
	 * Restore filters removed previously with $this->remove_filter().
	 *
	 * @since 2.11.0
	 * */
	public function restore_filters() {
		foreach ( $this->removed_filters as $filter ) {
			add_filter( $filter['filter_name'], array( $this, $filter['function_name'] ), $filter['priority'] );
		}
	}

	/**
	 * Disable default podcast RSS feed link. We add the correct feed link manually @see rss_meta_tag()
	 *
	 * @param string $link
	 *
	 * @return string|null
	 */
	public function disable_default_podcast_rss_feed_link( $link ) {
		if ( is_post_type_archive( SSP_CPT_PODCAST ) && strpos( $link, SSP_CPT_PODCAST . '/feed' ) ) {
			return null;
		}

		return $link;
	}

	/**
	 * Adds filter for private podcast episodes content.
	 */
	protected function protect_private_podcast_episodes() {
		$filter = array( $this, 'show_private_content_message' );
		add_filter( 'the_content', $filter, 20 );
		add_filter( 'the_content_rss', $filter, 20 );
		add_filter( 'comment_text_rss', $filter, 20 );
	}

	/**
	 * Show a message that episode belongs to private podcast.
	 *
	 * @param $content
	 *
	 * @return mixed|string|void
	 */
	public function show_private_content_message( $content ) {

		$post = get_post();

		$ssp_post_types = ssp_post_types();

		if ( empty( $post->post_type ) || ! in_array( $post->post_type, $ssp_post_types ) ) {
			return $content;
		}

		$terms = wp_get_post_terms( $post->ID, ssp_series_taxonomy() );

		if ( ! is_array( $terms ) ) {
			return $content;
		}

		$message = __( 'This content is Private. To access this podcast, contact the site owner.', 'seriously-simple-podcasting' );

		// Protect default feed episodes.
		if ( empty( $terms ) && 'yes' === ssp_get_option( 'is_podcast_private' ) ) {
			return $message;
		}

		/**
		 * Protect episodes that belong to series.
		 *
		 * @var WP_Term[] $terms
		 * */
		foreach ( $terms as $term ) {
			if ( 'yes' === ssp_get_option( 'is_podcast_private', '', $term->term_id ) ) {
				return $message;
			}
		}

		return $content;
	}

	/**
	 * Unfortunately, WP core doesn't search for archive-podcast.php automatically (though it should).
	 * So add the template to search list manually.
	 *
	 * @param array $templates
	 *
	 * @return array
	 */
	public function fix_template_hierarchy( $templates ) {
		$use_post_tag = apply_filters( 'ssp_use_post_tags', true );

		// Use queried object because is_tax('post_tag') doesn't work ( is_tax is false ).
		$queried = get_queried_object();

		if ( is_tax( ssp_series_taxonomy() ) || ( $use_post_tag && isset( $queried->taxonomy ) && 'post_tag' === $queried->taxonomy ) ) {
			$templates = array_merge( array( 'archive-' . SSP_CPT_PODCAST . '.php' ), $templates );
		}

		return $templates;
	}

	public function register_ajax_actions() {
		add_action( 'wp_ajax_get_playlist_items', array( $this, 'get_ajax_playlist_items' ) );
		add_action( 'wp_ajax_nopriv_get_playlist_items', array( $this, 'get_ajax_playlist_items' ) );
	}

	public function get_ajax_playlist_items() {
		$items = $this->players_controller->get_ajax_playlist_items();
		wp_send_json_success( $items );
	}

	/**
	 * Add episode meta data to the full content
	 *
	 * @param  string $content Existing content
	 * @return string          Modified content
	 * @deprecated Use embed_player_in_content() instead
	 */
	public function content_meta_data( $content = '' ) {
		return $this->embed_player_in_content( $content );
	}

	/**
	 * Add episode meta data to the full content
	 *
	 * @param  string $content Existing content
	 * @return string          Modified content
	 */
	public function embed_player_in_content( $content = '' ) {

		global $post, $wp_current_filter, $episode_context;

		// Don't do anything if we don't have a valid post object
		if ( ! is_a( $post, 'WP_Post' ) ) {
			return $content;
		}

		// Don't output unformatted data on excerpts
		if ( in_array( 'get_the_excerpt', (array) $wp_current_filter, true ) ) {
			return $content;
		}

		// Don't output player if Elementor Player widget was embedded manually
		if ( false !== strpos( $content, 'data-widget_type="Castos Player.default"' ) ) {
			return $content;
		}

		// Don't output episode meta in shortcode or widget
		if ( isset( $episode_context ) && in_array( $episode_context, array( 'shortcode', 'widget' ), true ) ) {
			return $content;
		}

		if ( post_password_required( $post->ID ) ) {
			return $content;
		}

		// Don't output episode meta in a REST Request
		if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
			return $content;
		}

		if ( ! apply_filters( 'ssp_show_media_player_in_content', true, $post, $content ) ) {
			return $content;
		}

		$podcast_post_types = ssp_post_types( true );

		$player_visibility = get_option( 'ss_podcasting_player_content_visibility', 'all' );

		switch ( $player_visibility ) {
			case 'membersonly':
				$show_player = is_user_logged_in();
				break;
			default:
				$show_player = true;
				break;
		}

		if ( $show_player && in_array( $post->post_type, $podcast_post_types ) && ! is_feed() && ! isset( $_GET['feed'] ) ) {

			// Get episode player
			$player = $this->get_episode_player( $post->ID, 'content' );

			// Get specified player position
			$player_position = get_option( 'ss_podcasting_player_content_location', 'above' );

			switch ( $player_position ) {
				case 'above':
					$content = $player . $content;
					break;
				case 'below':
					$content = $content . $player;
					break;
			}
		}

		return $content;
	}

	/**
	 * Runs checks to see if the player should be rendered or not
	 *
	 * @param int  $episode_id Episode post ID.
	 * @param bool $show_player Default true.
	 *
	 * @return boolean
	 */
	public function validate_media_player( $episode_id, $show_player = true ) {
		/**
		 * If the show_player variable was already set false (specifically for the ss_podcast shortcode)
		 */
		if ( false === $show_player ) {
			return false;
		}
		/**
		 * Check if the user is using the ss_player shortcode anywhere in this post
		 */
		if ( ssp_check_if_podcast_has_shortcode( $episode_id, 'ss_player' ) ) {
			return false;
		}

		/**
		 * Check if this post is using the HTML5 player block
		 */
		if ( has_block( 'seriously-simple-podcasting/castos-player' ) || has_block( 'seriously-simple-podcasting/castos-html-player' ) ) {
			return false;
		}

		/**
		 * Check if the user is using an elementor widget version of the player.
		 */
		if ( ssp_is_elementor_ok() && ssp_check_if_podcast_has_elementor_player( $episode_id ) ) {
			return false;
		}

		return true;
	}

	/**
	 * Get episode meta data
	 *
	 * @param  integer $episode_id ID of episode post
	 * @param  string  $context Context for display
	 *
	 * @return string               Episode meta
	 */
	public function get_episode_player( $episode_id = 0, $context = 'content' ) {

		$player = '';

		if ( ! $episode_id ) {
			return $player;
		}

		$file = $this->get_enclosure( $episode_id );

		if ( $file ) {

			// Hide audio player in `ss_podcast` shortcode by default
			$show_player = true;
			if ( 'shortcode' === $context ) {
				$show_player = false;
			}

			// Show audio player if requested
			$player_style = get_option( 'ss_podcasting_player_style' );

			$show_player = $this->validate_media_player( $episode_id, $show_player );

			// Allow media player to be dynamically hidden/displayed
			$show_player = apply_filters( 'ssp_show_media_player', $show_player, $context );

			if ( $show_player ) {
				$player .= '<div class="podcast_player">' . $this->load_media_player( $file, $episode_id, $player_style, $context ) . '</div>';
			}
		}

		return apply_filters( 'ssp_episode_meta', $player, $episode_id, $context );
	}

	/**
	 * Get episode enclosure
	 * Wrapper for Episode_Controller get_enclosure method
	 *
	 * @param  integer $episode_id ID of episode
	 * @return string              URL of enclosure
	 */
	public function get_enclosure( $episode_id = 0 ) {
		return $this->episode_repository->get_enclosure( $episode_id );
	}

	/**
	 * Get download link for episode
	 * Wrapper for Episode_Controller get_episode_download_link method
	 *
	 * @param  integer $episode_id ID of episode
	 * @param  string  $referrer Referrer
	 * @return string              Episode download link
	 */
	public function get_episode_download_link( $episode_id, $referrer = '' ) {
		return $this->episode_repository->get_episode_download_link( $episode_id, $referrer );
	}

	/**
	 * Return media player for a given file. Used to enable other checks or to prevent the player from loading
	 *
	 * @param string $src_file
	 * @param int    $episode_id
	 * @param string $player_size
	 * @param string $context
	 *
	 * @return string
	 */
	public function media_player( $src_file = '', $episode_id = 0, $player_size = 'large', $context = 'block' ) {
		$media_player = '';
		$show_player  = $this->validate_media_player( $episode_id );
		if ( $show_player ) {
			$media_player = $this->load_media_player( $src_file, $episode_id, $player_size, $context );
		}
		return $media_player;
	}

	/**
	 * @param string $src_file
	 * @param int    $episode_id
	 * @param string $player_size
	 * @param string $context
	 *
	 * @return mixed|void
	 */
	public function load_media_player( $src_file, $episode_id, $player_size, $context = 'block' ) {
		return $this->players_controller->load_media_player( $src_file, $episode_id, $player_size, $context );
	}

	/**
	 * Get the type of podcast episode (audio or video)
	 *
	 * @param  integer $episode_id ID of episode
	 * @return mixed              [description]
	 * @deprecated Use Episode_Repository::get_episode_type() instead
	 */
	public function get_episode_type( $episode_id = 0 ) {
		return $this->episode_repository->get_episode_type( $episode_id );
	}

	/**
	 * Fetch episode meta details
	 *
	 * @param  integer $episode_id ID of episode post
	 * @param  string  $context    Context for display
	 * @return string              Episode meta details
	 */
	public function episode_meta_details( $episode_id = 0, $context = 'content', $return = false ) {
		return $this->players_controller->episode_meta_details( $episode_id, $context, $return );
	}

	/**
	 * Add the meta data to the episode excerpt
	 *
	 * @param  string $excerpt Existing excerpt
	 * @return string          Modified excerpt
	 */
	public function get_excerpt_meta_data( $excerpt = '' ) {
		return $this->excerpt_meta_data( $excerpt, 'excerpt' );
	}

	/**
	 * Add episode meta data to the excerpt
	 *
	 * @param  string $excerpt Existing excerpt
	 * @return string          Modified excerpt
	 */
	public function excerpt_meta_data( $excerpt = '', $content = 'excerpt' ) {

		global $post;

		if ( ! apply_filters( 'ssp_show_excerpt_player', true, $post, $excerpt, $content ) ) {
			return $excerpt;
		}

		if ( ! is_a( $post, 'WP_Post' ) ) {
			return $excerpt;
		}

		if ( post_password_required( $post->ID ) ) {
			return $excerpt;
		}

		// Don't output episode meta in a REST Request
		if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
			return $content;
		}

		$podcast_post_types = ssp_post_types( true );

		$player_visibility = get_option( 'ss_podcasting_player_content_visibility', 'all' );

		switch ( $player_visibility ) {
			case 'all':
				$show_player = true;
				break;
			case 'membersonly':
				$show_player = is_user_logged_in();
				break;
			default:
				$show_player = true;
				break;
		}

		if ( $show_player && in_array( $post->post_type, $podcast_post_types ) && ! is_feed() && ! isset( $_GET['feed'] ) ) {
			$meta = $this->get_episode_player( $post->ID, $content );

			$excerpt = $meta . $excerpt;
		}

		return $excerpt;
	}

	/**
	 * Add the meta data to the embedded episode excerpt
	 *
	 * @param  string $excerpt Existing excerpt
	 * @return string          Modified excerpt
	 */
	public function get_embed_meta_data( $excerpt = '' ) {
		return $this->excerpt_meta_data( $excerpt, 'embed' );
	}

	/**
	 * Add podcast to home page query
	 *
	 * @param object $query The query object
	 */
	public function add_to_home_query( $query ) {

		if ( is_admin() ) {
			return;
		}

		$include_in_main_query = get_option( 'ss_podcasting_include_in_main_query' );
		if ( $include_in_main_query && $include_in_main_query == 'on' ) {
			if ( $query->is_home() && $query->is_main_query() ) {
				$query->set( 'post_type', array( 'post', SSP_CPT_PODCAST ) );
			}
		}
	}

	public function add_all_post_types( $query ) {

		if ( is_admin() ) {
			return;
		}

		if ( ! $query->is_main_query() ) {
			return;
		}

		if ( is_post_type_archive( SSP_CPT_PODCAST ) || is_tax( ssp_series_taxonomy() ) ) {
			$podcast_post_types = ssp_post_types( false );

			if ( empty( $podcast_post_types ) ) {
				$query->set( 'post_type', SSP_CPT_PODCAST );
				return;
			}

			$episode_ids = ssp_episode_ids();
			if ( ! empty( $episode_ids ) ) {
				$query->set( 'post__in', $episode_ids );

				$podcast_post_types = array_merge( array( SSP_CPT_PODCAST ), $podcast_post_types );
				$query->set( 'post_type', $podcast_post_types );
			}
		}
	}

	/**
	 * @see Archive_Page_Handler::maybe_serve_archive_page()
	 */
	public function maybe_serve_archive_page() {
		$this->archive_page_handler->maybe_serve_archive_page();
	}

	/**
	 * @see Archive_Page_Handler::maybe_redirect_archive_page_to_podcast_url()
	 */
	public function maybe_redirect_archive_page_to_podcast_url() {
		$this->archive_page_handler->maybe_redirect_archive_page_to_podcast_url();
	}

	public function add_all_post_types_for_tag_archive( $query ) {

		if ( is_admin() ) {
			return;
		}

		if ( ! $query->is_main_query() ) {
			return;
		}

		if ( ! is_tag() ) {
			return;
		}

		if ( ! apply_filters( 'ssp_use_post_tags', true ) ) {
			return;
		}

		$post_types             = $query->get( 'post_type' ) ?: array();
		$tag_archive_post_types = apply_filters( 'ssp_tag_archive_post_types', array( 'post', SSP_CPT_PODCAST ) );

		$query->set( 'post_type', array_merge( (array) $post_types, (array) $tag_archive_post_types ) );
	}

	/**
	 * Get duration of audio file
	 *
	 * @param  string $file File name & path
	 * @return mixed        File duration on success, boolean false on failure
	 */
	public function get_file_duration( $file ) {
		return $this->episode_repository->get_file_duration( $file );
	}

	/**
	 * Load audio player for given file - wrapper for `media_player` method to maintain backwards compatibility
	 *
	 * @param  string  $src        Source of audio file
	 * @param  integer $episode_id Episode ID for audio empty string
	 * @return string              Audio player HTML on success, false on failure
	 */
	public function audio_player( $src = '', $episode_id = 0 ) {
		$player = $this->media_player( $src, $episode_id );
		return apply_filters( 'ssp_audio_player', $player, $src, $episode_id );
	}

	/**
	 * Get episode image
	 *
	 * @param  integer $post_id   ID of episode
	 * @param  string  $size Image size
	 * @return string        Image HTML markup
	 */
	public function get_image( $post_id = 0, $size = 'full' ) {

		$image = '';

		if ( is_int( $size ) || ( 0 < intval( $size ) ) ) {
			$size = array( intval( $size ), intval( $size ) );
		} elseif ( ! is_string( $size ) && ! is_array( $size ) ) {
			$size = array( 200, 9999 );
		}

		$image_id = get_post_meta( $post_id, 'cover_image_id', true );

		if ( $image_id ) {
			$image = wp_get_attachment_image( $image_id, $size );
		}

		if ( empty( $image ) && has_post_thumbnail( $post_id ) ) {
			$image = get_the_post_thumbnail( intval( $post_id ), $size );
		}

		return apply_filters( 'ssp_episode_image', $image, $post_id );
	}

	/**
	 * Get episode image url
	 *
	 * @param integer $post_id ID of episode
	 * @param string  $size Image size
	 *
	 * @return string        Image url
	 */
	public function get_episode_image_url( $post_id = 0, $size = 'full' ) {
		$image_url = '';
		$image_id  = get_post_meta( $post_id, 'cover_image_id', true );

		/**
		 * Controls whether the featured image is used as a fallback for feed/episode images.
		 *
		 * @param bool $use_featured Whether to use the featured image. Default true.
		 * @param int  $post_id      The episode post ID.
		 */
		$use_featured_image = apply_filters( 'ssp_use_featured_image_for_feed', true, $post_id );

		if ( ! $image_id && $use_featured_image ) {
			$image_id = get_post_thumbnail_id( $post_id );
		}

		if ( $image_id ) {
			$image_att = wp_get_attachment_image_src( $image_id, $size );
			$image_url = isset( $image_att[0] ) ? $image_att[0] : '';
		}

		return apply_filters( 'ssp_episode_image_url', $image_url, $post_id );
	}

	/**
	 * Get episode ID from WordPress query
	 *
	 * @since 3.14.2
	 * @return int Episode ID or 0 if not found.
	 */
	protected function get_episode_id_from_query() {
		global $wp_query;
		return isset( $wp_query->query_vars['podcast_episode'] ) ? intval( $wp_query->query_vars['podcast_episode'] ) : 0;
	}

	/**
	 * Clean file URL by removing newlines
	 *
	 * File URLs may contain newlines which need to be removed before processing.
	 *
	 * @since 3.14.2
	 * @param string $file File URL to clean.
	 * @return string Cleaned file URL.
	 */
	protected function clean_file_url( $file ) {
		if ( false !== strpos( $file, "\n" ) ) {
			$parts = explode( "\n", $file );
			$file  = $parts[0];
		}
		return $file;
	}

	/**
	 * Encode file URL for safe transmission
	 *
	 * Encodes spaces and removes newlines from file URLs to ensure
	 * safe transmission over HTTP.
	 *
	 * @since 3.14.2
	 * @param string $file File URL to encode.
	 * @return string Encoded file URL.
	 */
	protected function encode_file_url( $file ) {
		$file = str_replace( ' ', '%20', $file );
		$file = str_replace( PHP_EOL, '', $file );
		return $file;
	}

	/**
	 * Get download referrer from request
	 *
	 * Checks query vars and GET parameters for referrer information.
	 *
	 * @since 3.14.2
	 * @return string Referrer value or empty string.
	 */
	protected function get_download_referrer() {
		global $wp_query;

		$referrer = '';
		if ( isset( $wp_query->query_vars['podcast_ref'] ) && $wp_query->query_vars['podcast_ref'] ) {
			$referrer = $wp_query->query_vars['podcast_ref'];
		} elseif ( isset( $_GET['ref'] ) ) {
			$referrer = sanitize_text_field( wp_unslash( $_GET['ref'] ) );
		}

		return $referrer;
	}

	/**
	 * Trigger download action hook
	 *
	 * Allows other plugins to hook into the download process.
	 * Skipped for test-nginx referrer to avoid interference with testing.
	 *
	 * @since 3.14.2
	 * @param string   $file     File URL being downloaded.
	 * @param \WP_Post $episode  Episode post object.
	 * @param string   $referrer Download referrer.
	 * @return void
	 */
	protected function trigger_download_action( $file, $episode, $referrer ) {
		if ( 'test-nginx' !== $referrer ) {
			// Allow other actions - functions hooked on here must not output any data
			do_action( 'ssp_file_download', $file, $episode, $referrer );
		}
	}

	/**
	 * Set cache control headers
	 *
	 * Sets HTTP headers to prevent caching of download responses.
	 *
	 * @since 3.14.2
	 * @return void
	 */
	protected function set_cache_control_headers() {
		header( 'Pragma: no-cache' );
		header( 'Expires: 0' );
		header( 'Cache-Control: must-revalidate, post-check=0, pre-check=0' );
		header( 'Robots: none' );
	}

	/**
	 * Get file size with caching
	 *
	 * Attempts to get file size from multiple sources in order:
	 * 1. WordPress cache
	 * 2. Post meta
	 * 3. Filesystem (for local attachments)
	 *
	 * @since 3.14.2
	 * @param int    $episode_id Episode post ID.
	 * @param string $file       File URL.
	 * @return int|false File size in bytes or false if not determinable.
	 */
	protected function get_file_size( $episode_id, $file ) {
		// Check cache first.
		$size = wp_cache_get( $episode_id, 'filesize_raw' );

		$this->log( __METHOD__ . ': Cached size: ' . $size );

		// Nothing in the cache, let's see if we can figure it out.
		if ( false === $size ) {

			// Check post meta.
			$size = get_post_meta( $episode_id, 'filesize_raw', true );

			$this->log( __METHOD__ . ': Size raw: ' . $size );

			if ( empty( $size ) ) {

				// Try to get size from filesystem for local attachments.
				$attachment_id = $this->get_attachment_id_from_url( $file );

				if ( ! empty( $attachment_id ) ) {
					$attached_file = get_attached_file( $attachment_id );
					if ( $attached_file && file_exists( $attached_file ) ) {
						$size = filesize( $attached_file );
						$this->log( __METHOD__ . ': Estimated size: ' . $size );
						update_post_meta( $episode_id, 'filesize_raw', $size );
					}
				}
			}

			// Update the cache.
			wp_cache_set( $episode_id, $size, 'filesize_raw' );
		}

		return $size;
	}

	/**
	 * Set download headers
	 *
	 * Sets HTTP headers required for file downloads.
	 *
	 * @since 3.14.2
	 * @param int      $episode_id Episode post ID.
	 * @param int|bool $size       File size in bytes or false.
	 * @return void
	 */
	protected function set_download_headers( $episode_id, $size ) {
		// Send Content-Length header if size is known.
		if ( ! empty( $size ) ) {
			header( 'Content-Length: ' . $size );
		}

		// Force file download.
		header( 'Content-Type: application/force-download' );

		// Set other relevant headers.
		header( 'Content-Description: File Transfer' );
		header( 'Content-Disposition: attachment; filename="' . esc_html( $this->get_file_name( $episode_id ) ) . '";' );
		header( 'Content-Transfer-Encoding: binary' );
	}

	/**
	 * Serve file as direct download
	 *
	 * Handles the actual file serving for direct downloads with proper headers
	 * and file size detection.
	 *
	 * @since 3.14.2
	 * @param int    $episode_id Episode post ID.
	 * @param string $file       File URL to serve.
	 * @return void Exits after serving file.
	 */
	protected function serve_download_file( $episode_id, $file ) {
		// Get file size with caching.
		$size = $this->get_file_size( $episode_id, $file );

		// Set download headers.
		$this->set_download_headers( $episode_id, $size );

		// Encode file URL for safe transmission.
		$file = $this->encode_file_url( $file );

		// Re-validate URL immediately before file access.
		if ( ! $this->validate_file_url( $file ) ) {
			$this->block_invalid_url( $file, 'Pre-access validation failed' );
		}

		// Use ssp_readfile_chunked() if allowed on the server or simply access file directly.
		@ssp_readfile_chunked( $file ) or header( 'Location: ' . $file );
	}

	/**
	 * Serve file as redirect
	 *
	 * Redirects to the file URL for non-download referrers.
	 *
	 * @since 3.14.2
	 * @param string $file File URL to redirect to.
	 * @return void Exits after redirect.
	 */
	protected function serve_redirect_file( $file ) {
		// Encode file URL for safe transmission.
		$file = $this->encode_file_url( $file );

		// Re-validate URL immediately before redirect.
		if ( ! $this->validate_file_url( $file ) ) {
			$this->block_invalid_url( $file, 'Pre-redirect validation failed' );
		}

		// For all other referrers redirect to the raw file.
		wp_redirect( $file, 302 );
	}

	/**
	 * Download file from `podcast_episode` query variable
	 *
	 * This method orchestrates the file download process:
	 * 1. Validates the download request
	 * 2. Checks episode access permissions
	 * 3. Prepares and validates file URL
	 * 4. Sets appropriate headers
	 * 5. Serves file or redirects based on referrer
	 *
	 * @since 1.0.0
	 * @since 3.14.2 Refactored for better maintainability and testability.
	 * @return void
	 */
	public function download_file() {
		// Early return if not a download request.
		if ( ! ssp_is_podcast_download() ) {
			return;
		}

		// Get and validate episode ID.
		$episode_id = $this->get_episode_id_from_query();
		if ( ! $episode_id ) {
			return;
		}

		// Get episode post object and validate access.
		$episode = get_post( $episode_id );
		if ( ! $this->check_episode_file_access( $episode_id, $episode ) ) {
			return;
		}

		// Get and prepare file URL.
		$file = $this->get_enclosure( $episode_id );
		$file = $this->clean_file_url( $file );
		$this->validate_file( $file );

		// Get referrer and trigger download hooks.
		$referrer = $this->get_download_referrer();
		$this->trigger_download_action( $file, $episode, $referrer );

		// Set cache control headers.
		$this->set_cache_control_headers();

		// Apply filters to allow dynamic URL modification (used for ads, etc.).
		$original_file = $file;
		$file          = apply_filters( 'ssp_enclosure_url', $file, $episode_id, $referrer );
		$this->validate_file( $file );

		// Serve file based on referrer type.
		if ( 'download' === $referrer && $file === $original_file ) {
			$this->serve_download_file( $episode_id, $file );
		} else {
			$this->serve_redirect_file( $file );
		}

		// Exit to prevent other processes running later on.
		exit;
	}

	/**
	 * Checks if the current user has access to the specified episode for file download
	 *
	 * Validates episode existence, post type, post status, and user permissions.
	 * Returns false if access is denied (error response is handled internally).
	 *
	 * @param int            $episode_id Episode ID to check.
	 * @param \WP_Post|false $episode    Episode post object, or false if not found.
	 *
	 * @return bool True if access allowed, false if access denied (error response sent).
	 */
	protected function check_episode_file_access( $episode_id, $episode ) {
		// Make sure we have a valid episode post object
		if ( ! $episode || ! is_object( $episode ) || is_wp_error( $episode ) || ! isset( $episode->ID ) ) {
			status_header( 404 );
			nocache_headers();
			wp_die( esc_html__( 'Episode not found.', 'seriously-simple-podcasting' ), '', array( 'response' => 404 ) );
		}

		// Check if episode is a valid podcast post type
		$podcast_post_types = ssp_post_types();
		if ( ! in_array( $episode->post_type, $podcast_post_types, true ) ) {
			status_header( 404 );
			nocache_headers();
			wp_die( esc_html__( 'Episode not found.', 'seriously-simple-podcasting' ), '', array( 'response' => 404 ) );
		}

		// Check post status and user authorization
		$post_status_obj = get_post_status_object( $episode->post_status );

		// Use WordPress's built-in permission system which handles author checks and capabilities
		if ( 'private' === $episode->post_status ) {
			// For private posts, check if user can read this specific post
			// This handles: author check, read_private_posts capability, etc.
			if ( ! current_user_can( 'read_post', $episode_id ) ) {
				// User not authorized - return 403 Forbidden
				status_header( 403 );
				nocache_headers();
				wp_die( esc_html__( 'Sorry, you are not allowed to access this episode.', 'seriously-simple-podcasting' ), '', array( 'response' => 403 ) );
			}
		} elseif ( 'draft' === $episode->post_status || 'pending' === $episode->post_status ) {
			// Check if user can edit the post
			if ( ! current_user_can( 'edit_post', $episode_id ) ) {
				// User not authorized - return 403 Forbidden
				status_header( 403 );
				nocache_headers();
				wp_die( esc_html__( 'Sorry, you are not allowed to access this episode.', 'seriously-simple-podcasting' ), '', array( 'response' => 403 ) );
			}
		} elseif ( ! $post_status_obj || ! $post_status_obj->public ) {
			// Reject other non-public statuses
			if ( ! current_user_can( 'read_post', $episode_id ) ) {
				// User not authorized - return 403 Forbidden
				status_header( 403 );
				nocache_headers();
				wp_die( esc_html__( 'Sorry, you are not allowed to access this episode.', 'seriously-simple-podcasting' ), '', array( 'response' => 403 ) );
			}
		}

		return true;
	}

	/**
	 * @param int $episode_id
	 *
	 * @return string
	 */
	protected function get_file_name( $episode_id ) {
		$file_data = new Castos_File_Data(
			json_decode( get_post_meta( $episode_id, 'castos_file_data', true ), true )
		);

		$allowed_extensions = array( 'm4a', 'mp3', 'mov', 'mp4' );
		$file_name          = $file_data->name;

		$file_name = $file_name && in_array( pathinfo( $file_name, PATHINFO_EXTENSION ), $allowed_extensions ) ?
			$file_name : basename( $this->get_enclosure( $episode_id ) );

		return apply_filters( 'ssp_file_name', $file_name, $episode_id );
	}

	/**
	 * Check if URL is from a trusted domain
	 *
	 * Trusted domains are well-known CDNs and podcast hosting providers that are
	 * considered safe. URLs from these domains bypass expensive validation checks.
	 *
	 * @since 3.14.2
	 * @param string $host The hostname to check.
	 * @return bool True if trusted, false otherwise.
	 */
	protected function is_trusted_domain( $host ) {
		static $trusted_domains = null;
		
		// Initialize once per request.
		if ( null === $trusted_domains ) {
			$trusted_domains = array();
			
			// Add current WordPress site domain if it meets validation requirements.
			$site_url = parse_url( home_url(), PHP_URL_HOST );
			if ( $site_url && $this->is_public_domain( $site_url ) ) {
				$trusted_domains[] = strtolower( $site_url );
			}
		
			// Add upload directory domain if it meets validation requirements.
			$upload_dir = wp_upload_dir();
			if ( ! empty( $upload_dir['baseurl'] ) ) {
				$upload_host = parse_url( $upload_dir['baseurl'], PHP_URL_HOST );
				if ( $upload_host && $upload_host !== $site_url && $this->is_public_domain( $upload_host ) ) {
					$trusted_domains[] = strtolower( $upload_host );
				}
			}
			
			// Add well-known CDNs and podcast hosting providers.
			$cdn_domains = array(
				// Castos CDN.
				'castos.com',
				
				// Major podcast hosting providers.
				'blubrry.com',
				
				// Amazon S3/CloudFront.
				's3.amazonaws.com',
				'cloudfront.net',
				
				// Google Cloud Storage.
				'storage.googleapis.com',
				'storage.cloud.google.com',
				
				// WordPress.com.
				'files.wordpress.com',
				
				// Common CDNs.
				'akamaized.net',
				'fastly.net',
			);
			
			$trusted_domains = array_merge( $trusted_domains, $cdn_domains );
			
			// Allow filtering for extensibility.
			$trusted_domains = apply_filters( 'ssp_trusted_cdn_domains', $trusted_domains );
		}
		
		// Check exact match.
		if ( in_array( $host, $trusted_domains, true ) ) {
			return true;
		}
		
		// Check if subdomain of trusted domain.
		foreach ( $trusted_domains as $trusted ) {
			if ( substr( $host, - strlen( '.' . $trusted ) ) === '.' . $trusted ) {
				return true;
			}
		}
		
		return false;
	}

	/**
	 * Check if a domain is publicly accessible
	 *
	 * Validates that the domain is suitable for external file access.
	 * Internal and reserved addresses are excluded from trusted sources.
	 *
	 * @since 3.14.2
	 * @param string $host The hostname to check.
	 * @return bool True if public, false if private/localhost.
	 */
	protected function is_public_domain( $host ) {
		// Check localhost patterns.
		if ( in_array( strtolower( $host ), $this->get_localhost_patterns(), true ) ) {
			return false;
		}
		
		// Check IP address ranges.
		if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
			return ! $this->is_private_ip( $host );
		}
		
		// Domain name - proceed with validation.
		return true;
	}

	/**
	 * Check if an IP address is private or reserved
	 *
	 * @since 3.14.2
	 * @param string $ip The IP address to check.
	 * @return bool True if private/reserved, false if public.
	 */
	protected function is_private_ip( $ip ) {
		// Check IP address using PHP filters.
		return ! filter_var(
			$ip,
			FILTER_VALIDATE_IP,
			FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
		);
	}

	/**
	 * Get validation cache key for a URL
	 *
	 * @since 3.14.2
	 * @param string $url The URL to generate cache key for.
	 * @return string Cache key.
	 */
	protected function get_validation_cache_key( $url ) {
		return 'ssp_url_valid_' . md5( $url );
	}

	/**
	 * Cache URL validation result
	 *
	 * @since 3.14.2
	 * @param string $cache_key Cache key.
	 * @param bool   $is_valid  Whether URL is valid.
	 * @param int    $duration  Cache duration in seconds.
	 * @return void
	 */
	protected function cache_validation_result( $cache_key, $is_valid, $duration ) {
		set_transient( $cache_key, $is_valid ? 1 : 0, $duration );
	}

	/**
	 * Check if URL matches blocked patterns
	 *
	 * Performs quick validation checks against blocked patterns that don't require DNS resolution.
	 * Checks for: user credentials, suspicious fragments, non-HTTP protocols, localhost,
	 * special TLDs, and cloud metadata endpoints.
	 *
	 * @since 3.14.2
	 * @param string $url URL to check.
	 * @return bool True if URL matches blocked patterns, false otherwise.
	 */
	protected function url_matches_blocked_patterns( $url ) {
		// Parse URL.
		$parsed = wp_parse_url( $url );
		if ( ! $parsed || ! isset( $parsed['host'] ) ) {
			return true;
		}

		$host = strtolower( $parsed['host'] );

		// Block URLs with user info.
		if ( isset( $parsed['user'] ) || isset( $parsed['pass'] ) ) {
			return true;
		}

		// Block suspicious fragments.
		if ( isset( $parsed['fragment'] ) && strpos( $parsed['fragment'], '@' ) !== false ) {
			return true;
		}

		// Only allow HTTP and HTTPS protocols.
		if ( ! isset( $parsed['scheme'] ) || ! in_array( strtolower( $parsed['scheme'] ), self::ALLOWED_SCHEMES, true ) ) {
			return true;
		}

		// Block localhost variations.
		$localhost_patterns = $this->get_localhost_patterns();
		if ( in_array( $host, $localhost_patterns, true ) ) {
			return true;
		}

		// Block special TLDs.
		$blocked_tlds = $this->get_blocked_tlds();
		foreach ( $blocked_tlds as $tld ) {
			if ( substr( $host, - strlen( $tld ) ) === $tld ) {
				return true;
			}
		}

		// Block cloud metadata hostnames.
		$metadata_hosts = $this->get_metadata_hosts();
		if ( in_array( $host, $metadata_hosts, true ) ) {
			return true;
		}

		return false;
	}

	/**
	 * Get localhost patterns to block
	 *
	 * @since 3.14.2
	 * @return array Localhost patterns.
	 */
	protected function get_localhost_patterns() {
		$patterns = array(
			'localhost',
			'localhost.localdomain',
		);

		return apply_filters( 'ssp_blocked_localhost_patterns', $patterns );
	}

	/**
	 * Get blocked TLDs
	 *
	 * @since 3.14.2
	 * @return array Blocked TLDs.
	 */
	protected function get_blocked_tlds() {
		$tlds = array( '.local', '.internal', '.private', '.lan' );

		return apply_filters( 'ssp_blocked_tlds', $tlds );
	}

	/**
	 * Get cloud metadata hostnames to block
	 *
	 * @since 3.14.2
	 * @return array Metadata hostnames.
	 */
	protected function get_metadata_hosts() {
		$hosts = array(
			'metadata.google.internal',
			'169.254.169.254',
		);

		return apply_filters( 'ssp_blocked_metadata_hosts', $hosts );
	}

	/**
	 * Validate URL IP address
	 *
	 * Handles both direct IP URLs and hostname resolution.
	 *
	 * @since 3.14.2
	 * @param string $host Hostname to validate.
	 * @return bool True if valid, false otherwise.
	 */
	protected function validate_url_ip( $host ) {
		// Extract IP from host (handles encoded formats).
		$ip = $this->extract_ip_from_host( $host );
		
		if ( ! $ip ) {
			// Not a valid IP, could be a hostname - resolve and validate it.
			$resolved_ips = $this->resolve_hostname( $host );
			
			if ( empty( $resolved_ips ) ) {
				// Could not resolve - check if it looks like a valid external hostname.
				if ( ! filter_var( $host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME ) ) {
					return false;
				}
				// Hostname appears valid but couldn't resolve - allow it.
				return true;
			}

			// Validate all resolved IPs.
			foreach ( $resolved_ips as $resolved_ip ) {
				if ( ! $this->validate_ip_address( $resolved_ip ) ) {
					return false;
				}
			}
			
			return true;
		}

		// Validate the extracted IP.
		return $this->validate_ip_address( $ip );
	}

	/**
	 * Validate file URL for download requests
	 *
	 * Validates URLs to ensure they meet security requirements for podcast file access.
	 * Uses result caching and trusted domain whitelist for performance optimization.
	 * Checks protocol, hostname format, IP ranges, and performs DNS validation.
	 *
	 * @since 3.14.2
	 * @param string $url The URL to validate.
	 * @return bool True if URL is valid, false otherwise.
	 */
	public function validate_file_url( $url ) {
		// Basic validation.
		if ( ! is_string( $url ) || empty( $url ) ) {
			return false;
		}

		// Parse URL for trusted domain check.
		$parsed = wp_parse_url( $url );
		if ( ! $parsed || ! isset( $parsed['host'] ) ) {
			return false;
		}

		$host = strtolower( $parsed['host'] );

		// Fast path: Trusted domain bypass for performance.
		if ( $this->is_trusted_domain( $host ) ) {
			$cache_key = $this->get_validation_cache_key( $url );
			$this->cache_validation_result( $cache_key, true, self::CACHE_TTL_TRUSTED );
			return true;
		}

		// Check cache after trusted domain check (cache key generation is deferred).
		$cache_key = $this->get_validation_cache_key( $url );
		$cached_result = get_transient( $cache_key );
		if ( false !== $cached_result ) {
			return (bool) $cached_result;
		}

		// Fast rejections before expensive operations.
		if ( $this->url_matches_blocked_patterns( $url ) ) {
			$this->cache_validation_result( $cache_key, false, self::CACHE_TTL_INVALID );
			return false;
		}

		// Use WordPress's built-in URL validation (moderately expensive).
		if ( function_exists( 'wp_http_validate_url' ) ) {
			if ( ! wp_http_validate_url( $url ) ) {
				$this->cache_validation_result( $cache_key, false, self::CACHE_TTL_INVALID );
				return false;
			}
		}

		// Validate IP address (expensive: DNS resolution may occur).
		$is_valid = $this->validate_url_ip( $host );
		$cache_duration = $is_valid ? self::CACHE_TTL_VALID : self::CACHE_TTL_INVALID;
		$this->cache_validation_result( $cache_key, $is_valid, $cache_duration );
		
		return $is_valid;
	}

	/**
	 * Resolve hostname to IP addresses
	 *
	 * Performs DNS lookup for both IPv4 and IPv6 addresses with caching.
	 * Cache duration is intentionally short (5 minutes) to balance performance
	 * with protection against DNS rebinding attacks.
	 *
	 * @since 3.14.2
	 * @since 3.14.2 Added DNS result caching for performance.
	 * @param string $host The hostname to resolve.
	 * @return array Array of resolved IP addresses, empty array if resolution fails.
	 */
	protected function resolve_hostname( $host ) {
		// Check DNS cache first.
		$cache_key = 'ssp_dns_' . md5( $host );
		$cached_ips = get_transient( $cache_key );
		
		if ( false !== $cached_ips && is_array( $cached_ips ) ) {
			return $cached_ips;
		}

		$resolved_ips = array();

		// Try DNS lookup for both IPv4 and IPv6.
		$dns_records = @dns_get_record( $host, DNS_A + DNS_AAAA );
		
		if ( ! empty( $dns_records ) && is_array( $dns_records ) ) {
			foreach ( $dns_records as $record ) {
				if ( isset( $record['ip'] ) ) {
					// IPv4 record.
					$resolved_ips[] = $record['ip'];
				} elseif ( isset( $record['ipv6'] ) ) {
					// IPv6 record.
					$resolved_ips[] = $record['ipv6'];
				}
			}
		} else {
			// Fallback to gethostbyname for IPv4 only.
			$ip = @gethostbyname( $host );
			// gethostbyname returns the hostname if it fails.
			if ( $ip !== $host && filter_var( $ip, FILTER_VALIDATE_IP ) ) {
				$resolved_ips[] = $ip;
			}
		}

		$resolved_ips = array_unique( $resolved_ips );
		
		// Cache DNS results for 5 minutes.
		// Intentionally short to prevent DNS rebinding attacks while improving performance.
		set_transient( $cache_key, $resolved_ips, 5 * MINUTE_IN_SECONDS );
		
		return $resolved_ips;
	}

	/**
	 * Extract IP address from hostname
	 *
	 * Handles IPv4, IPv6, and various IP encoding formats (decimal, hex, octal).
	 *
	 * @since 3.14.2
	 * @param string $host The hostname to parse.
	 * @return string|false IP address or false if not an IP.
	 */
	protected function extract_ip_from_host( $host ) {
		// Handle IPv6 in brackets [::1].
		if ( preg_match( '/^\[(.*)\]$/', $host, $matches ) ) {
			$ipv6 = $matches[1];
			// Validate IPv6.
			if ( filter_var( $ipv6, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
				return $ipv6;
			}
			return false;
		}

		// Check if it's a standard IPv4 or IPv6.
		if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
			return $host;
		}

		// Handle decimal IP (e.g., 2130706433 = 127.0.0.1).
		if ( ctype_digit( $host ) && $host[0] !== '0' ) {
			$long = (int) $host;
			// Convert to standard IPv4.
			return long2ip( $long );
		}

		// Handle octal IP (e.g., 017700000001 = 127.0.0.1).
		if ( preg_match( '/^0[0-7]+$/', $host ) ) {
			$long = octdec( $host );
			return long2ip( $long );
		}

		// Handle hex IP (e.g., 0x7f.0x00.0x00.0x01 = 127.0.0.1 or 0x7f000001).
		if ( preg_match( '/^0x[\da-f]+$/i', $host ) ) {
			$long = hexdec( $host );
			return long2ip( $long );
		}

		// Handle dotted decimal/hex/octal notation.
		if ( strpos( $host, '.' ) !== false ) {
			$parts = explode( '.', $host );
			if ( count( $parts ) === 4 ) {
				$bytes = array();
				foreach ( $parts as $part ) {
					// Hex.
					if ( preg_match( '/^0x[\da-f]+$/i', $part ) ) {
						$bytes[] = hexdec( $part );
					} elseif ( preg_match( '/^0\d+$/', $part ) ) {
						// Octal.
						$bytes[] = octdec( $part );
					} elseif ( ctype_digit( $part ) ) {
						// Decimal.
						$bytes[] = (int) $part;
					} else {
						// Not a valid IP format.
						return false;
					}
				}
				// Validate bytes are in range.
				foreach ( $bytes as $byte ) {
					if ( $byte < 0 || $byte > 255 ) {
						return false;
					}
				}
				return implode( '.', $bytes );
			}
		}

		return false;
	}

	/**
	 * Validate IP address for external access
	 *
	 * Checks if an IP address is valid for external file access requests.
	 * Validates against private/reserved IP ranges.
	 *
	 * @since 3.14.2
	 * @param string $ip The IP address to check.
	 * @return bool True if IP is valid, false if it's private/reserved.
	 */
	protected function validate_ip_address( $ip ) {
		// Validate it's a proper IP.
		if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
			return false;
		}

		// Use PHP's built-in check for private/reserved IPs.
		// This covers: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, etc.
		if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
			return false;
		}

		// Additional check for IPv6 localhost.
		$ipv6_localhost = array( '::1', '0:0:0:0:0:0:0:1' );
		if ( in_array( $ip, $ipv6_localhost, true ) ) {
			return false;
		}

		// Check for IPv4-mapped IPv6 localhost (::ffff:127.0.0.1).
		if ( strpos( $ip, '::ffff:127.' ) === 0 ) {
			return false;
		}

		return true;
	}

	/**
	 * Validate a download file URL
	 *
	 * @since 3.14.2
	 * @param string $file File path.
	 * @return string|void Error message if validation fails, void on success.
	 */
	protected function validate_file( $file ) {
		// Ensure that $file is a URL.
		$is_url = is_string( $file ) && ( 0 === strpos( $file, 'http' ) );

		// Exit if file is not URL.
		if ( ! $is_url ) {
			$this->send_404();
		}

		// Validate URL format and accessibility.
		if ( ! $this->validate_file_url( $file ) ) {
			$this->block_invalid_url( $file, 'URL validation failed' );
		}
	}

	/**
	 * Block invalid URL and log access attempt
	 *
	 * Terminates the request when URL validation fails.
	 * Logs the attempt for monitoring and returns 403 Forbidden.
	 *
	 * @since 3.14.2
	 * @param string $url     The blocked URL.
	 * @param string $context Context where the block occurred.
	 * @return void Terminates execution with wp_die().
	 */
	protected function block_invalid_url( $url, $context = 'Invalid URL' ) {
		// Get client IP address with proxy support.
		$client_ip = $this->get_client_ip();
		
		// Log the blocked attempt for monitoring.
		error_log(
			sprintf(
				'SSP: Blocked URL access - URL: %s, User ID: %d, IP: %s, Reason: %s, Context: %s',
				$url,
				get_current_user_id(),
				$client_ip,
				$context,
				wp_json_encode( array(
					'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : 'unknown',
					'referer'    => isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : 'unknown',
				) )
			)
		);

		// Return 403 Forbidden for blocked URLs.
		status_header( 403 );
		nocache_headers();
		wp_die(
			esc_html__( 'Access to this resource is not permitted.', 'seriously-simple-podcasting' ),
			esc_html__( 'Forbidden', 'seriously-simple-podcasting' ),
			array( 'response' => 403 )
		);
	}

	/**
	 * Get client IP address with proxy support
	 *
	 * Attempts to get the real client IP address even when behind proxies.
	 * Falls back to REMOTE_ADDR if proxy headers are not available.
	 *
	 * @since 3.14.2
	 * @return string Client IP address or 'unknown' if not available.
	 */
	protected function get_client_ip() {
		// Check for proxy headers in order of reliability.
		$headers = array(
			'HTTP_CF_CONNECTING_IP', // Cloudflare
			'HTTP_X_REAL_IP',        // Nginx proxy
			'HTTP_X_FORWARDED_FOR',  // Standard proxy header
			'REMOTE_ADDR',           // Direct connection
		);

		foreach ( $headers as $header ) {
			if ( ! empty( $_SERVER[ $header ] ) ) {
				$ip = sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) );
				
				// X-Forwarded-For can contain multiple IPs (client, proxy1, proxy2...)
				// Use the first one (the original client).
				if ( 'HTTP_X_FORWARDED_FOR' === $header && strpos( $ip, ',' ) !== false ) {
					$ips = array_map( 'trim', explode( ',', $ip ) );
					$ip  = $ips[0];
				}
				
				// Validate it's a proper IP address.
				if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
					return $ip;
				}
			}
		}

		return 'unknown';
	}

	/**
	 * Clear all URL validation and DNS caches
	 *
	 * Useful for debugging or after security updates.
	 * Clears both URL validation results and DNS resolution caches.
	 *
	 * @since 3.14.2
	 * @return int Number of cache entries deleted.
	 */
	public function clear_validation_cache() {
		global $wpdb;
		
		// Delete all transients matching our patterns.
		$deleted = $wpdb->query(
			$wpdb->prepare(
				"DELETE FROM {$wpdb->options} 
				 WHERE option_name LIKE %s 
				 OR option_name LIKE %s
				 OR option_name LIKE %s
				 OR option_name LIKE %s",
				'_transient_ssp_url_valid_%',
				'_transient_timeout_ssp_url_valid_%',
				'_transient_ssp_dns_%',
				'_transient_timeout_ssp_dns_%'
			)
		);
		
		// Flush object cache if available.
		wp_cache_flush();
		
		return $deleted;
	}

	/**
	 * Show the 404 not found page content.
	 */
	protected function send_404() {
		global $wp_query;
		$wp_query->set_404();
		status_header( 404 );
		get_template_part( 404 );
		exit();
	}

	/**
	 * Get the ID of an attachment from its image URL.
	 *
	 * @param   string $url    The path to an image.
	 * @return  int|bool            ID of the attachment or 0 on failure.
	 */
	public function get_attachment_id_from_url( $url = '' ) {

		// Let's hash the URL to ensure that we don't get
		// any illegal chars that might break the cache.
		$key = md5( $url );

		// Do we have anything in the cache for this URL?
		$attachment_id = wp_cache_get( $key, 'attachment_id' );

		if ( $attachment_id === false ) {

			// Globalize
			global $wpdb;

			// If there is no url, return.
			if ( '' === $url ) {
				return false;
			}

			// Set the default
			$attachment_id = 0;

			// Function introduced in 4.0, let's try this first.
			if ( function_exists( 'attachment_url_to_postid' ) ) {
				$attachment_id = absint( attachment_url_to_postid( $url ) );
				if ( 0 !== $attachment_id ) {
					wp_cache_set( $key, $attachment_id, 'attachment_id', DAY_IN_SECONDS );
					return $attachment_id;
				}
			}

			// Then this.
			if ( preg_match( '#\.[a-zA-Z0-9]+$#', $url ) ) {
				$sql           = $wpdb->prepare(
					"SELECT ID FROM $wpdb->posts WHERE post_type = 'attachment' AND guid = %s",
					esc_url_raw( $url )
				);
				$attachment_id = absint( $wpdb->get_var( $sql ) );
				if ( 0 !== $attachment_id ) {
					wp_cache_set( $key, $attachment_id, 'attachment_id', DAY_IN_SECONDS );
					return $attachment_id;
				}
			}

			// And then try this
			$upload_dir_paths = wp_upload_dir();
			if ( false !== strpos( $url, $upload_dir_paths['baseurl'] ) ) {
				// Ensure that we have file extension that matches iTunes.
				$url = preg_replace( '/(?=\.(m4a|mp3|mov|mp4)$)/i', '', $url );
				// Remove the upload path base directory from the attachment URL
				$url = str_replace( $upload_dir_paths['baseurl'] . '/', '', $url );
				// Finally, run a custom database query to get the attachment ID from the modified attachment URL
				$sql           = $wpdb->prepare( "SELECT wposts.ID FROM $wpdb->posts wposts, $wpdb->postmeta wpostmeta WHERE wposts.ID = wpostmeta.post_id AND wpostmeta.meta_key = '_wp_attached_file' AND wpostmeta.meta_value = '%s' AND wposts.post_type = 'attachment'", $url );
				$attachment_id = absint( $wpdb->get_var( $sql ) );
				if ( 0 !== $attachment_id ) {
					wp_cache_set( $key, $attachment_id, 'attachment_id', DAY_IN_SECONDS );
					return $attachment_id;
				}
			}
		}

		return $attachment_id;
	}

	/**
	 * Get MIME type of attachment file
	 *
	 * @param  string $attachment URL of resource
	 *
	 * @return string|false MIME type on success, false on failure
	 */
	public function get_attachment_mimetype( $attachment = '' ) {
		$key = md5( $attachment );
		if ( ! $attachment ) {
			return false;
		}

		$mime = wp_cache_get( $key, 'mime-type' );
		if ( ! $mime ) {
			$filetype = wp_check_filetype( $attachment );
			$mime     = isset( $filetype['type'] ) ? $filetype['type'] : false;
			wp_cache_set( $key, $mime, 'mime-type', DAY_IN_SECONDS );
		}

		return $mime;
	}

	/**
	 * Display plugin name and version in generator meta tag
	 *
	 * @return void
	 */
	public function generator_tag( $gen, $type ) {

		// Allow generator tags to be hidden if necessary
		if ( apply_filters( 'ssp_show_generator_tag', true, $type ) ) {
			$generator = 'Seriously Simple Podcasting ' . esc_attr( $this->version );

			switch ( $type ) {
				case 'html':
					$gen .= "\n" . '<meta name="generator" content="' . $generator . '">';
					break;
				case 'xhtml':
					$gen .= "\n" . '<meta name="generator" content="' . $generator . '" />';
					break;
			}
		}

		return $gen;
	}

	/**
	 * Display feed meta tag in site HTML
	 *
	 * @return void
	 */
	public function rss_meta_tag() {

		// Get feed slug
		$feed_slug = apply_filters( 'ssp_feed_slug', $this->token );

		if ( get_option( 'permalink_structure' ) ) {
			$feed_url = $this->home_url . 'feed/' . $feed_slug;
		} else {
			$feed_url = $this->home_url . '?feed=' . $feed_slug;
		}

		$custom_feed_url = get_option( 'ss_podcasting_feed_url' );
		if ( $custom_feed_url ) {
			$feed_url = $custom_feed_url;
		}

		$feed_url = apply_filters( 'ssp_feed_url', $feed_url );

		$html = '';

		if ( apply_filters( 'ssp_show_global_feed_tag', true ) ) {
			$html = '<link rel="alternate" type="application/rss+xml" title="' . __( 'Podcast RSS feed', 'seriously-simple-podcasting' ) . '" href="' . esc_url( $feed_url ) . '" />';
		}

		// Check if this is a series taxonomy archive and display series-specific RSS feed tag
		$current_obj = get_queried_object();
		if ( isset( $current_obj->taxonomy ) && ssp_series_taxonomy() == $current_obj->taxonomy && isset( $current_obj->slug ) && $current_obj->slug ) {
			if ( apply_filters( 'ssp_show_series_feed_tag', true, $current_obj->slug ) ) {
				if ( get_option( 'permalink_structure' ) ) {
					$series_feed_url = $feed_url . '/' . $current_obj->slug;
				} else {
					$series_feed_url = $feed_url . '&podcast_series=' . $current_obj->slug;
				}

				$html .= "\n" . '<link rel="alternate" type="application/rss+xml" title="' . sprintf( __( '%s RSS feed', 'seriously-simple-podcasting' ), $current_obj->name ) . '" href="' . esc_url( $series_feed_url ) . '" />';
			}
		}

		echo "\n" . apply_filters( 'ssp_rss_meta_tag', $html ) . "\n\n";
	}

	/**
	 * Set RSS content type for podcast feed
	 *
	 * @param  string $content_type Current content type
	 * @param  string $type         Type of feed
	 * @return string               Updated content type
	 */
	public function feed_content_type( $content_type = '', $type = '' ) {

		if ( SSP_CPT_PODCAST == $type ) {
			$content_type = 'text/xml';
		}

		return $content_type;
	}

	public function load_localisation() {
		load_plugin_textdomain( 'seriously-simple-podcasting', false, basename( $this->dir ) . '/languages/' );
	}

	/**
	 * Show single podcast episode with specified content items
	 * This is used in the SeriouslySimplePodcasting\Widgets\Single_Episode widget
	 * as well as the SeriouslySimplePodcasting\ShortCodes\Podcast_Episode shortcode
	 *
	 * @param  int     $episode_id    ID of episode post
	 * @param  array   $content_items Ordered array of content items to display
	 * @return string                 HTML of episode with specified content items
	 */
	public function podcast_episode( $episode_id = 0, $content_items = array( 'title', 'player', 'details' ), $context = '', $style = 'standard' ) {
		global $post, $episode_context;

		if ( ! $episode_id || ! is_array( $content_items ) || empty( $content_items ) ) {
			return '';
		}

		if ( '-1' == $episode_id ) {
			$episode_id = $this->episode_repository->get_latest_episode_id();
		}

		// Ensure episode_id is a positive integer for security and type safety
		$episode_id = absint( $episode_id );

		// Get episode object
		$episode = get_post( $episode_id );

		if ( ! $episode || is_wp_error( $episode ) ) {
			return '';
		}

		$html = '<div class="podcast-episode episode-' . esc_attr( $episode_id ) . '">' . "\n";

		// Setup post data for episode post object
		$post = $episode;
		setup_postdata( $post );

		$episode_context = $context;

		foreach ( $content_items as $item ) {
			switch ( $item ) {
				case 'title':
					$html .= '<h3 class="episode-title">' . get_the_title() . '</h3>' . "\n";
					break;

				case 'excerpt':
					$html .= '<p class="episode-excerpt">' . get_the_excerpt() . '</p>' . "\n";
					break;

				case 'content':
					$html .= '<div class="episode-content">' . apply_filters( 'the_content', get_the_content() ) . '</div>' . "\n";
					break;

				case 'player':
					$file = $this->get_enclosure( $episode_id );
					if ( get_option( 'permalink_structure' ) ) {
						$file = $this->get_episode_download_link( $episode_id );
					}

					$html .= '<div id="podcast_player_' . esc_attr( $episode_id ) . '" class="podcast_player">' . $this->media_player( $file, $episode_id, $style, 'podcast_episode' ) . '</div>' . "\n";
					break;

				case 'details':
				case 'meta':
					$html .= $this->episode_meta_details( $episode_id, $episode_context );
					break;

				case 'image':
					$html .= get_the_post_thumbnail( $episode_id, apply_filters( 'ssp_frontend_context_thumbnail_size', 'thumbnail' ) );
					break;
			}
		}

		// Reset post data after fetching episode details
		wp_reset_postdata();

		$html .= '</div>' . "\n";

		return $html;
	}
}

