<?php

defined('ABSPATH') || exit;

use memberpress\courses\controllers\admin\CourseCategories;
use memberpress\courses\controllers\admin\CourseTags;
use memberpress\courses\helpers\Courses;
use memberpress\courses\helpers\Lessons;
use memberpress\courses\lib\Utils;
use memberpress\courses\models\Course;
use memberpress\courses\models\Lesson;
use memberpress\quizzes\helpers\App;
use memberpress\quizzes\models\Attempt;
use memberpress\quizzes\models\Question;
use memberpress\quizzes\models\Quiz;
use memberpress\courses\models\Section;
use memberpress\courses\models\UserProgress;

class MeprMigratorLearnDash extends MeprMigrator
{
    /**
     * The unique key for this migrator.
     *
     * @var string
     */
    public const KEY = 'learndash';

    /**
     * Mapping of LearnDash question type -> Courses question type.
     *
     * @var array
     */
    protected const QUESTION_TYPE_MAP = [
        'single'             => 'multiple-choice',
        'multiple'           => 'multiple-answer',
        'free_answer'        => 'short-answer',
        'sort_answer'        => 'sort-values',
        'matrix_sort_answer' => 'match-matrix',
        'cloze_answer'       => 'fill-blank',
        'assessment_answer'  => 'likert-scale',
        'essay'              => 'essay',
    ];

    /**
     * Maximum rows per bulk INSERT. Caps how large a single multi-row write can
     * grow so it stays well under the database max_allowed_packet, independent
     * of the batch processor's per-batch item limit.
     *
     * @var integer
     */
    protected const BULK_INSERT_CHUNK = 500;

    /**
     * Do the migration based on the given data.
     *
     * @param array $data The data array for the current step.
     */
    public function migrate(array $data)
    {
        if (!empty($data['step'])) {
            switch ($data['step']) {
                case 'start':
                    $this->check_prerequisites($data);
                    break;
                case 'taxonomies':
                    $this->migrate_taxonomies($data);
                    break;
                case 'courses':
                    $this->migrate_courses($data);
                    break;
                case 'progress':
                    $this->migrate_progress($data);
                    break;
                case 'attempts':
                    $this->migrate_attempts($data);
                    break;
            }
        }

        wp_send_json_error(__('Bad request', 'memberpress'));
    }

    /**
     * Check that the migrator prerequisites are met.
     *
     * Makes sure that the tables exist for our later queries.
     *
     * @param array $data The data for the current request.
     *
     * @return void
     */
    protected function check_prerequisites(array $data)
    {
        global $wpdb;
        $mepr_db = MeprDb::fetch();

        $tables = [
            "{$wpdb->prefix}learndash_pro_quiz_master",
            "{$wpdb->prefix}learndash_pro_quiz_question",
        ];

        if (!empty($data['options']['userProgress'])) {
            $tables = array_merge($tables, [
                "{$wpdb->prefix}learndash_pro_quiz_statistic",
                "{$wpdb->prefix}learndash_user_activity",
                "{$wpdb->prefix}learndash_user_activity_meta",
            ]);
        }

        foreach ($tables as $table) {
            if (!$mepr_db->table_exists($table)) {
                wp_send_json_error(
                    sprintf(
                        // Translators: %s: the table name.
                        __('Migration not started: table `%s` not found, try updating LearnDash to the latest version first.', 'memberpress'),
                        $table
                    )
                );
            }
        }

        if (MeprUtils::is_addon_active(MeprUtils::ADDON_COURSES)) {
            if (version_compare(memberpress\courses\VERSION, '1.3.6', '<=')) {
                wp_send_json_error(
                    // phpcs:ignore Generic.Files.LineLength.TooLong
                    __('Please update the MemberPress Courses plugin to the latest version and try again.', 'memberpress')
                );
            } else {
                if (
                    empty($data['retry-quizzes']) &&
                    !MeprUtils::is_addon_active(MeprUtils::ADDON_COURSE_QUIZZES) &&
                    file_exists(WP_PLUGIN_DIR . '/memberpress-course-quizzes/main.php')
                ) {
                    activate_plugin('memberpress-course-quizzes/main.php');

                    if (
                        MeprUtils::is_addon_active(MeprUtils::ADDON_COURSE_QUIZZES) &&
                        function_exists('memberpress\\quizzes\\init') &&
                        defined('LEARNDASH_VERSION') &&
                        class_exists('LearnDash_Settings_Section') &&
                        method_exists('LearnDash_Settings_Section', 'get_section_setting')
                    ) {
                        $options = get_option('mpcs-options');
                        $options = is_array($options) ? $options : [];
                        $update  = false;

                        $quizzes_slug = LearnDash_Settings_Section::get_section_setting(
                            'LearnDash_Settings_Section_Permalinks',
                            'quizzes'
                        );

                        memberpress\quizzes\init();

                        if ($quizzes_slug === App::get_quizzes_permalink_base()) {
                            $options['quizzes-slug'] = 'mp-quizzes';
                            $update                  = true;
                        }

                        if ($update) {
                            update_option('mpcs-options', $options);
                            delete_option('mepr_courses_flushed_rewrite_rules');
                        }

                        $this->send_success_response($data, [
                            'step'          => 'start',
                            'retry-quizzes' => 'true',
                        ]);
                    }
                }
            }
        } else {
            if (file_exists(WP_PLUGIN_DIR . '/memberpress-courses/main.php')) {
                if (empty($data['retry'])) {
                    activate_plugin('memberpress-courses/main.php');

                    if (
                        MeprUtils::is_addon_active(MeprUtils::ADDON_COURSES) &&
                        version_compare(memberpress\courses\VERSION, '1.3.6', '>') &&
                        defined('LEARNDASH_VERSION') &&
                        class_exists('LearnDash_Settings_Section') &&
                        method_exists('LearnDash_Settings_Section', 'get_section_setting')
                    ) {
                        // Handles any conflicts with post type slugs between LearnDash and MemberPress Courses.
                        // If any slugs are the same, we'll set a different slug to avoid breaking existing courses.
                        $courses_slug = LearnDash_Settings_Section::get_section_setting(
                            'LearnDash_Settings_Section_Permalinks',
                            'courses'
                        );

                        $lessons_slug = LearnDash_Settings_Section::get_section_setting(
                            'LearnDash_Settings_Section_Permalinks',
                            'lessons'
                        );

                        $options = get_option('mpcs-options');
                        $options = is_array($options) ? $options : [];
                        $update  = false;

                        if ($courses_slug === Courses::get_permalink_base()) {
                            $options['courses-slug'] = 'learn';
                            $update                  = true;
                        }

                        if ($lessons_slug === Lessons::get_permalink_base()) {
                            $options['lessons-slug'] = 'mp-lessons';
                            $update                  = true;
                        }

                        if ($update) {
                            update_option('mpcs-options', $options);
                            delete_option('mepr_courses_flushed_rewrite_rules');
                        }
                    }

                    $this->send_success_response($data, [
                        'step'  => 'start',
                        'retry' => 'true',
                    ]);
                }

                wp_send_json_error(
                    __('Please activate the MemberPress Courses plugin and try again.', 'memberpress')
                );
            } else {
                wp_send_json_error(
                    __('Please install and activate the MemberPress Courses plugin and try again.', 'memberpress')
                );
            }
        }

        $this->send_success_response($data, ['step' => 'taxonomies']);
    }

    /**
     * Get the enabled LearnDash taxonomies.
     *
     * @return array
     */
    protected function get_enabled_taxonomies(): array
    {
        $settings   = get_option('learndash_settings_courses_taxonomies');
        $taxonomies = [];

        if (is_array($settings)) {
            if (!empty($settings['ld_course_category']) && $settings['ld_course_category'] === 'yes') {
                $taxonomies['ld_course_category'] = CourseCategories::$tax;
            }

            if (!empty($settings['wp_post_category']) && $settings['wp_post_category'] === 'yes') {
                $taxonomies['category'] = CourseCategories::$tax;
            }

            if (!empty($settings['ld_course_tag']) && $settings['ld_course_tag'] === 'yes') {
                $taxonomies['ld_course_tag'] = CourseTags::$tax;
            }

            if (!empty($settings['wp_post_tag']) && $settings['wp_post_tag'] === 'yes') {
                $taxonomies['post_tag'] = CourseTags::$tax;
            }
        }

        return $taxonomies;
    }

