<?php
/**
 * File Path: includes/metaboxes/class-mbs-metabox-buy-links.php
 *
 * Global Links Metabox
 *
 * Reader Platforms section: dynamic sortable repeater pre-seeded with GoodReads,
 * BookBub, and StoryGraph. Rows stored with link_type = 'platform'.
 * Buy Links section: generic purchase URL repeater. Rows stored with link_type = 'buy'.
 * Format-specific buy links are managed within each format row.
 *
 * Sprint 180: replaced fixed 3-row discovery table with dynamic platform repeater;
 * added link_label and icon support to buy link rows.
 *
 * @package MyBookShowroom
 * @since   1.0.0
 */

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

// phpcs:disable WordPress.Security.NonceVerification -- nonce verified in parent class verify_save() / check_admin_referer() before $_POST access; PHPCS cannot trace cross-method nonce verification
// phpcs:disable WordPress.Security.ValidatedSanitizedInput -- input sanitized at point of use (sanitize_text_field, absint, wp_kses_post, etc.); false positives from PHPCS not tracing sanitization helpers called before this method
class MBS_Metabox_Buy_Links extends MBS_Metabox_Base { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound -- mbs_/MBS_ prefix is correct; scanner not configured with plugin prefix

	public function __construct() {
	}

	/**
	 * Render one section of the Global Links metabox.
	 *
	 * Routes to the Reader Platforms repeater ('main') or the buy-link repeater
	 * ('global-links'). Both sections output separate nonces and HTML fragments.
	 *
	 * @since  1.2.0
	 * @param  string  $section_key  'main' or 'global-links'.
	 * @param  WP_Post $post         Current post object.
	 * @return void
	 */
	public function render_section( string $section_key, WP_Post $post ): void {
		if ( 'main' === $section_key ) {
			wp_nonce_field( 'mbs_buy_links_discovery', 'mbs_buy_links_discovery_nonce' );
			$platforms = MBS_Buy_Link_DAO::get_platforms_for_book( $post->ID );
			$this->render_platforms_repeater( $platforms );
		} elseif ( 'global-links' === $section_key ) {
			wp_nonce_field( 'mbs_buy_links_global_links', 'mbs_buy_links_global_links_nonce' );
			$buy_links = MBS_Buy_Link_DAO::get_buy_links_for_book( $post->ID );
			$this->render_buy_links_repeater( $buy_links );
		}
	}

	/**
	 * Render the Global Links metabox (combined view).
	 *
	 * @since  1.0.0
	 * @param  WP_Post $post  Current post object.
	 * @return void
	 */
	public function render( WP_Post $post ): void {
		wp_nonce_field( 'mbs_buy_links_nonce', 'mbs_buy_links_nonce' );

		$platforms = MBS_Buy_Link_DAO::get_platforms_for_book( $post->ID );
		$buy_links = MBS_Buy_Link_DAO::get_buy_links_for_book( $post->ID );

		$this->render_platforms_repeater( $platforms );
		echo '<hr style="margin:16px 0 12px;">';
		$this->render_buy_links_repeater( $buy_links );
	}

	// -------------------------------------------------------------------------
	// Reader Platforms Repeater
	// -------------------------------------------------------------------------

