<?php
/**
 * File Path: includes/import/class-mbs-competitor-wxr-base.php
 *
 * MBS_Competitor_WXR_Base -- Abstract base for competitor WXR importers.
 *
 * Owns all shared logic for the five zero-mapping-step competitor importers
 * (CI-001 through CI-005). Each concrete importer extends this class and
 * implements three abstract methods:
 *
 *   source_key()   -- Returns the canonical source identifier string.
 *   target_cpt()   -- Returns the WXR post_type to filter on.
 *   map_item()     -- Transforms a raw $item_data array into a normalised
 *                     MBS data array ready for insert_book(), or null to skip.
 *
 * Shared responsibilities handled here:
 *   - File-size validation (50 MB cap)
 *   - XMLReader streaming loop (analyze + import passes)
 *   - CPT disambiguation via matches_source() (default: true; override when
 *     multiple plugins share the same post_type slug)
 *   - Duplicate detection (MBS_Duplicate_Detector::find_duplicate)
 *   - wp_insert_post() + taxonomy assignment
 *   - MBS_Format_Row_DAO::save() and MBS_Buy_Link_DAO::save()
 *   - Stats tracking {imported, skipped, errors[], notes[]}
 *   - MBS_Telemetry_Failure_Tracker::record_import_failure()
 *   - Cover-image skip note (spec §2.5)
 *
 * Normalised MBS data array shape (returned by map_item()):
 *   [
 *     'title'      => string,
 *     'content'    => string,                        // post_content
 *     'post_meta'  => [ 'mbs_key' => value, ... ],
 *     'format_row' => [ 'isbn13' => ..., ... ],     // passed to Format_Row_DAO
 *     'buy_links'  => [ ['url' => ..., 'retailer_key' => ..., 'label' => ...], ... ],
 *     'taxonomies' => [ MBS_TAX_AUTHOR => [...], MBS_TAX_SERIES => [...], ... ],
 *     'notes'      => string[],                     // per-item notes
 *   ]
 *
 * Tier: FREE (competitor migration -- no mbs_is_pro() gate).
 *
 * @package    My_Book_Showroom
 * @subpackage Includes/Import
 * @since      1.2.0
 */

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

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

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

	/** Cover-image skip note -- identical for all five importers (spec §2.5). */
	const COVER_SKIP_NOTE = 'Book covers could not be transferred — attachment IDs are site-specific. Please upload cover images manually for each imported book.';

	// ── Abstract interface ────────────────────────────────────────────────────

	/**
	 * Return the canonical source key for telemetry and UI identification.
	 *
	 * @since  1.2.0
	 * @return string  e.g. 'wp_books_gallery', 'genesis_author_pro'.
	 */
	abstract protected static function source_key(): string;

	/**
	 * Return the WXR post_type slug this importer targets.
	 *
	 * @since  1.2.0
	 * @return string  e.g. 'books', 'book'.
	 */
	abstract protected static function target_cpt(): string;

	/**
	 * Transform a raw WXR item data array into a normalised MBS data array.
	 *
	 * Return null to skip the item entirely (e.g. wrong source plugin
	 * detected after full item parse, or a non-book item type).
	 *
	 * @since  1.2.0
	 * @param  array $item_data  { title: string, content: string,
	 *                             post_type: string, meta: array<string,string>,
	 *                             categories: array[] }.
	 * @return array|null  Normalised MBS data array, or null to skip.
	 */
	abstract protected static function map_item( array $item_data ): ?array;

	// ── Optional overrides ────────────────────────────────────────────────────

	/**
	 * Confirm this WXR file belongs to this specific source plugin.
	 *
	 * Called during analyze() with the meta keys from the first item found.
	 * Override when multiple plugins share the same post_type slug (e.g.
	 * WP Books Gallery and Genesis Author Pro both use 'books').
	 * Default implementation returns true (no disambiguation needed).
	 *
	 * @since  1.2.0
	 * @param  string[] $meta_keys  All unique meta_key strings from the first item.
	 * @return bool
	 */
	protected static function matches_source( array $meta_keys ): bool {
		return true;
	}

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

	/**
	 * Stream the WXR file and return a count of importable items.
	 *
	 * Unlike the generic MBS_Importer_WXR::analyze() (which returns meta keys
	 * for a user-mapping UI), this method returns only a book count and a
	 * source-confirmation flag -- competitor importers need no mapping step.
	 *
	 * @since  1.2.0
	 * @param  string $file_path  Absolute path to the WXR file.
	 * @return array{ count: int, source: string, error: string|null }
	 */
	public static function analyze( string $file_path ): array {
		$result = array(
			'count'  => 0,
			'source' => static::source_key(),
			'error'  => null,
		);

		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		$size = @filesize( $file_path );
		if ( false === $size || $size > static::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();
		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		if ( ! @$reader->open( $file_path ) ) {
			$result['error'] = 'Could not open the WXR file for reading.';
			return $result;
		}

		$ns_wp        = 'http://wordpress.org/export/1.2/';
		$target_cpt   = static::target_cpt();
		$source_confirmed = false;
		$first_item_done  = false;
		$in_item      = false;
		$item_cpt     = '';
		$item_meta_keys = array();
		$count        = 0;

		while ( $reader->read() ) {
			if ( XMLReader::ELEMENT === $reader->nodeType ) {
				$local = $reader->localName;
				$ns    = $reader->namespaceURI;

				if ( 'item' === $local && '' === $ns ) {
					$in_item      = true;
					$item_cpt     = '';
					$item_meta_keys = array();
					continue;
				}

				if ( ! $in_item ) {
					continue;
				}

				if ( 'post_type' === $local && $ns_wp === $ns ) {
					$reader->read();
					$item_cpt = trim( (string) $reader->value );
					continue;
				}

				if ( 'postmeta' === $local && $ns_wp === $ns ) {
					$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
							&& $ns_wp === $reader->namespaceURI ) {
							$reader->read();
							$item_meta_keys[] = trim( (string) $reader->value );
						}
					}
					continue;
				}
			}

			if ( XMLReader::END_ELEMENT === $reader->nodeType
				&& 'item' === $reader->localName ) {
				if ( $in_item && $item_cpt === $target_cpt ) {
					if ( ! $first_item_done ) {
						$first_item_done  = true;
						$source_confirmed = static::matches_source( $item_meta_keys );
						if ( ! $source_confirmed ) {
							$result['error'] = sprintf(
								'This file does not appear to be a %s export.',
								static::source_key()
							);
							$reader->close();
							return $result;
						}
					}
					$count++;
				}
				$in_item = false;
			}
		}

		$reader->close();
		$result['count'] = $count;
		return $result;
	}

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

	/**
	 * Stream the WXR file and import all matching items.
	 *
	 * Calls static::map_item() per item to get normalised MBS data, then
	 * delegates insertion to static::insert_book().
	 *
	 * @since  1.2.0
	 * @param  string $file_path  Absolute path to the WXR file.
	 * @return array{ imported: int, skipped: int, errors: string[], notes: string[] }
	 */
	public static function import( string $file_path ): array {
		$stats = static::make_stats();

		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		$size = @filesize( $file_path );
		if ( false === $size || $size > static::MAX_FILE_SIZE ) {
			static::add_error( $stats, 'File exceeds the 50 MB import limit.' );
			MBS_Telemetry_Failure_Tracker::record_import_failure( static::source_key() );
			return $stats;
		}

		$reader = new XMLReader();
		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		if ( ! @$reader->open( $file_path ) ) {
			static::add_error( $stats, 'Could not open the WXR file for reading.' );
			MBS_Telemetry_Failure_Tracker::record_import_failure( static::source_key() );
			return $stats;
		}

		$ns_wp      = 'http://wordpress.org/export/1.2/';
		$ns_content = 'http://purl.org/rss/1.0/modules/content/';
		$target_cpt = static::target_cpt();
		$item_data  = null;
		$cover_note_added = false;

		while ( $reader->read() ) {
			if ( XMLReader::ELEMENT === $reader->nodeType
				&& 'item' === $reader->localName
				&& '' === $reader->namespaceURI ) {
				$item_data = array(
					'title'      => '',
					'content'    => '',
					'post_type'  => '',
					'meta'       => array(),
					'categories' => array(),
				);
				continue;
			}

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

			if ( XMLReader::ELEMENT === $reader->nodeType ) {
				$local = $reader->localName;
				$ns    = $reader->namespaceURI;

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

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

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

				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;
				}

				// Collect <category> elements (taxonomies in WXR).
				if ( 'category' === $local && '' === $ns && ! $reader->isEmptyElement ) {
					$domain   = $reader->getAttribute( 'domain' ) ?? '';
					$nicename = $reader->getAttribute( 'nicename' ) ?? '';
					$reader->read();
					$term_name = trim( (string) $reader->value );
					if ( '' !== $domain ) {
						$item_data['categories'][] = array(
							'domain'   => $domain,
							'nicename' => $nicename,
							'name'     => $term_name,
						);
					}
					continue;
				}
			}

			if ( XMLReader::END_ELEMENT === $reader->nodeType
				&& 'item' === $reader->localName ) {
				if ( null !== $item_data && $item_data['post_type'] === $target_cpt ) {
					$mbs_data = static::map_item( $item_data );

					if ( null === $mbs_data ) {
						$stats['skipped']++;
					} else {
						$outcome = static::insert_book( $mbs_data );

						if ( true === $outcome ) {
							$stats['imported']++;
							if ( ! $cover_note_added ) {
								$stats['notes'][] = static::COVER_SKIP_NOTE;
								$cover_note_added = true;
							}
							// Merge any per-item notes from map_item().
							if ( ! empty( $mbs_data['notes'] ) ) {
								foreach ( $mbs_data['notes'] as $note ) {
									if ( ! in_array( $note, $stats['notes'], true ) ) {
										$stats['notes'][] = $note;
									}
								}
							}
						} elseif ( 'skipped' === $outcome ) {
							$stats['skipped']++;
						} else {
							$stats['errors'][] = sprintf(
								'"%s": %s',
								esc_html( $mbs_data['title'] ?? '(no title)' ),
								$outcome
							);
						}
					}
				}
				$item_data = null;
			}
		}

		$reader->close();

		if ( ! empty( $stats['errors'] ) ) {
			MBS_Telemetry_Failure_Tracker::record_import_failure(
				static::source_key(),
				count( $stats['errors'] )
			);
		}

		return $stats;
	}

	// ── Shared insertion ──────────────────────────────────────────────────────

	/**
	 * Insert a single book from a normalised MBS data array.
	 *
	 * Runs duplicate detection, creates the post, assigns taxonomy terms,
	 * writes postmeta, saves the format row, and saves buy links.
	 *
	 * @since  1.2.0
	 * @param  array $mbs_data  Normalised data array from map_item().
	 * @return true|'skipped'|string  true on success, 'skipped' on duplicate,
	 *                                 error message string on failure.
	 */
	protected static function insert_book( array $mbs_data ): true|string {
		$title = sanitize_text_field( $mbs_data['title'] ?? '' );
		if ( '' === $title ) {
			return 'No title -- skipped.';
		}

		// Build duplicate-detection candidates.
		$dup = array( 'title' => $title );
		$fr  = $mbs_data['format_row'] ?? array();
		if ( ! empty( $fr['isbn13'] ) ) {
			$dup['isbn13'] = $fr['isbn13'];
		}
		if ( ! empty( $fr['asin'] ) ) {
			$dup['asin'] = $fr['asin'];
		}
		// Author name for title+author dedup.
		$authors = $mbs_data['taxonomies'][ MBS_TAX_AUTHOR ] ?? array();
		if ( ! empty( $authors ) ) {
			$dup['author'] = $authors[0];
		}

		if ( null !== MBS_Duplicate_Detector::find_duplicate( $dup ) ) {
			return 'skipped';
		}

		// Build the MBS Book Object and resolve retailer keys before handing off.
		// Retailer-key auto-detection must happen here because the hydrator has no
		// knowledge of MBS_Importer_Retailer_Detector.
		$book_buy_links = array();
		foreach ( ( $mbs_data['buy_links'] ?? array() ) as $i => $link ) {
			$clean_url = esc_url_raw( $link['url'] ?? '' );
			if ( '' === $clean_url ) {
				continue;
			}
			$retailer_key = sanitize_key( $link['retailer_key'] ?? '' );
			if ( '' === $retailer_key ) {
				$retailer_key = MBS_Importer_Retailer_Detector::detect_from_url( $clean_url );
			}
			if ( '' === $retailer_key ) {
				$retailer_key = MBS_Importer_Retailer_Detector::detect_from_label( $link['label'] ?? '' );
			}
			$book_buy_links[] = array(
				'url'          => $clean_url,
				'retailer_key' => $retailer_key,
				'link_label'   => sanitize_text_field( $link['label'] ?? '' ),
				'sort_order'   => $i,
			);
		}

		$format_rows = ! empty( $fr ) ? array( $fr ) : array();

		$book_object = array(
			'post_title'     => $title,
			'post_content'   => $mbs_data['content'] ?? '',
			'post_status'    => 'publish',
			'post_meta'      => $mbs_data['post_meta'] ?? array(),
			'taxonomies'     => $mbs_data['taxonomies'] ?? array(),
			'format_rows'    => $format_rows,
			'book_buy_links' => $book_buy_links,
		);

		// Dedup already ran above; skip the hydrator's internal check.
		$result = MBS_Book_Hydrator::hydrate( $book_object, array( 'skip_duplicate_check' => true ) );

		if ( is_wp_error( $result ) ) {
			return $result->get_error_message();
		}

		return true;
	}

	// ── Shared helpers ────────────────────────────────────────────────────────

	/**
	 * Create or find taxonomy terms and assign them to a post.
	 *
	 * @since  1.2.0
	 * @param  int      $post_id     Post ID.
	 * @param  string   $taxonomy    Taxonomy slug.
	 * @param  string[] $term_names  Term display names to create/assign.
	 * @return void
	 */
	public static function assign_terms( int $post_id, string $taxonomy, array $term_names ): void {
		$term_ids = array();
		foreach ( $term_names as $name ) {
			$name = sanitize_text_field( $name );
			if ( '' === $name ) {
				continue;
			}
			$inserted = wp_insert_term( $name, $taxonomy );
			if ( is_wp_error( $inserted ) ) {
				$existing = get_term_by( 'name', $name, $taxonomy );
				if ( $existing ) {
					$term_ids[] = (int) $existing->term_id;
				}
			} else {
				$term_ids[] = (int) $inserted['term_id'];
			}
		}
		if ( ! empty( $term_ids ) ) {
			wp_set_object_terms( $post_id, $term_ids, $taxonomy, true );
		}
	}

	/**
	 * Return a zeroed stats array.
	 *
	 * @since  1.2.0
	 * @return array{ imported: int, skipped: int, errors: string[], notes: string[] }
	 */
	protected static function make_stats(): array {
		return array(
			'imported' => 0,
			'skipped'  => 0,
			'errors'   => array(),
			'notes'    => array(),
		);
	}

	/**
	 * Append an error message to the stats array.
	 *
	 * @since  1.2.0
	 * @param  array  &$stats   Stats array by reference.
	 * @param  string  $message Error description.
	 * @return void
	 */
	protected static function add_error( array &$stats, string $message ): void {
		$stats['errors'][] = $message;
	}

	/**
	 * Append a note to the stats array (deduplicated).
	 *
	 * @since  1.2.0
	 * @param  array  &$stats Stats array by reference.
	 * @param  string  $note  Note text.
	 * @return void
	 */
	protected static function add_note( array &$stats, string $note ): void {
		if ( ! in_array( $note, $stats['notes'], true ) ) {
			$stats['notes'][] = $note;
		}
	}

	// ── Shared format / data helpers ─────────────────────────────────────────

	/**
	 * Normalise a raw format string to an MBS format slug.
	 *
	 * Subclasses may override this method to extend or replace the mapping.
	 * Recognized slugs: hardcover | audiobook | large-print | board-book |
	 * spiral-bound | ebook | paperback (default).
	 *
	 * @since  1.3.0
	 * @param  string $raw Raw format label from source plugin meta.
	 * @return string MBS format slug.
	 */
	protected static function normalize_format( string $raw ): string {
		$lc = strtolower( trim( $raw ) );
		if ( '' === $lc ) {
			return 'paperback';
		}
		if ( false !== strpos( $lc, 'hardcover' )
			|| false !== strpos( $lc, 'hard cover' )
			|| false !== strpos( $lc, 'hardback' ) ) {
			return 'hardcover';
		}
		if ( false !== strpos( $lc, 'audio' )
			|| false !== strpos( $lc, ' cd' ) ) {
			return 'audiobook';
		}
		if ( false !== strpos( $lc, 'large' ) ) {
			return 'large-print';
		}
		if ( false !== strpos( $lc, 'board' ) ) {
			return 'board-book';
		}
		if ( false !== strpos( $lc, 'spiral' )
			|| false !== strpos( $lc, 'ring' ) ) {
			return 'spiral-bound';
		}
		if ( false !== strpos( $lc, 'kindle' )
			|| false !== strpos( $lc, 'ebook' )
			|| false !== strpos( $lc, 'e-book' )
			|| false !== strpos( $lc, 'epub' )
			|| false !== strpos( $lc, 'digital' )
			|| false !== strpos( $lc, 'pdf' ) ) {
			return 'ebook';
		}
		return 'paperback';
	}

	/**
	 * Safely unserialise a PHP-serialised repeater field value.
	 *
	 * Returns an array of sub-arrays only; scalar entries are discarded.
	 * Empty/non-serialised values return an empty array.
	 *
	 * @since  1.3.0
	 * @param  mixed $raw Raw meta value from WXR postmeta.
	 * @return array<int, array<string, mixed>> Normalised repeater rows.
	 */
	protected static function unserialize_repeater( $raw ): array {
		if ( '' === (string) $raw ) {
			return array();
		}
		$data = ( is_serialized( $raw ) ? unserialize( $raw, array( 'allowed_classes' => false ) ) : $raw );
		if ( ! is_array( $data ) ) {
			return array();
		}
		return array_values( array_filter( $data, 'is_array' ) );
	}

	/**
	 * Build MBS format rows from ISBN-13 and ASIN values.
	 *
	 * Decision table:
	 *  - ISBN + B-ASIN  → two rows: paperback (isbn, format) + ebook (asin)
	 *  - ISBN + numeric ASIN → one paperback row (isbn + asin)
	 *  - B-ASIN only    → one ebook row (asin)
	 *  - numeric ASIN only → one paperback row (asin)
	 *  - neither        → one paperback fallback row
	 *
	 * $shared must contain at minimum: title, price, buy_link.
	 * pub_date and page_length are attached to the print row only.
	 *
	 * @since  1.3.0
	 * @param  string               $isbn13     Normalised ISBN-13 or empty string.
	 * @param  string               $asin       Normalised ASIN or empty string.
	 * @param  string               $format     MBS format slug for the primary row.
	 * @param  string               $pub_date   Publication date (YYYY-MM-DD or empty).
	 * @param  int                  $page_length Page count (0 if unknown).
	 * @param  array<string, mixed> $shared     Fields merged into every row.
	 * @return array<int, array<string, mixed>> Format rows ready for insert_book().
	 */
	protected static function build_isbn_asin_format_rows(
		string $isbn13,
		string $asin,
		string $format,
		string $pub_date,
		int $page_length,
		array $shared
	): array {
		$has_isbn  = '' !== $isbn13;
		$has_asin  = '' !== $asin;
		$asin_is_b = $has_asin && ( 'B' === strtoupper( $asin[0] ) );

		$rows = array();

		if ( $has_isbn && $asin_is_b ) {
			// Two rows: paperback (ISBN) + ebook (B-ASIN).
			$rows[] = array_merge( $shared, array(
				'isbn13'      => $isbn13,
				'asin'        => '',
				'format'      => '' !== $format ? $format : 'paperback',
				'pub_date'    => $pub_date,
				'page_length' => $page_length,
			) );
			$rows[] = array_merge( $shared, array(
				'isbn13'      => '',
				'asin'        => $asin,
				'format'      => 'ebook',
				'pub_date'    => '',
				'page_length' => 0,
			) );
		} elseif ( $has_isbn ) {
			// One print row; attach numeric ASIN if present.
			$rows[] = array_merge( $shared, array(
				'isbn13'      => $isbn13,
				'asin'        => $has_asin ? $asin : '',
				'format'      => '' !== $format ? $format : 'paperback',
				'pub_date'    => $pub_date,
				'page_length' => $page_length,
			) );
		} elseif ( $asin_is_b ) {
			// B-ASIN only → ebook row.
			$rows[] = array_merge( $shared, array(
				'isbn13'      => '',
				'asin'        => $asin,
				'format'      => 'ebook',
				'pub_date'    => '',
				'page_length' => 0,
			) );
		} elseif ( $has_asin ) {
			// Numeric ASIN only → paperback.
			$rows[] = array_merge( $shared, array(
				'isbn13'      => '',
				'asin'        => $asin,
				'format'      => '' !== $format ? $format : 'paperback',
				'pub_date'    => $pub_date,
				'page_length' => $page_length,
			) );
		} else {
			// No identifiers — fallback paperback row.
			$rows[] = array_merge( $shared, array(
				'isbn13'      => '',
				'asin'        => '',
				'format'      => '' !== $format ? $format : 'paperback',
				'pub_date'    => $pub_date,
				'page_length' => $page_length,
			) );
		}

		return $rows;
	}

	/**
	 * Remove duplicate buy links from an array, keyed by normalised URL.
	 *
	 * Each element in $buy_links must be an associative array with at least
	 * a 'url' key. Elements whose URL is empty or already seen are dropped.
	 *
	 * @since  1.3.0
	 * @param  array<int, array<string, mixed>> $buy_links Raw buy-link rows.
	 * @return array<int, array<string, mixed>> Deduplicated buy-link rows.
	 */
	protected static function deduplicate_buy_links( array $buy_links ): array {
		$seen = array();
		$out  = array();
		foreach ( $buy_links as $link ) {
			$url = esc_url_raw( $link['url'] ?? '' );
			if ( '' === $url || isset( $seen[ $url ] ) ) {
				continue;
			}
			$seen[ $url ] = true;
			$out[]        = $link;
		}
		return $out;
	}

	/**
	 * Return the taxonomy mapping for this importer.
	 *
	 * Subclasses should override this to return an associative array mapping
	 * source taxonomy slugs to MBS taxonomy slugs, e.g.:
	 *   array( 'book-genre' => 'mbs-genre', 'book-tag' => 'mbs-tag' )
	 *
	 * The default implementation returns an empty array (no taxonomy mapping).
	 *
	 * @since  1.3.0
	 * @return array<string, string> Source taxonomy slug => MBS taxonomy slug.
	 */
	protected static function taxonomy_map(): array {
		return array();
	}

}
