<?php

/**
 * Import RSS Feed.
 *
 * @link    https://aiopodcasting.com
 * @since   1.0.0
 *
 * @package AiO_Podcast_Studio
 */

// Exit if accessed directly
if ( ! defined( 'WPINC' ) ) {
	die;
}

/**
 * AIOPOD_Admin_Import class.
 *
 * @since 1.0.0
 */
class AIOPOD_Admin_Import {

	/**
	 * Add a custom cron schedule for every 5 minutes.
	 *
	 * @since  1.0.0
	 * @param  array $schedules An array of non-default cron schedules.
	 * @return array $schedules Filtered array of non-default cron schedules.
	 */
	public function cron_schedules( $schedules ) {
		$schedules['every-5-minutes'] = array( 
			'interval' => 5 * MINUTE_IN_SECONDS, 
			'display'  => __( 'Every 5 minutes', 'aio-podcast-studio' ) 
		);

		return $schedules;
	}

	/**
	 * Schedule an action if it's not already scheduled.
	 *
	 * @since 1.0.0
	 */
	public function schedule_events() {
		if ( ! wp_next_scheduled( 'aiopod_schedule_every_five_minutes' ) ) {
			wp_schedule_event( time(), 'every-5-minutes', 'aiopod_schedule_every_five_minutes' );
		}
	}

	/**
	 * Called every 5 minutes. Run the auto import script.
	 *
	 * Episodes are processed in bounded batches (default 100 per run, filterable
	 * via aiopod_cron_import_batch_size) to avoid exhausting PHP memory and
	 * execution time on shared hosting. Batch state is tracked in the
	 * import_cron_offset post meta. After each non-final batch import_next_schedule
	 * is set to "now" so the next cron cycle continues immediately. The parsed
	 * episode list is cached in a transient (aiopod_cron_feed_{id}) so only one
	 * HTTP request is made per full import cycle regardless of episode count.
	 *
	 * The import log entry is written only on the final batch. Intermediate batches
	 * are intentionally silent to avoid creating multiple partial entries in the
	 * import history.
	 *
	 * @since 1.0.0
	 */
	public function cron_event() {
		// Define the query
		$args = array(
			'post_type'              => 'aiopod_podcasts',
			'post_status'            => 'publish',
			'posts_per_page'         => 1,
			'meta_query'             => array(
				array(
					'key'     => 'import_next_schedule',
					'value'	  => gmdate( 'Y-m-d H:i:s' ),
					'compare' => '<=',
					'type'    => 'DATETIME'
				)
			),
			'meta_key'               => 'import_next_schedule',
			'meta_type'              => 'DATETIME',
			'orderby'                => 'meta_value',
			'order'                  => 'ASC',
			'fields'                 => 'ids',
			'no_found_rows'          => true,
			'ignore_sticky_posts'    => true,
			'update_post_term_cache' => false,
			'update_post_meta_cache' => false
		);

		$aiopod_query = new WP_Query( $args );

		// Find the podcast to import
		$podcast_id = ! empty( $aiopod_query->posts ) ? (int) $aiopod_query->posts[0] : 0;

		if ( empty( $podcast_id ) ) {
			return;
		}

		// Clear the next schedule to avoid overlapping runs
		delete_post_meta( $podcast_id, 'import_next_schedule' );

		// Get import settings
		$appearance_settings = aiopod_get_option( 'aiopod_appearance_settings' );

		$post_meta = get_post_meta( $podcast_id );

		$rss_feed_url            = isset( $post_meta['rss_feed_url'] ) ? aiopod_sanitize_url( $post_meta['rss_feed_url'][0] ) : '';
		$update_existing_content = isset( $post_meta['update_existing_content'] ) ? (int) $post_meta['update_existing_content'][0] : 0;
		$auto_update_episodes    = isset( $post_meta['auto_update_episodes'] ) ? (int) $post_meta['auto_update_episodes'][0] : 0;
		$auto_update_frequency   = isset( $post_meta['auto_update_frequency'] ) ? (int) $post_meta['auto_update_frequency'][0] : 0;
		$author_id               = $this->get_author_id( $podcast_id );

		$set_featured_image = 0;
		if ( ! empty( $appearance_settings['enable_featured_images'] ) && ! empty( $appearance_settings['download_external_images'] ) ) { // Set featured image
			$set_featured_image = (int) get_post_meta( $podcast_id, 'set_featured_image', true );
		}

		// Validate RSS feed URL
		if ( empty( $rss_feed_url ) ) {
			$this->add_import_log( $podcast_id, array( 'error' => __( 'RSS feed URL is missing.', 'aio-podcast-studio' ) ) );
			return;
		}

		// Set time limit
		if ( function_exists( 'set_time_limit' ) ) {
			set_time_limit( 1200 );
		}

		// Determine batch size and read the current batch offset.
		$batch_size = (int) apply_filters( 'aiopod_cron_import_batch_size', 100 );

		if ( $batch_size < 1 ) {
			$batch_size = 100;
		}
		
		$batch_offset = (int) get_post_meta( $podcast_id, 'import_cron_offset', true );

		$feed_transient_key = 'cron_feed_' . $podcast_id;

		// Parse the RSS feed or retrieve the cached episode list.
		if ( 0 === $batch_offset ) {
			// First batch: fetch and parse the live feed.
			$feed_data = $this->parse_feed( $rss_feed_url );

			if ( is_wp_error( $feed_data ) ) {
				$this->add_import_log( $podcast_id, array( 'error' => $feed_data->get_error_message() ) );
				$this->update_import_schedule( $podcast_id, $auto_update_episodes, $auto_update_frequency );
				return;
			}

			// Cache episode list for continuation batches — avoids re-fetching the remote feed.
			// Episodes are stored in 50-item chunks to stay within MySQL max_allowed_packet.
			$this->set_chunked_transient( $feed_transient_key, $feed_data['episodes'], 30 * MINUTE_IN_SECONDS );

			$all_episodes = $feed_data['episodes'];
			$podcast_feed = $feed_data['podcast'];
		} else {
			// Continuation batch: read episode list from transient — no HTTP request.
			$all_episodes = $this->get_chunked_transient( $feed_transient_key );

			if ( false === $all_episodes ) {
				// Transient expired (edge case: batches took longer than 30 min). Re-fetch.
				$feed_data = $this->parse_feed( $rss_feed_url );

				if ( is_wp_error( $feed_data ) ) {
					delete_post_meta( $podcast_id, 'import_cron_offset' );

					$this->add_import_log( $podcast_id, array( 'error' => $feed_data->get_error_message() ) );
					$this->update_import_schedule( $podcast_id, $auto_update_episodes, $auto_update_frequency );
					return;
				}

				$this->set_chunked_transient( $feed_transient_key, $feed_data['episodes'], 30 * MINUTE_IN_SECONDS );
				$all_episodes = $feed_data['episodes'];
			}
		}

		$this->set_import_performance_mode( true );

		// Import podcast metadata on the first batch only.
		if ( 0 === $batch_offset ) {
			$podcast_data = $this->import_podcast( $podcast_id, $podcast_feed, array(
				'rss_feed_url'            => $rss_feed_url,
				'update_existing_content' => $update_existing_content,
				'author_id'               => $author_id,
				'set_featured_image'      => $set_featured_image,
				'auto_update_episodes'    => $auto_update_episodes,
				'auto_update_frequency'   => $auto_update_frequency
			) );

			if ( is_wp_error( $podcast_data ) ) {
				$this->delete_chunked_transient( $feed_transient_key );

				$this->add_import_log( $podcast_id, array( 'error' => $podcast_data->get_error_message() ) );
				$this->update_import_schedule( $podcast_id, $auto_update_episodes, $auto_update_frequency );
				$this->set_import_performance_mode( false );
				return;
			}
		}

		// Process the current batch of episodes.
		$total_episodes = count( $all_episodes );
		$batch_episodes = array_slice( $all_episodes, $batch_offset, $batch_size );

		$episodes_data = $this->import_episodes( $podcast_id, $batch_episodes, array(
			'update_existing_content' => $update_existing_content,
			'author_id'               => $author_id,
			'set_featured_image'      => $set_featured_image
		) );

		if ( is_wp_error( $episodes_data ) ) {
			$this->delete_chunked_transient( $feed_transient_key );
			delete_post_meta( $podcast_id, 'import_cron_offset' );

			$this->add_import_log( $podcast_id, array( 'error' => $episodes_data->get_error_message() ) );
			$this->update_import_schedule( $podcast_id, $auto_update_episodes, $auto_update_frequency );
			$this->set_import_performance_mode( false );
			return;
		}

		// Advance by episodes actually processed rather than the nominal batch size,
		// so nothing is skipped if import_episodes ever stops a batch early.
		$processed   = isset( $episodes_data['processed'] ) ? (int) $episodes_data['processed'] : $batch_size;
		$next_offset = $batch_offset + $processed;

		if ( $next_offset < $total_episodes ) {
			// More episodes remain — store offset and reschedule immediately for the next batch.
			// The log entry is skipped here; only the final batch writes to the import history.
			update_post_meta( $podcast_id, 'import_cron_offset', $next_offset );
			update_post_meta( $podcast_id, 'import_next_schedule', gmdate( 'Y-m-d H:i:s' ) );
		} else {
			// All episodes processed — clean up batch state and schedule the next normal run.
			// Note: $episodes_data reflects the final batch only, not the full import cycle.
			$this->delete_chunked_transient( $feed_transient_key );
			delete_post_meta( $podcast_id, 'import_cron_offset' );
			
			$this->add_import_log( $podcast_id, $episodes_data );
			$this->update_import_schedule( $podcast_id, $auto_update_episodes, $auto_update_frequency );
		}

		$this->set_import_performance_mode( false );
	}

