<?php
/**
 * File Path: includes/import/class-mbs-epub-opf-reader.php
 *
 * EPUB Importer -- OPF Reader
 *
 * Parses the OPF package document, extracts Dublin Core metadata, structured
 * identifiers, contributors, series, TOC, and cover image. Builds the spine
 * map and TOC label map required by MBS_EPUB_Look_Inside_Extractor. Receives
 * an open ZipArchive and OPF directory prefix from MBS_EPUB_Parser.
 *
 * @package MyBookShowroom
 * @since   1.2.0
 */

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

/**
 * Class MBS_EPUB_OPF_Reader
 *
 * Reads one OPF document per instance. Receives an open ZipArchive and
 * opf_dir from MBS_EPUB_Parser; never opens or closes the ZIP handle.
 *
 * @since 1.2.0
 */
class MBS_EPUB_OPF_Reader { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix

	/** @var ZipArchive Open archive handle (owned by MBS_EPUB_Parser). */
	private ZipArchive $zip;

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

	/** @var SimpleXMLElement|null Parsed OPF document. */
	private ?SimpleXMLElement $opf = null;

	/** @var array Accumulated parsed fields. */
	private array $data = [];

	/** @var array Spine id-to-href map; built once, shared with MBS_EPUB_Look_Inside_Extractor. */
	private array $spine_map = [];

	/** @var array TOC href-basename-to-label map; built once, shared with MBS_EPUB_Look_Inside_Extractor. */
	private array $toc_label_map = [];

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

	/**
	 * Constructor.
	 *
	 * @since 1.2.0
	 * @param ZipArchive $zip     Open archive handle.
	 * @param string     $opf_dir Directory prefix for manifest hrefs (may be empty).
	 */
	public function __construct( ZipArchive $zip, string $opf_dir ) {
		$this->zip     = $zip;
		$this->opf_dir = $opf_dir;
	}

	/**
	 * Parse the OPF document and return extracted metadata.
	 *
	 * Returns the standard EPUB-001 ss13 data array plus two internal keys
	 * ('spine_map', 'toc_label_map') for use by MBS_EPUB_Look_Inside_Extractor.
	 * MBS_EPUB_Parser strips those internal keys before returning the final
	 * payload to callers.
	 *
	 * @since  1.2.0
	 * @param  string $opf_xml Raw OPF XML string.
	 * @return array  Data array per EPUB-001 ss13 plus 'spine_map' and 'toc_label_map'.
	 */
	public function read( string $opf_xml ): array {
		$this->data = [
			'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'                  => [],
		];

		$this->parse_opf( $opf_xml );
		$this->build_spine_map();
		$this->build_toc_label_map();

		$cover = $this->extract_cover();
		if ( false !== $cover ) {
			$this->data['cover_bytes'] = $cover[0];
			$this->data['cover_ext']   = $cover[1];
		}

		$this->data['contributors'] = $this->deduplicate_contributors( $this->data['contributors'] );

		// Include internal maps for MBS_EPUB_Look_Inside_Extractor.
		$this->data['spine_map']     = $this->spine_map;
		$this->data['toc_label_map'] = $this->toc_label_map;

		return $this->data;
	}

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

