<?php
/**
 * File Path: includes/import/class-mbs-catalog-archive-reader.php
 *
 * MBS Archive Reader
 *
 * Validates, extracts, and parses an MBS backup ZIP archive. Returns an
 * array of MBS Book Objects (Sprint Plan §4 shape) on success, or a
 * WP_Error on any validation failure.
 *
 * Validation sequence (Sprint Plan §Phase 5 / IEF-A-011, IEF-B-012, IEF-B-014):
 *
 *   1. File extension must be .zip.
 *   2. PK magic signature: first 4 bytes must be \x50\x4B\x03\x04.
 *      Both checks must pass. MIME type may be logged but does not gate.
 *   3. ZIP bomb protection: iterate ZipArchive::statIndex(), sum uncompressed
 *      sizes, abort if total > MBS_IMPORT_MAX_DECOMPRESSED_BYTES
 *      (default 200 MB; filterable via mbs_import_max_decompressed_bytes).
 *   4. Path traversal: validate every entry path with validate_file() === 0
 *      before extraction. Reject the ENTIRE archive on any single failure
 *      (IEF-B-014) — do not skip individual entries.
 *   5. Extract to temp via ZipArchive::extractTo() (no WP_Filesystem dependency).
 *   6. Locate catalog.xlsx and read all sheets via MBS_XLSX_Reader::get_sheets().
 *   7. Parse Tab 13 (Manifest): validate schema_version.
 *   8. Map books via MBS_Catalog_XLSX_Mapper::map() (Tabs 1-10).
 *   9. Parse Tab 11 (Taxonomy): reconstruct nested term array for the restorer.
 *
 * No DB writes. No post insertion. Unit-testable in isolation.
 *
 * @package    My_Book_Showroom
 * @since      1.0.0
 */

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

class MBS_Catalog_Archive_Reader {

	/** Default max decompressed bytes (200 MB). Filterable. */
	const DEFAULT_MAX_DECOMPRESSED_BYTES = 209715200;

	/** Minimum supported schema version. */
	const MIN_SCHEMA_VERSION = '1.0';

	/**
	 * Validate and parse an MBS backup ZIP file.
	 *
	 * @param string $zip_path Absolute filesystem path to the uploaded ZIP file.
	 * @return array|WP_Error {
	 *   On success:
	 *   @type array  $books       Array of MBS Book Objects (from XLSX mapper).
	 *   @type array  $manifest    Parsed manifest from Tab 12.
	 *   @type array  $taxonomy    Taxonomy terms from Tab 11 (keyed by taxonomy slug).
	 *   @type string $extract_dir Absolute path to the extracted temp dir.
	 *                             Caller MUST delete this directory when done.
	 * }
	 */
	public static function read( string $zip_path ): array|\WP_Error {
		// PLG-NEW-004: Guard against nonexistent or unreadable files before any
		// fopen() call in check_pk_magic(), which would emit an E_WARNING.
		if ( ! file_exists( $zip_path ) || ! is_readable( $zip_path ) ) {
			return new \WP_Error(
				'mbs_file_not_found',
				sprintf( 'Archive not found or not readable: %s', esc_html( $zip_path ) )
			);
		}


		// ── Step 1: File extension check ──────────────────────────────────────
		if ( strtolower( pathinfo( $zip_path, PATHINFO_EXTENSION ) ) !== 'zip' ) {
			return new \WP_Error( 'mbs_bad_extension', 'Upload rejected: file must have a .zip extension.' );
		}

		// ── Step 2: PK magic signature check ─────────────────────────────────
		$magic_check = self::check_pk_magic( $zip_path );
		if ( is_wp_error( $magic_check ) ) {
			return $magic_check;
		}

		// ── Step 3: ZIP bomb protection ───────────────────────────────────────
		$bomb_check = self::check_zip_bomb( $zip_path );
		if ( is_wp_error( $bomb_check ) ) {
			return $bomb_check;
		}

		// ── Step 4: Path traversal pre-check ─────────────────────────────────
		$traversal_check = self::check_path_traversal( $zip_path );
		if ( is_wp_error( $traversal_check ) ) {
			return $traversal_check;
		}

		// ── Step 5: Extract via ZipArchive::extractTo() ─────────────────────
		// unzip_file() requires $wp_filesystem to be initialized, which is not
		// guaranteed in an AJAX context. ZipArchive::extractTo() is used instead
		// as it has no WP_Filesystem dependency.
		$extract_dir = get_temp_dir() . 'mbs-import-' . wp_generate_uuid4() . '/';
		wp_mkdir_p( $extract_dir );

		$zip_extract = new \ZipArchive();
		if ( true !== $zip_extract->open( $zip_path ) ) {
			self::cleanup_dir( $extract_dir );
			return new \WP_Error( 'mbs_unzip_failed', 'Archive extraction failed: could not open ZIP.' );
		}
		$extracted = $zip_extract->extractTo( $extract_dir );
		$zip_extract->close();
		if ( ! $extracted ) {
			self::cleanup_dir( $extract_dir );
			return new \WP_Error( 'mbs_unzip_failed', 'Archive extraction failed: ZipArchive could not extract files.' );
		}

		// ── Step 6: Locate catalog.xlsx ────────────────────────────────────────
		$xlsx_path = $extract_dir . 'catalog.xlsx';
		if ( ! file_exists( $xlsx_path ) ) {
			self::cleanup_dir( $extract_dir );
			return new \WP_Error( 'mbs_missing_xlsx', 'Archive is missing catalog.xlsx.' );
		}

		// ── Step 7: Read all sheets from catalog.xlsx ────────────────────────────
		$sheets = MBS_XLSX_Reader::get_sheets( $xlsx_path );
		if ( is_wp_error( $sheets ) ) {
			self::cleanup_dir( $extract_dir );
			return new \WP_Error(
				'mbs_xlsx_unreadable',
				'Could not read catalog.xlsx: ' . $sheets->get_error_message()
			);
		}

		// ── Step 8: Parse manifest from Tab 13 ───────────────────────────────────
		$manifest = self::parse_manifest_tab( $sheets );
		if ( is_wp_error( $manifest ) ) {
			self::cleanup_dir( $extract_dir );
			return $manifest;
		}

		// ── Step 9: Map books via MBS_Catalog_XLSX_Mapper (Tabs 1-10) ───────────
		$mapped = MBS_Catalog_XLSX_Mapper::map( $sheets );
		if ( ! empty( $mapped['errors'] ) && empty( $mapped['books'] ) ) {
			self::cleanup_dir( $extract_dir );
			return new \WP_Error(
				'mbs_xlsx_map_failed',
				implode( ' ', $mapped['errors'] )
			);
		}
		$books = $mapped['books'];

		// ── Step 10: Parse taxonomy terms from Tab 11 ────────────────────────────
		$taxonomy = self::parse_taxonomy_tab( $sheets );

		return array(
			'books'       => $books,
			'manifest'    => $manifest,
			'taxonomy'    => $taxonomy,
			'extract_dir' => $extract_dir,
		);
	}