	/**
	 * Render the Reader Platforms sortable repeater.
	 * Pre-seeds three default rows (GoodReads, BookBub, StoryGraph) when empty.
	 *
	 * @since  1.4.0
	 * @param  array $platforms  Existing platform link rows from DB.
	 * @return void
	 */
	private function render_platforms_repeater( array $platforms ): void {
		// Pre-seed defaults when no platform rows exist yet.
		if ( empty( $platforms ) ) {
			$platforms = array(
				(object) array( 'id' => 0, 'retailer_key' => 'goodreads',  'url' => '', 'link_label' => 'GoodReads' ),
				(object) array( 'id' => 0, 'retailer_key' => 'bookbub',    'url' => '', 'link_label' => 'BookBub' ),
				(object) array( 'id' => 0, 'retailer_key' => 'storygraph', 'url' => '', 'link_label' => 'StoryGraph' ),
			);
		}
		?>

		<!-- ── Reader Platforms ─────────────────────────────────────────────── -->
		<p style="font-weight:600;margin:4px 0 4px;">
			<?php esc_html_e( 'Reader Platforms', 'my-book-showroom' ); ?>
		</p>
		<p class="description" style="margin-bottom:8px;">
			<?php esc_html_e( 'Add GoodReads, BookBub, StoryGraph, or any reader community URL. These appear as discovery buttons on your book page.', 'my-book-showroom' ); ?>
		</p>

		<div class="mbs-repeater" id="mbs-reader-platforms-repeater">

			<div class="mbs-repeater-rows" id="mbs-reader-platforms-rows">
				<?php foreach ( $platforms as $platform ) : ?>
					<?php $this->render_platform_row( $platform ); ?>
				<?php endforeach; ?>
			</div>

			<?php /* Hidden template row -- cloned by JS. display:none must stay inline. */ ?>
			<div id="reader-platform-row-template" class="mbs-platform-row-template" style="display:none;">
				<?php $this->render_platform_row( null ); ?>
			</div>

			<button type="button" class="button mbs-add-platform-row" style="margin-top:8px;">
				<?php esc_html_e( '+ Add Platform', 'my-book-showroom' ); ?>
			</button>

		</div>

		<?php
	}