	/**
	 * Parse the OPF document and populate $this->data with DC metadata,
	 * identifiers, contributors, series, and TOC.
	 *
	 * @param  string $opf_xml Raw XML string.
	 * @return void
	 */
	private function parse_opf( string $opf_xml ): void {
		$xml = @simplexml_load_string( $opf_xml, 'SimpleXMLElement', LIBXML_NONET | LIBXML_NOENT );
		if ( ! $xml ) {
			return;
		}

		$xml->registerXPathNamespace( 'opf', 'http://www.idpf.org/2007/opf' );
		$xml->registerXPathNamespace( 'dc',  'http://purl.org/dc/elements/1.1/' );

		$this->opf = $xml;

		// Title
		$titles = $xml->xpath( '//dc:title' );
		if ( ! empty( $titles ) ) {
			$this->data['title'] = sanitize_text_field( (string) $titles[0] );
		}
		$this->detect_opf_placeholders();

		// Description
		$desc = $xml->xpath( '//dc:description' );
		if ( ! empty( $desc ) ) {
			$this->data['description'] = sanitize_textarea_field( (string) $desc[0] );
		}

		// Publisher
		$pub = $xml->xpath( '//dc:publisher' );
		if ( ! empty( $pub ) ) {
			$this->data['publisher'] = sanitize_text_field( (string) $pub[0] );
		}

		// Date: skip modification events; use first non-modification date.
		$dates = $xml->xpath( '//dc:date' );
		foreach ( $dates as $d ) {
			$opf_attrs  = $d->attributes( 'http://www.idpf.org/2007/opf' );
			$plain_attr = $d->attributes();
			$event      = (string) ( $opf_attrs['event'] ?? $plain_attr['event'] ?? '' );
			if ( 'modification' !== $event && '' === $this->data['pub_date'] ) {
				$raw    = sanitize_text_field( (string) $d );
				$parsed = $this->parse_date( $raw );
				if ( '' !== $parsed ) {
					$this->data['pub_date'] = $parsed;
				}
			}
		}

		// Language
		$lang = $xml->xpath( '//dc:language' );
		if ( ! empty( $lang ) ) {
			$this->data['language'] = $this->bcp47_to_iso639_2b( (string) $lang[0] );
		}

		// Structured identifiers
		$ids = $xml->xpath( '//dc:identifier' );
		$this->parse_dc_identifiers( $ids );

		// Series -- Sigil EPUB 2 extension meta elements
		$s_name = $xml->xpath( '//opf:meta[@name="series"]' );
		if ( ! empty( $s_name ) ) {
			$val = sanitize_text_field( (string) $s_name[0]['content'] );
			if ( '' !== $val ) {
				$this->data['series_name'] = $val;
			}
		}
		$s_num = $xml->xpath( '//opf:meta[@name="series_index"]' );
		if ( ! empty( $s_num ) ) {
			$this->data['series_num'] = sanitize_text_field( (string) $s_num[0]['content'] );
		}

		// Contributors (dc:creator + dc:contributor)
		$sort         = 0;
		$role_options = MBS_Contributor_Book_DAO::get_role_options();

		foreach ( $xml->xpath( '//dc:creator' ) as $c ) {
			$name = sanitize_text_field( (string) $c );
			if ( '' === $name ) {
				continue;
			}
			$opf_role = $c->attributes( 'http://www.idpf.org/2007/opf' );
			$marc     = strtolower( (string) ( $opf_role['role'] ?? 'aut' ) );
			$onix     = MBS_Contributor_Book_DAO::get_onix_from_marc( $marc );
			$this->data['contributors'][] = [
				'name'  => $name,
				'marc'  => $marc,
				'onix'  => $onix,
				'label' => $role_options[ $onix ] ?? 'Other',
				'sort'  => $sort++,
			];
		}

		foreach ( $xml->xpath( '//dc:contributor' ) as $c ) {
			$name = sanitize_text_field( (string) $c );
			if ( '' === $name ) {
				continue;
			}
			$opf_role = $c->attributes( 'http://www.idpf.org/2007/opf' );
			$marc     = strtolower( (string) ( $opf_role['role'] ?? 'ctb' ) );
			$onix     = MBS_Contributor_Book_DAO::get_onix_from_marc( $marc );
			$this->data['contributors'][] = [
				'name'  => $name,
				'marc'  => $marc,
				'onix'  => $onix,
				'label' => $role_options[ $onix ] ?? 'Other',
				'sort'  => $sort++,
			];
		}

		// Table of Contents
		$toc_path = $this->find_toc_path( $xml );
		if ( false !== $toc_path ) {
			$toc_content = $this->zip->getFromName( $toc_path );
			if ( false !== $toc_content ) {
				$lower = strtolower( $toc_path );
				if ( str_ends_with( $lower, '.ncx' ) ) {
					$this->data['toc'] = $this->extract_toc_ncx( $toc_content );
				} else {
					$this->data['toc'] = $this->extract_toc_nav( $toc_content );
				}
			}
		}
	}

	/**
	 * Clear title if the OPF value is a known placeholder.
	 * Pattern: dc:title contains "[No data]" (case-insensitive) or is empty.
	 *
	 * @return void
	 */
	private function detect_opf_placeholders(): void {
		if ( '' === $this->data['title'] ) {
			return;
		}
		if ( false !== stripos( $this->data['title'], '[No data]' ) ) {
			$this->data['title'] = '';
		}
	}

