<?php
/**
 * WPC Smart Wishlist Importer class.
 *
 * @package SaveToWishlist\Classes\Importers
 * @since 1.0.4
 */

namespace SaveToWishlist\Classes\Importers;

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

use SaveToWishlist\Helpers\Helper_Database;
use SaveToWishlist\Classes\Factories\Collections;

/**
 * Class WPC_Importer
 *
 * Imports wishlists from WPC Smart Wishlist for WooCommerce.
 *
 * WPC stores wishlist metadata in usermeta under key `woosw_keys`
 * (an array keyed by wishlist key), and the product IDs for each
 * wishlist in a WordPress option named `woosw_list_{KEY}`.
 *
 * @since 1.0.4
 */
class WPC_Importer extends Abstract_Importer {

    /**
     * User meta key that stores the map of wishlist keys.
     */
    const WPC_KEYS_META = 'woosw_keys';

    /**
     * Prefix for the per-wishlist option storing product IDs.
     */
    const WPC_LIST_OPTION_PREFIX = 'woosw_list_';

    /**
     * Main plugin file paths relative to WP_PLUGIN_DIR.
     *
     * Lists both the free and Premium variants so either activation flips the
     * importer on.
     */
    const WPC_PLUGIN_FILES = array(
        'woo-smart-wishlist/wpc-smart-wishlist.php',
        'woo-smart-wishlist-premium/wpc-smart-wishlist.php',
    );

    /**
     * Batch size for processing.
     *
     * Batches are counted in usermeta rows (one row per user), not
     * individual wishlists, because all of a user's wishlists must be
     * unserialised together. The cursor is the highest umeta_id seen.
     */
    const BATCH_SIZE = 50;

    /**
     * Transient key for cached `get_source_data_count()` result.
     */
    const COUNT_CACHE_KEY = 'stwlite_wpc_importer_source_count';

    /**
     * Cache TTL for the source-data count.
     *
     * `get_source_data_count()` pages every WPC usermeta row + options row,
     * which is expensive on large stores. The count is only used to drive
     * the progress-bar percentage, so a stale value within this window is
     * acceptable.
     */
    const COUNT_CACHE_TTL = 5 * MINUTE_IN_SECONDS;

    /**
     * Get source name.
     *
     * @since 1.0.4
     * @access public
     * @return string
     */
    public function get_source_name(): string {
        return 'WPC Smart Wishlist for WooCommerce';
    }

    /**
     * Get source slug.
     *
     * @since 1.0.4
     * @access public
     * @return string
     */
    public function get_source_slug(): string {
        return 'wpc';
    }

    /**
     * Get source icon.
     *
     * Star icon to reflect WPC Smart Wishlist's "smart"/featured branding and
     * to visually distinguish it from the other heart-based importer entries.
     *
     * @since 1.0.4
     * @access public
     * @return string
     */
    public function get_source_icon(): string {
        return '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>';
    }

    /**
     * Check if source is active.
     *
     * Returns true only when the WPC source plugin is installed and active —
     * leftover wishlist usermeta alone is not enough to surface the importer.
     *
     * @since 1.0.4
     * @access public
     * @return bool
     */
    public function is_source_active(): bool {
        return $this->is_plugin_installed_and_active( self::WPC_PLUGIN_FILES );
    }

    /**
     * Get source version.
     *
     * @since 1.0.4
     * @access public
     * @return string
     */
    public function get_source_version(): string {
        if ( ! function_exists( 'get_plugin_data' ) ) {
            require_once ABSPATH . 'wp-admin/includes/plugin.php';
        }

        foreach ( self::WPC_PLUGIN_FILES as $plugin_file ) {
            $absolute_path = WP_PLUGIN_DIR . '/' . $plugin_file;
            if ( file_exists( $absolute_path ) ) {
                $plugin_data = get_plugin_data( $absolute_path );
                return $plugin_data['Version'] ?? 'Unknown';
            }
        }

        return 'Unknown';
    }