	// -------------------------------------------------------------------------
	// Private XLSX parsing helpers

	// -------------------------------------------------------------------------

	/**
	 * Verify the PK magic signature (\x50\x4B\x03\x04) at the file head.
	 *
	 * @param string $zip_path
	 * @return true|WP_Error
	 */
	private static function check_pk_magic( string $zip_path ): bool|\WP_Error {
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
		$handle = fopen( $zip_path, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- WP_Filesystem migration deferred to PC-FILESYS sprint; PHP native file ops are safe here (temp files in get_temp_dir(), never inside wp-content)
		if ( ! $handle ) {
			return new \WP_Error( 'mbs_unreadable', 'Uploaded file could not be opened for reading.' );
		}
		$header = fread( $handle, 4 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- WP_Filesystem migration deferred to PC-FILESYS sprint; PHP native file ops are safe here (temp files in get_temp_dir(), never inside wp-content)
		fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- WP_Filesystem migration deferred to PC-FILESYS sprint; PHP native file ops are safe here (temp files in get_temp_dir(), never inside wp-content)

		if ( $header !== "\x50\x4B\x03\x04" ) {
			return new \WP_Error( 'mbs_bad_magic', 'Upload rejected: file does not have a valid ZIP signature.' );
		}

		return true;
	}

	/**
	 * ZIP bomb protection: sum all entry uncompressed sizes and reject if
	 * the total exceeds the configured limit.
	 *
	 * @param string $zip_path
	 * @return true|WP_Error
	 */
	private static function check_zip_bomb( string $zip_path ): bool|\WP_Error {
		$max_bytes = (int) apply_filters(
			'mbs_import_max_decompressed_bytes', // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix
			self::DEFAULT_MAX_DECOMPRESSED_BYTES
		);

		$zip = new \ZipArchive();
		if ( true !== $zip->open( $zip_path ) ) {
			return new \WP_Error( 'mbs_zip_open_failed', 'Could not open ZIP for bomb check.' );
		}

		$total = 0;
		$count = $zip->numFiles;
		for ( $i = 0; $i < $count; $i++ ) {
			$stat   = $zip->statIndex( $i );
			$total += (int) ( $stat['size'] ?? 0 );
			if ( $total > $max_bytes ) {
				$zip->close();
				return new \WP_Error(
					'mbs_zip_bomb',
					sprintf(
						'Archive rejected: decompressed size exceeds the %d MB limit.',
						(int) round( $max_bytes / 1048576 )
					)
				);
			}
		}

		$zip->close();
		return true;
	}

	/**
	 * Path traversal check: validate every ZIP entry path with validate_file().
	 *
	 * Per IEF-B-014, the ENTIRE archive is rejected if any single entry fails —
	 * individual entries are not skipped.
	 *
	 * @param string $zip_path
	 * @return true|WP_Error
	 */
	private static function check_path_traversal( string $zip_path ): bool|\WP_Error {
		$zip = new \ZipArchive();
		if ( true !== $zip->open( $zip_path ) ) {
			return new \WP_Error( 'mbs_zip_open_failed', 'Could not open ZIP for path traversal check.' );
		}

		$count = $zip->numFiles;
		for ( $i = 0; $i < $count; $i++ ) {
			$name = $zip->getNameIndex( $i );
			if ( false === $name ) {
				$zip->close();
				return new \WP_Error( 'mbs_zip_entry_unreadable', 'Could not read entry name at index ' . $i . '.' );
			}
			// validate_file() returns 0 for safe paths; non-zero for traversal attempts.
			if ( 0 !== validate_file( $name ) ) {
				$zip->close();
				return new \WP_Error(
					'mbs_path_traversal',
					'Archive rejected: entry path contains a directory traversal sequence: ' . esc_html( $name )
				);
			}
		}

		$zip->close();
		return true;
	}

	/**
	 * Parse the Manifest tab (Tab 13) from the XLSX sheets array.
	 *
	 * Expects one header row followed by one data row with columns:
	 * Schema Version | Export Date | Plugin Version | Site URL | Book Count.
	 * Validates that the schema_version is not newer than what this plugin supports.
	 *
	 * @param array $sheets Sheets array from MBS_XLSX_Reader::get_sheets().
	 * @return array|WP_Error Manifest array on success, WP_Error on failure.
	 */
	private static function parse_manifest_tab( array $sheets ): array|\WP_Error {
		$rows = $sheets['Manifest'] ?? array();
		// Row 0 is the header; row 1 is the single data row.
		if ( count( $rows ) < 2 || empty( $rows[1] ) ) {
			return new \WP_Error(
				'mbs_missing_manifest',
				'catalog.xlsx is missing the Manifest tab or has no data row.'
			);
		}

		$row      = array_values( $rows[1] );
		$manifest = array(
			'schema_version' => (string) ( $row[0] ?? '' ),
			'export_date'    => (string) ( $row[1] ?? '' ),
			'plugin_version' => (string) ( $row[2] ?? '' ),
			'site_url'       => (string) ( $row[3] ?? '' ),
			'book_count'     => (int)    ( $row[4] ?? 0 ),
		);

		if ( empty( $manifest['schema_version'] ) ) {
			return new \WP_Error(
				'mbs_missing_schema_version',
				'Manifest tab is missing schema_version.'
			);
		}

		if ( version_compare( $manifest['schema_version'], MBS_Book_Serializer::SCHEMA_VERSION, '>' ) ) {
			return new \WP_Error(
				'mbs_schema_too_new',
				sprintf(
					'Archive schema version %s is newer than this plugin supports (%s). Please update the plugin.',
					esc_html( $manifest['schema_version'] ),
					MBS_Book_Serializer::SCHEMA_VERSION
				)
			);
		}

		return $manifest;
	}

	/**
	 * Parse the Taxonomy tab (Tab 11) from the XLSX sheets array.
	 *
	 * Tab 11 columns: Taxonomy | Term Name | Slug | Description | Parent Name | Meta Key | Meta Value.
	 * Multiple rows with the same taxonomy + slug are merged into one term entry
	 * with multiple term_meta keys.
	 *
	 * @param array $sheets Sheets array from MBS_XLSX_Reader::get_sheets().
	 * @return array<string, array[]> Keyed by taxonomy slug; compatible with MBS_Import_Taxonomy_Restorer.
	 */
	private static function parse_taxonomy_tab( array $sheets ): array {
		$rows   = $sheets['Taxonomy'] ?? array();
		$result = array();

		foreach ( $rows as $i => $row ) {
			if ( 0 === $i ) {
				continue; // Skip header row.
			}
			// V7-D-006: Sanitize all string values from the spreadsheet before use.
			$tax         = sanitize_key( (string) ( $row[0] ?? '' ) );
			$name        = sanitize_text_field( (string) ( $row[1] ?? '' ) );
			$slug        = sanitize_key( (string) ( $row[2] ?? '' ) );
			$description = sanitize_text_field( (string) ( $row[3] ?? '' ) );
			$meta_key    = sanitize_key( (string) ( $row[5] ?? '' ) );
			$meta_value  = sanitize_text_field( (string) ( $row[6] ?? '' ) );

			if ( '' === $tax || '' === $name ) {
				continue;
			}

			if ( ! isset( $result[ $tax ] ) ) {
				$result[ $tax ] = array();
			}

			// Find an existing entry for this term slug within the taxonomy.
			$found = false;
			foreach ( $result[ $tax ] as &$existing ) {
				if ( $existing['slug'] === $slug ) {
					if ( '' !== $meta_key ) {
						$existing['term_meta'][ $meta_key ] = $meta_value;
					}
					$found = true;
					break;
				}
			}
			unset( $existing );

			if ( ! $found ) {
				$entry = array(
					'name'        => $name,
					'slug'        => $slug,
					'description' => $description,
					'parent'      => 0, // Flat import; parent re-linking not in scope.
					'term_meta'   => array(),
				);
				if ( '' !== $meta_key ) {
					$entry['term_meta'][ $meta_key ] = $meta_value;
				}
				$result[ $tax ][] = $entry;
			}
		}

		return $result;
	}

	/**
	 * Register the import cleanup cron schedule and callback.
	 *
	 * @return void
	 * @since 1.3.5
	 */
	public static function register_hooks(): void {
		if ( is_admin() && ! wp_next_scheduled( 'mbs_cleanup_import_temp_dirs' ) ) {
			wp_schedule_event( time(), 'hourly', 'mbs_cleanup_import_temp_dirs' );
		}
		add_action( 'mbs_cleanup_import_temp_dirs', array( __CLASS__, 'cleanup_import_temp_dirs' ) );
	}

	/**
	 * V7-D-005: Remove orphaned mbs-import-* temp directories older than 2 hours.
	 *
	 * Hooked to the mbs_cleanup_import_temp_dirs hourly cron event.
	 * Abandoned import sessions leave extract dirs in get_temp_dir(); this method
	 * removes any that have not been modified in the last 2 hours.
	 *
	 * @return void
	 */
	public static function cleanup_import_temp_dirs(): void {
		$tmp  = get_temp_dir();
		$dirs = glob( $tmp . 'mbs-import-*', GLOB_ONLYDIR );
		if ( ! $dirs ) {
			return;
		}
		$cutoff = time() - ( 2 * HOUR_IN_SECONDS );
		foreach ( $dirs as $dir ) {
			if ( filemtime( $dir ) < $cutoff ) {
				// Delegate to the reader's static cleanup helper (no WP_Filesystem dependency).
				self::cleanup_extracted( $dir );
			}
		}
	}

	/**
	 * Public entry point for cleaning up an extracted archive directory.
	 *
	 * Called by MBS_Import_Orchestrator after all books in a session have been
	 * sideloaded. Delegates to the private recursive helper.
	 *
	 * @param string $dir Absolute path to the directory to remove.
	 */
	public static function cleanup_extracted( string $dir ): void {
		self::cleanup_dir( $dir );
	}

	/**
	 * Remove a temp directory and all its contents.
	 *
	 * @param string $dir Absolute path to the directory.
	 */
	private static function cleanup_dir( string $dir ): void {
		if ( ! is_dir( $dir ) ) {
			return;
		}
		// Use WP_Filesystem if available; fall back to shell-safe recursive delete.
		global $wp_filesystem;
		if ( ! empty( $wp_filesystem ) && is_object( $wp_filesystem ) ) {
			$wp_filesystem->delete( $dir, true );
			return;
		}
		// phpcs:ignore WordPress.PHP.NoSilencedErrors -- best-effort cleanup; error not actionable
		@self::rmdir_recursive( $dir );
	}

	/**
	 * Recursively remove a directory.
	 *
	 * @param string $dir
	 */
	private static function rmdir_recursive( string $dir ): void {
		$items = scandir( $dir );
		foreach ( $items as $item ) {
			if ( '.' === $item || '..' === $item ) {
				continue;
			}
			$path = $dir . DIRECTORY_SEPARATOR . $item;
			if ( is_dir( $path ) ) {
				self::rmdir_recursive( $path );
			} else {
				// phpcs:ignore WordPress.PHP.NoSilencedErrors
				wp_delete_file( $path );
			}
		}
		// phpcs:ignore WordPress.PHP.NoSilencedErrors
		@rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- WP_Filesystem migration deferred to PC-FILESYS sprint; PHP native file ops are safe here (temp files in get_temp_dir(), never inside wp-content)
	}
}