	/**
	 * Process an array of dc:identifier SimpleXML elements.
	 * Routes each to the appropriate destination field per EPUB-001 ss6b.
	 *
	 * @param  SimpleXMLElement[] $elements
	 * @return void
	 */
	private function parse_dc_identifiers( array $elements ): void {
		foreach ( $elements as $el ) {
			$opf_attrs = $el->attributes( 'http://www.idpf.org/2007/opf' );
			$pl_attrs  = $el->attributes();
			$scheme    = strtolower( trim( (string) ( $opf_attrs['scheme'] ?? $pl_attrs['scheme'] ?? '' ) ) );
			$value     = trim( sanitize_text_field( (string) $el ) );

			if ( '' === $value || str_contains( $scheme, 'uuid' ) ) {
				continue;
			}

			if ( str_contains( $scheme, 'isbn' ) ) {
				$normalized = MBS_ISBN_Helper::normalize_isbn( $value );
				if ( ! $normalized ) {
					continue;
				}

				$is_ebook = str_contains( $scheme, 'ebook' )
				         || str_contains( $scheme, 'epub' )
				         || str_contains( $scheme, 'digital' );
				$is_print = str_contains( $scheme, 'paperback' )
				         || str_contains( $scheme, 'hardcover' )
				         || str_contains( $scheme, 'pod' )
				         || str_contains( $scheme, 'audio' )
				         || str_contains( $scheme, 'trade' )
				         || str_contains( $scheme, 'large print' )
				         || str_contains( $scheme, 'print' );

				if ( $is_ebook ) {
					if ( '' === $this->data['isbn13'] ) {
						$this->data['isbn13'] = $normalized;
					}
				} elseif ( $is_print ) {
					// Label: strip the "isbn-13", "isbn:" prefix, ucwords the rest.
					$label = trim( preg_replace( '/\s*isbn[^a-z]*/i', '', $scheme ) );
					$label = ucwords( $label );
					$this->data['other_isbns'][] = [
						'isbn'  => $normalized,
						'label' => '' !== $label ? $label : ucwords( $scheme ),
					];
				} else {
					// Generic ISBN -- use as ebook fallback, then other_isbns.
					if ( '' === $this->data['isbn13'] ) {
						$this->data['isbn13'] = $normalized;
					} else {
						$this->data['other_isbns'][] = [
							'isbn'  => $normalized,
							'label' => ucwords( $scheme ),
						];
					}
				}

			} elseif ( 'lccn' === $scheme && '' === $this->data['lccn'] ) {
				if ( preg_match( '/(\d{7,10})/', $value, $m ) ) {
					$this->data['lccn'] = $m[1];
				}

			} elseif ( str_contains( $scheme, 'bisac' ) ) {
				$this->data['bisac_codes'][] = $value;

			} elseif ( 'bic' === $scheme || str_starts_with( $scheme, 'bic ' ) ) {
				$this->data['bic_codes'][] = $value;

			} elseif ( str_contains( $scheme, 'thema' ) ) {
				$this->data['thema_codes'][] = $value;
			}
			// LCSH, ePCN, UUID -- discarded (no action).
		}
	}

	/**
	 * Locate the TOC file (NCX for EPUB 2, nav for EPUB 3) in the manifest.
	 *
	 * @param  SimpleXMLElement $xml Parsed OPF.
	 * @return string|false ZIP-relative path, or false if not found.
	 */
	private function find_toc_path( SimpleXMLElement $xml ): string|false {
		// EPUB 2 -- NCX
		$ncx = $xml->xpath( '//opf:item[@media-type="application/x-dtbncx+xml"]' );
		if ( ! empty( $ncx ) ) {
			$href = urldecode( (string) $ncx[0]['href'] );
			return $this->opf_dir . $href;
		}
		// EPUB 3 -- nav document
		$nav = $xml->xpath( '//opf:item[contains(@properties,"nav")]' );
		if ( ! empty( $nav ) ) {
			$href = urldecode( (string) $nav[0]['href'] );
			return $this->opf_dir . $href;
		}
		return false;
	}