	/**
	 * Import Podcast from RSS Feed.
	 *
	 * @since 1.0.0
	 */
	public function ajax_callback_import_podcast() {
		// Security check
		check_ajax_referer( 'aiopod_ajax_nonce', 'security' );

		if ( ! current_user_can( 'manage_aiopod_options' ) ) {
			wp_send_json_error( array( 'error' => __( 'Sorry, you don\'t have permission to perform this action.', 'aio-podcast-studio' ) ) );
		}

		// Get import settings
		$appearance_settings = aiopod_get_option( 'aiopod_appearance_settings' );

		$podcast_id              = isset( $_POST['podcast_id'] ) ? (int) wp_unslash( $_POST['podcast_id'] ) : 0;
		$rss_feed_url            = isset( $_POST['rss_feed_url'] ) ? aiopod_sanitize_url( wp_unslash( $_POST['rss_feed_url'] ) ) : '';
		$update_existing_content = isset( $_POST['update_existing_content'] ) ? (int) wp_unslash( $_POST['update_existing_content'] ) : 0;
		$auto_update_episodes    = isset( $_POST['auto_update_episodes'] ) ? (int) wp_unslash( $_POST['auto_update_episodes'] ) : 0;
		$auto_update_frequency   = isset( $_POST['auto_update_frequency'] ) ? (int) wp_unslash( $_POST['auto_update_frequency'] ) : 0;
		$offset                  = isset( $_POST['offset'] ) ? (int) wp_unslash( $_POST['offset'] ) : 0;
		$limit                   = isset( $_POST['limit'] ) ? (int) wp_unslash( $_POST['limit'] ) : 20;
		$total                   = isset( $_POST['total'] ) ? (int) wp_unslash( $_POST['total'] ) : 0;
		$imported                = isset( $_POST['imported'] ) ? (int) wp_unslash( $_POST['imported'] ) : 0;
		$updated                 = isset( $_POST['updated'] ) ? (int) wp_unslash( $_POST['updated'] ) : 0;
		$skipped                 = isset( $_POST['skipped'] ) ? (int) wp_unslash( $_POST['skipped'] ) : 0;
		$failed                  = isset( $_POST['failed'] ) ? (int) wp_unslash( $_POST['failed'] ) : 0;
		$author_id               = $this->get_author_id( $podcast_id );
		$is_update               = $podcast_id > 0 ? true : false;

		$set_featured_image = 0;
		if ( ! empty( $appearance_settings['enable_featured_images'] ) && ! empty( $appearance_settings['download_external_images'] ) ) { // Set featured image
			$set_featured_image = isset( $_POST['set_featured_image'] ) ? (int) wp_unslash( $_POST['set_featured_image'] ) : 0;
		}

		// Validate RSS feed URL
		if ( empty( $rss_feed_url ) ) {
			wp_send_json_error( array( 'error' => __( 'Please enter an RSS feed URL', 'aio-podcast-studio' ) ) );
		}

		// Validate podcast_id post type before fetching the feed.
		if ( $podcast_id > 0 ) {
			$post = get_post( $podcast_id );
			
			if ( ! $post || 'aiopod_podcasts' !== $post->post_type ) {
				wp_send_json_error( array( 'error' => __( 'Invalid podcast ID.', 'aio-podcast-studio' ) ) );
			}
		}

		// Set time limit
		if ( function_exists( 'set_time_limit' ) ) {
			set_time_limit( 1200 );
		}

		// Parse the RSS feed — use a transient cache on subsequent batches so
		// the remote feed is fetched only once per import run instead of once
		// per batch (e.g. 50 fetches for a 1,000-episode feed at batch size 20).
		$feed_transient_key = 'import_feed_' . md5( $rss_feed_url );

		if ( 0 === $offset ) {
			$feed_data = $this->parse_feed( $rss_feed_url );

			if ( ! is_wp_error( $feed_data ) ) {
				// Store podcast metadata (small) and episodes (chunked) separately to
				// avoid max_allowed_packet errors when the serialised episode list is large.
				set_transient( 'aiopod_' . $feed_transient_key . '_podcast', $feed_data['podcast'], 30 * MINUTE_IN_SECONDS );
				$this->set_chunked_transient( $feed_transient_key, $feed_data['episodes'], 30 * MINUTE_IN_SECONDS );
			}
		} else {
			$podcast_cached  = get_transient( 'aiopod_' . $feed_transient_key . '_podcast' );
			$episodes_cached = $this->get_chunked_transient( $feed_transient_key );

			$feed_data = ( false !== $podcast_cached && false !== $episodes_cached ) ? array( 'podcast' => $podcast_cached, 'episodes' => $episodes_cached ) : false;

			if ( false === $feed_data ) {
				// Transient expired mid-import (edge case) — re-fetch and re-cache.
				$feed_data = $this->parse_feed( $rss_feed_url );

				if ( ! is_wp_error( $feed_data ) ) {
					set_transient( 'aiopod_' . $feed_transient_key . '_podcast', $feed_data['podcast'], 30 * MINUTE_IN_SECONDS );
					$this->set_chunked_transient( $feed_transient_key, $feed_data['episodes'], 30 * MINUTE_IN_SECONDS );
				}
			}
		}

		if ( is_wp_error( $feed_data ) ) {
			delete_transient( 'aiopod_' . $feed_transient_key . '_podcast' );
			$this->delete_chunked_transient( $feed_transient_key );

			wp_send_json_error( array( 'error' => $feed_data->get_error_message() ) );
		}

		// Import podcast
		$this->set_import_performance_mode( true );

		if ( 0 === $offset ) {
			$podcast_data = $this->import_podcast( $podcast_id, $feed_data['podcast'], array(
				'rss_feed_url'            => $rss_feed_url,
				'update_existing_content' => $update_existing_content,
				'author_id'               => $author_id,
				'set_featured_image'      => $set_featured_image,
				'auto_update_episodes'    => $auto_update_episodes,
				'auto_update_frequency'   => $auto_update_frequency
			) );

			if ( is_wp_error( $podcast_data ) ) {
				delete_transient( 'aiopod_' . $feed_transient_key . '_podcast' );
				$this->delete_chunked_transient( $feed_transient_key );

				$this->set_import_performance_mode( false );				
				wp_send_json_error( array( 'error' => $podcast_data->get_error_message() ) );
			}

			if ( empty( $podcast_id ) ) {
				$podcast_id = $podcast_data;
			}
		}

		// Import episodes
		$episodes      = ( $limit > 0 ) ? array_slice( $feed_data['episodes'], $offset, $limit ) : array_slice( $feed_data['episodes'], $offset );
		$episodes_data = $this->import_episodes( $podcast_id, $episodes, array(
			'update_existing_content' => $update_existing_content,
			'author_id'               => $author_id,
			'set_featured_image'      => $set_featured_image,
			// Keep each AJAX request within the host's execution limit. Filterable so
			// hosts with tighter (or looser) caps can tune it. 0 disables the budget.
			'time_budget'             => (int) apply_filters( 'aiopod_import_time_budget', 20 )
		) );

		if ( is_wp_error( $episodes_data ) ) {
			delete_transient( 'aiopod_' . $feed_transient_key . '_podcast' );
			$this->delete_chunked_transient( $feed_transient_key );

			$this->set_import_performance_mode( false );			
			wp_send_json_error( array( 'error' => $episodes_data->get_error_message() ) );
		}

		// Prepare response
		// Advance by the number of episodes actually processed. This may be fewer
		// than the requested $limit when the time budget stops the batch early, so
		// the next request resumes exactly where this one left off.
		$new_offset = min( $offset + (int) $episodes_data['processed'], count( $feed_data['episodes'] ) );

		$response = array(
			'podcast_id' => $podcast_id,
			'step'       => 'process',
			'offset'     => $new_offset,
			'total'      => count( $feed_data['episodes'] ),
			'imported'   => $imported + $episodes_data['imported'],
			'updated'    => $updated + $episodes_data['updated'],
			'skipped'    => $skipped + $episodes_data['skipped'],
			'failed'     => $failed + $episodes_data['failed']
		);

		// Interim response for pagination
		if ( count( $feed_data['episodes'] ) > $new_offset ) {
			$response['message'] = sprintf(
				__( '<strong>Importing Episodes:</strong> Processed %1$d of %2$d episodes. Please do not close or refresh this page.', 'aio-podcast-studio' ),
				$new_offset,
				count( $feed_data['episodes'] )
			);
			
			$this->add_import_log( $podcast_id, $response );
			$this->set_import_performance_mode( false );
			wp_send_json_success( $response );
		}
		
		// Final response
		$response['step']     = 'complete';
		$response['redirect'] = get_edit_post_link( $podcast_id, 'raw' );

		if ( $is_update ) {
			$response['message'] = sprintf(
				__( '<strong>Congrats!</strong> Podcast updated successfully. Total episodes: %d (Imported: %d, Updated: %d, Duplicate: %d, Failed: %d). Please wait while we refresh the page.', 'aio-podcast-studio' ),
				$response['total'],
				$response['imported'],
				$response['updated'],
				$response['skipped'],
				$response['failed']
			);
		} else {
			$response['message'] = sprintf(
				__( '<strong>Congrats!</strong> Podcast imported successfully. Total episodes: %d (Imported: %d, Updated: %d, Duplicate: %d, Failed: %d). Please wait while we refresh the page.', 'aio-podcast-studio' ),
				$response['total'],
				$response['imported'],
				$response['updated'],
				$response['skipped'],
				$response['failed']
			);			
		}

		delete_transient( 'aiopod_' . $feed_transient_key . '_podcast' );
		$this->delete_chunked_transient( $feed_transient_key );

		$this->add_import_log( $podcast_id, $response );
		$this->update_import_schedule( $podcast_id, $auto_update_episodes, $auto_update_frequency );
		$this->set_import_performance_mode( false );		

		wp_send_json_success( $response );
	}

