<?php
/**
 * File Path: includes/import/class-mbs-importer-wxr.php
 *
 * WXR Importer — My Book Showroom
 *
 * Imports books from a WordPress eXtended RSS (WXR) export file produced by
 * Novelist, WP Books Gallery, or any other WP-based book-catalog plugin.
 *
 * Two-pass approach:
 *   Pass 1 (analyze): Stream the WXR file with XMLReader, collect all unique
 *                     wp:postmeta keys. Return the key list for the mapping UI.
 *   Pass 2 (import):  Using the user-supplied mapping, stream the file again
 *                     and insert matching posts via wp_insert_post + MBS DAOs.
 *
 * Uses XMLReader throughout — NOT simplexml_load_file(). WXR files from active
 * sites can exceed 100 MB; loading the full DOM into memory is not acceptable.
 *
 * Mapping UI: see class-mbs-tab-child-import-api.php (AJAX handlers) and
 * templates/admin/imp-exp-ui.php (UI section).
 *
 * Tier: FREE (competitor migration — no mbs_is_pro() gate).
 *
 * @package    My_Book_Showroom
 * @subpackage Includes/Import
 * @since      1.0.0
 */

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

class MBS_Importer_WXR { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix

	/** Maximum file size accepted (50 MB). */
	const MAX_FILE_SIZE = 52428800;

	/** Transient TTL for storing the user's field mapping (1 hour). */
	const MAPPING_TTL = HOUR_IN_SECONDS;

	/** MBS postmeta keys a user can map WXR postmeta to. */
	const MBS_TARGET_FIELDS = array(
		''                    => '— Skip this field —',
		'mbs_publisher'       => 'Publisher',
		'mbs_publisher_url'   => 'Publisher URL',
		'mbs_isbn13'          => 'ISBN-13',
		'mbs_asin'            => 'ASIN',
		'mbs_pub_date'        => 'Publication Date',
		'mbs_page_count'      => 'Page Count',
		'mbs_series_name'     => 'Series Name',
		'mbs_series_number'   => 'Series Number',
		'mbs_subtitle'        => 'Subtitle',
		'mbs_synopsis'        => 'Synopsis',
		'mbs_teaser'          => 'Teaser',
		'mbs_word_count'      => 'Word Count',
		'mbs_goodreads_url'   => 'GoodReads URL',
		'mbs_storygraph_url'  => 'StoryGraph URL',
		'mbs_bookbub_url'     => 'BookBub URL',
		'mbs_lifecycle_state' => 'Book Status',
	);

	// ── Pass 1: Analyze ───────────────────────────────────────────────────────

	/**
	 * Stream a WXR file and return all unique wp:postmeta keys found.
	 *
	 * @param string $file_path Absolute path to the WXR file.
	 * @return array{ keys: string[], cpt: string, error: string|null }
	 *   keys  = unique postmeta key list
	 *   cpt   = most common post_type found (for context in the UI)
	 *   error = null on success, error string on failure
	 */
	public static function analyze( string $file_path ): array {
		$result = array(
			'keys'   => array(),
			'cpt'    => '',
			'source' => '',
			'error'  => null,
		);

		// Guard: file size.
		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		$size = @filesize( $file_path );
		if ( false === $size || $size > self::MAX_FILE_SIZE ) {
			$result['error'] = sprintf(
				'File exceeds the 50 MB maximum (%s MB). Please split the export and import in batches.',
				$size ? round( $size / 1048576, 1 ) : '?'
			);
			return $result;
		}

		$reader = new XMLReader();
		// P-07 (F-07): XMLReader uses libxml under the hood. PHP 8.2 (this plugin's
		// minimum) disables external entity loading by default, so XXE is not reachable
		// here; libxml_disable_entity_loader() is deprecated/no-op on 8.2. LIBXML_NONET
		// is passed as an explicit, forward-compatible guard that blocks network access
		// during any entity resolution regardless of PHP version.
		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		if ( ! @$reader->open( $file_path, null, LIBXML_NONET ) ) {
			$result['error'] = 'Could not open the WXR file for reading.';
			return $result;
		}

		$keys      = array();
		$cpt_count = array();
		$in_item   = false;

		while ( $reader->read() ) {
			// F-12: detect </item> (END_ELEMENT) BEFORE the ELEMENT-only filter below.
			// The old bottom reset was unreachable (dead code) because of the continue,
			// so $in_item stayed true after the first <item>, unbounding collection.
			if ( XMLReader::END_ELEMENT === $reader->nodeType
				&& 'item' === $reader->localName && '' === $reader->namespaceURI ) {
				$in_item = false;
				continue;
			}

			if ( XMLReader::ELEMENT !== $reader->nodeType ) {
				continue;
			}

			// Detect <item> boundaries.
			if ( 'item' === $reader->localName && '' === $reader->namespaceURI ) {
				$in_item = true;
				continue;
			}

			if ( ! $in_item ) {
				continue;
			}

			// Collect post_type.
			if ( 'post_type' === $reader->localName && 'http://wordpress.org/export/1.2/' === $reader->namespaceURI ) {
				$reader->read();
				$cpt = trim( (string) $reader->value );
				if ( '' !== $cpt ) {
					$cpt_count[ $cpt ] = ( $cpt_count[ $cpt ] ?? 0 ) + 1;
				}
				continue;
			}

			// Collect wp:postmeta → wp:meta_key values.
			if ( 'postmeta' === $reader->localName && 'http://wordpress.org/export/1.2/' === $reader->namespaceURI ) {
				// Read child nodes of <wp:postmeta> to find <wp:meta_key>.
				$depth = $reader->depth;
				while ( $reader->read() ) {
					if ( XMLReader::END_ELEMENT === $reader->nodeType
						&& 'postmeta' === $reader->localName
						&& $reader->depth === $depth ) {
						break;
					}
					if ( XMLReader::ELEMENT === $reader->nodeType
						&& 'meta_key' === $reader->localName
						&& 'http://wordpress.org/export/1.2/' === $reader->namespaceURI ) {
						$reader->read();
						$key = trim( (string) $reader->value );
						if ( '' !== $key && ! isset( $keys[ $key ] ) ) {
							$keys[ $key ] = true;
						}
					}
				}
				continue;
			}
		}

		$reader->close();

		if ( ! empty( $cpt_count ) ) {
			arsort( $cpt_count );
			$result['cpt'] = array_key_first( $cpt_count );
		}

		$result['keys']   = array_keys( $keys );
		$result['source'] = self::detect_source( $result['cpt'], $result['keys'] );
		sort( $result['keys'] );

		return $result;
	}