	/**
	 * Extract chapter titles from an EPUB 2 NCX file.
	 *
	 * @param  string $ncx_xml Raw NCX XML string.
	 * @return string Chapter titles joined with PHP_EOL.
	 */
	private function extract_toc_ncx( string $ncx_xml ): string {
		$xml = @simplexml_load_string( $ncx_xml, 'SimpleXMLElement', LIBXML_NONET | LIBXML_NOENT );
		if ( ! $xml ) {
			return '';
		}
		$xml->registerXPathNamespace( 'ncx', 'http://www.daisy.org/z3986/2005/ncx/' );
		$nodes   = $xml->xpath( '//ncx:navPoint/ncx:navLabel/ncx:text' );
		$entries = [];
		foreach ( $nodes as $node ) {
			$entries[] = trim( (string) $node );
		}
		$filtered = $this->filter_toc_entries( $entries );
		return implode( PHP_EOL, $filtered );
	}

	/**
	 * Extract chapter titles from an EPUB 3 nav document.
	 *
	 * @param  string $nav_html Raw XHTML/HTML string.
	 * @return string Chapter titles joined with PHP_EOL.
	 */
	private function extract_toc_nav( string $nav_html ): string {
		$xml = @simplexml_load_string( $nav_html, 'SimpleXMLElement', LIBXML_NONET | LIBXML_NOENT );
		if ( ! $xml ) {
			// Fallback: try wrapping in a root element.
			$xml = @simplexml_load_string( '<root>' . $nav_html . '</root>', 'SimpleXMLElement', LIBXML_NONET | LIBXML_NOENT );
			if ( ! $xml ) {
				return '';
			}
		}
		$xml->registerXPathNamespace( 'html', 'http://www.w3.org/1999/xhtml' );
		$xml->registerXPathNamespace( 'epub', 'http://www.idpf.org/2007/ops' );
		$nodes   = $xml->xpath( '//html:nav[@epub:type="toc"]//html:a' );
		$entries = [];
		foreach ( $nodes as $node ) {
			$entries[] = trim( (string) $node );
		}
		if ( empty( $entries ) ) {
			// Fallback: all anchor text within any nav element.
			$all = $xml->xpath( '//html:nav//html:a' );
			foreach ( $all as $node ) {
				$entries[] = trim( (string) $node );
			}
		}
		$filtered = $this->filter_toc_entries( $entries );
		return implode( PHP_EOL, $filtered );
	}

	/**
	 * Filter out front / back matter entries per EPUB-001 ss8c.
	 *
	 * @param  string[] $entries Raw TOC entry labels.
	 * @return string[] Filtered list.
	 */
	private function filter_toc_entries( array $entries ): array {
		$exclude = '/^(copyright|dedication|prologue|foreword|preface|introduction|'
		         . 'acknowledgments?|about the author|find .+ online|newsletter|'
		         . 'more .+ books|excerpt|reader.s guide|translation key|glossary|'
		         . 'appendix|bibliography|index|notes|afterword|conclusion|'
		         . 'discussion questions|inspired by)$/i';

		$keep = [];
		foreach ( $entries as $e ) {
			$text = trim( $e );
			if ( '' === $text ) {
				continue;
			}
			if ( preg_match( $exclude, $text ) ) {
				continue;
			}
			$keep[] = $text;
		}
		return $keep;
	}

	/**
	 * Locate the cover image in the OPF manifest and return its raw bytes + extension.
	 *
	 * @return array{0:string,1:string}|false  [bytes, ext] or false if not found.
	 */
	private function extract_cover(): array|false {
		if ( ! $this->opf ) {
			return false;
		}

		$href = $this->find_cover_href();
		if ( false === $href ) {
			$this->data['notes'][] = 'Cover image not found in EPUB manifest.';
			return false;
		}

		// Try with opf_dir prefix, then without.
		$zip_entry = $this->opf_dir . $href;
		$bytes     = $this->zip->getFromName( $zip_entry );
		if ( false === $bytes ) {
			$bytes = $this->zip->getFromName( $href );
		}
		if ( false === $bytes || '' === $bytes ) {
			$this->data['notes'][] = 'Cover image entry not readable in EPUB archive.';
			return false;
		}

		$ext = strtolower( pathinfo( $href, PATHINFO_EXTENSION ) );
		if ( ! in_array( $ext, [ 'jpg', 'jpeg', 'png', 'gif', 'webp' ], true ) ) {
			$ext = 'jpg';
		}

		return [ $bytes, $ext ];
	}