	/**
	 * Render a single Reader Platform repeater row.
	 * $link is null when rendering the hidden JS template row.
	 *
	 * @since  1.4.0
	 * @param  object|null $link  Saved platform row, or null for the template.
	 * @return void
	 */
	private function render_platform_row( ?object $link ): void {
		$is_template  = ( null === $link );
		$id           = $is_template ? '' : (int) $link->id;
		$url          = $is_template ? '' : esc_attr( $link->url ?? '' );
		$retailer_key = $is_template ? '' : sanitize_key( $link->retailer_key ?? '' );
		$link_label   = $is_template ? '' : sanitize_text_field( $link->link_label ?? '' );
		$row_attr     = $is_template ? '' : ' data-row-id="' . esc_attr( $id ) . '"';

		// Icon resolution (3-step chain, A4-003).
		$retailers_url  = MBS_ASSETS_URL . 'images/retailers/';
		$icon_att_id    = $is_template ? 0 : (int) ( $link->icon_attachment_id ?? 0 );
		if ( $icon_att_id > 0 ) {
			$icon_src = wp_get_attachment_image_url( $icon_att_id, array( 24, 24 ) ) ?: ''; // R5-exempt: admin metabox display context
		} elseif ( $retailer_key ) {
			$icon_src = MBS_Link_Icon_Helper::resolve_icon_src( $retailer_key, $retailers_url );
		} else {
			$icon_src = MBS_Link_Icon_Helper::get_site_icon_url( 32 );
		}
		?>
		<div class="mbs-platform-row" style="display:flex;align-items:flex-start;gap:6px;margin-bottom:8px;"<?php echo $row_attr;  // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $row_attr contains only esc_attr() output ?>>

			<!-- Drag handle (PRO only) + fallback arrows -->
			<?php if ( mbs_is_pro() ) : ?>
			<span class="mbs-drag-handle dashicons dashicons-menu"
			      style="cursor:move;color:#999;font-size:20px;margin-top:6px;flex-shrink:0;"
			      title="<?php esc_attr_e( 'Drag to reorder', 'my-book-showroom' ); ?>"></span>
			<?php endif; ?>
			<span class="mbs-platform-fallback" style="flex-shrink:0;margin-top:4px;">
				<button type="button" class="button button-small mbs-platform-move-up"
				        title="<?php esc_attr_e( 'Move up', 'my-book-showroom' ); ?>"
				        aria-label="<?php esc_attr_e( 'Move up', 'my-book-showroom' ); ?>">&#9650;</button>
				<button type="button" class="button button-small mbs-platform-move-down"
				        title="<?php esc_attr_e( 'Move down', 'my-book-showroom' ); ?>"
				        aria-label="<?php esc_attr_e( 'Move down', 'my-book-showroom' ); ?>">&#9660;</button>
			</span>

			<!-- Icon preview (A2-001: shared CSS classes with buy-link rows) -->
			<div class="mbs-buy-link-icon-wrap" style="width:28px;flex-shrink:0;margin-top:6px;">
				<?php if ( $icon_src ) : ?>
				<img src="<?php echo esc_url( $icon_src ); ?>"
				     width="24" height="24"
				     class="mbs-buy-link-icon-preview"
				     alt="">
				<?php else : ?>
				<span class="mbs-buy-link-icon-placeholder" style="display:inline-block;width:24px;height:24px;background:#ddd;border-radius:4px;"></span>
				<?php endif; ?>
			</div>

			<div style="flex:1;min-width:0;">
				<input type="hidden" name="mbs_platform_id[]" value="<?php echo esc_attr( $id ); ?>">
				<input type="hidden" name="mbs_platform_retailer_key[]"
				       value="<?php echo esc_attr( $retailer_key ); ?>"
				       class="mbs-platform-retailer-key">
				<!-- Icon ID (A2-001: shared class mbs-buy-link-icon-id; name differs for PHP POST processing) -->
				<input type="hidden" name="mbs_platform_icon_id[]"
				       value="<?php echo esc_attr( $icon_att_id ?: '' ); ?>"
				       class="mbs-buy-link-icon-id">

				<!-- Label -->
				<input type="text"
				       name="mbs_platform_label[]"
				       value="<?php echo esc_attr( $link_label ); ?>"
				       class="widefat mbs-platform-label"
				       autocomplete="off"
				       placeholder="<?php esc_attr_e( 'Display name (auto-filled)', 'my-book-showroom' ); ?>"
				       style="margin-bottom:4px;">

				<!-- URL -->
				<input type="url"
				       name="mbs_platform_url[]"
				       value="<?php echo esc_attr( $url ); ?>"
				       class="widefat mbs-platform-url"
				       autocomplete="off"
				       placeholder="https://...">

				<!-- Change Icon button (PRO/PUB only) -->
				<?php if ( mbs_is_pro() || mbs_is_pub() ) : ?>
				<button type="button"
				        class="button button-small mbs-change-icon-btn"
				        style="margin-top:4px;"
				        data-current-icon-id="<?php echo esc_attr( $icon_att_id ?: '' ); ?>">
					<?php esc_html_e( 'Change Icon', 'my-book-showroom' ); ?>
				</button>
				<?php else : ?>
				<!-- MBS: custom icon upload requires PRO license -->
				<?php endif; ?>

				<!-- Remove -->
				<button type="button" class="button mbs-remove-platform-row" style="margin-top:4px;">
					<?php esc_html_e( 'Remove', 'my-book-showroom' ); ?>
				</button>
			</div>

		</div>
		<?php
	}

	// -------------------------------------------------------------------------
	// Buy Links Repeater
	// -------------------------------------------------------------------------