    /**
     * Migrate course taxonomies.
     *
     * @param array $data The data for the current request.
     */
    protected function migrate_taxonomies(array $data)
    {
        $taxonomies = $this->get_enabled_taxonomies();

        if (count($taxonomies)) {
            self::before_start();

            [$limit, $cursor] = $this->get_request_limit_cursor($data, 10);

            $tax_slugs = array_keys($taxonomies);

            $processor = new class ($limit, $cursor, $tax_slugs) extends MeprBatchMigrator {
                /**
                 * The taxonomy slugs to fetch terms for.
                 *
                 * @var string[]
                 */
                private array $tax_slugs;

                /**
                 * Initializes the subclass with limit, cursor, and taxonomy slugs.
                 *
                 * @param integer  $limit     The batch size limit.
                 * @param integer  $cursor    The starting cursor (last seen term_taxonomy_id); 0 means start from the beginning.
                 * @param string[] $tax_slugs The taxonomy slugs to paginate.
                 */
                public function __construct(int $limit, int $cursor, array $tax_slugs)
                {
                    parent::__construct($limit, $cursor);
                    $this->tax_slugs = $tax_slugs;
                }

                /**
                 * Fetches the next batch of taxonomy terms using keyset pagination on term_taxonomy_id.
                 * Keying on term_taxonomy_id (the join-table PK) rather than term_id avoids skipping
                 * duplicate rows when a term is shared across multiple taxonomies and LIMIT cuts
                 * inside the duplicate group.
                 *
                 * @return array
                 */
                protected function fetch_batch(): array
                {
                    global $wpdb;
                    $placeholders = implode(',', array_fill(0, count($this->tax_slugs), '%s'));
                    $args         = array_merge(
                        $this->tax_slugs,
                        [$this->cursor, $this->limit]
                    );

                    // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
                    return $wpdb->get_results(
                        $wpdb->prepare(
                            "SELECT tt.term_taxonomy_id, t.term_id, t.name, t.slug, tt.description, tt.taxonomy
                             FROM {$wpdb->terms} t
                             INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_id = t.term_id
                             WHERE tt.taxonomy IN ($placeholders)
                               AND tt.term_taxonomy_id > %d
                             ORDER BY tt.term_taxonomy_id ASC
                             LIMIT %d",
                            ...$args
                        )
                    ) ?: [];
                    // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
                }

                /**
                 * The keyset cursor column — the term-taxonomy join-table PK.
                 *
                 * @return string
                 */
                protected function cursor_column(): string
                {
                    return 'term_taxonomy_id';
                }
            };

            while ($processor->next_batch()) {
                /**
                 * Terms to migrate.
                 *
                 * @var WP_Term[] $terms
                 */
                $terms = $processor->get_items();

                foreach ($terms as $term) {
                    if (!empty($taxonomies[$term->taxonomy])) {
                        wp_insert_term($term->name, $taxonomies[$term->taxonomy], [
                            'description' => $term->description,
                            'slug'        => $term->slug,
                        ]);
                    }
                }
            }

            self::finish();

            if ($processor->has_items()) {
                $this->send_success_response($data, [
                    'step'   => 'taxonomies',
                    'cursor' => $processor->get_cursor(),
                    'limit'  => $processor->get_limit(),
                ]);
            }
        }

        $this->send_success_response($data, [
            'step' => 'courses',
            'logs' => $this->logs,
        ]);
    }

    /**
     * Migrate courses.
     *
     * @param array $data The data for the current request.
     *
     * @return void
     */
    protected function migrate_courses(array $data)
    {
        self::before_start();

        [$limit, $cursor] = $this->get_request_limit_cursor($data, 1);

        $processor = new class ($limit, $cursor) extends MeprBatchMigrator {
            /**
             * Fetches the next batch of published LearnDash courses using keyset pagination on ID.
             *
             * @return array
             */
            protected function fetch_batch(): array
            {
                global $wpdb;
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery
                return $wpdb->get_results(
                    $wpdb->prepare(
                        "SELECT * FROM {$wpdb->posts}
                         WHERE post_type = 'sfwd-courses'
                           AND post_status = 'publish'
                           AND ID > %d
                         ORDER BY ID ASC
                         LIMIT %d",
                        $this->cursor,
                        $this->limit
                    )
                ) ?: [];
            }

            /**
             * The keyset cursor column — the posts table PK.
             *
             * @return string
             */
            protected function cursor_column(): string
            {
                return 'ID';
            }
        };

        while ($processor->next_batch()) {
            $courses = $processor->get_items();

            foreach ($courses as $ld_course) {
                $course_data = [
                    'post_title'    => $ld_course->post_title,
                    'post_content'  => $ld_course->post_content,
                    'post_excerpt'  => $ld_course->post_excerpt,
                    'post_name'     => $ld_course->post_name,
                    'post_date'     => $ld_course->post_date,
                    'post_date_gmt' => $ld_course->post_date_gmt,
                    'post_status'   => 'publish',
                ];

                $settings = get_post_meta($ld_course->ID, '_sfwd-courses', true);

                if (is_array($settings)) {
                    if (
                        !empty($settings['sfwd-courses_course_materials_enabled']) &&
                        $settings['sfwd-courses_course_materials_enabled'] === 'on' &&
                        !empty($settings['sfwd-courses_course_materials'])
                    ) {
                        $resources = Courses::get_default_resources();

                        $resources['sections']['custom'] = [
                            'id'    => 'custom',
                            'label' => '',
                            'items' => ['custom'],
                        ];

                        $resources['items']['custom'] = [
                            'id'      => 'custom',
                            'content' => $settings['sfwd-courses_course_materials'],
                        ];

                        $course_data['resources'] = wp_json_encode($resources);
                    } else {
                        $course_data['resources'] = '';
                    }

                    $disable_progression = !empty($settings['sfwd-courses_course_disable_lesson_progression']) &&
                                 $settings['sfwd-courses_course_disable_lesson_progression'] === 'on';

                    $course_data['require_previous'] = $disable_progression ? 'disabled' : 'enabled';
                }

                try {
                    /**
                     * Course to migrate.
                     *
                     * @var Course $course
                     */
                    $course = $this->migrate_model(
                        Course::class,
                        $this->get_existing_course_id((int) $ld_course->ID),
                        $course_data
                    );

                    update_post_meta($course->ID, '_mepr_learndash_course_id', $ld_course->ID);

                    $this->migrate_course_taxonomies($course->ID, $ld_course->ID);

                    $this->migrate_lessons($course, $ld_course);
                } catch (Exception $e) {
                    $this->logs[] = $this->model_migration_failed_log(
                        Course::class,
                        !empty($course_data['post_title']) ? $course_data['post_title'] : __('Untitled', 'memberpress'),
                        $ld_course->ID,
                        $e->getMessage()
                    );
                }
            }
        }

        self::finish();

        if ($processor->has_items()) {
            $this->send_success_response($data, [
                'step'   => 'courses',
                'cursor' => $processor->get_cursor(),
                'limit'  => $processor->get_limit(),
                'logs'   => $this->logs,
            ]);
        }

        if (empty($data['options']['userProgress'])) {
            update_option('mepr_migrator_learndash_completed', true);

            $this->send_success_response($data, [
                'status' => 'complete',
                'logs'   => $this->logs,
            ]);
        } else {
            $this->send_success_response($data, [
                'step' => 'progress',
                'logs' => $this->logs,
            ]);
        }
    }

    /**
     * Get the ID of the Courses course that has been previously migrated for the given LearnDash course ID.
     *
     * @param  integer $ld_course_id The LearnDash course ID.
     * @return integer
     */
    protected function get_existing_course_id(int $ld_course_id): int
    {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery
        return (int) $wpdb->get_var(
            $wpdb->prepare(
                "
                SELECT p.ID
                FROM $wpdb->posts p
                INNER JOIN $wpdb->postmeta pm ON pm.post_id = p.ID AND pm.meta_key = '_mepr_learndash_course_id'
                WHERE p.post_type = %s
                AND p.post_status = 'publish'
                AND pm.meta_value = %d
                ",
                Course::$cpt,
                $ld_course_id
            )
        );
    }

    /**
     * Build a flat map of LearnDash course ID to Courses course ID for every migrated Courses course.
     *
     * Mirrors the per-row resolution that get_existing_course_id() does, but emits
     * the whole table at once so callers can resolve LearnDash course IDs to Courses course IDs
     * via a single PHP array lookup instead of one correlated subquery per activity row.
     *
     * @return array<int,int> A map keyed by LearnDash course ID with the Courses course ID as the value.
     */
    protected function build_course_id_map(): array
    {
        global $wpdb;

        // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                "
                SELECT pm.meta_value AS ld_course_id, p.ID AS courses_course_id
                FROM $wpdb->posts p
                INNER JOIN $wpdb->postmeta pm ON pm.post_id = p.ID AND pm.meta_key = '_mepr_learndash_course_id'
                WHERE p.post_type = %s
                AND p.post_status = 'publish'
                ",
                Course::$cpt
            )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared

        // $wpdb returns columns as strings; cast the key and value to int so the
        // map is integer-typed for callers.
        $map = [];
        foreach ($rows ?: [] as $row) {
            $map[(int) $row->ld_course_id] = (int) $row->courses_course_id;
        }