	/**
	 * Resolve the cover image href from the OPF manifest.
	 * Implements the fallback chain specified in EPUB-001 ss9a.
	 *
	 * @return string|false URL-decoded manifest href, or false.
	 */
	private function find_cover_href(): string|false {
		$opf       = $this->opf;
		$all_items = $opf->xpath( '//opf:item' );

		// EPUB 2: <meta name="cover" content="{item-id or href}"/>
		$metas = $opf->xpath( '//opf:meta[@name="cover"]' );
		if ( ! empty( $metas ) ) {
			$cover_ref = (string) $metas[0]['content'];

			// Pass 1: match by item id
			foreach ( $all_items as $item ) {
				if ( (string) $item['id'] === $cover_ref ) {
					return urldecode( (string) $item['href'] );
				}
			}
			// Pass 2: match by decoded href (fallback per EPUB-001 ss9a)
			foreach ( $all_items as $item ) {
				if ( urldecode( (string) $item['href'] ) === $cover_ref ) {
					return urldecode( (string) $item['href'] );
				}
			}
		}

		// EPUB 3: manifest item with properties="cover-image"
		$cover_items = $opf->xpath( '//opf:item[contains(@properties,"cover-image")]' );
		if ( ! empty( $cover_items ) ) {
			return urldecode( (string) $cover_items[0]['href'] );
		}

		// Final fallback: any image item whose id or href contains "cover"
		foreach ( $all_items as $item ) {
			$mt = strtolower( (string) $item['media-type'] );
			if ( ! str_starts_with( $mt, 'image/' ) ) {
				continue;
			}
			$id   = strtolower( (string) $item['id'] );
			$href = strtolower( (string) $item['href'] );
			if ( str_contains( $id, 'cover' ) || str_contains( $href, 'cover' ) ) {
				return urldecode( (string) $item['href'] );
			}
		}

		return false;
	}

	/**
	 * Build spine id-to-href map from OPF, preserving document order.
	 * Called once from read() after parse_opf(). Both find_copyright_page()
	 * and find_look_inside_candidates() in MBS_EPUB_Look_Inside_Extractor
	 * receive this map rather than rebuilding it independently.
	 *
	 * @since  1.3.0
	 * @return void
	 */
	private function build_spine_map(): void {
		$this->spine_map = [];
		if ( ! $this->opf ) {
			return;
		}
		$spine_refs = $this->opf->xpath( '//opf:itemref' );
		$items      = $this->opf->xpath( '//opf:item' );
		$id_to_href = [];
		foreach ( $items as $item ) {
			$id_to_href[ (string) $item['id'] ] = urldecode( (string) $item['href'] );
		}
		foreach ( $spine_refs as $ref ) {
			$idref = (string) $ref['idref'];
			if ( isset( $id_to_href[ $idref ] ) ) {
				$this->spine_map[ $idref ] = $id_to_href[ $idref ];
			}
		}
	}

	/**
	 * Build unfiltered href-basename-to-label map from the EPUB TOC (NCX or NAV).
	 * Called once from read() after build_spine_map(). The full unfiltered set
	 * is required -- filter_toc_entries() must NOT be applied here because it
	 * excludes prologue/preface entries that are valid Look Inside candidates.
	 *
	 * @since  1.3.0
	 * @return void
	 */
	private function build_toc_label_map(): void {
		$this->toc_label_map = [];
		if ( ! $this->opf ) {
			return;
		}
		$toc_path = $this->find_toc_path( $this->opf );
		if ( false === $toc_path ) {
			return;
		}
		$toc_content = $this->zip->getFromName( $toc_path );
		if ( false === $toc_content ) {
			return;
		}
		$is_ncx = str_ends_with( strtolower( $toc_path ), '.ncx' );
		// LIBXML_NONET: prevent libxml from fetching external DTD (e.g. the NCX DTD
		// at daisy.org) which hangs or fails on servers without outbound HTTP access,
		// causing simplexml_load_string() to return false and leaving toc_label_map empty.
		$xml    = @simplexml_load_string( $toc_content, 'SimpleXMLElement', LIBXML_NONET | LIBXML_NOENT ); // SA-L1: add LIBXML_NOENT for XXE prevention.
		if ( ! $xml ) {
			return;
		}
		if ( $is_ncx ) {
			$xml->registerXPathNamespace( 'ncx', 'http://www.daisy.org/z3986/2005/ncx/' );
			$nav_points = $xml->xpath( '//ncx:navPoint' );
			foreach ( $nav_points as $np ) {
				// Re-register namespace on the child element -- PHP SimpleXML does not
				// inherit registerXPathNamespace() from the root when XPath is called
				// on a child node returned by a prior XPath query.
				$np->registerXPathNamespace( 'ncx', 'http://www.daisy.org/z3986/2005/ncx/' );
				$content = $np->xpath( 'ncx:content' );
				$label_n = $np->xpath( 'ncx:navLabel/ncx:text' );
				if ( empty( $content ) || empty( $label_n ) ) {
					continue;
				}
				// Strip fragment (#anchor) before basename extraction.
				$src   = urldecode( strtok( (string) $content[0]['src'], '#' ) );
				$label = trim( (string) $label_n[0] );
				if ( '' !== $src && '' !== $label ) {
					$this->toc_label_map[ basename( $src ) ] = $label;
				}
			}
		} else {
			$xml->registerXPathNamespace( 'html', 'http://www.w3.org/1999/xhtml' );
			$xml->registerXPathNamespace( 'epub', 'http://www.idpf.org/2007/ops' );
			$anchors = $xml->xpath( '//html:nav[@epub:type="toc"]//html:a' );
			if ( empty( $anchors ) ) {
				// Fallback: any nav element.
				$anchors = $xml->xpath( '//html:nav//html:a' );
			}
			foreach ( $anchors as $a ) {
				$a->registerXPathNamespace( 'html', 'http://www.w3.org/1999/xhtml' );
				$href  = urldecode( strtok( (string) $a['href'], '#' ) );
				$label = trim( (string) $a );
				if ( '' !== $href && '' !== $label ) {
					$this->toc_label_map[ basename( $href ) ] = $label;
				}
			}
		}
	}