	/**
	 * Render the Buy Links repeater.
	 *
	 * @since  1.4.0
	 * @param  array $buy_links  Existing buy link rows from DB.
	 * @return void
	 */
	private function render_buy_links_repeater( array $buy_links ): void {
		?>
		<!-- ── Buy Links ────────────────────────────────────────────────────── -->
		<p style="font-weight:600;margin:0 0 6px;">
			<?php esc_html_e( 'Buy Links', 'my-book-showroom' ); ?>
		</p>
		<p class="description" style="margin-bottom:8px;">
			<?php esc_html_e( 'Links added here apply to all formats. To add buy links for a specific edition, use the Buy Links section within each format row above.', 'my-book-showroom' ); ?>
		</p>

		<div class="mbs-repeater" id="mbs-buy-links-repeater">

			<div class="mbs-repeater-rows" id="mbs-buy-links-rows">
				<?php if ( empty( $buy_links ) ) : ?>
					<p class="mbs-no-rows-message"><?php esc_html_e( 'No buy links added yet.', 'my-book-showroom' ); ?></p>
				<?php else : ?>
					<?php foreach ( $buy_links as $link ) : ?>
						<?php $this->render_row( $link ); ?>
					<?php endforeach; ?>
				<?php endif; ?>
			</div>

			<?php /* Hidden template row -- cloned by JS. display:none must stay inline. */ ?>
			<div id="buy-link-row-template" class="mbs-buy-link-row-template" style="display:none;">
				<?php $this->render_row( null ); ?>
			</div>

			<button type="button" class="button mbs-add-buy-link-row" style="margin-top:12px;">
				<?php esc_html_e( '+ Add Buy Link', 'my-book-showroom' ); ?>
			</button>

		</div>
		<?php
	}

	/**
	 * Render a single global buy link row (shared across all formats).
	 * $link is null when rendering the hidden template.
	 *
	 * @since  1.0.0
	 * @param  object|null $link  Saved buy link row, or null for the template.
	 * @return void
	 */
	private function render_row( ?object $link ): void {
		MBS_Buy_Link_Row_Renderer::render_row(
			$link,
			array(
				'id'           => 'mbs_buy_link_id[]',
				'retailer_key' => 'mbs_buy_link_retailer_key[]',
				'icon_id'      => 'mbs_buy_link_icon_id[]',
				'label'        => 'mbs_buy_link_label[]',
				'url'          => 'mbs_buy_link_url[]',
				'embed'        => 'mbs_buy_link_embed[]',
			),
			mbs_is_pro(),
			mbs_is_pub()
			// $show_sort_controls omitted -- defaults to true for global buy link rows (A4-004)
		);
	}
	// -------------------------------------------------------------------------
	// Save
	// -------------------------------------------------------------------------