    /**
     * Get source data count.
     *
     * Pages through usermeta with a DB cursor and aggregates the
     * pending-wishlist totals without materialising the full list
     * in memory. Options for each page are fetched in one query.
     *
     * @since 1.0.4
     * @access public
     * @return array
     */
    public function get_source_data_count(): array {
        if ( ! $this->is_source_active() ) {
            return array(
                'countFromSource' => 0,
                'wishlists'       => 0,
                'items'           => 0,
                'users'           => 0,
                'guests'          => 0,
            );
        }

        $cached = get_transient( self::COUNT_CACHE_KEY );
        if ( is_array( $cached ) ) {
            return $cached;
        }

        $total_wishlists = $this->count_total_source_wishlists();
        $unique_users    = array();
        $pending_items   = 0;
        $pending_count   = 0;
        $cursor          = 0;

        while ( true ) {
            $page = $this->get_flattened_batch( $cursor, self::BATCH_SIZE );

            if ( ! empty( $page['wishlists'] ) ) {
                $option_names = array();
                foreach ( $page['wishlists'] as $wishlist ) {
                    $option_names[] = self::WPC_LIST_OPTION_PREFIX . $wishlist->key;
                }
                $option_map = $this->get_options_map( $option_names );

                foreach ( $page['wishlists'] as $wishlist ) {
                    $unique_users[ $wishlist->user_id ] = true;
                    $products                           = $option_map[ self::WPC_LIST_OPTION_PREFIX . $wishlist->key ] ?? array();
                    $pending_items                     += is_array( $products ) ? count( $products ) : 0;
                    ++$pending_count;
                }
            }

            if ( $page['rows_scanned'] < self::BATCH_SIZE ) {
                break;
            }

            $cursor = $page['last_umeta_id'];
        }

        $counts = array(
            'countFromSource' => $total_wishlists,
            'wishlists'       => $pending_count,
            'items'           => $pending_items,
            'users'           => count( $unique_users ),
            'guests'          => 0,
        );

        set_transient( self::COUNT_CACHE_KEY, $counts, self::COUNT_CACHE_TTL );

        return $counts;
    }

    /**
     * Count every WPC wishlist on the site (imported or not), for the
     * `countFromSource` field surfaced in the importer UI.
     *
     * @since 1.0.4
     * @access protected
     * @return int
     */
    protected function count_total_source_wishlists(): int {
        global $wpdb;

        // phpcs:disable

        $rows = $wpdb->get_col(
            $wpdb->prepare(
                "SELECT meta_value FROM {$wpdb->usermeta} WHERE meta_key = %s",
                self::WPC_KEYS_META
            )
        );

        // phpcs:enable

        $total = 0;
        foreach ( $rows as $meta_value ) {
            $keys = maybe_unserialize( $meta_value );
            if ( is_array( $keys ) ) {
                $total += count( $keys );
            }
        }

        return $total;
    }

    /**
     * Get preview data.
     *
     * Pages through usermeta until it has up to 5 pending wishlists,
     * then batches the user and option lookups in single queries.
     *
     * @since 1.0.4
     * @access public
     * @return array
     */
    public function get_preview_data(): array {
        if ( ! $this->is_source_active() ) {
            return array();
        }

        $needed            = 5;
        $preview_wishlists = array();
        $collected         = 0;
        $cursor            = 0;

        while ( $collected < $needed ) {
            $page = $this->get_flattened_batch( $cursor, self::BATCH_SIZE );

            foreach ( $page['wishlists'] as $wishlist ) {
                $preview_wishlists[] = $wishlist;
                if ( ++$collected >= $needed ) {
                    break;
                }
            }

            if ( $page['rows_scanned'] < self::BATCH_SIZE ) {
                break;
            }

            $cursor = $page['last_umeta_id'];
        }

        if ( empty( $preview_wishlists ) ) {
            return array();
        }

        $option_names = array();
        $user_ids     = array();
        foreach ( $preview_wishlists as $wishlist ) {
            $option_names[] = self::WPC_LIST_OPTION_PREFIX . $wishlist->key;
            $user_ids[]     = (int) $wishlist->user_id;
        }
        $option_map = $this->get_options_map( $option_names );
        $user_map   = $this->get_user_map( array_unique( $user_ids ) );

        $preview = array();
        foreach ( $preview_wishlists as $wishlist ) {
            $user      = $user_map[ (int) $wishlist->user_id ] ?? null;
            $products  = $option_map[ self::WPC_LIST_OPTION_PREFIX . $wishlist->key ] ?? array();
            $preview[] = array(
                'id'         => $wishlist->key,
                'name'       => $this->resolve_wishlist_name( $wishlist ),
                'user_id'    => (int) $wishlist->user_id,
                'user_email' => $user ? $user->user_email : '',
                'item_count' => is_array( $products ) ? count( $products ) : 0,
                'created_at' => $this->format_wpc_time( $wishlist->time ),
                'privacy'    => 0,
            );
        }

        return $preview;
    }

