<?php
/**
 * File Path: includes/import/class-mbs-epub-parser.php
 *
 * EPUB Importer -- Parser (Coordinator)
 *
 * Coordinator for the MBS EPUB Import feature (EPUB-001). Opens the EPUB
 * ZIP archive, locates the OPF package document, instantiates
 * MBS_EPUB_OPF_Reader and MBS_EPUB_Look_Inside_Extractor, delegates all
 * parsing to them, closes the ZIP, and returns the Phase 1 AJAX response
 * payload defined in EPUB-001-spec.md ss13.
 *
 * Parsing logic lives in:
 *   includes/import/class-mbs-epub-opf-reader.php
 *   includes/import/class-mbs-epub-look-inside-extractor.php
 *
 * @package MyBookShowroom
 * @since   1.2.0
 */

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

/**
 * Class MBS_EPUB_Parser
 *
 * Stateless-style coordinator: call parse() once per EPUB file. Opens
 * the ZIP, delegates to MBS_EPUB_OPF_Reader and
 * MBS_EPUB_Look_Inside_Extractor, closes the ZIP, and returns the data
 * array. Never stores raw EPUB bytes.
 *
 * @since 1.2.0
 */
class MBS_EPUB_Parser { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix

	/** @var ZipArchive|null Open ZipArchive handle for the current EPUB. */
	private ?ZipArchive $zip = null;

	/** @var string Directory prefix for resolving manifest hrefs. */
	private string $opf_dir = '';

	// -- Public API ----------------------------------------------------------

	/**
	 * Parse an EPUB file and return extracted metadata.
	 *
	 * @since  1.2.0
	 * @param  string $file_path Absolute path to the .epub file on disk.
	 * @return array  Flat associative array per EPUB-001 ss13 Phase 1 payload.
	 *                Keys 'ok' (bool) and 'error' (string) indicate failure.
	 */
	public function parse( string $file_path ): array {
		$this->opf_dir = '';
		$data          = $this->init_data();

		if ( ! $this->open_zip( $file_path ) ) {
			return $this->fail( $data, 'Cannot open EPUB as ZIP archive.' );
		}
		// V8-C-013: Guard against crafted EPUBs with excessive ZIP entries.
		if ( $this->zip->numFiles > 500 ) {
			$this->close_zip();
			return $this->fail( $data, 'EPUB contains too many entries (limit: 500).' );
		}

		$opf_path = $this->find_opf_path();
		if ( false === $opf_path ) {
			$this->close_zip();
			return $this->fail( $data, 'Cannot locate OPF file in EPUB.' );
		}

		$opf_xml = $this->zip->getFromName( $opf_path );
		if ( false === $opf_xml ) {
			$this->close_zip();
			return $this->fail( $data, 'Cannot read OPF file from archive.' );
		}

		$dir           = dirname( $opf_path );
		$this->opf_dir = ( '.' === $dir || '' === $dir ) ? '' : rtrim( $dir, '/' ) . '/';

		$reader    = new MBS_EPUB_OPF_Reader( $this->zip, $this->opf_dir );
		$extractor = new MBS_EPUB_Look_Inside_Extractor( $this->zip, $this->opf_dir );

		$data = $reader->read( $opf_xml );
		$extractor->extract( $data['spine_map'], $data['toc_label_map'], $data );

		$this->close_zip();

		// Strip internal-only keys before returning to callers.
		unset( $data['spine_map'], $data['toc_label_map'] );

		return $data;
	}

	// -- Private helpers -----------------------------------------------------

	/**
	 * Return the empty data structure used for early-exit failure responses.
	 * Shape matches the EPUB-001 ss13 Phase 1 payload.
	 *
	 * @return array
	 */
	private function init_data(): array {
		return [
			'ok'                     => true,
			'error'                  => '',
			'title'                  => '',
			'description'            => '',
			'publisher'              => '',
			'pub_date'               => '',
			'language'               => '',
			'copyright_year'         => '',
			'lccn'                   => '',
			'lc_url'                 => '',
			'isbn13'                 => '',
			'series_name'            => '',
			'series_num'             => '',
			'page_count'             => '',
			'trim_size'              => '',
			'pub_address'            => '',
			'cover_artist'           => '',
			'toc'                    => '',
			'bisac_codes'            => [],
			'bic_codes'              => [],
			'thema_codes'            => [],
			'other_isbns'            => [],
			'contributors'           => [],
			'cover_bytes'            => '',
			'cover_ext'              => '',
			'duplicate'              => [ 'found' => false ],
			'look_inside_candidates' => [],
			'notes'                  => [],
		];
	}

	/**
	 * Stamp a data array with failure info and return it.
	 *
	 * @param  array  $data  Data array to stamp.
	 * @param  string $error Human-readable error description.
	 * @return array
	 */
	private function fail( array $data, string $error ): array {
		$data['ok']    = false;
		$data['error'] = $error;
		return $data;
	}

	/**
	 * Open the EPUB file as a ZipArchive (read-only).
	 *
	 * @param  string $path Absolute filesystem path.
	 * @return bool
	 */
	private function open_zip( string $path ): bool {
		$this->zip = new ZipArchive();
		return true === $this->zip->open( $path, ZipArchive::RDONLY );
	}

	/**
	 * Close the ZipArchive handle and reset the property.
	 *
	 * @return void
	 */
	private function close_zip(): void {
		if ( $this->zip instanceof ZipArchive ) {
			$this->zip->close();
			$this->zip = null;
		}
	}

	/**
	 * Read META-INF/container.xml and return the OPF rootfile path.
	 *
	 * @return string|false OPF path relative to archive root, or false.
	 */
	private function find_opf_path(): string|false {
		$container_xml = $this->zip->getFromName( 'META-INF/container.xml' );
		if ( false === $container_xml ) {
			return false;
		}
		$xml = @simplexml_load_string( $container_xml, 'SimpleXMLElement', LIBXML_NONET | LIBXML_NOENT );
		if ( ! $xml ) {
			return false;
		}
		// Try with container namespace first, then without.
		$xml->registerXPathNamespace( 'cn', 'urn:oasis:names:tc:opendocument:xmlns:container' );
		$nodes = $xml->xpath( '//cn:rootfile[@full-path]' );
		if ( empty( $nodes ) ) {
			$nodes = $xml->xpath( '//rootfile[@full-path]' );
		}
		if ( empty( $nodes ) ) {
			return false;
		}
		$path = (string) $nodes[0]['full-path'];
		return '' !== $path ? $path : false;
	}
}