	/**
	 * Save all Global Links: Reader Platforms + buy links repeater.
	 *
	 * @since  1.0.0
	 * @param  int     $post_id  Post ID.
	 * @param  WP_Post $post     Post object.
	 * @return void
	 */
	public function save( int $post_id, WP_Post $post ): void {
		if ( ! $this->verify_save( $post_id, 'mbs_buy_links_nonce', 'mbs_buy_links_nonce' ) ) {
			return;
		}

		$keep_ids = array();

		// ── Reader Platform rows ──────────────────────────────────────────────
		$plat_ids      = wp_unslash( $_POST['mbs_platform_id']           ?? array() );
		$plat_urls     = wp_unslash( $_POST['mbs_platform_url']          ?? array() );
		$plat_labels   = wp_unslash( $_POST['mbs_platform_label']        ?? array() );
		$plat_keys     = wp_unslash( $_POST['mbs_platform_retailer_key'] ?? array() );
		$plat_icon_ids = isset( $_POST['mbs_platform_icon_id'] )
			? array_map( 'absint', wp_unslash( (array) $_POST['mbs_platform_icon_id'] ) )
			: array();

		$plat_count = count( $plat_urls );
		for ( $i = 0; $i < $plat_count; $i++ ) {
			$url = esc_url_raw( trim( $plat_urls[ $i ] ?? '' ) );
			if ( empty( $url ) ) {
				continue;
			}
			$retailer_key  = self::detect_retailer_key( $url ) ?? sanitize_key( $plat_keys[ $i ] ?? '' );
			$raw_label     = sanitize_text_field( $plat_labels[ $i ] ?? '' );
			$link_label    = $retailer_key ? self::get_canonical_label( $retailer_key ) ?: $raw_label : $raw_label;
			$data = array(
				'post_id'            => $post_id,
				'url'                => $url,
				'retailer_key'       => $retailer_key,
				'link_type'          => 'platform',
				'link_label'         => $link_label,
				'icon_attachment_id' => $plat_icon_ids[ $i ] ?: null,
				'format_row_id'      => null,
				'sort_order'         => $i,
			);
			$row_id = ! empty( $plat_ids[ $i ] ) ? (int) $plat_ids[ $i ] : 0;
			if ( $row_id > 0 ) {
				MBS_Buy_Link_DAO::save( array_merge( $data, array( 'id' => $row_id ) ) );
				$keep_ids[] = $row_id;
			} else {
				$new_id = MBS_Buy_Link_DAO::save( $data );
				if ( $new_id ) {
					$keep_ids[] = $new_id;
				}
			}
		}

		// ── Buy links repeater ───────────────────────────────────────────────
		$ids      = wp_unslash( $_POST['mbs_buy_link_id']    ?? array() );
		$urls     = wp_unslash( $_POST['mbs_buy_link_url']   ?? array() );
		$embeds   = wp_unslash( $_POST['mbs_buy_link_embed'] ?? array() );
		$labels   = wp_unslash( $_POST['mbs_buy_link_label'] ?? array() );
		$icon_ids = wp_unslash( $_POST['mbs_buy_link_icon_id'] ?? array() );

		$count = count( $urls );
		for ( $i = 0; $i < $count; $i++ ) {
			$url = esc_url_raw( trim( $urls[ $i ] ?? '' ) );
			if ( empty( $url ) ) {
				continue;
			}
			$retailer_key  = self::detect_retailer_key( $url );
			$raw_label     = sanitize_text_field( $labels[ $i ] ?? '' );
			$link_label    = $retailer_key ? self::get_canonical_label( $retailer_key ) ?: $raw_label : $raw_label;
			$allowed_embed = array(
				'div'    => array( 'id' => array() ),
				'script' => array( 'type' => array() ),
			);
			$embed_raw     = trim( $embeds[ $i ] ?? '' );
			$embed_snippet = wp_kses( $embed_raw, $allowed_embed ) ?: null;
			$icon_att_id   = absint( $icon_ids[ $i ] ?? 0 );
			$data = array(
				'post_id'            => $post_id,
				'url'                => $url,
				'retailer_key'       => $retailer_key,
				'link_type'          => 'buy',
				'link_label'         => $link_label,
				'embed_snippet'      => $embed_snippet,
				'icon_attachment_id' => $icon_att_id ?: null,
				'format_row_id'      => null,
				'sort_order'         => $i,
			);
			$row_id = ! empty( $ids[ $i ] ) ? (int) $ids[ $i ] : 0;
			if ( $row_id > 0 ) {
				MBS_Buy_Link_DAO::save( array_merge( $data, array( 'id' => $row_id ) ) );
				$keep_ids[] = $row_id;
			} else {
				$new_id = MBS_Buy_Link_DAO::save( $data );
				if ( $new_id ) {
					$keep_ids[] = $new_id;
				}
			}
		}

		// Soft-delete any shared links not in the submitted set.
		MBS_Buy_Link_DAO::soft_delete_missing_shared_for_post( $post_id, $keep_ids );
	}