    /**
     * Get import batch.
     *
     * Cursor-based pagination: `$offset` is the highest source umeta_id
     * already processed (0 on first call). Each call returns at most
     * `$limit` usermeta rows expanded into wishlist objects.
     *
     * @param int $offset Highest umeta_id already processed (cursor).
     * @param int $limit Max usermeta rows to scan in this batch.
     * @since 1.0.4
     * @access public
     * @return array
     */
    public function get_import_batch( int $offset, int $limit ): array {
        if ( ! $this->is_source_active() ) {
            return array();
        }

        $page = $this->get_flattened_batch( $offset, $limit );
        return $page['wishlists'];
    }

    /**
     * Format a WPC `time` value for display. WPC stores Unix timestamps in
     * recent versions, but older data may contain pre-formatted strings.
     *
     * @param mixed $time WPC time value.
     * @since 1.0.4
     * @access protected
     * @return string
     */
    protected function format_wpc_time( $time ): string {
        if ( empty( $time ) ) {
            return '';
        }

        if ( is_numeric( $time ) ) {
            return gmdate( 'Y-m-d H:i:s', (int) $time );
        }

        return (string) $time;
    }

    /**
     * Import data.
     *
     * @param array $options Import options.
     * @since 1.0.4
     * @access public
     * @return array
     */
    public function import( array $options ): array {
        $this->clear_import_log();

        if ( ! $this->is_source_active() ) {
            return array(
                'success' => false,
                'message' => __( 'WPC Smart Wishlist is not installed or active.', 'saveto-wishlist-lite-for-woocommerce' ),
            );
        }

        $is_batch_request = isset( $options['batch_number'] ) && isset( $options['job_id'] );

        if ( $is_batch_request ) {
            return $this->import_batch( $options );
        }

        $this->set_time_limit_with_fallback();

        $stats = array(
            'wishlists_imported' => 0,
            'items_imported'     => 0,
            'wishlists_skipped'  => 0,
            'items_skipped'      => 0,
            'users_affected'     => 0,
            'errors'             => array(),
        );

        $processed_users = array();
        $cursor          = 0;

        while ( true ) {
            $page = $this->get_flattened_batch( $cursor, self::BATCH_SIZE );

            if ( ! empty( $page['wishlists'] ) ) {
                $batch_result    = $this->process_wishlists_batch( $page['wishlists'], $options, $processed_users, $stats );
                $stats           = $batch_result['stats'];
                $processed_users = $batch_result['processed_users'];
            }

            if ( $page['rows_scanned'] < self::BATCH_SIZE ) {
                break;
            }

            $cursor = $page['last_umeta_id'];
        }

        $this->clear_import_caches( array_keys( $processed_users ) );

        delete_transient( self::COUNT_CACHE_KEY );

        return array(
            'success' => true,
            'stats'   => $stats,
            'log'     => $this->get_import_log(),
        );
    }