	/**
	 * Resolve a canonical source slug from the dominant CPT and collected meta keys.
	 *
	 * The 'books' CPT is shared by WP Books Gallery and Genesis Author Pro.
	 * Disambiguation uses the meta key set: wbg_* prefix -> wp_books_gallery,
	 * _genesis_author_pro key -> genesis_author_pro.
	 *
	 * @since  1.2.0
	 * @param  string   $cpt  Dominant post_type from the WXR file.
	 * @param  string[] $keys All unique wp:meta_key values found in the file.
	 * @return string  Canonical source slug, or '' if unrecognized.
	 */
	protected static function detect_source( string $cpt, array $keys ): string {
		switch ( $cpt ) {
			case 'gs_bookshowcase':
				// A-003: slug uses hyphens to match all source_key() methods.
				return 'gs-bookshowcase';
			case 'mbdb_book':
				return 'mooberry';
			case 'book':
				// C-001: 'book' CPT is shared by RS WP Books Showcase and Novelist.
				// Inspect meta keys to disambiguate.
				foreach ( $keys as $k ) {
					if ( 0 === strpos( $k, '_rsbs_' ) ) {
						return 'rs-wp-books';
					}
				}
				if ( in_array( 'novelist_title', $keys, true ) || in_array( 'novelist_isbn', $keys, true ) ) {
					return 'novelist';
				}
				return '';
			case 'mbt_book':
				return 'mybooktable';
			case 'books':
				// Shared CPT -- inspect meta keys to disambiguate.
				foreach ( $keys as $k ) {
					if ( 0 === strpos( $k, 'wbg_' ) ) {
						// A-003: slug uses hyphens.
						return 'wp-books-gallery';
					}
				}
				if ( in_array( '_genesis_author_pro', $keys, true ) ) {
					// A-003: slug uses hyphens.
					return 'genesis-author-pro';
				}
				return '';
			default:
				return '';
		}
	}

	// ── Pass 2: Import ────────────────────────────────────────────────────────