	/**
	 * Save one section of the Global Links metabox.
	 *
	 * 'main' saves Reader Platform rows only; also re-fetches buy link IDs from DB
	 * so the shared soft-delete does not prune them.
	 * 'global-links' saves the buy-link repeater; re-fetches platform IDs to prevent pruning.
	 *
	 * @since  1.2.0
	 * @param  string  $section_key  'main' or 'global-links'.
	 * @param  int     $post_id      Post ID.
	 * @param  WP_Post $post         Post object.
	 * @return void
	 */
	public function save_section( string $section_key, int $post_id, WP_Post $post ): void {
		if ( 'main' === $section_key ) {
			if ( ! $this->verify_save( $post_id, 'mbs_buy_links_discovery_nonce', 'mbs_buy_links_discovery' ) ) {
				return;
			}

			$plat_ids      = wp_unslash( $_POST['mbs_platform_id']           ?? array() );
			$plat_urls     = wp_unslash( $_POST['mbs_platform_url']          ?? array() );
			$plat_labels   = wp_unslash( $_POST['mbs_platform_label']        ?? array() );
			$plat_keys     = wp_unslash( $_POST['mbs_platform_retailer_key'] ?? array() );
			$plat_icon_ids = isset( $_POST['mbs_platform_icon_id'] )
				? array_map( 'absint', wp_unslash( (array) $_POST['mbs_platform_icon_id'] ) )
				: array();

			$keep_ids   = array();
			$plat_count = count( $plat_urls );

			for ( $i = 0; $i < $plat_count; $i++ ) {
				$url = esc_url_raw( trim( $plat_urls[ $i ] ?? '' ) );
				if ( empty( $url ) ) {
					continue;
				}
				$retailer_key = self::detect_retailer_key( $url ) ?? sanitize_key( $plat_keys[ $i ] ?? '' );
				$raw_label    = sanitize_text_field( $plat_labels[ $i ] ?? '' );
				$link_label   = $retailer_key ? self::get_canonical_label( $retailer_key ) ?: $raw_label : $raw_label;
				$data = array(
					'post_id'            => $post_id,
					'url'                => $url,
					'retailer_key'       => $retailer_key,
					'link_type'          => 'platform',
					'link_label'         => $link_label,
					'icon_attachment_id' => $plat_icon_ids[ $i ] ?: null,
					'format_row_id'      => null,
					'sort_order'         => $i,
				);
				$row_id = ! empty( $plat_ids[ $i ] ) ? (int) $plat_ids[ $i ] : 0;
				if ( $row_id > 0 ) {
					MBS_Buy_Link_DAO::save( array_merge( $data, array( 'id' => $row_id ) ) );
					$keep_ids[] = $row_id;
				} else {
					$new_id = MBS_Buy_Link_DAO::save( $data );
					if ( $new_id ) {
						$keep_ids[] = $new_id;
					}
				}
			}

			// Fetch buy link IDs from DB so the shared soft-delete does not prune them.
			foreach ( MBS_Buy_Link_DAO::get_buy_links_for_book( $post_id ) as $bl ) {
				$keep_ids[] = (int) $bl->id;
			}

			MBS_Buy_Link_DAO::soft_delete_missing_shared_for_post( $post_id, $keep_ids );

		} elseif ( 'global-links' === $section_key ) {
			if ( ! $this->verify_save( $post_id, 'mbs_buy_links_global_links_nonce', 'mbs_buy_links_global_links' ) ) {
				return;
			}

			$ids      = wp_unslash( $_POST['mbs_buy_link_id']      ?? array() );
			$urls     = wp_unslash( $_POST['mbs_buy_link_url']     ?? array() );
			$embeds   = wp_unslash( $_POST['mbs_buy_link_embed']   ?? array() );
			$labels   = wp_unslash( $_POST['mbs_buy_link_label']   ?? array() );
			$icon_ids = wp_unslash( $_POST['mbs_buy_link_icon_id'] ?? array() );

			$keep_ids = array();
			$count    = count( $urls );

			for ( $i = 0; $i < $count; $i++ ) {
				$url = esc_url_raw( trim( $urls[ $i ] ?? '' ) );
				if ( empty( $url ) ) {
					continue;
				}
				$retailer_key  = self::detect_retailer_key( $url );
				$raw_label     = sanitize_text_field( $labels[ $i ] ?? '' );
				$link_label    = $retailer_key ? self::get_canonical_label( $retailer_key ) ?: $raw_label : $raw_label;
				$allowed_embed = array(
					'div'    => array( 'id' => array() ),
					'script' => array( 'type' => array() ),
				);
				$embed_raw     = trim( $embeds[ $i ] ?? '' );
				$embed_snippet = wp_kses( $embed_raw, $allowed_embed ) ?: null;
				$icon_att_id   = absint( $icon_ids[ $i ] ?? 0 );
				$data = array(
					'post_id'            => $post_id,
					'url'                => $url,
					'retailer_key'       => $retailer_key,
					'link_type'          => 'buy',
					'link_label'         => $link_label,
					'embed_snippet'      => $embed_snippet,
					'icon_attachment_id' => $icon_att_id ?: null,
					'format_row_id'      => null,
					'sort_order'         => $i,
				);
				$row_id = ! empty( $ids[ $i ] ) ? (int) $ids[ $i ] : 0;
				if ( $row_id > 0 ) {
					MBS_Buy_Link_DAO::save( array_merge( $data, array( 'id' => $row_id ) ) );
					$keep_ids[] = $row_id;
				} else {
					$new_id = MBS_Buy_Link_DAO::save( $data );
					if ( $new_id ) {
						$keep_ids[] = $new_id;
					}
				}
			}

			// Fetch platform IDs from DB so the shared soft-delete does not prune them.
			foreach ( MBS_Buy_Link_DAO::get_platforms_for_book( $post_id ) as $pl ) {
				$keep_ids[] = (int) $pl->id;
			}

			MBS_Buy_Link_DAO::soft_delete_missing_shared_for_post( $post_id, $keep_ids );
		}
	}