    /**
     * Import a single batch (for AJAX processing).
     *
     * Uses an umeta_id cursor (`last_umeta_id`) in the progress
     * transient instead of caching the full flattened list. The
     * transient holds only lightweight progress metadata.
     *
     * @param array $options Import options including batch_number and job_id.
     * @since 1.0.4
     * @access public
     * @return array
     */
    public function import_batch( array $options ): array {
        $job_id       = $options['job_id'] ?? '';
        $batch_number = (int) ( $options['batch_number'] ?? 0 );

        if ( empty( $job_id ) ) {
            return array(
                'success' => false,
                'message' => __( 'Invalid job ID.', 'saveto-wishlist-lite-for-woocommerce' ),
            );
        }

        $progress = get_transient( STWLITE_IMPORT_PROGRESS_GROUP . $job_id );

        if ( false === $progress && 0 === $batch_number ) {
            $counts          = $this->get_source_data_count();
            $total_wishlists = $counts['wishlists'];
            $total_batches   = ceil( $total_wishlists / self::BATCH_SIZE );

            $progress = array(
                'user_id'            => get_current_user_id(),
                'job_id'             => $job_id,
                'status'             => 'processing',
                'current_batch'      => 0,
                'total_batches'      => $total_batches,
                'total_wishlists'    => $total_wishlists,
                'wishlists_imported' => 0,
                'items_imported'     => 0,
                'wishlists_skipped'  => 0,
                'items_skipped'      => 0,
                'users_affected'     => 0,
                'processed_users'    => array(),
                'errors'             => array(),
                'last_umeta_id'      => 0,
                'started_at'         => current_time( 'mysql' ),
            );
        } elseif ( false === $progress ) {
            return array(
                'success' => false,
                'message' => __( 'Import job not found or expired.', 'saveto-wishlist-lite-for-woocommerce' ),
            );
        }

        $last_umeta_id = (int) ( $progress['last_umeta_id'] ?? 0 );
        $page          = $this->get_flattened_batch( $last_umeta_id, self::BATCH_SIZE );
        $wishlists     = $page['wishlists'];

        if ( 0 === $page['rows_scanned'] ) {
            $progress['status']       = 'completed';
            $progress['completed_at'] = current_time( 'mysql' );

            $this->clear_import_caches( array_keys( $progress['processed_users'] ) );
            delete_transient( self::COUNT_CACHE_KEY );

            set_transient( STWLITE_IMPORT_PROGRESS_GROUP . $job_id, $progress, HOUR_IN_SECONDS );

            return array(
                'success'  => true,
                'complete' => true,
                'progress' => $progress,
            );
        }

        $stats           = array(
            'wishlists_imported' => $progress['wishlists_imported'],
            'items_imported'     => $progress['items_imported'],
            'wishlists_skipped'  => $progress['wishlists_skipped'],
            'items_skipped'      => $progress['items_skipped'],
            'users_affected'     => $progress['users_affected'],
            'errors'             => $progress['errors'],
        );
        $processed_users = $progress['processed_users'];

        if ( ! empty( $wishlists ) ) {
            $batch_result    = $this->process_wishlists_batch( $wishlists, $options, $processed_users, $stats );
            $stats           = $batch_result['stats'];
            $processed_users = $batch_result['processed_users'];
        }

        $is_complete = $page['rows_scanned'] < self::BATCH_SIZE;

        $progress['current_batch']      = $batch_number;
        $progress['wishlists_imported'] = $stats['wishlists_imported'];
        $progress['items_imported']     = $stats['items_imported'];
        $progress['wishlists_skipped']  = $stats['wishlists_skipped'];
        $progress['items_skipped']      = $stats['items_skipped'];
        $progress['users_affected']     = $stats['users_affected'];
        $progress['processed_users']    = $processed_users;
        $progress['errors']             = $stats['errors'];
        $progress['last_umeta_id']      = $page['last_umeta_id'];
        $progress['percentage']         = ( $progress['total_batches'] > 0 ) ? round( ( ( $batch_number + 1 ) / $progress['total_batches'] ) * 100, 2 ) : 100;

        if ( $is_complete ) {
            $progress['status']       = 'completed';
            $progress['completed_at'] = current_time( 'mysql' );
            $this->clear_import_caches( array_keys( $processed_users ) );
            delete_transient( self::COUNT_CACHE_KEY );
        }

        set_transient( STWLITE_IMPORT_PROGRESS_GROUP . $job_id, $progress, HOUR_IN_SECONDS );

        return array(
            'success'  => true,
            'complete' => $is_complete,
            'progress' => $progress,
        );
    }