	/**
	 * Convert a BCP 47 language tag to ISO 639-2/B code.
	 *
	 * @param  string $lang BCP 47 tag (e.g. 'en', 'en-US', 'fr').
	 * @return string ISO 639-2/B code (e.g. 'eng', 'fre'); defaults to 'eng'.
	 */
	private function bcp47_to_iso639_2b( string $lang ): string {
		$base = strtolower( explode( '-', trim( $lang ) )[0] );
		$base = strtolower( explode( '_', $base )[0] );
		$map  = [
			'en' => 'eng', 'fr' => 'fre', 'de' => 'ger', 'es' => 'spa',
			'it' => 'ita', 'pt' => 'por', 'ru' => 'rus', 'zh' => 'chi',
			'ja' => 'jpn', 'ko' => 'kor', 'ar' => 'ara', 'nl' => 'dut',
			'pl' => 'pol', 'sv' => 'swe', 'no' => 'nor', 'da' => 'dan',
			'fi' => 'fin', 'cs' => 'cze', 'hu' => 'hun', 'ro' => 'rum',
			'tr' => 'tur', 'he' => 'heb', 'id' => 'ind', 'vi' => 'vie',
		];
		if ( isset( $map[ $base ] ) ) {
			return $map[ $base ];
		}
		// Already 3-letter code.
		return ( 3 === strlen( $base ) ) ? $base : 'eng';
	}

	/**
	 * Remove duplicate contributor entries by name (case-insensitive).
	 * Preserves first occurrence of each name.
	 *
	 * @param  array[] $list Contributor arrays.
	 * @return array[]
	 */
	private function deduplicate_contributors( array $list ): array {
		$seen = [];
		$out  = [];
		foreach ( $list as $c ) {
			$key = strtolower( trim( $c['name'] ) );
			if ( isset( $seen[ $key ] ) ) {
				continue;
			}
			$seen[ $key ] = true;
			$out[]        = $c;
		}
		return $out;
	}

	/**
	 * Attempt to parse a date string into YYYY-MM-DD format.
	 *
	 * @param  string $raw Raw date string from OPF.
	 * @return string YYYY-MM-DD, or empty string if not parseable.
	 */
	private function parse_date( string $raw ): string {
		$raw = trim( $raw );
		if ( preg_match( '/^(\d{4})-(\d{2})-(\d{2})/', $raw, $m ) ) {
			return $m[1] . '-' . $m[2] . '-' . $m[3];
		}
		if ( preg_match( '/^(\d{4})-(\d{2})/', $raw, $m ) ) {
			return $m[1] . '-' . $m[2] . '-01';
		}
		if ( preg_match( '/^(\d{4})/', $raw, $m ) ) {
			return $m[1] . '-01-01';
		}
		return '';
	}
}