	/**
	 * Return the canonical display label for a known retailer/platform key.
	 *
	 * Mirrors MBS_Buy_Link_Renderer::get_retailer_label() for save-time use.
	 * Task 30 will make the renderer method public; this stub can then be removed.
	 *
	 * @since  1.4.0
	 * @param  string $key  sanitize_key()-processed retailer key.
	 * @return string  Canonical label, or empty string if key is unknown.
	 */
	private static function get_canonical_label( string $key ): string {
		return MBS_Link_Icon_Helper::get_canonical_label( $key );
	}

	/**
	 * Domain-based retailer key detection.
	 * Public so MBS_Metabox_Format_Repeater can reuse it for format-level buy links.
	 *
	 * @since  1.0.0
	 * @param  string $url  Full URL.
	 * @return string|null  Retailer key, or null if unrecognized.
	 */
	public static function detect_retailer_key( string $url ): ?string {
		$host = wp_parse_url( $url, PHP_URL_HOST );
		if ( ! $host ) {
			return null;
		}
		$host = strtolower( $host );

		// Intentional gap: 16 entries here vs. 19 in JS LINK_DOMAINS (mbs-link-icon-helper.js).
		// scribd, librarything, and reedsy are JS-only -- their keys are written to the hidden
		// retailer_key input by applyDetection() on URL blur, so PHP receives the key on save.
		$map = array(
			'amazon.com'         => 'amazon',
			'barnesandnoble.com' => 'bn',
			'booksamillion.com'  => 'bam',
			'bookshop.org'       => 'bookshop',
			'kobo.com'           => 'kobo',
			'google.com'         => 'google',
			'apple.com'          => 'apple',
			'audible.com'        => 'audible',
			'waterstones.com'    => 'waterstones',
			'powells.com'        => 'powells',
			'gumroad.com'        => 'gumroad',
			'bookfunnel.com'     => 'bookfunnel',
			'myshopify.com'      => 'shopify-buy',
			'goodreads.com'      => 'goodreads',
			'bookbub.com'        => 'bookbub',
			'thestorygraph.com'  => 'storygraph',
		);

		foreach ( $map as $domain => $key ) {
			if ( $host === $domain || str_ends_with( $host, '.' . $domain ) ) {
				return $key;
			}
		}
		return null;
	}
}