    /**
     * Process a batch of wishlists.
     *
     * Options for all wishlists in the batch are fetched in a single
     * `WHERE option_name IN (...)` query, eliminating per-wishlist
     * `get_option()` round-trips.
     *
     * @param array $wishlists Flattened wishlist objects from get_flattened_batch().
     * @param array $options Import options.
     * @param array $processed_users Already processed user IDs (keyed map).
     * @param array $stats Current stats.
     * @since 1.0.4
     * @access protected
     * @return array Updated stats and processed_users.
     */
    protected function process_wishlists_batch( array $wishlists, array $options, array $processed_users, array $stats ): array {
        $preserve_default = $options['preserve_default'] ?? true;

        $option_names = array();
        foreach ( $wishlists as $wpc_wishlist ) {
            $option_names[] = self::WPC_LIST_OPTION_PREFIX . $wpc_wishlist->key;
        }
        $option_map = $this->get_options_map( $option_names );

        foreach ( $wishlists as $wpc_wishlist ) {
            if ( ! $this->validate_user( (int) $wpc_wishlist->user_id ) ) {
                ++$stats['wishlists_skipped'];
                $this->log( sprintf( 'Skipped wishlist key %s - user does not exist', $wpc_wishlist->key ), 'warning' );
                continue;
            }

            $wishlist_name = $this->resolve_wishlist_name( $wpc_wishlist );

            $products = $option_map[ self::WPC_LIST_OPTION_PREFIX . $wpc_wishlist->key ] ?? array();
            if ( ! is_array( $products ) ) {
                $products = array();
            }

            $item_mapping = array();
            $position     = 0;
            foreach ( $products as $product_id => $product_data ) {
                $resolved = $this->resolve_product_variation( (int) $product_id );

                if ( 0 === $resolved['product_id'] ) {
                    ++$stats['items_skipped'];
                    continue;
                }

                $item_mapping[] = array(
                    'product_id'   => $resolved['product_id'],
                    'variation_id' => $resolved['variation_id'],
                    'quantity'     => 1,
                    'sort_order'   => $position,
                );

                ++$position;
            }

            $collection = Collections::instance()->get_collection( $wpc_wishlist->key, 'url_code' );
            if ( ! empty( $collection ) ) {
                ++$stats['wishlists_skipped'];
                $this->log( sprintf( 'Collection already exists for wishlist key %s', $wpc_wishlist->key ), 'warning' );
                continue;
            }

            $collection_data = array(
                'user_id'     => (int) $wpc_wishlist->user_id,
                'name'        => $wishlist_name,
                'url_code'    => ! empty( $wpc_wishlist->key ) ? $wpc_wishlist->key : $this->generate_unique_url_code(),
                'status'      => 'publish',
                'is_default'  => $preserve_default ? ( 'primary' === $wpc_wishlist->type ) : false,
                'is_public'   => 1,
                'description' => '',
                'items'       => $item_mapping,
            );

            $collections       = Collections::instance();
            $collection_result = $collections->save_collection( $collection_data );

            if ( empty( $collection_result ) || is_wp_error( $collection_result ) ) {
                ++$stats['wishlists_skipped'];
                $error_msg         = is_wp_error( $collection_result ) ? $collection_result->get_error_message() : 'Unknown error';
                $stats['errors'][] = sprintf( 'Failed to create collection for wishlist key %s: %s', $wpc_wishlist->key, $error_msg );
                $this->log( sprintf( 'Failed to create collection for wishlist key %s', $wpc_wishlist->key ), 'error' );
                continue;
            }

            $target_collection_id = isset( $collection_result->id ) ? (int) $collection_result->id : 0;

            if ( ! $target_collection_id ) {
                ++$stats['wishlists_skipped'];
                $this->log( sprintf( 'Failed to retrieve collection ID from result for wishlist key %s', $wpc_wishlist->key ), 'error' );
                continue;
            }

            $user_id = (int) $wpc_wishlist->user_id;
            if ( ! isset( $processed_users[ $user_id ] ) ) {
                $processed_users[ $user_id ] = true;
                ++$stats['users_affected'];
            }

            $stats['items_imported'] += count( $item_mapping );

            ++$stats['wishlists_imported'];
        }

        $stats['errors'] = $this->trim_errors( $stats['errors'] );

        return array(
            'stats'           => $stats,
            'processed_users' => $processed_users,
        );
    }