        return $map;
    }

    /**
     * Build a nested map of (LearnDash course ID, LearnDash lesson/quiz ID) to Courses post ID for every migrated Courses post of the given type.
     *
     * Mirrors the get_existing_lesson_id() per-row resolution but emits the whole
     * table at once. The composite key is load-bearing — LearnDash Shared Course
     * Steps reuse one LearnDash lesson POST across multiple LearnDash courses, and the migrator
     * un-shares this in migrate_courses (one Courses post per (LearnDash course, LearnDash lesson)
     * pair, with both _mepr_learndash_course_id AND _mepr_learndash_lesson_id
     * postmeta written together — see the update_post_meta pair around the
     * migrate_lessons write site). A flat lesson-keyed map would silently collapse
     * those legitimate duplicates and misattribute progress for shared lessons.
     *
     * @param  string $courses_post_type The Courses post type slug (Lesson::$cpt or Quiz::$cpt).
     * @return array<int,array<int,int>> A map indexed first by LearnDash course ID, then by LearnDash lesson/quiz ID, with the Courses post ID as the value.
     */
    protected function build_composite_id_map(string $courses_post_type): array
    {
        global $wpdb;

        // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                "
                SELECT pm1.meta_value AS ld_course_id,
                       pm2.meta_value AS ld_lesson_id,
                       p.ID           AS courses_post_id
                FROM $wpdb->posts p
                INNER JOIN $wpdb->postmeta pm1 ON pm1.post_id = p.ID AND pm1.meta_key = '_mepr_learndash_course_id'
                INNER JOIN $wpdb->postmeta pm2 ON pm2.post_id = p.ID AND pm2.meta_key = '_mepr_learndash_lesson_id'
                WHERE p.post_type = %s
                AND p.post_status = 'publish'
                ",
                $courses_post_type
            )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared

        // $wpdb returns columns as strings; cast the keys and values to int so the
        // map is integer-typed for callers at both levels.
        $map = [];
        foreach ($rows ?: [] as $row) {
            $map[(int) $row->ld_course_id][(int) $row->ld_lesson_id] = (int) $row->courses_post_id;
        }

        return $map;
    }

    /**
     * Build a nested map of (LearnDash course ID, LearnDash topic ID) to Courses parent lesson ID.
     *
     * The LearnDash `lesson_id` postmeta on a sfwd-topic post points at its LearnDash parent
     * lesson; we then resolve that parent to the Courses lesson post that mirrors it.
     * The outer LearnDash-course dimension is a new pin compared to the previous per-row
     * resolver — the old correlated subquery filtered on _mepr_learndash_lesson_id
     * only, so on shared LearnDash parent lessons (one LearnDash parent reused across multiple
     * LearnDash courses, un-shared into per-course Courses lessons by migrate_courses) it
     * picked one Courses parent arbitrarily via LIMIT 1, independent of which course
     * the activity belonged to. Indexing on [ld_course_id][ld_topic_id] pins the
     * lookup to the Courses parent lesson belonging to the activity row's course.
     *
     * @return array<int,array<int,int>> A map indexed first by LearnDash course ID, then by LearnDash topic ID, with the Courses parent lesson ID as the value.
     */
    protected function build_topic_parent_lesson_map(): array
    {
        global $wpdb;

        // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                "
                SELECT courses_course_pm.meta_value AS ld_course_id,
                       pm_topic.post_id        AS ld_topic_id,
                       courses_lesson.ID            AS courses_parent_lesson_id
                FROM $wpdb->postmeta pm_topic
                INNER JOIN $wpdb->postmeta courses_lesson_pm
                  ON courses_lesson_pm.meta_value = pm_topic.meta_value
                 AND courses_lesson_pm.meta_key   = '_mepr_learndash_lesson_id'
                INNER JOIN $wpdb->posts courses_lesson
                  ON courses_lesson.ID          = courses_lesson_pm.post_id
                 AND courses_lesson.post_type   = %s
                 AND courses_lesson.post_status = 'publish'
                INNER JOIN $wpdb->postmeta courses_course_pm
                  ON courses_course_pm.post_id = courses_lesson.ID
                 AND courses_course_pm.meta_key = '_mepr_learndash_course_id'
                WHERE pm_topic.meta_key = 'lesson_id'
                ",
                Lesson::$cpt
            )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared

        // $wpdb returns columns as strings; cast the keys and values to int so the
        // map is integer-typed for callers at both levels.
        $map = [];
        foreach ($rows ?: [] as $row) {
            $map[(int) $row->ld_course_id][(int) $row->ld_topic_id] = (int) $row->courses_parent_lesson_id;
        }

        return $map;
    }

    /**
     * Bulk-write a buffered list of user-progress rows in a single INSERT IGNORE.
     *
     * Each entry in $rows is a positional 5-tuple shaped
     * [int $user_id, int $lesson_id, int $course_id, string $created_at, string $completed_at].
     * Column order in the INSERT matches the tuple order verbatim — the table's
     * `id` (auto_increment) and `progress` (DEFAULT 100.00) columns are omitted so
     * the storage layer fills them with the same defaults the per-row store() path
     * relied on. Idempotency is handled by the UNIQUE KEY user_lesson_course on
     * the user-progress table: duplicate (user_id, lesson_id, course_id) tuples
     * become silent no-ops at INSERT-IGNORE time.
     *
     * The empty-buffer guard is the first statement — a batch where every row
     * was orphan-skipped must not emit `INSERT IGNORE ... VALUES ` (a syntax
     * error). A failed write is recorded to the migration log so a lost batch
     * surfaces in the migrator UI rather than passing silently.
     *
     * @param  array<int,array{0:int,1:int,2:int,3:string,4:string}> $rows The buffered (user_id, lesson_id, course_id, created_at, completed_at) tuples.
     * @return void
     */
    protected function bulk_insert_user_progress(array $rows): void
    {
        if (empty($rows)) {
            return;
        }

        global $wpdb;

        foreach (array_chunk($rows, self::BULK_INSERT_CHUNK) as $chunk) {
            $placeholders = implode(', ', array_fill(0, count($chunk), '(%d, %d, %d, %s, %s)'));
            $flat         = array_merge(...$chunk);

            // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            $result = $wpdb->query(
                $wpdb->prepare(
                    "INSERT IGNORE INTO {$wpdb->prefix}mpcs_user_progress
                        (user_id, lesson_id, course_id, created_at, completed_at)
                        VALUES {$placeholders}",
                    $flat
                )
            );
            // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared

            if (false === $result) {
                $this->log_bulk_write_failure("{$wpdb->prefix}mpcs_user_progress");
            }
        }
    }

    /**
     * Record a migration log entry when a bulk write fails, so a lost batch
     * surfaces in the migrator UI instead of passing silently.
     *
     * @param  string $table The table the failed write targeted.
     * @return void
     */
    private function log_bulk_write_failure(string $table): void
    {
        global $wpdb;

        $this->logs[] = sprintf(
            // Translators: %1$s: the database table, %2$s: the database error message.
            __('A batch write to %1$s failed during migration and was skipped: %2$s', 'memberpress'),
            $table,
            $wpdb->last_error
        );
    }

    /**
     * Fetch LearnDash activity metadata for a batch of activity IDs in one query,
     * pivoted to [activity_id][meta_key] => meta_value, so the attempts loop
     * resolves each row's metadata from memory instead of a per-row query.
     *
     * @param  array<int,int> $activity_ids The activity IDs in the current batch.
     * @return array<int,array<string,string>> Metadata indexed by activity ID then meta key.
     */
    protected function fetch_activity_meta_map(array $activity_ids): array
    {
        if (empty($activity_ids)) {
            return [];
        }

        global $wpdb;
        $placeholders = implode(', ', array_fill(0, count($activity_ids), '%d'));

        // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT activity_id, activity_meta_key, activity_meta_value
                FROM {$wpdb->prefix}learndash_user_activity_meta
                WHERE activity_id IN ({$placeholders})",
                $activity_ids
            )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared

        $map = [];
        foreach ($rows ?: [] as $row) {
            $map[(int) $row->activity_id][$row->activity_meta_key] = $row->activity_meta_value;
        }

        return $map;
    }

    /**
     * Build the set of LearnDash activity IDs that are the best-score attempt for their
     * (user, post, course) group, so migrate_attempts can keyset-paginate the
     * activity table with a flat query and skip the non-best attempts in PHP
     * instead of running a per-row correlated best-score subquery.
     *
     * Best = highest `percentage`, ties broken by highest activity_id (matching
     * the prior ORDER BY percentage DESC, activity_id DESC). The query is
     * set-based — a GROUP BY for the per-group max percentage joined back to the
     * activities at that max — and avoids window functions for MySQL 5.7 support.
     *
     * @return array<int,bool> Map of best activity_id => true.
     */
    protected function build_best_quiz_activity_ids(): array
    {
        global $wpdb;

        // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $ids = $wpdb->get_col(
            "SELECT MAX(ua.activity_id) AS best_activity_id
            FROM {$wpdb->prefix}learndash_user_activity ua
            INNER JOIN {$wpdb->prefix}learndash_user_activity_meta uam
              ON uam.activity_id = ua.activity_id AND uam.activity_meta_key = 'percentage'
            INNER JOIN (
                SELECT ua2.user_id, ua2.post_id, ua2.course_id,
                       MAX(CAST(uam2.activity_meta_value AS DECIMAL(10, 2))) AS max_pct
                FROM {$wpdb->prefix}learndash_user_activity ua2
                INNER JOIN {$wpdb->prefix}learndash_user_activity_meta uam2
                  ON uam2.activity_id = ua2.activity_id AND uam2.activity_meta_key = 'percentage'
                WHERE ua2.activity_type = 'quiz' AND ua2.activity_completed > 0 AND ua2.user_id > 0
                GROUP BY ua2.user_id, ua2.post_id, ua2.course_id
            ) m ON m.user_id = ua.user_id AND m.post_id = ua.post_id AND m.course_id = ua.course_id
               AND CAST(uam.activity_meta_value AS DECIMAL(10, 2)) = m.max_pct
            WHERE ua.activity_type = 'quiz' AND ua.activity_completed > 0 AND ua.user_id > 0
            GROUP BY ua.user_id, ua.post_id, ua.course_id"
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared

        $set = [];
        foreach ($ids ?: [] as $id) {
            $set[(int) $id] = true;
        }

        return $set;
    }

    /**
     * Bulk-insert the Courses quiz attempts for one batch and return a
     * (quiz_id:user_id) => attempt_id map covering every pair in the batch.
     *
     * Idempotency rides on a single batch-level existence pre-check rather than
     * a per-row Attempt::get_one(): mpcs_attempts has no unique key on
     * (quiz_id, user_id) — the product allows multiple attempts per user per
     * quiz — so pairs already present are filtered out before the INSERT. The
     * migrator collapses each LearnDash (user, quiz) to one best-score Courses attempt, so
     * there is exactly one buffered row per pair.
     *
     * @param  array<int,array{quiz_id:int,user_id:int,points_awarded:int,points_possible:int,score:int,started_at:string,finished_at:string}> $rows The buffered attempt rows.
     * @return array<string,int> Map of "quiz_id:user_id" to the Courses attempt ID.
     */
    protected function bulk_insert_attempts(array $rows): array
    {
        if (empty($rows)) {
            return [];
        }

        global $wpdb;
        $table = "{$wpdb->prefix}mpcs_attempts";

        // Row-value tuple list reused for both (quiz_id, user_id) IN (...) lookups.
        $tuple_placeholders = implode(', ', array_fill(0, count($rows), '(%d, %d)'));
        $tuple_args         = [];
        foreach ($rows as $row) {
            $tuple_args[] = $row['quiz_id'];
            $tuple_args[] = $row['user_id'];
        }

        // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
        $existing = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT quiz_id, user_id FROM {$table} WHERE (quiz_id, user_id) IN ({$tuple_placeholders})",
                $tuple_args
            )
        );

        $existing_set = [];
        foreach ($existing ?: [] as $row) {
            $existing_set[$row->quiz_id . ':' . $row->user_id] = true;
        }

        $value_placeholders = [];
        $insert_args        = [];
        foreach ($rows as $row) {
            if (isset($existing_set[$row['quiz_id'] . ':' . $row['user_id']])) {
                continue;
            }

            $value_placeholders[] = '(%d, %d, %s, %d, %d, %d, %s, %s)';
            array_push(
                $insert_args,
                $row['quiz_id'],
                $row['user_id'],
                Attempt::$complete_str,
                $row['points_awarded'],
                $row['points_possible'],
                $row['score'],
                $row['started_at'],
                $row['finished_at']
            );
        }

        if (!empty($value_placeholders)) {
            $result = $wpdb->query(
                $wpdb->prepare(
                    "INSERT INTO {$table}
                        (quiz_id, user_id, status, points_awarded, points_possible, score, started_at, finished_at)
                        VALUES " . implode(', ', $value_placeholders),
                    $insert_args
                )
            );

            if (false === $result) {
                $this->log_bulk_write_failure($table);
            }
        }

        // Re-query the full batch so the answer pass can resolve attempt IDs for
        // both freshly-inserted and pre-existing pairs. GROUP BY collapses each
        // pair to its highest id in the engine, so at most one row per pair is
        // fetched rather than every duplicate.
        $id_rows = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT quiz_id, user_id, MAX(id) AS id
                    FROM {$table}
                    WHERE (quiz_id, user_id) IN ({$tuple_placeholders})
                    GROUP BY quiz_id, user_id",
                $tuple_args
            )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared

        return $this->resolve_attempt_id_map($id_rows ?: []);
    }

    /**
     * Key the re-queried attempt rows into a "quiz_id:user_id" => attempt_id map
     * for the answer pass.
     *
     * The caller aggregates the re-query with GROUP BY quiz_id, user_id and
     * MAX(id), so each pair arrives as a single row already resolved to its
     * highest id — the attempt the answer pass must attach to. This helper only
     * keys and int-casts those rows.
     *
     * @param  array<int,object> $id_rows One row per pair, holding id, quiz_id and user_id.
     * @return array<string,int> Map of "quiz_id:user_id" to the attempt id.
     */
    protected function resolve_attempt_id_map(array $id_rows): array
    {
        $id_map = [];
        foreach ($id_rows as $row) {
            $id_map[$row->quiz_id . ':' . $row->user_id] = (int) $row->id;
        }

        return $id_map;
    }

    /**
     * Bulk-insert a batch of answer rows.
     *
     * Idempotent via the mpcs_answers UNIQUE KEY (attempt_id, question_id):
     * ON DUPLICATE KEY UPDATE rewrites an existing answer rather than failing,
     * mirroring Answer::insert_or_replace_answer but for a whole batch in one
     * statement. Chunked so a single INSERT stays under max_allowed_packet.
     *
     * @param  array<int,array{attempt_id:int,question_id:int,answer:string,points_possible:int,points_awarded:int,grader:int,answered_at:string,graded_at:string}> $rows The buffered answer rows.
     * @return void
     */
    protected function bulk_insert_answers(array $rows): void
    {
        if (empty($rows)) {
            return;
        }

        global $wpdb;
        $table = "{$wpdb->prefix}mpcs_answers";

        foreach (array_chunk($rows, self::BULK_INSERT_CHUNK) as $chunk) {
            $placeholders = [];
            $args         = [];

            foreach ($chunk as $row) {
                $placeholders[] = '(%d, %d, %s, %d, %d, %d, %s, %s)';
                array_push(
                    $args,
                    $row['attempt_id'],
                    $row['question_id'],
                    $row['answer'],
                    $row['points_possible'],
                    $row['points_awarded'],
                    $row['grader'],
                    $row['answered_at'],
                    $row['graded_at']
                );
            }

            // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
            $result = $wpdb->query(
                $wpdb->prepare(
                    "INSERT INTO {$table}
                        (attempt_id, question_id, answer, points_possible, points_awarded, grader, answered_at, graded_at)
                        VALUES " . implode(', ', $placeholders) . '
                    ON DUPLICATE KEY UPDATE
                        answer = VALUES(answer),
                        points_possible = VALUES(points_possible),
                        points_awarded = VALUES(points_awarded),
                        grader = VALUES(grader),
                        answered_at = VALUES(answered_at),
                        graded_at = VALUES(graded_at)',
                    $args
                )
            );
            // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared

            if (false === $result) {
                $this->log_bulk_write_failure($table);
            }
        }
    }

    /**
     * Write the answer rows for a batch of attempts.
     *
     * Only attempts whose LearnDash activity carried a statistic_ref_id have answers.
     * The statistics for the whole batch are fetched in one IN (...) query and
     * written with one bulk INSERT (idempotent via the mpcs_answers UNIQUE KEY),
     * keyed to the attempt IDs resolved by bulk_insert_attempts().
     *
     * @param  array<int,array>  $attempt_rows   The buffered attempt rows for the batch.
     * @param  array<string,int> $attempt_id_map Map of "quiz_id:user_id" to Courses attempt ID.
     * @param  integer           $grader_user_id The grader user ID for migrated answers.
     * @return void
     */
    protected function migrate_attempt_answers(array $attempt_rows, array $attempt_id_map, int $grader_user_id): void
    {
        global $wpdb;

        // Resolve every attempt in this batch that carries answer statistics to
        // its Courses attempt ID, keyed by statistic_ref_id for the batched fetch.
        $refs = [];

        foreach ($attempt_rows as $row) {
            $statistic_ref_id = (int) $row['statistic_ref_id'];

            if ($statistic_ref_id === 0) {
                continue;
            }

            $attempt_id = (int) ($attempt_id_map[$row['quiz_id'] . ':' . $row['user_id']] ?? 0);

            if ($attempt_id === 0) {
                continue;
            }

            $refs[$statistic_ref_id] = [
                'attempt_id'  => $attempt_id,
                'quiz_id'     => (int) $row['quiz_id'],
                'user_id'     => (int) $row['user_id'],
                'answered_at' => $row['finished_at'],
            ];
        }

        if (empty($refs)) {
            return;
        }

        // One query for every answer statistic in the batch, instead of one per attempt.
        $ref_ids      = array_keys($refs);
        $placeholders = implode(', ', array_fill(0, count($ref_ids), '%d'));

        // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $statistics = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT s.statistic_ref_id, s.question_id, s.answer_data, s.points, q.points AS points_possible, q.answer_type
                FROM {$wpdb->prefix}learndash_pro_quiz_statistic s
                INNER JOIN {$wpdb->prefix}learndash_pro_quiz_question q ON q.id = s.question_id
                WHERE s.statistic_ref_id IN ($placeholders)
                AND q.online = 1",
                $ref_ids
            ),
            ARRAY_A
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared

        // Cache each quiz's LearnDash-question -> Courses-question map; statistics share a quiz.
        $migrated_questions = [];
        $answer_rows        = [];

        foreach ($statistics as $statistic) {
            $ref = $refs[(int) $statistic['statistic_ref_id']] ?? null;

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

            $quiz_id = $ref['quiz_id'];

            if (!isset($migrated_questions[$quiz_id])) {
                $map                          = get_post_meta($quiz_id, '_mepr_learndash_migrated_questions', true);
                $migrated_questions[$quiz_id] = is_array($map) ? $map : [];
            }

            $ld_question_id = $statistic['question_id'];
            $question_id    = !empty($migrated_questions[$quiz_id][$ld_question_id])
                ? (int) $migrated_questions[$quiz_id][$ld_question_id]
                : 0;

            if ($question_id === 0) {
                continue;
            }

            $answer_rows[] = [
                'attempt_id'      => $ref['attempt_id'],
                'question_id'     => $question_id,
                'answer'          => maybe_serialize($this->get_answer_value($statistic, $ref['user_id'], $question_id)),
                'points_possible' => (int) $statistic['points_possible'],
                'points_awarded'  => (int) $statistic['points'],
                'grader'          => $grader_user_id,
                'answered_at'     => $ref['answered_at'],
                'graded_at'       => $ref['answered_at'],
            ];
        }

        $this->bulk_insert_answers($answer_rows);
    }

    /**
     * Migrate the taxonomies for the given course.
     *
     * @param integer $course_id    The MemberPress course ID.
     * @param integer $ld_course_id The LearnDash course ID.
     *
     * @return void
     */
    protected function migrate_course_taxonomies($course_id, $ld_course_id)
    {
        $taxonomies = $this->get_enabled_taxonomies();

        if (count($taxonomies)) {
            $categories = array_values(
                array_intersect(
                    array_keys($taxonomies),
                    ['ld_course_category', 'category']
                )
            );

            if (!empty($categories)) {
                $terms = wp_get_object_terms($ld_course_id, $categories, ['fields' => 'slugs']);

                if (is_array($terms)) {
                    wp_set_object_terms($course_id, $terms, CourseCategories::$tax);
                }
            }

            $tags = array_values(
                array_intersect(
                    array_keys($taxonomies),
                    ['ld_course_tag', 'post_tag']
                )
            );

            if (!empty($tags)) {
                $terms = wp_get_object_terms($ld_course_id, $tags, ['fields' => 'slugs']);

                if (is_array($terms)) {
                    wp_set_object_terms($course_id, $terms, CourseTags::$tax);
                }
            }
        }
    }

    /**
     * Migrate the lessons and quizzes for the given course.
     *
     * @param Course   $course    The course object.
     * @param stdClass $ld_course The LearnDash course object.
     *
     * @throws Exception If migration fails.
     */
    protected function migrate_lessons(Course $course, $ld_course)
    {
        $sections = $this->populate_course_sections(
            $ld_course->ID,
            $this->get_course_sections($ld_course->ID)
        );

        $existing_sections = $this->filter_existing_sections(
            $course->sections(),
            wp_list_pluck($sections, 'uuid')
        );

        $lessons_by_section = [];

        foreach (array_values($sections) as $section_order => $section_data) {
            $section_id = $existing_sections[$section_data['uuid']] ?? 0;

            $section                = new Section($section_id);
            $section->title         = $section_data['title'];
            $section->course_id     = $course->ID;
            $section->section_order = $section_order;
            $section->uuid          = $section_data['uuid'];

            $section_id = $section->store();

            if ($section_id instanceof WP_Error || empty($section_id)) {
                $this->logs[] = sprintf(
                    // Translators: %1$s: section title, %2$s: error message.
                    __('Failed to migrate course section "%1$s": %2$s', 'memberpress'),
                    $section_data['title'],
                    $section_id instanceof WP_Error ? $section_id->get_error_message() : 'section ID was empty'
                );

                continue;
            }

            $lessons_by_section[$section_id] = [];

            if (count($section_data['children'])) {
                foreach ($section_data['children'] as $lesson_order => $ld_lesson_id) {
                    $ld_lesson = get_post($ld_lesson_id);

                    if ($ld_lesson instanceof WP_Post) {
                        $post_types = ['sfwd-lessons', 'sfwd-topic'];

                        if (MeprUtils::is_addon_active(MeprUtils::ADDON_COURSE_QUIZZES)) {
                            $post_types[] = 'sfwd-quiz';
                        }

                        if (!in_array($ld_lesson->post_type, $post_types, true)) {
                            continue;
                        }

                        $post_content = $ld_lesson->post_content;
                        $materials    = $this->get_lesson_materials($ld_lesson);

                        if (!empty($materials)) {
                            $post_content .= "\n\n";
                            $post_content .= sprintf('<h3>%s</h3>', esc_html__('Materials', 'memberpress'));
                            $post_content .= "\n\n";
                            $post_content .= $materials;
                        }

                        $lesson_data = [
                            'post_title'    => $ld_lesson->post_title,
                            'post_content'  => $post_content,
                            'post_excerpt'  => $ld_lesson->post_excerpt,
                            'post_name'     => $ld_lesson->post_name,
                            'post_date'     => $ld_lesson->post_date,
                            'post_date_gmt' => $ld_lesson->post_date_gmt,
                            'post_status'   => 'publish',
                            'section_id'    => $section_id,
                            'lesson_order'  => $lesson_order,
                        ];

                        try {
                            $model = $this->migrate_model(
                                $ld_lesson->post_type === 'sfwd-quiz' ? Quiz::class : Lesson::class,
                                $this->get_existing_lesson_id(
                                    $ld_course->ID,
                                    $ld_lesson->ID,
                                    $ld_lesson->post_type === 'sfwd-quiz' ? Quiz::$cpt : Lesson::$cpt
                                ),
                                $lesson_data
                            );

                            update_post_meta($model->ID, '_mepr_learndash_course_id', $ld_course->ID);
                            update_post_meta($model->ID, '_mepr_learndash_lesson_id', $ld_lesson->ID);

                            $lessons_by_section[$section_id][] = $model->ID;

                            if ($model instanceof Quiz) {
                                $this->migrate_quiz($model, $ld_lesson);
                            }
                        } catch (Exception $e) {
                            if (!empty($lesson_data['post_title'])) {
                                $title = $lesson_data['post_title'];
                            } else {
                                $title = __('Untitled', 'memberpress');
                            }

                            $this->logs[] = $this->model_migration_failed_log(
                                $ld_lesson->post_type === 'sfwd-quiz' ? Quiz::class : Lesson::class,
                                $title,
                                $ld_lesson->ID,
                                $e->getMessage()
                            );
                        }
                    }
                }
            }

            foreach ($lessons_by_section as $section_id => $lesson_ids) {
                $section = new Section($section_id);

                foreach ($section->lessons() as $lesson) {
                    if (!in_array($lesson->ID, $lesson_ids, true)) {
                        $lesson->remove_from_section();
                    }
                }
            }
        }
    }

    /**
     * Get the materials for an LearnDash Lesson, Topic or Quiz (if any).
     *
     * @param  WP_Post $ld_lesson The LearnDash lesson, topic or quiz post instance.
     * @return string|null
     */
    protected function get_lesson_materials($ld_lesson)
    {
        $settings = get_post_meta($ld_lesson->ID, "_$ld_lesson->post_type", true);

        if (is_array($settings)) {
            switch ($ld_lesson->post_type) {
                case 'sfwd-lessons':
                    $key = "{$ld_lesson->post_type}_lesson";
                    break;
                case 'sfwd-topic':
                    $key = "{$ld_lesson->post_type}_topic";
                    break;
                case 'sfwd-quiz':
                    $key = "{$ld_lesson->post_type}_quiz";
                    break;
                default:
                    return null;
            }

            if (
                !empty($settings["{$key}_materials_enabled"]) &&
                $settings["{$key}_materials_enabled"] === 'on' &&
                !empty($settings["{$key}_materials"])
            ) {
                return $settings["{$key}_materials"];
            }
        }

        return null;
    }

    /**
     * Migrate a single quiz and the questions within it.
     *
     * @param Quiz    $quiz      The Courses quiz instance.
     * @param WP_Post $ld_lesson The LearnDash Quiz post instance.
     *
     * @throws Exception If migration fails.
     */
    protected function migrate_quiz(Quiz $quiz, $ld_lesson)
    {
        global $wpdb;

        $quiz_pro_id = get_post_meta($ld_lesson->ID, 'quiz_pro_id', true);

        if (is_numeric($quiz_pro_id)) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery
            $ld_questions = $wpdb->get_results(
                $wpdb->prepare(
                    "SELECT * FROM {$wpdb->prefix}learndash_pro_quiz_question
                    WHERE quiz_id = %d
                    AND online = 1
                    ORDER BY sort",
                    $quiz_pro_id
                )
            );

            if (!empty($ld_questions)) {
                $questions          = [];
                $required           = false;
                $settings           = get_post_meta($ld_lesson->ID, '_sfwd-quiz', true);
                $migrated_questions = get_post_meta($quiz->ID, '_mepr_learndash_migrated_questions', true);
                $migrated_questions = is_array($migrated_questions) ? $migrated_questions : [];

                if (is_array($settings)) {
                    $required = !empty($settings['sfwd-quiz_forcingQuestionSolve']);
                }

                foreach ($ld_questions as $i => $ld_question) {
                    if (!array_key_exists($ld_question->answer_type, self::QUESTION_TYPE_MAP)) {
                        continue;
                    }

                    $question_data = [
                        'quiz_id'  => $quiz->ID,
                        'number'   => $i + 1,
                        'text'     => wp_strip_all_tags($ld_question->question),
                        'type'     => self::QUESTION_TYPE_MAP[$ld_question->answer_type],
                        'required' => (int) $required,
                        'points'   => (int) $ld_question->points,
                        'feedback' => $ld_question->incorrect_msg,
                    ];

                    $answer_data = $this->parse_question_answer_data($ld_question->answer_data);

                    switch ($ld_question->answer_type) {
                        case 'single':
                            $question_data['options'] = [];

                            foreach ($answer_data as $index => $option) {
                                if (isset($option['_answer']) && is_string($option['_answer'])) {
                                    $question_data['options'][] = $option['_answer'];
                                } else {
                                    $question_data['options'][] = '';
                                }

                                if (!empty($option['_correct'])) {
                                    $question_data['answer'] = $index;
                                }
                            }

                            if (empty($question_data['options'])) {
                                      $question_data['options'][] = '';
                            }

                            if (!isset($question_data['answer']) || !is_numeric($question_data['answer'])) {
                                      $question_data['answer'] = 0;
                            }
                            break;
                        case 'multiple':
                            $question_data['options'] = [];
                            $question_data['answer']  = [];

                            foreach ($answer_data as $index => $option) {
                                if (isset($option['_answer']) && is_string($option['_answer'])) {
                                    $question_data['options'][] = $option['_answer'];
                                } else {
                                    $question_data['options'][] = '';
                                }

                                if (!empty($option['_correct'])) {
                                    $question_data['answer'][] = $index;
                                }
                            }

                            if (empty($question_data['options'])) {
                                $question_data['options'][] = '';
                            }

                            if (empty($question_data['answer'])) {
                                $question_data['answer'] = [0];
                            }
                            break;
                        case 'free_answer':
                        case 'essay':
                            // No additional processing required.
                            break;
                        case 'sort_answer':
                            $question_data['options'] = [];

                            foreach ($answer_data as $option) {
                                if (isset($option['_answer']) && is_string($option['_answer'])) {
                                    $question_data['options'][] = $option['_answer'];
                                } else {
                                    $question_data['options'][] = '';
                                }
                            }
                            break;
                        case 'matrix_sort_answer':
                            $question_data['options'] = [];
                            $question_data['answer']  = [];

                            foreach ($answer_data as $option) {
                                if (isset($option['_answer']) && is_string($option['_answer'])) {
                                    $question_data['options'][] = $option['_answer'];
                                } else {
                                    $question_data['options'][] = '';
                                }

                                if (isset($option['_sortString']) && is_string($option['_sortString'])) {
                                    $question_data['answer'][] = $option['_sortString'];
                                } else {
                                    $question_data['answer'][] = '';
                                }
                            }
                            break;
                        case 'cloze_answer':
                            if (isset($answer_data[0]['_answer']) && is_string($answer_data[0]['_answer'])) {
                                $answer = $answer_data[0]['_answer'];
                            } else {
                                $answer = '';
                            }

                            $question_data['answer'] = $this->convert_fill_blank_answer($answer);
                            break;
                        case 'assessment_answer':
                            if (isset($answer_data[0]['_answer']) && is_string($answer_data[0]['_answer'])) {
                                $answer = $answer_data[0]['_answer'];
                            } else {
                                $answer = '';
                            }

                            $likert_scale_answer = $this->convert_likert_scale_answer($answer);

                            list($question_data['options'], $question_data['settings']) = $likert_scale_answer;
                            break;
                        default:
                            continue 2;
                    }

                    if (!empty($migrated_questions[$ld_question->id])) {
                        $existing_id = $migrated_questions[$ld_question->id];
                    } else {
                        $existing_id = 0;
                    }

                    try {
                        $question                             = $this->migrate_model(Question::class, $existing_id, $question_data);
                        $questions[]                          = $question;
                        $migrated_questions[$ld_question->id] = $question->id;
                    } catch (Exception $e) {
                        $this->logs[] = $this->model_migration_failed_log(
                            Question::class,
                            !empty($question_data['text']) ? $question_data['text'] : __('Untitled', 'memberpress'),
                            $ld_question->id,
                            $e->getMessage()
                        );
                    }
                }

                $content = [];

                if ($quiz->post_content !== '' && count($questions)) {
                    $content[] = ''; // Adds two newlines below, after existing content.
                }

                foreach ($questions as $question) {
                    $block     = "<!-- wp:memberpress-courses/$question->type-question {\"questionId\":$question->id} /-->";
                    $content[] = $block;
                }

                $quiz->post_content = $quiz->post_content . implode("\n\n", $content);
                $quiz->store();

                update_post_meta($quiz->ID, '_mepr_learndash_migrated_questions', $migrated_questions);
            }
        }
    }

    /**
     * Get the ID of the Courses lesson or quiz that has been previously migrated for the given LearnDash lesson/topic/quiz ID.
     *
     * @param integer $ld_course_id The LearnDash course ID.
     * @param integer $ld_lesson_id The LearnDash lesson ID.
     * @param string  $post_type    The post type (lesson or quiz).
     *
     * @return integer The ID of the migrated lesson or quiz.
     */
    protected function get_existing_lesson_id($ld_course_id, $ld_lesson_id, $post_type): int
    {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery
        return (int) $wpdb->get_var(
            $wpdb->prepare(
                "
                SELECT p.ID
                FROM $wpdb->posts p
                INNER JOIN $wpdb->postmeta pm1 ON pm1.post_id = p.ID AND pm1.meta_key = '_mepr_learndash_course_id'
                INNER JOIN $wpdb->postmeta pm2 ON pm2.post_id = p.ID AND pm2.meta_key = '_mepr_learndash_lesson_id'
                WHERE p.post_type = %s
                AND p.post_status = 'publish'
                AND pm1.meta_value = %d
                AND pm2.meta_value = %d
                ",
                $post_type,
                $ld_course_id,
                $ld_lesson_id
            )
        );
    }

    /**
     * Get the array of course sections for the given LearnDash Course ID.
     *
     * @param  integer $ld_course_id The LearnDash course ID.
     * @return array
     */
    protected function get_course_sections($ld_course_id): array
    {
        $course_sections = get_post_meta($ld_course_id, 'course_sections', true);
        $sections        = [];

        if (!empty($course_sections)) {
            $course_sections = json_decode($course_sections);

            if (is_array($course_sections)) {
                foreach ($course_sections as $course_section) {
                    if (
                        is_object($course_section) &&
                        property_exists($course_section, 'ID') && !empty($course_section->ID) &&
                        property_exists($course_section, 'order') &&
                        property_exists($course_section, 'post_title') && !empty($course_section->post_title)
                    ) {
                        $sections[(int) $course_section->order] = [
                            'title'    => $course_section->post_title,
                            'uuid'     => substr("ld-course-$ld_course_id-" . dechex($course_section->ID), 0, 40),
                            'children' => [],
                        ];
                    }
                }

                // Sort sections in the order they appear.
                ksort($sections);

                if (key($sections) !== 0) {
                    // LearnDash can have lessons outside (before) a section, but this isn't possible in Courses. So we force the
                    // first section to start from index 0 to ensure that all lessons and quizzes are inside a section.
                    foreach ($sections as $key => $section) {
                        $sections[0] = $section;
                        unset($sections[$key]);
                        break;
                    }

                    // Re-sort sections after changing the key.
                    ksort($sections);
                }
            }
        }

        if (empty($sections)) {
            $sections[] = [
                'title'    => __('Lessons', 'memberpress'),
                'uuid'     => substr("ld-course-$ld_course_id-default", 0, 40),
                'children' => [],
            ];
        }

        return $sections;
    }

    /**
     * Populate each section with the IDs for lessons, topics and quizzes contained within it.
     *
     * @param integer $ld_course_id The LearnDash course ID.
     * @param array   $sections     The sections array.
     *
     * @return array The populated sections array.
     */
    protected function populate_course_sections($ld_course_id, $sections): array
    {
        $ld_course_steps = get_post_meta($ld_course_id, 'ld_course_steps', true);

        if (isset($ld_course_steps['steps']['h']) && is_array($ld_course_steps['steps']['h'])) {
            $steps = $ld_course_steps['steps']['h'];
        } else {
            $steps = [];
        }

        $lessons = isset($steps['sfwd-lessons']) && is_array($steps['sfwd-lessons']) ? $steps['sfwd-lessons'] : [];

        // Add the section dividers into the list of lessons.
        foreach ($sections as $order => $section) {
            $lessons = array_slice($lessons, 0, $order, true) +
            ["section-$order" => $order] +
            array_slice($lessons, $order, null, true);
        }

        // Flatten the course structure into the two-level structure supported by MemberPress.
        $key = 0;

        foreach ($lessons as $lesson_post_id => $ld_lesson) {
            if (is_string($lesson_post_id) && preg_match('/section-\d+/', $lesson_post_id)) {
                $key = $ld_lesson; // This is a section divider, move to the next section.
                continue;
            }

            $sections[$key]['children'][] = $lesson_post_id;

            // Topic that is a child of this ld_lesson.
            if (isset($ld_lesson['sfwd-topic']) && is_array($ld_lesson['sfwd-topic'])) {
                foreach ($ld_lesson['sfwd-topic'] as $topic_post_id => $topic) {
                    $sections[$key]['children'][] = $topic_post_id;

                    if (MeprUtils::is_addon_active(MeprUtils::ADDON_COURSE_QUIZZES)) {
                        // Quiz that is a child of this topic.
                        if (isset($topic['sfwd-quiz']) && is_array($topic['sfwd-quiz'])) {
                            foreach ($topic['sfwd-quiz'] as $quiz_post_id => $quiz) {
                                $sections[$key]['children'][] = $quiz_post_id;
                            }
                        }
                    }
                }
            }

            if (MeprUtils::is_addon_active(MeprUtils::ADDON_COURSE_QUIZZES)) {
                // Quiz that is a child of this ld_lesson.
                if (isset($ld_lesson['sfwd-quiz']) && is_array($ld_lesson['sfwd-quiz'])) {
                    foreach ($ld_lesson['sfwd-quiz'] as $quiz_post_id => $quiz) {
                        $sections[$key]['children'][] = $quiz_post_id;
                    }
                }
            }
        }

        if (MeprUtils::is_addon_active(MeprUtils::ADDON_COURSE_QUIZZES)) {
            // End of course quiz(zes).
            $quizzes = isset($steps['sfwd-quiz']) && is_array($steps['sfwd-quiz']) ? $steps['sfwd-quiz'] : [];

            foreach ($quizzes as $quiz_post_id => $quiz) {
                $sections[$key]['children'][] = $quiz_post_id;
            }
        }

        return $sections;
    }

    /**
     * Get the IDs of sections that exist from a previous migration, and delete sections that have been removed.
     *
     * @param  Section[] $course_sections The existing sections for the course.
     * @param  array     $uuids           The array of uuids for the sections that will be migrated.
     * @return array
     */
    protected function filter_existing_sections($course_sections, $uuids): array
    {
        $existing_sections = [];

        foreach ($course_sections as $existing_section) {
            if (in_array($existing_section->uuid, $uuids, true)) {
                $existing_sections[$existing_section->uuid] = $existing_section->id;
            } else {
                $existing_section->destroy();
            }
        }

        return $existing_sections;
    }

    /**
     * Parse the answer data for a LearnDash question and return an array of answer data.
     *
     * @param string $answer_data Serialized answer data.
     *
     * @return array The parsed answer data.
     */
    protected function parse_question_answer_data($answer_data): array
    {
        // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
        $answer_data = @unserialize($answer_data);
        $answers     = [];

        if (is_array($answer_data)) {
            foreach ($answer_data as $answer) {
                if ($answer instanceof WpProQuiz_Model_AnswerTypes) {
                    $answers[] = $answer->get_object_as_array();
                } elseif ($answer instanceof __PHP_Incomplete_Class) {
                    $values = [];

                    foreach ($answer as $k => $v) {
                        $values[$k] = $v;
                    }

                    $answers[] = $values;
                }
            }
        }

        return $answers;
    }

    /**
     * Convert the given fill in the blanks answer from LearnDash format to Courses format.
     *
     * @param string $answer The fill in the blanks answer.
     *
     * @return string The converted answer.
     */
    protected function convert_fill_blank_answer($answer): string
    {
        if (!is_string($answer)) {
            return '';
        }

        preg_match_all('#\{(.*?)}#im', $answer, $matches, PREG_SET_ORDER);

        foreach ($matches as $v) {
            $text    = $v[1];
            $answers = [];

            if (preg_match_all('#\[(.*?)]#im', $text, $multi_matches)) {
                foreach ($multi_matches[1] as $multi_text) {
                    if (strpos($multi_text, '|') !== false) {
                        list($multi_text) = explode('|', $multi_text); // Ignore per-answer points.
                    }

                    $answers[] = trim(html_entity_decode($multi_text, ENT_QUOTES));
                }
            } elseif (strpos($text, '|') !== false) {
                list($text) = explode('|', $text); // Ignore per-answer points.

                $answers[] = trim(html_entity_decode($text, ENT_QUOTES));
            } else {
                $answers[] = trim(html_entity_decode($text, ENT_QUOTES));
            }

            $pos = strpos($answer, $v[0]);

            if ($pos !== false) {
                $answer = substr_replace(
                    $answer,
                    sprintf('[%s]', implode(', ', $answers)),
                    $pos,
                    strlen($v[0])
                );
            }
        }

        return $answer;
    }

    /**
     * Convert the given Assessment answer to options and settings for the Likert Scale question in Courses.
     *
     * @param string $answer The assessment answer.
     *
     * @return array The options and settings for the Likert Scale question.
     */
    protected function convert_likert_scale_answer($answer)
    {
        $answer   = wp_strip_all_tags($answer);
        $options  = [];
        $settings = [
            'lowLabel'  => '',
            'highLabel' => '',
        ];

        if (!empty($answer)) {
            preg_match_all('#\{(.*?)}#im', $answer, $matches);

            if (!empty($matches[0][0]) && !empty($matches[1][0])) {
                $pos = strpos($answer, $matches[0][0]);

                $settings['lowLabel']  = trim(substr($answer, 0, $pos));
                $settings['highLabel'] = trim(substr($answer, $pos + strlen($matches[0][0])));

                preg_match_all('#\[([^|\]]+)]#im', $matches[1][0], $option_matches);

                if (!empty($option_matches[1])) {
                    foreach ($option_matches[1] as $option) {
                        $options[] = trim($option);
                    }
                }
            }
        }

        return [$options, $settings];
    }

    /**
     * Migrate user progress.
     *
     * @param array $data The data for the current request.
     */
    protected function migrate_progress(array $data)
    {
        self::before_start();

        [$limit, $cursor] = $this->get_request_limit_cursor($data, 10);

        $course_map        = $this->build_course_id_map();
        $lesson_map        = $this->build_composite_id_map(Lesson::$cpt);
        $parent_lesson_map = $this->build_topic_parent_lesson_map();

        $processor = new class ($limit, $cursor) extends MeprBatchMigrator {
            /**
             * Fetches the next batch of LearnDash lesson/topic activity rows using keyset pagination on activity_id.
             *
             * @return array
             */
            protected function fetch_batch(): array
            {
                global $wpdb;
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery
                return $wpdb->get_results(
                    $wpdb->prepare(
                        "SELECT
                        ua.activity_id,
                        ua.user_id,
                        ua.course_id,
                        ua.post_id,
                        ua.activity_type,
                        ua.activity_started,
                        ua.activity_completed
                        FROM {$wpdb->prefix}learndash_user_activity ua
                        WHERE ua.user_id > 0
                        AND ua.activity_type IN ('lesson', 'topic')
                        AND ua.activity_completed IS NOT NULL
                        AND ua.activity_completed > 0
                        AND ua.activity_id > %d
                        ORDER BY ua.activity_id ASC
                        LIMIT %d",
                        $this->cursor,
                        $this->limit
                    )
                ) ?: [];
            }

            /**
             * The keyset cursor column — the user-activity PK.
             *
             * @return string
             */
            protected function cursor_column(): string
            {
                return 'activity_id';
            }
        };

        while ($processor->next_batch()) {
            // A per-batch buffer that resets each next_batch() cycle and flushes
            // via one INSERT IGNORE at the bottom of the while body.
            $progress_rows = [];
            $user_activity = $processor->get_items();

            foreach ($user_activity as $activity) {
                $user_id   = (int) $activity->user_id;
                $course_id = (int) $activity->course_id;
                $post_id   = (int) $activity->post_id;

                $courses_course_id        = $course_map[$course_id] ?? null;
                $courses_lesson_id        = $lesson_map[$course_id][$post_id] ?? null;
                $courses_parent_lesson_id = $parent_lesson_map[$course_id][$post_id] ?? null;

                if ($courses_course_id === null) {
                    // Matches the current INNER JOIN drop-row semantics on missing course.
                    continue;
                }

                // Dedupe so a lesson that is its own parent does not buffer a duplicate progress row.
                $lesson_ids       = array_unique(array_filter([(int) $courses_lesson_id, (int) $courses_parent_lesson_id]));
                $created_at_str   = Utils::ts_to_mysql_date((int) $activity->activity_started);
                $completed_at_str = Utils::ts_to_mysql_date((int) $activity->activity_completed);

                foreach ($lesson_ids as $lesson_id) {
                    $progress_rows[] = [
                        $user_id,
                        (int) $lesson_id,
                        (int) $courses_course_id,
                        $created_at_str,
                        $completed_at_str,
                    ];
                }
            }

            $this->bulk_insert_user_progress($progress_rows);
        }

        self::finish();

        if ($processor->has_items()) {
            $this->send_success_response($data, [
                'step'   => 'progress',
                'cursor' => $processor->get_cursor(),
                'limit'  => $processor->get_limit(),
                'logs'   => $this->logs,
            ]);
        }

        if (!MeprUtils::is_addon_active(MeprUtils::ADDON_COURSE_QUIZZES)) {
            update_option('mepr_migrator_learndash_completed', true);

            $this->send_success_response($data, [
                'status' => 'complete',
                'logs'   => $this->logs,
            ]);
        } else {
            $this->send_success_response($data, [
                'step' => 'attempts',
                'logs' => $this->logs,
            ]);
        }
    }

    /**
     * Migrate quiz attempts.
     *
     * @param array $data The data for the current request.
     */
    protected function migrate_attempts(array $data)
    {
        self::before_start();

        [$limit, $cursor] = $this->get_request_limit_cursor($data, 10);

        $course_map        = $this->build_course_id_map();
        $quiz_map          = $this->build_composite_id_map(Quiz::$cpt);
        $parent_lesson_map = $this->build_topic_parent_lesson_map();

        // The best-score attempt's activity_id per (user, post, course); the
        // loop below skips every other (non-best) attempt. Computing this set
        // once lets fetch_batch paginate the activity table with a flat query
        // instead of a per-row correlated best-score subquery.
        $best_quiz_activity_ids = $this->build_best_quiz_activity_ids();

        $processor = new class ($limit, $cursor) extends MeprBatchMigrator {
            /**
             * Fetches the next batch of LearnDash quiz activity rows using keyset pagination on activity_id.
             *
             * @return array
             */
            protected function fetch_batch(): array
            {
                global $wpdb;
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery
                return $wpdb->get_results(
                    $wpdb->prepare(
                        "SELECT
                        ua.activity_id,
                        ua.user_id,
                        ua.course_id,
                        ua.post_id,
                        ua.activity_started,
                        ua.activity_completed
                        FROM {$wpdb->prefix}learndash_user_activity ua
                        WHERE ua.user_id > 0
                        AND ua.activity_type = 'quiz'
                        AND ua.activity_completed IS NOT NULL
                        AND ua.activity_completed > 0
                        AND ua.activity_id > %d
                        ORDER BY ua.activity_id ASC
                        LIMIT %d",
                        $this->cursor,
                        $this->limit
                    )
                ) ?: [];
            }

            /**
             * The keyset cursor column — the user-activity PK.
             *
             * @return string
             */
            protected function cursor_column(): string
            {
                return 'activity_id';
            }
        };
        $grader_user_id = $this->get_grader_user_id();

        while ($processor->next_batch()) {
            // Per-batch buffers; each resets every next_batch() cycle and flushes
            // via bulk writes at the bottom of the while body.
            $progress_rows = [];
            $attempt_rows  = [];
            $user_activity = $processor->get_items();

            // Fetch every activity's metadata for the whole batch in one query.
            $activity_ids = array_map(
                static function ($activity) {
                    return (int) $activity->activity_id;
                },
                $user_activity
            );
            $meta_map = $this->fetch_activity_meta_map($activity_ids);

            foreach ($user_activity as $activity) {
                if (!isset($best_quiz_activity_ids[(int) $activity->activity_id])) {
                    // Only the best-score attempt per (user, quiz, course) migrates.
                    continue;
                }

                $user_id   = (int) $activity->user_id;
                $course_id = (int) $activity->course_id;
                $post_id   = (int) $activity->post_id;

                $courses_course_id        = $course_map[$course_id] ?? null;
                $quiz_id                  = $quiz_map[$course_id][$post_id] ?? null;
                $courses_parent_lesson_id = $parent_lesson_map[$course_id][$post_id] ?? null;

                if ($courses_course_id === null) {
                    // Skip the attempt when its course has no migrated Courses course.
                    continue;
                }

                $quiz_id = (int) $quiz_id;

                if ($quiz_id === 0) {
                    // Skip the attempt when its quiz did not migrate; there is no Courses quiz to attach it to.
                    continue;
                }

                $activity_started   = Utils::ts_to_mysql_date((int) $activity->activity_started);
                $activity_completed = Utils::ts_to_mysql_date((int) $activity->activity_completed);
                // Dedupe so a quiz that shares an id with its parent lesson does not buffer a duplicate progress row.
                $lesson_ids         = array_unique(array_filter([$quiz_id, (int) $courses_parent_lesson_id]));

                foreach ($lesson_ids as $lesson_id) {
                    $progress_rows[] = [
                        $user_id,
                        (int) $lesson_id,
                        (int) $courses_course_id,
                        $activity_started,
                        $activity_completed,
                    ];
                }

                $metadata = $meta_map[(int) $activity->activity_id] ?? [];

                $attempt_rows[] = [
                    'quiz_id'          => $quiz_id,
                    'user_id'          => $user_id,
                    'points_awarded'   => (int) ($metadata['points'] ?? 0),
                    'points_possible'  => (int) ($metadata['total_points'] ?? 0),
                    'score'            => (int) round((float) ($metadata['percentage'] ?? 0)),
                    'started_at'       => $activity_started,
                    'finished_at'      => $activity_completed,
                    'statistic_ref_id' => (int) ($metadata['statistic_ref_id'] ?? 0),
                ];
            }

            $this->bulk_insert_user_progress($progress_rows);

            // Bulk-insert the attempts (idempotent via a batch existence pre-check),
            // then write their answers keyed to the resolved attempt IDs.
            $attempt_id_map = $this->bulk_insert_attempts($attempt_rows);
            $this->migrate_attempt_answers($attempt_rows, $attempt_id_map, $grader_user_id);
        }

        self::finish();

        if ($processor->has_items()) {
            $this->send_success_response($data, [
                'step'   => 'attempts',
                'cursor' => $processor->get_cursor(),
                'limit'  => $processor->get_limit(),
                'logs'   => $this->logs,
            ]);
        }

        update_option('mepr_migrator_learndash_completed', true);

        $this->send_success_response($data, [
            'status' => 'complete',
            'logs'   => $this->logs,
        ]);
    }

    /**
     * Convert the answer from LearnDash to Courses format.
     *
     * @param array   $answer      The answer data.
     * @param integer $user_id     The user ID.
     * @param integer $question_id The question ID.
     *
     * @return array|string The converted answer value.
     */
    protected function get_answer_value(array $answer, int $user_id, int $question_id)
    {
        $answer_data = json_decode($answer['answer_data'], true);
        $value       = '';

        if (is_array($answer_data)) {
            switch ($answer['answer_type']) {
                case 'single':
                    $index = array_search(1, $answer_data, true);

                    if (is_int($index)) {
                        $value = (string) $index;
                    }
                    break;
                case 'multiple':
                    $question = $this->get_question($question_id);

                    if ($question instanceof Question && is_array($question->options)) {
                        $value = [];

                        foreach ($answer_data as $key => $selected) {
                            if ($selected && isset($question->options[$key])) {
                                $value[] = $question->options[$key];
                            }
                        }
                    }
                    break;
                case 'free_answer':
                case 'assessment_answer':
                    $value = $answer_data[0] ?? '';
                    break;
                case 'sort_answer':
                    $question = $this->get_question($question_id);

                    if ($question instanceof Question && is_array($question->options)) {
                        $updated = 0;

                        foreach ($question->options as $key => $value) {
                                $answer_hash = md5($user_id . $answer['question_id'] . $key);
                                $index       = array_search($answer_hash, $answer_data, true);

                            if (is_int($index)) {
                                $answer_data[$index] = $value;
                                ++$updated;
                            }
                        }

                        if ($updated === count($answer_data)) {
                              $value = $answer_data;
                        }
                    }
                    break;
                case 'matrix_sort_answer':
                    $question = $this->get_question($question_id);

                    if ($question instanceof Question && is_array($question->options) && is_array($question->answer)) {
                        $updated = 0;

                        foreach ($question->options as $key => $value) {
                                $answer_hash = md5($user_id . $answer['question_id'] . $key);
                                $index       = array_search($answer_hash, $answer_data, true);

                            if (is_int($index) && isset($question->answer[$key])) {
                                $answer_data[$index] = $question->answer[$key];
                                ++$updated;
                            }
                        }

                        if ($updated === count($answer_data)) {
                              $value = $answer_data;
                        }
                    }
                    break;
                case 'cloze_answer':
                    $value = $answer_data;
                    break;
                case 'essay':
                    $value = get_post_field('post_content', $answer_data['graded_id'] ?? 0, 'raw');
                    break;
            }
        }

        return $value;
    }

    /**
     * Get the default grader user ID.
     *
     * @return integer
     */
    protected function get_grader_user_id(): int
    {
        $admins = get_users([
            'role'    => 'administrator',
            'number'  => 1,
            'fields'  => 'ID',
            'orderby' => 'ID',
            'order'   => 'ASC',
        ]);

        return isset($admins[0]) ? (int) $admins[0] : 0;
    }

    /**
     * Get a question instance by ID.
     *
     * @param  integer $question_id The question ID.
     * @return Question|null
     */
    protected function get_question(int $question_id): ?Question
    {
        $question = wp_cache_get($question_id, 'mepr-migrator-question');

        if ($question instanceof Question) {
            return $question;
        }

        $question = new Question($question_id);

        if ($question->id > 0) {
            wp_cache_add($question_id, $question, 'mepr-migrator-question');

            return $question;
        }

        return null;
    }

    /**
     * Is the migration from LearnDash to Courses possible?
     *
     * Currently, this just checks for at least one existing LearnDash Course and that the quiz table exists.
     *
     * @return boolean
     */
    public static function is_migration_possible(): bool
    {
        static $result;

        if (is_bool($result)) {
            return $result;
        }

        global $wpdb;
        $mepr_db = MeprDb::fetch();

        $ld_course_exists = count(get_posts([
            'post_type'   => 'sfwd-courses',
            'numberposts' => 1,
            'fields'      => 'ids',
        ])) > 0;

        $ld_table_exists = $mepr_db->table_exists("{$wpdb->prefix}learndash_pro_quiz_master");

        $result = $ld_course_exists && $ld_table_exists;

        return $result;
    }
}