	/**
	 * Stream a WXR file and import books using the user-supplied field mapping.
	 *
	 * The mapping array has the shape:
	 *   [ 'source_meta_key' => 'mbs_target_field', ... ]
	 *
	 * Fields mapped to '' are skipped. Unrecognized target fields are logged
	 * and skipped — never cause a fatal.
	 *
	 * Standard WXR fields (title, content) are always imported without mapping.
	 *
	 * @param string               $file_path  Absolute path to the WXR file.
	 * @param array<string,string> $mapping    Source key → MBS target field map.
	 * @param string               $target_cpt WXR post_type to import (e.g. 'book').
	 * @return array{ imported: int, skipped: int, errors: string[] }
	 */
	public static function import( string $file_path, array $mapping, string $target_cpt = '' ): array {
		$result = array(
			'imported' => 0,
			'skipped'  => 0,
			'errors'   => array(),
		);

		// Guard: file size.
		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		$size = @filesize( $file_path );
		if ( false === $size || $size > self::MAX_FILE_SIZE ) {
			$result['errors'][] = 'File exceeds the 50 MB import limit.';
			MBS_Telemetry_Failure_Tracker::record_import_failure( 'wxr' ); // F-006
			return $result;
		}

		$reader = new XMLReader();
		// P-07 (F-07): XMLReader uses libxml under the hood. PHP 8.2 (this plugin's
		// minimum) disables external entity loading by default, so XXE is not reachable
		// here; libxml_disable_entity_loader() is deprecated/no-op on 8.2. LIBXML_NONET
		// is passed as an explicit, forward-compatible guard that blocks network access
		// during any entity resolution regardless of PHP version.
		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		if ( ! @$reader->open( $file_path, null, LIBXML_NONET ) ) {
			$result['errors'][] = 'Could not open the WXR file for reading.';
			MBS_Telemetry_Failure_Tracker::record_import_failure( 'wxr' ); // F-006
			return $result;
		}

		$ns_wp = 'http://wordpress.org/export/1.2/';
		$ns_content = 'http://purl.org/rss/1.0/modules/content/';

		$item_data = null;

		while ( $reader->read() ) {
			if ( XMLReader::ELEMENT === $reader->nodeType && 'item' === $reader->localName && '' === $reader->namespaceURI ) {
				// Start of an <item>: collect all fields into $item_data.
				$item_data = array(
					'title'     => '',
					'content'   => '',
					'post_type' => '',
					'meta'      => array(), // [ key => value ]
				);
				continue;
			}

			if ( null === $item_data ) {
				continue;
			}

			// Inside an <item>.
			if ( XMLReader::ELEMENT === $reader->nodeType ) {
				$local = $reader->localName;
				$ns    = $reader->namespaceURI;

				// <title> (no namespace)
				if ( 'title' === $local && '' === $ns ) {
					$reader->read();
					$item_data['title'] = trim( (string) $reader->value );
					continue;
				}

				// <content:encoded>
				if ( 'encoded' === $local && $ns_content === $ns ) {
					$reader->read();
					$item_data['content'] = trim( (string) $reader->value );
					continue;
				}

				// <wp:post_type>
				if ( 'post_type' === $local && $ns_wp === $ns ) {
					$reader->read();
					$item_data['post_type'] = trim( (string) $reader->value );
					continue;
				}

				// <wp:postmeta>
				if ( 'postmeta' === $local && $ns_wp === $ns ) {
					$meta_key   = '';
					$meta_value = '';
					$depth      = $reader->depth;

					while ( $reader->read() ) {
						if ( XMLReader::END_ELEMENT === $reader->nodeType
							&& 'postmeta' === $reader->localName
							&& $reader->depth === $depth ) {
							break;
						}
						if ( XMLReader::ELEMENT !== $reader->nodeType ) {
							continue;
						}
						if ( 'meta_key' === $reader->localName && $ns_wp === $reader->namespaceURI ) {
							$reader->read();
							$meta_key = trim( (string) $reader->value );
						} elseif ( 'meta_value' === $reader->localName && $ns_wp === $reader->namespaceURI ) {
							$reader->read();
							$meta_value = trim( (string) $reader->value );
						}
					}

					if ( '' !== $meta_key ) {
						$item_data['meta'][ $meta_key ] = $meta_value;
					}
					continue;
				}
			}

			// </item>: process the collected item.
			if ( XMLReader::END_ELEMENT === $reader->nodeType && 'item' === $reader->localName ) {
				if ( null !== $item_data ) {
					// Filter by CPT if specified.
					if ( '' !== $target_cpt && $item_data['post_type'] !== $target_cpt ) {
						$item_data = null;
						continue;
					}

					$import_result = MBS_Importer_WXR_Inserter::import_item( $item_data, $mapping );

					if ( true === $import_result ) {
						$result['imported']++;
					} elseif ( 'skipped' === $import_result ) {
						$result['skipped']++;
					} else {
						$result['errors'][] = sprintf(
							'"%s": %s',
							esc_html( $item_data['title'] ),
							$import_result
						);
					}
				}
				$item_data = null;
			}
		}

		$reader->close();
		if ( ! empty( $result['errors'] ) ) {
			MBS_Telemetry_Failure_Tracker::record_import_failure( 'wxr', count( $result['errors'] ) ); // F-006
		}
		return $result;
	}

	// ── Transient helpers for mapping storage ─────────────────────────────────

	/**
	 * Store a user's field mapping in a transient.
	 *
	 * @param string               $nonce   Nonce string used as part of the transient key.
	 * @param array<string,string> $mapping Source key → MBS target field.
	 */
	public static function store_mapping( string $nonce, array $mapping ): void {
		set_transient( 'mbs_wxr_mapping_' . md5( $nonce ), $mapping, self::MAPPING_TTL );
	}

	/**
	 * Retrieve a stored field mapping.
	 *
	 * @param  string $nonce
	 * @return array<string,string>|false
	 */
	public static function get_mapping( string $nonce ) {
		return get_transient( 'mbs_wxr_mapping_' . md5( $nonce ) );
	}
}