    /**
     * Paginated fetch of pending WPC wishlists.
     *
     * Scans usermeta in increasing `umeta_id` order with a cursor,
     * expanding each row's `woosw_keys` array into wishlist objects
     * and filtering out any whose key is already an existing
     * SaveTo collection url_code. Memory stays bounded to one page.
     *
     * @param int $after_umeta_id Cursor — only rows with umeta_id > this are returned.
     * @param int $limit Max usermeta rows to scan.
     * @since 1.0.4
     * @access protected
     * @return array{wishlists: array, last_umeta_id: int, rows_scanned: int}
     */
    protected function get_flattened_batch( int $after_umeta_id, int $limit ): array {
        global $wpdb;

        // phpcs:disable

        $rows = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT umeta_id, user_id, meta_value FROM {$wpdb->usermeta} WHERE meta_key = %s AND umeta_id > %d ORDER BY umeta_id ASC LIMIT %d",
                self::WPC_KEYS_META,
                $after_umeta_id,
                $limit
            )
        );

        // phpcs:enable

        $last_umeta_id = $after_umeta_id;
        $wishlists     = array();

        if ( empty( $rows ) ) {
            return array(
                'wishlists'     => $wishlists,
                'last_umeta_id' => $last_umeta_id,
                'rows_scanned'  => 0,
            );
        }

        $batch_keys   = array();
        $unserialized = array();
        foreach ( $rows as $row ) {
            $meta                                 = maybe_unserialize( $row->meta_value );
            $unserialized[ (int) $row->umeta_id ] = is_array( $meta ) ? $meta : array();
            if ( is_array( $meta ) ) {
                foreach ( array_keys( $meta ) as $key ) {
                    $batch_keys[] = (string) $key;
                }
            }
        }

        $existing = $this->get_existing_url_codes_for_keys( $batch_keys );

        foreach ( $rows as $row ) {
            $last_umeta_id = (int) $row->umeta_id;
            $meta          = $unserialized[ $last_umeta_id ] ?? array();

            foreach ( $meta as $key => $info ) {
                $key_string = (string) $key;
                if ( isset( $existing[ $key_string ] ) ) {
                    continue;
                }

                $wishlist          = new \stdClass();
                $wishlist->user_id = (int) $row->user_id;
                $wishlist->key     = $key_string;
                $wishlist->type    = is_array( $info ) && isset( $info['type'] ) ? (string) $info['type'] : 'list';
                $wishlist->name    = is_array( $info ) && isset( $info['name'] ) ? (string) $info['name'] : '';
                $wishlist->time    = is_array( $info ) && isset( $info['time'] ) ? $info['time'] : '';
                $wishlists[]       = $wishlist;
            }
        }

        return array(
            'wishlists'     => $wishlists,
            'last_umeta_id' => $last_umeta_id,
            'rows_scanned'  => count( $rows ),
        );
    }

    /**
     * Resolve a display name for a WPC wishlist, mirroring the plugin's own fallback.
     *
     * @param object $wishlist Flattened wishlist object.
     * @since 1.0.4
     * @access protected
     * @return string
     */
    protected function resolve_wishlist_name( $wishlist ): string {
        if ( ! empty( $wishlist->name ) ) {
            return (string) $wishlist->name;
        }

        if ( 'primary' === $wishlist->type ) {
            return __( 'Wishlist', 'saveto-wishlist-lite-for-woocommerce' );
        }

        return $this->get_default_wishlist_name();
    }

    /**
     * Get WPC's configured "New Wishlist" placeholder name with a safe fallback.
     *
     * Pulls from WPC's localization option via its Woosw_Helper accessor, and
     * falls back to a localized string if WPC isn't loaded.
     *
     * @since 1.0.6
     * @access protected
     * @return string
     */
    protected function get_default_wishlist_name(): string {
        if ( class_exists( 'Woosw_Helper' ) && method_exists( 'Woosw_Helper', 'localization' ) ) {
            $default = \Woosw_Helper::localization( 'placeholder_name', '' );
            if ( ! empty( $default ) ) {
                return (string) $default;
            }
        }

        return $this->default_wishlist_fallback();
    }

    /**
     * Look up existing collection url_codes for a specific set of keys.
     *
     * Bounded by the batch's keys instead of loading the entire
     * url_code column.
     *
     * @param string[] $keys WPC wishlist keys to look up as existing SaveTo url_codes.
     * @since 1.0.4
     * @access protected
     * @return array<string,true>
     */
    protected function get_existing_url_codes_for_keys( array $keys ): array {
        $keys = array_values( array_unique( array_filter( array_map( 'strval', $keys ), 'strlen' ) ) );
        if ( empty( $keys ) ) {
            return array();
        }

        global $wpdb;

        $collections_table = Helper_Database::get_collections_table_name();
        $placeholders      = implode( ',', array_fill( 0, count( $keys ), '%s' ) );

        // phpcs:disable

        $codes = $wpdb->get_col(
            $wpdb->prepare(
                "SELECT url_code FROM {$collections_table} WHERE url_code IN ({$placeholders})",
                ...$keys
            )
        );

        // phpcs:enable

        return empty( $codes ) ? array() : array_fill_keys( $codes, true );
    }

    /**
     * Batch-fetch options by name and return them as a name => unserialised-value map.
     *
     * Replaces N+1 `get_option()` calls for `woosw_list_*` rows.
     *
     * @param string[] $option_names Fully-qualified option names to fetch from wp_options.
     * @since 1.0.4
     * @access protected
     * @return array<string,mixed>
     */
    protected function get_options_map( array $option_names ): array {
        $option_names = array_values( array_unique( array_filter( array_map( 'strval', $option_names ), 'strlen' ) ) );
        if ( empty( $option_names ) ) {
            return array();
        }

        global $wpdb;
        $placeholders = implode( ',', array_fill( 0, count( $option_names ), '%s' ) );

        // phpcs:disable

        $rows = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name IN ({$placeholders})",
                ...$option_names
            )
        );

        // phpcs:enable

        $map = array();
        foreach ( $rows as $row ) {
            $map[ $row->option_name ] = maybe_unserialize( $row->option_value );
        }

        return $map;
    }
}