	/**
	 * Parses a podcast RSS feed into a structured array.
	 *
	 * @since  1.0.0
	 * @access private
	 * @param  string         $feed_url The URL of the podcast RSS feed to parse.
	 * @return array|WP_Error           Associative array of podcast details or WP_Error on failure.
	 */
	private function parse_feed( $feed_url ) {
		if ( empty( $feed_url ) || ! filter_var( $feed_url, FILTER_VALIDATE_URL ) ) {
			return new WP_Error( 'invalid_url', __( 'The RSS feed URL format seems invalid. Please check and try again.', 'aio-podcast-studio' ) );
		}

		if ( ! function_exists( 'simplexml_load_string' ) ) {
			return new WP_Error( 'missing_simplexml', __( 'The server is missing required functionality (SimpleXML). Please contact your host.', 'aio-podcast-studio' ) );
		}

		$response = wp_remote_get( $feed_url, array( 'timeout' => 30 ) );
		if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) !== 200 ) {
			return new WP_Error( 'http_error', __( 'We couldn\'t fetch the RSS feed. Please make sure the URL is correct and accessible.', 'aio-podcast-studio' ) );
		}

		$body = wp_remote_retrieve_body( $response );
		libxml_use_internal_errors( true );
		$rss = simplexml_load_string( $body, 'SimpleXMLElement', LIBXML_NOCDATA );
		libxml_clear_errors();
		libxml_use_internal_errors( false );
		if ( ! $rss || ! isset( $rss->channel ) ) {
			return new WP_Error( 'rss_parse_error', __( 'The RSS feed could not be parsed. Please verify its structure or try a different feed.', 'aio-podcast-studio' ) );
		}

		$channel = $rss->channel;	

		$itunes_ns = 'http://www.itunes.com/dtds/podcast-1.0.dtd';
		$itunes = $channel->children( $itunes_ns );

		$content_ns = 'http://purl.org/rss/1.0/modules/content/';
		$content = $channel->children( $content_ns );

		$podcast = array(
			'title'       => '',
			'description' => ''
		);

		$episodes = array();

		// Podcast: Title
		if ( isset( $channel->title ) && trim( (string) $channel->title ) !== '' ) {
			$podcast['title'] = trim( (string) $channel->title );
		} elseif ( isset( $itunes->title ) && trim( (string) $itunes->title ) !== '' ) {
			$podcast['title'] = trim( (string) $itunes->title );
		}

		// Podcast: Description
		//
		// Every text element in the feed is read into the description, because none of them is
		// reliably short. `itunes:summary` is a full description by specification, and although
		// `itunes:subtitle` is meant to be a single line, nothing enforces that and some feeds
		// simply repeat the description in it. An excerpt is therefore never imported: it is
		// generated from the description instead, so the excerpt length setting is always honoured
		// and the excerpt field stays free for wording the user has written themselves.
		if ( isset( $content->encoded ) && trim( (string) $content->encoded ) !== '' ) {
			$podcast['description'] = trim( (string) $content->encoded );
		} elseif ( isset( $channel->description ) && trim( (string) $channel->description ) !== '' ) {
			$podcast['description'] = trim( (string) $channel->description );
		} elseif ( isset( $itunes->summary ) && trim( (string) $itunes->summary ) !== '' ) {
			$podcast['description'] = trim( (string) $itunes->summary );
		} elseif ( isset( $itunes->subtitle ) && trim( (string) $itunes->subtitle ) !== '' ) {
			$podcast['description'] = trim( (string) $itunes->subtitle );
		}

		// Podcast: Image
		if ( isset( $channel->image->url ) && trim( (string) $channel->image->url ) !== '' ) {
			$podcast['image'] = aiopod_sanitize_url( trim( (string) $channel->image->url ) );
		} elseif ( isset( $itunes->image ) && $itunes->image->attributes() && isset( $itunes->image->attributes()->href ) ) {
			$podcast['image'] = aiopod_sanitize_url( trim( (string) $itunes->image->attributes()->href ) );
		}

		// Podcast: Language
		if ( isset( $channel->language ) && trim( (string) $channel->language ) !== '' ) {
			$podcast['podcast_language'] = sanitize_text_field( trim( (string) $channel->language ) );
		}

		// Podcast: Categories
		$categories = array();

		if ( isset( $itunes->category ) ) {
			foreach ( $itunes->category as $main_cat ) {
				$main_attr = $main_cat->attributes();
				$main_text = isset( $main_attr['text'] ) ? trim( (string) $main_attr['text'] ) : '';

				if ( isset( $main_cat->category ) ) {
					foreach ( $main_cat->category as $sub_cat ) {
						$sub_attr = $sub_cat->attributes();
						$sub_text = isset( $sub_attr['text'] ) ? trim( (string) $sub_attr['text'] ) : '';

						if ( $main_text ) {
							$categories[] = $main_text . ( $sub_text ? '|' . $sub_text : '' );
						}
					}
				} elseif ( $main_text ) {
					$categories[] = $main_text;
				}
			}
		}

		if ( ! empty( $categories ) ) {
			$podcast['categories'] = array_map( 'sanitize_text_field', $categories );
		}

		// Podcast: Explicit
		if ( isset( $itunes->explicit ) && trim( (string) $itunes->explicit ) !== '' ) {
			$podcast['is_explicit'] = $this->parse_bool( $itunes->explicit );
		}

		// Podcast: Author
		if ( isset( $itunes->author ) && trim( (string) $itunes->author ) !== '' ) {
			$podcast['author_name'] = sanitize_text_field( trim( (string) $itunes->author ) );
		}

		// Podcast: Author Email / Owner Name fallback
		if ( isset( $itunes->owner ) ) {
			$owner = $itunes->owner->children( $itunes_ns );

			if ( isset( $owner->email ) && trim( (string) $owner->email ) !== '' ) {
				$podcast['author_email'] = sanitize_text_field( trim( (string) $owner->email ) );
			}
			
			if ( empty( $podcast['author_name'] ) && isset( $owner->name ) && trim( (string) $owner->name ) !== '' ) {
				$podcast['author_name'] = sanitize_text_field( trim( (string) $owner->name ) );
			}
		}

		// Podcast: Link
		if ( isset( $channel->link ) && trim( (string) $channel->link ) !== '' ) {
			$podcast['website_url'] = aiopod_sanitize_url( trim( (string) $channel->link ) );
		}

		// Podcast: Type
		if ( isset( $itunes->type ) && trim( (string) $itunes->type ) !== '' ) {
			$podcast['podcast_type'] = sanitize_text_field( strtolower( trim( (string) $itunes->type ) ) );
		}

		// Podcast: Copyright
		if ( isset( $channel->copyright ) && trim( (string) $channel->copyright ) !== '' ) {
			$podcast['copyright_text'] = sanitize_text_field( trim( (string) $channel->copyright ) );
		}

		// Podcast: New Feed URL
		if ( isset( $itunes->{'new-feed-url'} ) && trim( (string) $itunes->{'new-feed-url'} ) !== '' ) {
			$podcast['new_feed_url'] = aiopod_sanitize_url( trim( (string) $itunes->{'new-feed-url'} ) );
		}

		// Podcast: Block
		if ( isset( $itunes->block ) && trim( (string) $itunes->block ) !== '' ) {
			$podcast['is_blocked'] = $this->parse_bool( $itunes->block );
		}

		// Podcast: Complete
		if ( isset( $itunes->complete ) && trim( (string) $itunes->complete ) !== '' ) {
			$podcast['is_completed'] = $this->parse_bool( $itunes->complete );
		}

		// Episodes
		$allowed_episode_types = aiopod_get_episode_types();
		$allowed_episode_types = array_keys( $allowed_episode_types );

		if ( isset( $channel->item ) && ! empty( $channel->item ) ) {
			foreach ( $channel->item as $item ) {
				$itunes  = $item->children( $itunes_ns );
				$content = $item->children( $content_ns );

				$episode = array(
					'title'       => '',
					'description' => ''
				);

				// Episode: Title
				if ( isset( $item->title ) && trim( (string) $item->title ) !== '' ) {
					$episode['title'] = trim( (string) $item->title );
				} elseif ( isset( $itunes->title ) && trim( (string) $itunes->title ) !== '' ) {
					$episode['title'] = trim( (string) $itunes->title );
				}

				// Episode: Description
				//
				// See the note on the podcast description above: no feed element is reliably short,
				// so every one of them is read into the description and the excerpt is generated
				// from it rather than imported.
				if ( isset( $content->encoded ) && trim( (string) $content->encoded ) !== '' ) {
					$episode['description'] = trim( (string) $content->encoded );
				} elseif ( isset( $item->description ) && trim( (string) $item->description ) !== '' ) {
					$episode['description'] = trim( (string) $item->description );
				} elseif ( isset( $itunes->summary ) && trim( (string) $itunes->summary ) !== '' ) {
					$episode['description'] = trim( (string) $itunes->summary );
				} elseif ( isset( $itunes->subtitle ) && trim( (string) $itunes->subtitle ) !== '' ) {
					$episode['description'] = trim( (string) $itunes->subtitle );
				}

				// Episode: Tags
				if ( isset( $itunes->keywords ) && trim( (string) $itunes->keywords ) !== '' ) {
					$keywords = trim( (string) $itunes->keywords );
					$keywords = array_map( 'trim', explode( ',', $keywords ) );
					$keywords = array_map( 'sanitize_text_field', $keywords );
            		$keywords = array_filter( $keywords );

					$episode['tags'] = $keywords;			
				}

				// Episode: Enclosure
				if ( isset( $item->enclosure ) ) {
					if ( isset( $item->enclosure['url'] ) && trim( (string) $item->enclosure['url'] ) !== '' ) {
						$episode['episode_file'] = aiopod_sanitize_url( trim( (string) $item->enclosure['url'] ) );
					}

					if ( isset( $item->enclosure['type'] ) && trim( (string) $item->enclosure['type'] ) !== '' ) {
						$episode['episode_file_type'] = sanitize_text_field( trim( (string) $item->enclosure['type'] ) );
					}

					if ( isset( $item->enclosure['length'] ) && trim( (string) $item->enclosure['length'] ) !== '' ) {
						$episode['episode_file_size'] = aiopod_sanitize_int( trim( (string) $item->enclosure['length'] ) );
					}
				}
				
				// Episode: Duration
				if ( isset( $itunes->duration ) && trim( (string) $itunes->duration ) !== '' ) {
					$episode['episode_duration'] = aiopod_convert_time_to_seconds( trim( (string) $itunes->duration ) );
				}

				// Episode: GUID
				if ( isset( $item->guid ) && trim( (string) $item->guid ) !== '' ) {
					$episode['guid'] = trim( (string) $item->guid );
				}

				// Episode: Date
				if ( isset( $item->pubDate ) && trim( (string) $item->pubDate ) !== '' ) {
					$date_obj = date_create( trim( (string) $item->pubDate ) );
					$episode['post_date'] = $date_obj ? date_format( $date_obj, 'Y-m-d H:i:s' ) : '';
				}

				// Episode: Link
				if ( isset( $item->link ) && trim( (string) $item->link ) !== '' ) {
					$episode['link'] = trim( (string) $item->link );
				}

				// Episode: Image
				if ( isset( $itunes->image ) && $itunes->image->attributes() && isset( $itunes->image->attributes()->href ) ) {
					$episode['image'] = aiopod_sanitize_url( trim( (string) $itunes->image->attributes()->href ) );
				}

				// Episode: Explicit
				if ( isset( $itunes->explicit ) && trim( (string) $itunes->explicit ) !== '' ) {
					$episode['is_explicit'] = $this->parse_bool( $itunes->explicit );
				}

				// Episode Number
				if ( isset( $itunes->episode ) && trim( (string) $itunes->episode ) !== '' ) {
					$episode['episode_number'] = aiopod_sanitize_int( trim( (string) $itunes->episode ) );
				}

				// Episode: Season Number
				if ( isset( $itunes->season ) && trim( (string) $itunes->season ) !== '' ) {
					$episode['season_number'] = aiopod_sanitize_int( trim( (string) $itunes->season ) );
				}

				// Episode: Type
				if ( isset( $itunes->episodeType ) && trim( (string) $itunes->episodeType ) !== '' ) {
					$type = sanitize_key( strtolower( trim( (string) $itunes->episodeType ) ) );
					$episode['episode_type'] = in_array( $type, $allowed_episode_types, true ) ? $type : 'full';
				} else {
					// Default if not provided
					$episode['episode_type'] = 'full';
				}

				// Episode: Author
				if ( isset( $itunes->author ) && trim( (string) $itunes->author ) !== '' ) {
					$episode['author_name'] = sanitize_text_field( trim( (string) $itunes->author ) );
				}

				// Episode: Block
				if ( isset( $itunes->block ) && trim( (string) $itunes->block ) !== '' ) {
					$episode['is_blocked'] = $this->parse_bool( $itunes->block );
				}

				/**
				 * A single episode's parsed feed data, before it is imported.
				 *
				 * Every element the feed offers as a description is read into `description`, while
				 * `excerpt` is deliberately left unset so that the excerpt is generated from the
				 * description and the excerpt length setting is honoured. No feed element is
				 * reliably short: `itunes:summary` is a full description by specification, and
				 * although `itunes:subtitle` is meant to be a single line, nothing enforces it and
				 * some feeds repeat the whole description there.
				 *
				 * Where a particular feed is known to behave, this filter can map it across:
				 *
				 * add_filter( 'aiopod_import_parsed_episode', function( $episode, $item ) {
				 *     $itunes = $item->children( 'http://www.itunes.com/dtds/podcast-1.0.dtd' );
				 *
				 *     if ( isset( $itunes->subtitle ) ) {
				 *         $episode['excerpt'] = trim( (string) $itunes->subtitle );
				 *     }
				 *
				 *     return $episode;
				 * }, 10, 2 );
				 *
				 * @since 1.0.4
				 * @param array            $episode  Parsed episode data.
				 * @param SimpleXMLElement $item     The feed's <item> element.
				 * @param string           $feed_url URL of the feed being imported.
				 */
				$episode = apply_filters( 'aiopod_import_parsed_episode', $episode, $item, $feed_url );

				$episodes[] = $episode;
			}
		}

		/**
		 * The podcast's parsed feed data, before it is imported.
		 *
		 * See `aiopod_import_parsed_episode` for why `excerpt` is left unset, and for an example
		 * of mapping a feed element onto it.
		 *
		 * @since 1.0.4
		 * @param array            $podcast  Parsed podcast data.
		 * @param SimpleXMLElement $channel  The feed's <channel> element.
		 * @param string           $feed_url URL of the feed being imported.
		 */
		$podcast = apply_filters( 'aiopod_import_parsed_podcast', $podcast, $channel, $feed_url );

		// Return parsed data
		$response = array(
			'podcast'  => $podcast,
			'episodes' => $episodes
		);

		return $response;
	}

	/**
	 * Creates or updates a podcast from the given podcast data.
	 *
	 * @since  1.0.0
	 * @param  int          $podcast_id Podcast ID to update, or 0 to create a new podcast.
	 * @param  array        $podcast    Array of podcast data.
	 * @param  array		$args       Additional arguments for import settings.
	 * @return int|WP_Error             Podcast ID on success, or WP_Error on failure.
	 */
	public function import_podcast( $podcast_id, $podcast, $args = array() ) {
		// Validate required fields
		if ( empty( $podcast['title'] ) ) {
			return new WP_Error( 'create_podcast_failed', __( 'Podcast title not found in the feed. Please check the RSS source.', 'aio-podcast-studio' ) );
		}

		// Check if podcast already exists by RSS feed URL
		if ( 0 === $podcast_id ) {			
			$podcast_id = $this->is_podcast_exists( $args['rss_feed_url'] );
		}

		// Determine if existing content can be updated
		$can_update_existing_content = apply_filters( 'aiopod_import_can_update_podcast', $args['update_existing_content'], $podcast_id, $podcast, $args );
		if ( $podcast_id > 0 && ! $can_update_existing_content ) {
			// Update podcast meta
			$post_meta = array(
				'set_featured_image'      => $args['set_featured_image'],
				'update_existing_content' => $args['update_existing_content'],
				'auto_update_episodes'    => $args['auto_update_episodes'],
				'auto_update_frequency'   => $args['auto_update_frequency']
			);

			$this->bulk_update_post_meta( $podcast_id, $post_meta );

			return $podcast_id;			
		}

		// Create or update podcast				
		$is_new = false;

		$post_array = array(
			'post_type'      => 'aiopod_podcasts',
			'post_title'     => sanitize_text_field( wp_strip_all_tags( $podcast['title'] ) ),
			'post_content'   => ! empty( $podcast['description'] ) ? wp_kses_post( $podcast['description'] ) : '',
			// Never set while parsing, so this stays empty unless `aiopod_import_parsed_podcast`
			// supplied one. Cleared on update as well, so a refreshed description regenerates it
			'post_excerpt'   => ! empty( $podcast['excerpt'] ) ? sanitize_textarea_field( $podcast['excerpt'] ) : '',
			'post_author'    => $args['author_id'],
			'post_status'    => 'publish',
			'ping_status'    => 'closed',
			'comment_status' => 'closed'
		);	

		if ( empty( $podcast_id ) ) {
			$podcast_id = wp_insert_post( $post_array );

			if ( is_wp_error( $podcast_id ) || empty( $podcast_id ) ) {
				return new WP_Error( 'create_podcast_failed', is_wp_error( $podcast_id ) ? $podcast_id->get_error_message() : __( 'Unknown error creating podcast.', 'aio-podcast-studio' ) );
			}
			
			$is_new = true;
		} else {
			$post_array['ID'] = $podcast_id;
			$updated = wp_update_post( $post_array, true );

			if ( is_wp_error( $updated ) ) {
				return new WP_Error( 'update_podcast_failed', $updated->get_error_message() );
			}
		}

		// Podcast terms
		if ( ! empty( $podcast['categories'] ) ) {
			$this->import_podcast_categories( $podcast_id, $podcast['categories'] );
		} else {
			wp_set_object_terms( $podcast_id, array(), 'aiopod_categories' );
		}

		// Podcast meta		
		$defaults = array(
			'image'                  => '',
			'image_id'               => 0,
			'podcast_language'       => '',
			'podcast_type'           => '',
			'is_explicit'            => 0,
			'is_completed'           => 0,
			'author_name'            => '%%author_name%%',
			'author_email'           => '%%author_email%%',
			'website_url'            => '%%podcast_url%%',
			'copyright_text'         => '',
			'rss_feed_url'           => $args['rss_feed_url'],
			'new_feed_url'           => '',
			'is_blocked'             => 0,
			'total_downloads'        => 0,
			'total_plays'            => 0,
			'total_downloads_plays'  => 0
		);

		$post_meta = array_intersect_key( $podcast, $defaults );

		$post_meta['set_featured_image'] = $args['set_featured_image'];
		$post_meta['update_existing_content'] = $args['update_existing_content'];
		$post_meta['auto_update_episodes'] = $args['auto_update_episodes'];
		$post_meta['auto_update_frequency'] = $args['auto_update_frequency'];

		// Podcast image
		if ( ! empty( $args['set_featured_image'] ) && ! empty( $podcast['image'] ) ) {
			$image_id = aiopod_create_attachment_from_external_image_url( $podcast['image'], $podcast_id );
			if ( ! empty( $image_id ) ) {	
				$post_meta['image_id'] = $image_id;		
				set_post_thumbnail( $podcast_id, $image_id ); 
			}
		}

		// Update podcast meta
		if ( $is_new ) {
			$this->bulk_update_post_meta( $podcast_id, array_merge( $defaults, $post_meta ) );
		} else {
			$this->bulk_update_post_meta( $podcast_id, $post_meta );
		}	

		// Clear podcast cache
		clean_post_cache( $podcast_id );			

		return $podcast_id;		
	}

	/**
	 * Creates or updates podcast episodes from the given episodes data.
	 *
	 * @since  1.0.0
	 * @param  int            $podcast_id         Podcast ID to which episodes belong.
	 * @param  array          $episodes           Array of episode data.
	 * @param  array          $args               Additional arguments for import settings.
	 * @return array|WP_Error                     Import response data on success, or WP_Error on failure.
	 */
	public function import_episodes( $podcast_id, $episodes, $args = array() ) {
		$response = array(
			'imported'  => 0,
			'updated'   => 0,
			'skipped'   => 0,
			'failed'    => 0,
			'processed' => 0
		);

		// Bound the batch by wall-clock time so a single request never exceeds the
		// host's max execution limit while downloading external featured images.
		// Passed in by the caller; 0 (default) disables the time budget so the
		// full slice is always processed (e.g. background cron imports).
		$time_budget = isset( $args['time_budget'] ) ? (int) $args['time_budget'] : 0;
		$start_time  = microtime( true );

		// Import episodes
		foreach ( $episodes as $episode ) {
			// Stop before starting a new episode once the time budget is spent, but
			// always process at least one so the import offset keeps advancing.
			if ( $time_budget > 0 && $response['processed'] > 0 && ( microtime( true ) - $start_time ) >= $time_budget ) {
				break;
			}

			++$response['processed'];

			if ( empty( $episode['title'] ) || empty( $episode['episode_file'] ) ) {
				++$response['failed'];
				continue;
			}

			// Determine if existing content can be updated
			$episode_id = $this->is_episode_exists( $podcast_id, $episode );
			$can_update_existing_content = apply_filters( 'aiopod_import_can_update_episode', $args['update_existing_content'], $podcast_id, $episode, $args );

			if ( $episode_id > 0 && ! $can_update_existing_content ) {
				++$response['skipped'];
				continue;
			}

			// Create or update episode
			$is_new = false;

			$post_array = array(
				'post_type'      => 'aiopod_episodes',
				'post_title'     => sanitize_text_field( wp_strip_all_tags( $episode['title'] ) ),
				'post_content'   => ! empty( $episode['description'] ) ? wp_kses_post( $episode['description'] ) : '',
				// Never set while parsing, so this stays empty unless `aiopod_import_parsed_episode`
				// supplied one. Cleared on update as well, so a refreshed description regenerates it
				'post_excerpt'   => ! empty( $episode['excerpt'] ) ? sanitize_textarea_field( $episode['excerpt'] ) : '',
				'post_author'    => $args['author_id'],
				'post_status'    => 'publish',
				'post_parent'    => $podcast_id,
				'ping_status'    => 'closed',
				'comment_status' => 'closed'
			);

			if ( ! empty( $episode['post_date'] ) ) {
				$post_array['post_date'] = $episode['post_date'];
			}			

			if ( empty( $episode_id ) ) {
				$episode_id = wp_insert_post( $post_array );				

				if ( is_wp_error( $episode_id ) || empty( $episode_id ) ) {
					++$response['failed'];
					continue;
				}
				
				++$response['imported'];
				$is_new = true;
			} else {
				$post_array['ID'] = $episode_id;
				$updated = wp_update_post( $post_array, true );

				if ( is_wp_error( $updated ) ) {
					++$response['failed'];
					continue;
				}

				++$response['updated'];
			}

			// Episode terms
			if ( ! empty( $episode['tags'] ) ) {
				wp_set_object_terms( $episode_id, $episode['tags'], 'aiopod_tags', false );
			} else {
				wp_set_object_terms( $episode_id, array(), 'aiopod_tags' );
			}

			// Episode meta
			$defaults = array(
				'guid'                   => '',
				'episode_file'           => '',
				'episode_file_id'        => 0,
				'episode_file_type'      => '',
				'episode_file_size'      => '',
				'episode_duration'       => '',
				'is_video'               => 0,
				'image'                  => '',
				'image_id'               => 0,
				'set_featured_image'     => $args['set_featured_image'],
				'episode_type'           => 'full',
				'season_number'          => '',
				'episode_number'         => '',
				'is_explicit'            => 0,
				'enable_transcription'   => 0,
				'transcription'          => '',
				'enable_chapters'        => 0,
				'chapters'               => array(),
				'description'            => '',
				'author_name'            => '',
				'is_blocked'             => 0,
				'total_downloads'        => 0,
				'total_plays'            => 0,
				'total_downloads_plays'  => 0
			);

			$post_meta = array_intersect_key( $episode, $defaults );

			// Is Video?
			if ( ! empty( $post_meta['episode_file'] ) || ! empty( $post_meta['episode_file_type'] ) ) {
				$is_video = aiopod_is_video( $post_meta['episode_file_type'], $post_meta['episode_file'] );
				$post_meta['is_video'] = $is_video ? 1 : 0;
			}

			// Episode image
			if ( ! empty( $args['set_featured_image'] ) && ! empty( $post_meta['image'] ) ) {
				$image_id = aiopod_create_attachment_from_external_image_url( $post_meta['image'], $episode_id );
				if ( ! empty( $image_id ) ) {	
					$post_meta['image_id'] = $image_id;		
					set_post_thumbnail( $episode_id, $image_id ); 
				}
			}

			// Update episode meta
			if ( $is_new ) {
				$this->bulk_update_post_meta( $episode_id, array_merge( $defaults, $post_meta ) );
			} else {
				$this->bulk_update_post_meta( $episode_id, $post_meta );
			}

			// Clear episode cache
			clean_post_cache( $episode_id );
		}	

		return $response;
	}

	/**
	 * Toggle performance tuning during podcast import.
	 *
	 * @since  1.0.0
	 * @access private
	 * @param  bool    $enable True to start tuning, false to restore.
	 */
	private function set_import_performance_mode( $enable ) {
		static $original_actions = null;
		static $is_enabled       = false;

		$actions = array(
			'transition_post_status',
			'save_post',
			'pre_post_update',
			'add_attachment',
			'edit_attachment',
			'edit_post',
			'post_updated',
			'wp_insert_post',
			'save_post_aiopod_podcasts',
			'save_post_aiopod_episodes'
		);

		if ( $enable ) {

			// Prevent double-enable
			if ( $is_enabled ) {
				return;
			}

			// Defer expensive operations
			wp_defer_term_counting( true );
			wp_defer_comment_counting( true );

			// Store and remove hooks
			$original_actions = array();

			foreach ( $actions as $action ) {
				if ( isset( $GLOBALS['wp_filter'][ $action ] ) ) {
					$original_actions[ $action ] = $GLOBALS['wp_filter'][ $action ];
					remove_all_actions( $action );
				}
			}

			$is_enabled = true;

		} else {

			// Prevent double-restore
			if ( ! $is_enabled ) {
				return;
			}

			// Restore deferred operations
			wp_defer_term_counting( false );
			wp_defer_comment_counting( false );

			// Restore hooks
			if ( is_array( $original_actions ) ) {
				foreach ( $original_actions as $action => $hooks ) {
					$GLOBALS['wp_filter'][ $action ] = $hooks;
				}
			}

			$original_actions = null;
			$is_enabled       = false;
		}
	}
	
	/**
	 * Check if the podcast already exists based on the RSS feed URL.
	 *
	 * @since  1.0.0
	 * @access private
	 * @param  string $rss_feed_url The RSS feed URL to check.
	 * @return int|false        Post ID if podcast exists, false otherwise.
	 */
	private function is_podcast_exists( $rss_feed_url ) {
		if ( empty( $rss_feed_url ) ) {
			return false;
		}

		$args = array(				
			'post_type'              => 'aiopod_podcasts',			
			'post_status'            => 'any',
			'posts_per_page'         => 1,
			'fields'                 => 'ids',
			'no_found_rows'          => true,
			'update_post_term_cache' => false,
			'update_post_meta_cache' => false,
			'meta_query'             => array(
				array(
					'key'     => 'rss_feed_url',
					'value'   => aiopod_sanitize_url( $rss_feed_url ),
					'compare' => '='
				)
			)
		);

		$aiopod_query = new WP_Query( $args );
		return ! empty( $aiopod_query->posts ) ? (int) $aiopod_query->posts[0] : false;
	}

	/**
	 * Check if the episode already exists for the given podcast.
	 *
	 * @since  1.0.0
	 * @access private
	 * @param  int   $podcast_id Podcast post ID.
	 * @param  array $episode    Array of episode data.
	 * @return int|false         Episode post ID if exists, false otherwise.
	 */
	private function is_episode_exists( $podcast_id, $episode ) {
		if ( empty( $podcast_id ) || ( empty( $episode['guid'] ) && empty( $episode['episode_file'] ) ) ) {
			return false;
		}

		$args = array(
			'post_type'              => 'aiopod_episodes',
			'post_status'            => 'any',
			'post_parent'            => (int) $podcast_id,
			'posts_per_page'         => 1,
			'fields'                 => 'ids',
			'no_found_rows'          => true,
			'update_post_term_cache' => false,
			'update_post_meta_cache' => false
		);

		$meta_queries = array();

		// Optional match on GUID
		if ( ! empty( $episode['guid'] ) ) {
			$meta_queries[] = array(
				'key'     => 'guid',
				'value'   => sanitize_text_field( $episode['guid'] ),
				'compare' => '='
			);
		}

		// Optional match on episode_file
		if ( ! empty( $episode['episode_file'] ) ) {
			$meta_queries[] = array(
				'key'     => 'episode_file',
				'value'   => aiopod_sanitize_url( $episode['episode_file'] ),
				'compare' => '='
			);
		}

		// Combine meta queries with OR relation
		if ( ! empty( $meta_queries ) ) {
			$args['meta_query'] = ( count( $meta_queries ) > 1 ) ? array_merge( array( 'relation' => 'OR' ), $meta_queries ) : $meta_queries;
		}

		$aiopod_query = new WP_Query( $args );
		return ! empty( $aiopod_query->posts ) ? (int) $aiopod_query->posts[0] : false;
	}

	/**
	 * Get the author ID for a podcast.
	 *
	 * @since  1.0.0
	 * @access private
	 * @param  int     $podcast_id The podcast post ID.
	 * @return int                 The resolved author user ID.
	 */
	private function get_author_id( $podcast_id ) {
		$author_id = 0;

		// Attempt to get author directly from post field.
		if ( ! empty( $podcast_id ) ) {
			$author_id = (int) get_post_field( 'post_author', $podcast_id );
		}

		// Fallback to current user.
		if ( empty( $author_id ) ) {
			$author_id = get_current_user_id();
		}

		// Fallback to admin email user or user ID 1.
		if ( empty( $author_id ) ) {
			$admin_user = get_user_by( 'email', get_option( 'admin_email' ) );
			$author_id  = $admin_user ? (int) $admin_user->ID : 1;
		}

		return $author_id;
	}

	/**
	 * Imports and assigns podcast categories from an array of delimited strings.
	 *
	 * @since  1.0.0
	 * @access private
	 * @param  int     $podcast_id       Post ID of the podcast.
	 * @param  array   $category_strings Array of delimited category strings.
	 */
	private function import_podcast_categories( $podcast_id, $category_strings ) {
		// Normalize input
		if ( empty( $category_strings ) || ! is_array( $category_strings ) ) {
			wp_set_object_terms( $podcast_id, array(), 'aiopod_categories' );
			return;
		}

		$term_ids = array();

		foreach ( $category_strings as $category_string ) {
			if ( ! is_string( $category_string ) ) {
				continue;
			}

			// Split "Parent|Child"
			$parts = array_map( 'trim', explode( '|', $category_string ) );
			$parts = array_filter( $parts );

			if ( empty( $parts ) ) {
				continue;
			}

			// Parent + Child
			if ( count( $parts ) >= 2 ) {
				$parent_name = $parts[0];
				$child_name  = $parts[1];

				// Parent term
				$parent_term = term_exists( $parent_name, 'aiopod_categories' );
				if ( ! $parent_term ) {
					$parent_term = wp_insert_term( $parent_name, 'aiopod_categories' );
				}

				if ( is_wp_error( $parent_term ) || empty( $parent_term['term_id'] ) ) {
					continue;
				}

				$parent_id = (int) $parent_term['term_id'];

				// Child term (scoped to parent)
				$child_term = term_exists( $child_name, 'aiopod_categories', $parent_id );
				if ( ! $child_term ) {
					$child_term = wp_insert_term( $child_name, 'aiopod_categories',	array( 'parent' => $parent_id )	);
				}

				if ( ! is_wp_error( $child_term ) && ! empty( $child_term['term_id'] ) ) {
					$term_ids[] = (int) $child_term['term_id'];
				} else {
					// Fallback to parent if child fails
					$term_ids[] = $parent_id;
				}
			} else {
				// Single category
				$term_name = $parts[0];

				$term = term_exists( $term_name, 'aiopod_categories' );
				if ( ! $term ) {
					$term = wp_insert_term( $term_name, 'aiopod_categories' );
				}

				if ( ! is_wp_error( $term ) && ! empty( $term['term_id'] ) ) {
					$term_ids[] = (int) $term['term_id'];
				}
			}
		}

		// Assign all categories at once
		if ( ! empty( $term_ids ) ) {
			wp_set_object_terms( $podcast_id, array_unique( $term_ids ), 'aiopod_categories', false	);
		} else {
			wp_set_object_terms( $podcast_id, array(), 'aiopod_categories' );
		}
	}

	/**
	 * Bulk update post meta.
	 *
	 * @since  1.0.0
	 * @access private
	 * @param  int     $post_id Post ID.
	 * @param  array   $data    Meta key => value pairs.
	 * @return bool             True on success, false on failure.
	 */
	private function bulk_update_post_meta( $post_id, $data = array() ) {
		global $wpdb;

		$post_id = absint( $post_id );

		if ( empty( $post_id ) || ! is_array( $data ) || empty( $data ) ) {
			return false;
		}

		$meta_table   = $wpdb->postmeta;
		$meta_keys    = array();
		$placeholders = array();
		$values       = array();

		foreach ( $data as $key => $value ) {
			if ( ! is_string( $key ) || '' === $key ) {
				continue;
			}

			$meta_keys[]    = $key;
			$placeholders[] = '(%d, %s, %s)';
			$values[]       = $post_id;
			$values[]       = $key;
			$values[]       = maybe_serialize( $value );
		}

		if ( empty( $meta_keys ) ) {
			return false;
		}

		// Delete existing meta values for the given keys.
		$delete_placeholders = implode( ', ', array_fill( 0, count( $meta_keys ), '%s' ) );

		$delete_sql = $wpdb->prepare(
			"DELETE FROM {$meta_table} WHERE post_id = %d AND meta_key IN ({$delete_placeholders})",
			array_merge( array( $post_id ), $meta_keys )
		);

		if ( false === $wpdb->query( $delete_sql ) ) {
			return false;
		}

		// Bulk insert new meta values.
		$insert_sql = $wpdb->prepare(
			"INSERT INTO {$meta_table} (post_id, meta_key, meta_value) VALUES " . implode( ', ', $placeholders ),
			$values
		);

		if ( false === $wpdb->query( $insert_sql ) ) {
			return false;
		}

		return true;
	}

	/**
	 * Adds an import log entry for a podcast post.
	 *
	 * @since  1.0.0
	 * @access private
	 * @param  int     $podcast_id The post ID of the podcast.
	 * @param  array   $log_data   The import log data.
	 */
	private function add_import_log( $podcast_id, $log_data = array() ) {
		if ( empty( $podcast_id ) || ! is_array( $log_data ) || empty( $log_data ) ) {
			return false;
		}

		$allowed_properties = array(
			'date',
			'error',
			'imported',
			'updated',
			'skipped',
			'failed'
		);			

		foreach ( $log_data as $property => $value ) {
			if ( ! in_array( $property, $allowed_properties ) ) {
				unset( $log_data[ $property ] );
			}
		}

		if ( empty( $log_data['date'] ) ) {
			$log_data['date'] = gmdate( 'Y-m-d H:i:s' );
		}

		add_post_meta( $podcast_id, 'import_logs', $log_data );
	}

	/**
	 * Updates the next import schedule for a podcast.
	 *
	 * @since  1.0.0
	 * @access private
	 * @param  int     $podcast_id            The ID of the podcast post.
	 * @param  mixed   $auto_update_episodes  Whether to auto-update episodes (truthy/falsy).
	 * @param  mixed   $auto_update_frequency Update frequency in seconds.
	 */
	private function update_import_schedule( $podcast_id, $auto_update_episodes, $auto_update_frequency ) {
		if ( empty( $podcast_id ) || empty( $auto_update_episodes ) || empty( $auto_update_frequency ) ) {
			return false;
		}

		$next_schedule = gmdate( 'Y-m-d H:i:s', time() + (int) $auto_update_frequency );
		update_post_meta( $podcast_id, 'import_next_schedule', $next_schedule );
	}

	/**
	 * Stores a flat array across multiple transients (50 items each) to avoid
	 * MySQL max_allowed_packet errors when serialising large episode lists.
	 * Chunk keys follow the pattern "aiopod_{key_suffix}_{n}"; "aiopod_{key_suffix}_chunks"
	 * stores the total chunk count so get_chunked_transient() can reassemble the array.
	 *
	 * @since  1.0.0
	 * @access private
	 * @param  string  $key_suffix Unique identifier appended after the aiopod_ namespace prefix.
	 * @param  array   $items      Flat array of items to store.
	 * @param  int     $expiration TTL in seconds.
	 */
	private function set_chunked_transient( $key_suffix, array $items, $expiration ) {
		$chunks = array_chunk( $items, 50 );
		set_transient( 'aiopod_' . $key_suffix . '_chunks', count( $chunks ), $expiration );

		foreach ( $chunks as $index => $chunk ) {
			set_transient( 'aiopod_' . $key_suffix . '_' . $index, $chunk, $expiration );
		}
	}

	/**
	 * Retrieves and reassembles an array previously stored with set_chunked_transient().
	 *
	 * @since  1.0.0
	 * @access private
	 * @param  string     $key_suffix Unique identifier appended after the aiopod_ namespace prefix.
	 * @return array|bool             Reassembled array, or false if any chunk is missing.
	 */
	private function get_chunked_transient( $key_suffix ) {
		$count = get_transient( 'aiopod_' . $key_suffix . '_chunks' );

		if ( false === $count ) {
			return false;
		}

		$items = array();

		for ( $i = 0; $i < (int) $count; $i++ ) {
			$chunk = get_transient( 'aiopod_' . $key_suffix . '_' . $i );

			if ( false === $chunk ) {
				return false;
			}

			$items = array_merge( $items, $chunk );
		}

		return $items;
	}

	/**
	 * Deletes all transient entries created by set_chunked_transient().
	 *
	 * @since  1.0.0
	 * @access private
	 * @param  string  $key_suffix Unique identifier appended after the aiopod_ namespace prefix.
	 */
	private function delete_chunked_transient( $key_suffix ) {
		$count = get_transient( 'aiopod_' . $key_suffix . '_chunks' );

		if ( false !== $count ) {
			for ( $i = 0; $i < (int) $count; $i++ ) {
				delete_transient( 'aiopod_' . $key_suffix . '_' . $i );
			}
		}

		delete_transient( 'aiopod_' . $key_suffix . '_chunks' );
	}

	/**
	 * Parses boolean-like values for podcast tags.
	 *
	 * Accepts "yes", "true", "1" as true (1), else false (0).
	 *
	 * @since  1.0.0
	 * @access private
	 * @param  mixed   $value Input value to parse.
	 * @return int            1 if true-ish, 0 otherwise.
	 */
	private function parse_bool( $value ) {
		$val = strtolower( trim( (string) $value ) );
		return in_array( $val, array( 'yes', 'true', '1' ), true ) ? 1 : 0;
	}

}
