<?php

/**
 * Shared scan engine for WUClean image scans (manual + scheduled).
 *
 * Notes:
 * - This file intentionally contains procedural functions to match the existing plugin style.
 * - Manual (AJAX) and scheduled (cron) drivers should call these helpers to ensure parity.
 *
 * @package Wuclean
 */
if ( !defined( 'ABSPATH' ) ) {
    exit;
}
/**
 * Get WUClean trash directory path and URL under uploads.
 *
 * @return array{dir:string,url:string}
 */
function pt_wuclean_get_trash_location() {
    $uploads = wp_upload_dir();
    $base_dir = ( isset( $uploads['basedir'] ) && is_string( $uploads['basedir'] ) ? trailingslashit( (string) $uploads['basedir'] ) : '' );
    $base_url = ( isset( $uploads['baseurl'] ) && is_string( $uploads['baseurl'] ) ? trailingslashit( (string) $uploads['baseurl'] ) : '' );
    $relative = 'wuclean-trash/images/';
    return array(
        'dir' => $base_dir . $relative,
        'url' => $base_url . $relative,
    );
}

/**
 * Whether combined Media Library + uploads-folder scanning is permitted in this install.
 *
 * @return bool
 */
function pt_wuclean_image_scan_engine_combined_scan_allowed() {
    $allowed = false;
    if ( function_exists( 'pt_wuclean_fs' ) ) {
    }
    return $allowed;
}

/**
 * Normalize scan scope for the current install.
 *
 * @param string $scope Raw scope from settings.
 * @return string `media_only` or `media_and_uploads`.
 */
function pt_wuclean_image_scan_engine_normalize_scan_scope(  $scope  ) {
    $scope = ( is_string( $scope ) ? $scope : 'media_only' );
    if ( 'media_and_uploads' === $scope ) {
        if ( pt_wuclean_image_scan_engine_combined_scan_allowed() ) {
            return 'media_and_uploads';
        }
    }
    return 'media_only';
}

// -----------------------------------------------------------------------------
// DB helpers (scan runs + scan items)
// -----------------------------------------------------------------------------
/**
 * Get scan runs table name.
 *
 * @return string
 */
function pt_wuclean_image_scan_db_runs_table() {
    global $wpdb;
    return $wpdb->prefix . 'wuclean_image_scan_runs';
}

/**
 * Get scan items table name.
 *
 * @return string
 */
function pt_wuclean_image_scan_db_items_table() {
    global $wpdb;
    return $wpdb->prefix . 'wuclean_image_scan_items';
}

/**
 * Resolve image scan batch size for manual, scheduled, and AJAX drivers.
 *
 * Default base is 25 when no legacy `batch_size` exists in settings. The value is clamped to 10–200.
 *
 * @param array  $settings Image settings (may include legacy `batch_size`).
 * @param string $context  Context for developers (e.g. 'start', 'advance', 'ajax', 'display').
 * @return int
 */
function pt_wuclean_image_scan_resolve_batch_size(  array $settings, $context = 'default'  ) {
    $context = ( is_string( $context ) ? $context : 'default' );
    $base = ( isset( $settings['batch_size'] ) ? absint( $settings['batch_size'] ) : 0 );
    if ( $base < 1 ) {
        $base = 25;
    }
    /**
     * Filters the number of Media Library attachment IDs processed per scan step.
     *
     * @param int    $base     Resolved base batch size before clamping (from settings or 25).
     * @param string $context  Call context (e.g. start, advance, ajax).
     * @param array  $settings Image settings snapshot.
     */
    $filtered = (int) apply_filters(
        'pt_wuclean_image_scan_batch_size',
        $base,
        $context,
        $settings
    );
    return max( 10, min( 200, $filtered ) );
}

/**
 * Per-request wall-clock budget (seconds) for scan batch processing.
 *
 * @return int
 */
function pt_wuclean_image_scan_engine_wall_clock_budget_seconds() {
    $seconds = (int) apply_filters( 'pt_wuclean_image_scan_wall_clock_budget', 25 );
    return max( 5, min( 60, $seconds ) );
}

/**
 * Whether the wall-clock budget for the current batch has been exceeded.
 *
 * @param float $started_at microtime( true ) from batch start.
 * @return bool
 */
function pt_wuclean_image_scan_engine_is_budget_exceeded(  $started_at  ) {
    return microtime( true ) - (float) $started_at >= (float) pt_wuclean_image_scan_engine_wall_clock_budget_seconds();
}

/**
 * Apply PHP execution time limit for a single scan batch request.
 *
 * @return void
 */
function pt_wuclean_image_scan_engine_apply_request_time_limit() {
    // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged, WordPress.PHP.NoSilencedErrors.Discouraged -- Best-effort; may be disabled on some hosts.
    @set_time_limit( 30 );
}

/**
 * Count items to cross-check during refinement for a run.
 *
 * @param int $run_id Run ID.
 * @return array{media:int,orphan:int}
 */
function pt_wuclean_image_scan_engine_count_refinement_items(  $run_id  ) {
    global $wpdb;
    $run_id = absint( $run_id );
    if ( $run_id <= 0 ) {
        return array(
            'media'  => 0,
            'orphan' => 0,
        );
    }
    $table = pt_wuclean_image_scan_db_items_table();
    $table_escaped = esc_sql( $table );
    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table from esc_sql().
    $media_count = $wpdb->get_var( $wpdb->prepare( 
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table_escaped from esc_sql().
        "SELECT COUNT(DISTINCT object_id) FROM {$table_escaped} WHERE run_id = %d AND type = %s AND object_id > 0",
        $run_id,
        'media_library'
     ) );
    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table from esc_sql().
    $orphan_count = $wpdb->get_var( $wpdb->prepare( 
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table_escaped from esc_sql().
        "SELECT COUNT(DISTINCT path) FROM {$table_escaped} WHERE run_id = %d AND type = %s AND path IS NOT NULL AND path <> ''",
        $run_id,
        'orphaned_file'
     ) );
    return array(
        'media'  => absint( $media_count ),
        'orphan' => absint( $orphan_count ),
    );
}

/**
 * Total refinement work units (media + orphan paths) for a run.
 *
 * @param array $progress Progress array.
 * @return int
 */
function pt_wuclean_image_scan_engine_get_refinement_total(  array $progress  ) {
    $media = ( isset( $progress['refinement_media_total'] ) ? absint( $progress['refinement_media_total'] ) : 0 );
    $orphan = ( isset( $progress['refinement_orphan_total'] ) ? absint( $progress['refinement_orphan_total'] ) : 0 );
    return $media + $orphan;
}

/**
 * Refinement items checked so far (monotonic across both refinement steps).
 *
 * @param array $progress Progress array.
 * @return int
 */
function pt_wuclean_image_scan_engine_get_refinement_processed(  array $progress  ) {
    $media_total = ( isset( $progress['refinement_media_total'] ) ? absint( $progress['refinement_media_total'] ) : 0 );
    $offset = ( isset( $progress['refinement_offset'] ) ? absint( $progress['refinement_offset'] ) : 0 );
    $step = ( isset( $progress['refinement_step'] ) ? (string) $progress['refinement_step'] : 'media_library' );
    if ( 'orphaned_files' === $step ) {
        return $media_total + $offset;
    }
    return $offset;
}

/**
 * Ordered scan step keys for a given scope.
 *
 * @param string $scan_scope `media_only` or `media_and_uploads`.
 * @return string[]
 */
function pt_wuclean_image_scan_engine_get_scan_step_keys(  $scan_scope  ) {
    if ( 'media_only' === $scan_scope ) {
        return array('media_library', 'refinement');
    }
    return array('media_library', 'uploads_folder', 'refinement');
}

/**
 * Resolve scan scope from a run settings snapshot.
 *
 * @param array $settings Settings array.
 * @return string
 */
function pt_wuclean_image_scan_engine_resolve_scan_scope(  array $settings  ) {
    $scope = ( isset( $settings['scan_scope'] ) ? (string) $settings['scan_scope'] : 'media_only' );
    return pt_wuclean_image_scan_engine_normalize_scan_scope( $scope );
}

/**
 * Current step index and within-step progress for the active phase.
 *
 * @param array $progress Progress array.
 * @param array $settings Run settings snapshot.
 * @return array{step_index:int,step_count:int,step_key:string,step_processed:int,step_total:int,step_progress:int,scan_scope:string}
 */
function pt_wuclean_image_scan_engine_compute_step_metrics(  array $progress, array $settings  ) {
    $scan_scope = pt_wuclean_image_scan_engine_resolve_scan_scope( $settings );
    $step_keys = pt_wuclean_image_scan_engine_get_scan_step_keys( $scan_scope );
    $step_count = count( $step_keys );
    $phase = ( isset( $progress['phase'] ) ? (string) $progress['phase'] : 'media_library' );
    $step_key = $phase;
    if ( 'refinement' === $phase ) {
        $step_key = 'refinement';
    }
    $step_index = array_search( $step_key, $step_keys, true );
    if ( false === $step_index ) {
        $step_index = 0;
        $step_key = $step_keys[0];
    }
    $step_processed = 0;
    $step_total = 0;
    if ( 'media_library' === $step_key ) {
        $step_processed = ( isset( $progress['processed_media'] ) ? absint( $progress['processed_media'] ) : 0 );
        $step_total = ( isset( $progress['total_images'] ) ? absint( $progress['total_images'] ) : 0 );
    } elseif ( 'uploads_folder' === $step_key ) {
        $step_processed = ( isset( $progress['uploads_processed'] ) ? absint( $progress['uploads_processed'] ) : 0 );
        $step_total = ( isset( $progress['uploads_total'] ) ? absint( $progress['uploads_total'] ) : 0 );
        if ( pt_wuclean_image_scan_engine_combined_scan_allowed() ) {
            $up_disp = pt_wuclean_image_scan_engine_resolve_uploads_progress_display( $progress );
            $step_total = ( isset( $up_disp['display_total'] ) ? absint( $up_disp['display_total'] ) : $step_total );
        }
    } elseif ( 'refinement' === $step_key ) {
        $step_processed = pt_wuclean_image_scan_engine_get_refinement_processed( $progress );
        $step_total = pt_wuclean_image_scan_engine_get_refinement_total( $progress );
    }
    $step_progress = 0;
    if ( $step_total > 0 ) {
        $step_progress = (int) min( 100, max( 0, round( $step_processed / $step_total * 100 ) ) );
    }
    return array(
        'step_index'     => (int) $step_index,
        'step_count'     => (int) $step_count,
        'step_key'       => $step_key,
        'step_processed' => $step_processed,
        'step_total'     => $step_total,
        'step_progress'  => $step_progress,
        'scan_scope'     => $scan_scope,
    );
}

/**
 * Map step index + within-step progress to a single 0–99% bar value (equal segments per step).
 *
 * @param int $step_count    Total steps (2 or 3).
 * @param int $step_index    Active step index (0-based).
 * @param int $step_progress Progress within the active step (0–100).
 * @return int
 */
function pt_wuclean_image_scan_engine_compute_overall_percentage(  $step_count, $step_index, $step_progress  ) {
    $step_count = max( 1, absint( $step_count ) );
    $step_index = max( 0, absint( $step_index ) );
    $step_progress = min( 100, max( 0, absint( $step_progress ) ) );
    $overall = ($step_index + $step_progress / 100) / $step_count * 100;
    return (int) min( 99, max( 0, round( $overall ) ) );
}

/**
 * Build step status list for the admin step indicator.
 *
 * @param string[] $step_keys   Ordered step keys.
 * @param int      $step_index  Active step index.
 * @return array<int, array{key:string,status:string}>
 */
function pt_wuclean_image_scan_engine_build_steps_status(  array $step_keys, $step_index  ) {
    $steps = array();
    foreach ( $step_keys as $i => $key ) {
        $status = 'pending';
        if ( $i < $step_index ) {
            $status = 'done';
        } elseif ( $i === $step_index ) {
            $status = 'active';
        }
        $steps[] = array(
            'key'    => (string) $key,
            'status' => $status,
        );
    }
    return $steps;
}

/**
 * Merge stepped-progress fields into an in-progress AJAX payload.
 *
 * @param array $progress Progress array.
 * @param array $settings Run settings snapshot.
 * @param array $payload  Existing payload.
 * @return array
 */
function pt_wuclean_image_scan_engine_apply_stepped_progress(  array $progress, array $settings, array $payload  ) {
    if ( !empty( $payload['completed'] ) ) {
        return $payload;
    }
    $metrics = pt_wuclean_image_scan_engine_compute_step_metrics( $progress, $settings );
    $step_keys = pt_wuclean_image_scan_engine_get_scan_step_keys( $metrics['scan_scope'] );
    $percentage = pt_wuclean_image_scan_engine_compute_overall_percentage( $metrics['step_count'], $metrics['step_index'], $metrics['step_progress'] );
    $payload['phase'] = ( isset( $progress['phase'] ) ? (string) $progress['phase'] : $metrics['step_key'] );
    $payload['scan_scope'] = $metrics['scan_scope'];
    $payload['step_index'] = $metrics['step_index'];
    $payload['step_count'] = $metrics['step_count'];
    $payload['step_key'] = $metrics['step_key'];
    $payload['step_processed'] = $metrics['step_processed'];
    $payload['step_total'] = $metrics['step_total'];
    $payload['step_progress'] = $metrics['step_progress'];
    $payload['percentage'] = $percentage;
    $payload['steps'] = pt_wuclean_image_scan_engine_build_steps_status( $step_keys, $metrics['step_index'] );
    $payload['processed'] = $metrics['step_processed'];
    $payload['total_items'] = $metrics['step_total'];
    if ( 'refinement' === $metrics['step_key'] ) {
        $payload['refinement_processed'] = $metrics['step_processed'];
        $payload['refinement_total'] = $metrics['step_total'];
    }
    return $payload;
}

/**
 * Build AJAX payload for an in-progress refinement batch.
 *
 * @param int   $run_id   Run ID.
 * @param array $progress Progress array.
 * @param array $settings Run settings snapshot (for step plan / scope).
 * @return array
 */
function pt_wuclean_image_scan_engine_build_refinement_progress_response(  $run_id, array $progress, array $settings = array()  ) {
    $processed = pt_wuclean_image_scan_engine_get_refinement_processed( $progress );
    $total = pt_wuclean_image_scan_engine_get_refinement_total( $progress );
    $message = __( 'Checking flagged images...', 'wuclean-database-cleaner-for-woocommerce' );
    if ( $total > 0 ) {
        $message = sprintf( 
            /* translators: 1: items checked so far, 2: total items to check */
            __( 'Cross-checking flagged images... %1$d of %2$d', 'wuclean-database-cleaner-for-woocommerce' ),
            $processed,
            $total
         );
    }
    return pt_wuclean_image_scan_engine_build_progress_response(
        $run_id,
        $progress,
        array(
            'phase'           => 'refinement',
            'refinement_step' => ( isset( $progress['refinement_step'] ) ? (string) $progress['refinement_step'] : 'media_library' ),
            'message'         => $message,
        ),
        $settings
    );
}

/**
 * Initialize refinement phase progress after Media Library / uploads scanning.
 *
 * @param int   $run_id   Run ID.
 * @param array $progress Progress array.
 * @return array Updated progress.
 */
function pt_wuclean_image_scan_engine_init_refinement_progress(  $run_id, array $progress  ) {
    $counts = pt_wuclean_image_scan_engine_count_refinement_items( $run_id );
    $progress['phase'] = 'refinement';
    $progress['refinement_step'] = 'media_library';
    $progress['refinement_offset'] = 0;
    $progress['refinement_media_total'] = $counts['media'];
    $progress['refinement_orphan_total'] = $counts['orphan'];
    $progress['refinement_media_removed'] = ( isset( $progress['refinement_media_removed'] ) ? absint( $progress['refinement_media_removed'] ) : 0 );
    $progress['refinement_orphan_removed'] = ( isset( $progress['refinement_orphan_removed'] ) ? absint( $progress['refinement_orphan_removed'] ) : 0 );
    return $progress;
}

/**
 * Build a standard in-progress scan AJAX payload.
 *
 * @param int   $run_id   Run ID.
 * @param array $progress Progress snapshot.
 * @param array $overrides Keys to merge (message, etc.); percentage is derived from steps unless completed.
 * @param array $settings  Run settings snapshot (for step plan / scope).
 * @return array
 */
function pt_wuclean_image_scan_engine_build_progress_response(
    $run_id,
    array $progress,
    array $overrides = array(),
    array $settings = array()
) {
    $payload = array(
        'completed'    => false,
        'issues_found' => pt_wuclean_image_scan_db_count_items( $run_id, null ),
    );
    $payload = array_merge( $payload, $overrides );
    return pt_wuclean_image_scan_engine_apply_stepped_progress( $progress, $settings, $payload );
}

/**
 * Post types included when scanning post content/excerpt for image references.
 *
 * @return string[]
 */
function pt_wuclean_image_scan_content_post_types() {
    $defaults = array('post', 'page', 'product');
    /**
     * Filters which post types are searched for inline image references (content and excerpt).
     *
     * @param string[] $defaults Default post type names.
     */
    $types = apply_filters( 'pt_wuclean_image_scan_content_post_types', $defaults );
    if ( !is_array( $types ) || empty( $types ) ) {
        return $defaults;
    }
    $clean = array();
    foreach ( $types as $t ) {
        $s = sanitize_key( (string) $t );
        if ( '' !== $s ) {
            $clean[] = $s;
        }
    }
    $clean = array_values( array_unique( $clean ) );
    return ( !empty( $clean ) ? $clean : $defaults );
}

/**
 * Build SQL `IN (%s,...)` fragment and bound types for {@see pt_wuclean_image_scan_content_post_types()}.
 *
 * @return array{in:string,types:string[]}
 */
function pt_wuclean_image_scan_sql_post_type_in_parts() {
    $types = pt_wuclean_image_scan_content_post_types();
    $in = implode( ', ', array_fill( 0, count( $types ), '%s' ) );
    return array(
        'in'    => $in,
        'types' => $types,
    );
}

/**
 * Post statuses for which a parent post/page/product still "owns" attached media (featured, gallery, content).
 *
 * Excludes `auto-draft`. Default includes `trash` so images remain protected until the parent is deleted.
 *
 * @return string[]
 */
function pt_wuclean_image_scan_parent_post_statuses() {
    $defaults = array(
        'publish',
        'draft',
        'pending',
        'future',
        'private',
        'trash'
    );
    /**
     * Filters post statuses used when resolving image usage against parent posts.
     *
     * @param string[] $defaults Default status slugs.
     */
    $statuses = apply_filters( 'pt_wuclean_image_scan_parent_post_statuses', $defaults );
    if ( !is_array( $statuses ) || empty( $statuses ) ) {
        return $defaults;
    }
    $clean = array();
    foreach ( $statuses as $s ) {
        $key = sanitize_key( (string) $s );
        if ( '' !== $key && 'auto-draft' !== $key ) {
            $clean[] = $key;
        }
    }
    $clean = array_values( array_unique( $clean ) );
    return ( !empty( $clean ) ? $clean : $defaults );
}

/**
 * Build SQL `IN (%s,...)` fragment and bound values for {@see pt_wuclean_image_scan_parent_post_statuses()}.
 *
 * @return array{in:string,statuses:string[]}
 */
function pt_wuclean_image_scan_sql_post_status_in_parts() {
    $statuses = pt_wuclean_image_scan_parent_post_statuses();
    $in = implode( ', ', array_fill( 0, count( $statuses ), '%s' ) );
    return array(
        'in'       => $in,
        'statuses' => $statuses,
    );
}

/**
 * Get the latest scan run row.
 *
 * @param string|null $status Optional status filter.
 * @return array|null Associative row or null.
 */
function pt_wuclean_image_scan_db_get_latest_run(  $status = null  ) {
    global $wpdb;
    $table = pt_wuclean_image_scan_db_runs_table();
    $table_escaped = esc_sql( $table );
    if ( is_string( $status ) && '' !== $status ) {
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from esc_sql().
        $row = $wpdb->get_row( $wpdb->prepare( 
            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table_escaped from esc_sql
            "SELECT * FROM {$table_escaped} WHERE status = %s ORDER BY id DESC LIMIT 1",
            $status
         ), ARRAY_A );
    } else {
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from esc_sql().
        $row = $wpdb->get_row( "SELECT * FROM {$table_escaped} ORDER BY id DESC LIMIT 1", ARRAY_A );
    }
    return ( is_array( $row ) ? $row : null );
}

/**
 * Fetch a scan run row by primary key.
 *
 * @param int $run_id Run ID.
 * @return array|null Associative row or null.
 */
function pt_wuclean_image_scan_db_get_run_by_id(  $run_id  ) {
    global $wpdb;
    $run_id = absint( $run_id );
    if ( $run_id <= 0 ) {
        return null;
    }
    $table = pt_wuclean_image_scan_db_runs_table();
    $table_escaped = esc_sql( $table );
    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from esc_sql().
    $row = $wpdb->get_row( $wpdb->prepare( 
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table_escaped from esc_sql
        "SELECT * FROM {$table_escaped} WHERE id = %d",
        $run_id
     ), ARRAY_A );
    return ( is_array( $row ) ? $row : null );
}

/**
 * Decode a run JSON field into array.
 *
 * @param mixed $json JSON string or other value.
 * @return array Decoded array or empty array.
 */
function pt_wuclean_image_scan_db_decode_json_field(  $json  ) {
    if ( !is_string( $json ) || '' === $json ) {
        return array();
    }
    $decoded = json_decode( $json, true );
    return ( is_array( $decoded ) ? $decoded : array() );
}

/**
 * Delete completed runs (and their items) except for an optional run id to keep.
 *
 * @param int $keep_run_id Run id to keep (0 means keep none).
 * @return void
 */
function pt_wuclean_image_scan_db_delete_completed_runs_except(  $keep_run_id = 0  ) {
    global $wpdb;
    $keep_run_id = absint( $keep_run_id );
    $runs_table = pt_wuclean_image_scan_db_runs_table();
    $items_table = pt_wuclean_image_scan_db_items_table();
    $runs_table_escaped = esc_sql( $runs_table );
    $items_table_escaped = esc_sql( $items_table );
    if ( $keep_run_id > 0 ) {
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table from esc_sql().
        $old_ids = $wpdb->get_col( $wpdb->prepare( 
            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $runs_table_escaped from esc_sql
            "SELECT id FROM {$runs_table_escaped} WHERE status = %s AND id != %d",
            'completed',
            $keep_run_id
         ) );
    } else {
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table from esc_sql().
        $old_ids = $wpdb->get_col( $wpdb->prepare( 
            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $runs_table_escaped from esc_sql
            "SELECT id FROM {$runs_table_escaped} WHERE status = %s",
            'completed'
         ) );
    }
    if ( empty( $old_ids ) || !is_array( $old_ids ) ) {
        return;
    }
    $old_ids = array_values( array_filter( array_map( 'absint', $old_ids ) ) );
    if ( empty( $old_ids ) ) {
        return;
    }
    $placeholders = implode( ',', array_fill( 0, count( $old_ids ), '%d' ) );
    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Tables from esc_sql(); dynamic IN () from absint IDs.
    $wpdb->query( $wpdb->prepare( "DELETE FROM {$items_table_escaped} WHERE run_id IN ({$placeholders})", ...$old_ids ) );
    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Tables from esc_sql(); dynamic IN () from absint IDs.
    $wpdb->query( $wpdb->prepare( "DELETE FROM {$runs_table_escaped} WHERE id IN ({$placeholders})", ...$old_ids ) );
}

/**
 * Delete a scan run and all associated scan items.
 *
 * @param int $run_id Run ID.
 * @return void
 */
function pt_wuclean_image_scan_db_delete_run(  $run_id  ) {
    global $wpdb;
    $run_id = absint( $run_id );
    if ( $run_id <= 0 ) {
        return;
    }
    $runs_table = pt_wuclean_image_scan_db_runs_table();
    $items_table = pt_wuclean_image_scan_db_items_table();
    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
    $wpdb->delete( $items_table, array(
        'run_id' => $run_id,
    ), array('%d') );
    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
    $wpdb->delete( $runs_table, array(
        'id' => $run_id,
    ), array('%d') );
}

/**
 * Create a new scan run (does not delete stopped/running runs).
 *
 * @param string $mode 'manual' or 'scheduled'.
 * @param array  $settings Settings snapshot to store.
 * @param array  $progress Initial progress array to store.
 * @return int Run id (0 on failure).
 */
function pt_wuclean_image_scan_db_create_run(  $mode, array $settings, array $progress  ) {
    global $wpdb;
    $mode = ( is_string( $mode ) ? $mode : 'manual' );
    $now = current_time( 'mysql' );
    $runs_table = pt_wuclean_image_scan_db_runs_table();
    $data = array(
        'status'        => 'running',
        'mode'          => $mode,
        'created_at'    => $now,
        'updated_at'    => $now,
        'completed_at'  => null,
        'settings_json' => wp_json_encode( $settings ),
        'progress_json' => wp_json_encode( $progress ),
        'counts_json'   => null,
    );
    $formats = array(
        '%s',
        '%s',
        '%s',
        '%s',
        '%s',
        '%s',
        '%s',
        '%s'
    );
    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
    $ok = $wpdb->insert( $runs_table, $data, $formats );
    if ( !$ok ) {
        return 0;
    }
    return (int) $wpdb->insert_id;
}

/**
 * Update run progress.
 *
 * @param int   $run_id   Run ID.
 * @param array $progress Progress array to store.
 * @return void
 */
function pt_wuclean_image_scan_db_update_progress(  $run_id, array $progress  ) {
    global $wpdb;
    $run_id = absint( $run_id );
    if ( $run_id <= 0 ) {
        return;
    }
    $runs_table = pt_wuclean_image_scan_db_runs_table();
    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
    $wpdb->update(
        $runs_table,
        array(
            'updated_at'    => current_time( 'mysql' ),
            'progress_json' => wp_json_encode( $progress ),
        ),
        array(
            'id' => $run_id,
        ),
        array('%s', '%s'),
        array('%d')
    );
}

/**
 * Update run status (optionally set completion timestamp).
 *
 * @param int    $run_id  Run ID.
 * @param string $status  Status: running, completed, stopped, failed.
 * @param array  $counts  Optional summary counts to store.
 * @return void
 */
function pt_wuclean_image_scan_db_update_status(  $run_id, $status, array $counts = array()  ) {
    global $wpdb;
    $run_id = absint( $run_id );
    if ( $run_id <= 0 ) {
        return;
    }
    $status = ( is_string( $status ) ? $status : 'running' );
    $fields = array(
        'status'     => $status,
        'updated_at' => current_time( 'mysql' ),
    );
    if ( 'completed' === $status ) {
        $fields['completed_at'] = current_time( 'mysql' );
    }
    if ( !empty( $counts ) ) {
        $fields['counts_json'] = wp_json_encode( $counts );
    }
    $formats = array_fill( 0, count( $fields ), '%s' );
    $where = array(
        'id' => $run_id,
    );
    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
    $wpdb->update(
        pt_wuclean_image_scan_db_runs_table(),
        $fields,
        $where,
        $formats,
        array('%d')
    );
}

/**
 * Insert scan items for a run (deduplicated by (run_id,item_key)).
 *
 * @param int   $run_id Run ID.
 * @param array $items  Array of issue arrays in the existing shape.
 * @return void
 */
function pt_wuclean_image_scan_db_insert_items(  $run_id, array $items  ) {
    global $wpdb;
    $run_id = absint( $run_id );
    if ( $run_id <= 0 || empty( $items ) ) {
        return;
    }
    $table = pt_wuclean_image_scan_db_items_table();
    $table_escaped = esc_sql( $table );
    foreach ( $items as $item ) {
        if ( !is_array( $item ) ) {
            continue;
        }
        $type = ( isset( $item['type'] ) ? (string) $item['type'] : '' );
        $object_id = ( isset( $item['id'] ) ? absint( $item['id'] ) : 0 );
        $subtype = ( isset( $item['subtype'] ) ? (string) $item['subtype'] : null );
        $data = ( isset( $item['data'] ) && is_array( $item['data'] ) ? $item['data'] : array() );
        $path = ( isset( $data['path'] ) ? (string) $data['path'] : null );
        if ( null !== $path && '' !== $path ) {
            $path = wp_normalize_path( $path );
        }
        $filename = ( isset( $data['filename'] ) ? (string) $data['filename'] : null );
        $parent_filename = ( isset( $data['sibling_filename'] ) ? (string) $data['sibling_filename'] : null );
        $file_type = ( isset( $data['file_type'] ) ? strtolower( (string) $data['file_type'] ) : null );
        $sibling_used = ( isset( $data['sibling_used'] ) ? ( $data['sibling_used'] ? 1 : 0 ) : null );
        $size_bytes = ( isset( $data['size'] ) ? absint( $data['size'] ) : null );
        // Populate basic file metadata for Media Library items to support filters/search.
        if ( 'media_library' === $type && $object_id > 0 ) {
            $attached = get_attached_file( $object_id );
            if ( is_string( $attached ) && '' !== $attached ) {
                if ( null === $filename || '' === $filename ) {
                    $filename = basename( $attached );
                }
                if ( null === $file_type || '' === $file_type ) {
                    $ext = strtolower( (string) pathinfo( $attached, PATHINFO_EXTENSION ) );
                    $file_type = ( '' !== $ext ? $ext : null );
                }
            }
        }
        $item_key = md5( $type . '|' . $object_id . '|' . (string) $subtype . '|' . (string) $path . '|' . (string) $filename . '|' . (string) $parent_filename );
        $insert = array(
            'run_id'          => $run_id,
            'item_key'        => $item_key,
            'type'            => $type,
            'object_id'       => $object_id,
            'subtype'         => $subtype,
            'path'            => $path,
            'filename'        => $filename,
            'parent_filename' => $parent_filename,
            'file_type'       => $file_type,
            'sibling_used'    => $sibling_used,
            'size_bytes'      => $size_bytes,
            'data_json'       => wp_json_encode( $item ),
        );
        // Insert ignore via raw query because $wpdb->insert doesn't support it.
        $columns = array_keys( $insert );
        $col_sql = implode( ',', $columns );
        $placeholders = array();
        $args = array();
        foreach ( $columns as $col ) {
            $value = $insert[$col];
            if ( null === $value ) {
                $placeholders[] = 'NULL';
                continue;
            }
            if ( in_array( $col, array(
                'run_id',
                'object_id',
                'sibling_used',
                'size_bytes'
            ), true ) ) {
                $placeholders[] = '%d';
                $args[] = absint( $value );
                continue;
            }
            $placeholders[] = '%s';
            $args[] = (string) $value;
        }
        $values_sql = implode( ',', $placeholders );
        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Table/cols/values from esc_sql and known keys; variadic $args.
        // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $col_sql and $values_sql are built from fixed array keys and placeholder tokens only; all dynamic values are passed via $args to prepare()
        $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$table_escaped} ({$col_sql}) VALUES ({$values_sql})", ...$args ) );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
    }
}

/**
 * Count scan items for a run (optionally filtered by type).
 *
 * @param int         $run_id Run ID.
 * @param string|null $type   Optional type filter.
 * @return int
 */
function pt_wuclean_image_scan_db_count_items(  $run_id, $type = null  ) {
    global $wpdb;
    $run_id = absint( $run_id );
    if ( $run_id <= 0 ) {
        return 0;
    }
    $table = pt_wuclean_image_scan_db_items_table();
    $table_escaped = esc_sql( $table );
    if ( is_string( $type ) && '' !== $type ) {
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table from esc_sql().
        return (int) $wpdb->get_var( $wpdb->prepare( 
            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table_escaped from esc_sql
            "SELECT COUNT(*) FROM {$table_escaped} WHERE run_id = %d AND type = %s",
            $run_id,
            $type
         ) );
    }
    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table from esc_sql().
    return (int) $wpdb->get_var( $wpdb->prepare( 
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table_escaped from esc_sql
        "SELECT COUNT(*) FROM {$table_escaped} WHERE run_id = %d",
        $run_id
     ) );
}

/**
 * Get compact counts for a run (total + per type).
 *
 * @param int $run_id Run ID.
 * @return array Keys: total, media_library, orphaned_file.
 */
function pt_wuclean_image_scan_db_get_run_counts(  $run_id  ) {
    return array(
        'total'         => pt_wuclean_image_scan_db_count_items( $run_id, null ),
        'media_library' => pt_wuclean_image_scan_db_count_items( $run_id, 'media_library' ),
        'orphaned_file' => pt_wuclean_image_scan_db_count_items( $run_id, 'orphaned_file' ),
    );
}

/**
 * Delete Media Library issue items by attachment ids (used for refinement).
 *
 * @param int   $run_id          Run ID.
 * @param int[] $attachment_ids  Attachment IDs to remove from results.
 * @return void
 */
function pt_wuclean_image_scan_db_delete_media_library_items_by_ids(  $run_id, array $attachment_ids  ) {
    global $wpdb;
    $run_id = absint( $run_id );
    if ( $run_id <= 0 ) {
        return;
    }
    $attachment_ids = array_values( array_filter( array_map( 'absint', $attachment_ids ) ) );
    if ( empty( $attachment_ids ) ) {
        return;
    }
    $table = pt_wuclean_image_scan_db_items_table();
    $table_escaped = esc_sql( $table );
    $placeholders = implode( ',', array_fill( 0, count( $attachment_ids ), '%d' ) );
    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
    // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Table from esc_sql(); dynamic IN from absint IDs; variadic $attachment_ids.
    $wpdb->query( $wpdb->prepare(
        "DELETE FROM {$table_escaped} WHERE run_id = %d AND type = %s AND object_id IN ({$placeholders})",
        $run_id,
        'media_library',
        ...$attachment_ids
    ) );
    // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
}

/**
 * Start a new scan run (latest-only: deletes older completed runs).
 *
 * @param string $mode 'manual' or 'scheduled'.
 * @param array  $settings Image settings snapshot.
 * @return array{run_id:int,progress:array}|WP_Error
 */
function pt_wuclean_image_scan_engine_start_new_run(  $mode, array $settings  ) {
    $mode = ( is_string( $mode ) ? $mode : 'manual' );
    $latest = pt_wuclean_image_scan_db_get_latest_run();
    if ( is_array( $latest ) && !empty( $latest['status'] ) ) {
        $status = (string) $latest['status'];
        $latest_mode = ( isset( $latest['mode'] ) ? (string) $latest['mode'] : '' );
        $latest_id = absint( $latest['id'] ?? 0 );
        if ( 'running' === $status ) {
            return new WP_Error('wuclean_scan_exists', __( 'A scan is already in progress or paused. Please resume or stop it before starting a new scan.', 'wuclean-database-cleaner-for-woocommerce' ));
        }
        // Manual scans: "stopped/failed" runs are treated as cancelled; auto-discard and start fresh.
        if ( in_array( $status, array('stopped', 'failed'), true ) && 'manual' === $latest_mode ) {
            if ( $latest_id > 0 ) {
                pt_wuclean_image_scan_db_delete_run( $latest_id );
            }
        }
    }
    // Keep previous completed run until a new run completes successfully.
    // Retention pruning happens on completion in pt_wuclean_image_scan_engine_advance_run().
    $batch_size = pt_wuclean_image_scan_resolve_batch_size( $settings, 'start' );
    $progress = array(
        'status'             => 'running',
        'phase'              => 'media_library',
        'started_at'         => time(),
        'batch_size'         => $batch_size,
        'total_images'       => pt_wuclean_image_scan_engine_get_total_media_images(),
        'last_attachment_id' => 0,
        'processed_media'    => 0,
        'uploads_processed'  => 0,
        'uploads_total'      => 0,
    );
    $run_id = pt_wuclean_image_scan_db_create_run( $mode, $settings, $progress );
    if ( $run_id <= 0 ) {
        return new WP_Error('wuclean_scan_create_failed', __( 'Failed to start scan.', 'wuclean-database-cleaner-for-woocommerce' ));
    }
    return array(
        'run_id'   => $run_id,
        'progress' => $progress,
    );
}

/**
 * Re-check a chunk of orphaned_file paths against the Media Library.
 *
 * @param int      $run_id Run ID.
 * @param string[] $paths  Absolute or relative file paths.
 * @return int Number of rows removed.
 */
function pt_wuclean_image_scan_engine_refine_orphaned_paths_chunk(  $run_id, array $paths  ) {
    $run_id = absint( $run_id );
    if ( $run_id <= 0 || empty( $paths ) ) {
        return 0;
    }
    $upload_dir = wp_upload_dir();
    $base_dir = ( isset( $upload_dir['basedir'] ) && is_string( $upload_dir['basedir'] ) ? (string) $upload_dir['basedir'] : '' );
    $base_dir_normalized = wp_normalize_path( $base_dir );
    $base_dir_normalized = ( '' !== $base_dir_normalized ? trailingslashit( $base_dir_normalized ) : '' );
    $relpaths = array();
    foreach ( $paths as $path ) {
        if ( !is_string( $path ) || '' === $path ) {
            continue;
        }
        $norm = wp_normalize_path( $path );
        if ( '' === $norm ) {
            continue;
        }
        $rel = '';
        if ( '' !== $base_dir_normalized && 0 === stripos( $norm, $base_dir_normalized ) ) {
            $rel = ltrim( substr( $norm, strlen( $base_dir_normalized ) ), '/\\' );
        } else {
            $rel = ltrim( $norm, '/\\' );
        }
        $key = ( function_exists( 'pt_wuclean_normalize_uploads_attached_relpath' ) ? pt_wuclean_normalize_uploads_attached_relpath( $rel ) : ltrim( wp_normalize_path( $rel ), '/\\' ) );
        if ( '' !== $key ) {
            $relpaths[] = $key;
        }
    }
    if ( empty( $relpaths ) ) {
        return 0;
    }
    $registered = pt_wuclean_image_scan_engine_lookup_registered_relpaths_in_media_library( array_values( array_unique( $relpaths ) ) );
    if ( empty( $registered ) ) {
        return 0;
    }
    $remove_paths = array();
    foreach ( $paths as $path ) {
        if ( !is_string( $path ) || '' === $path ) {
            continue;
        }
        $norm = wp_normalize_path( $path );
        if ( '' === $norm ) {
            continue;
        }
        $rel = '';
        if ( '' !== $base_dir_normalized && 0 === stripos( $norm, $base_dir_normalized ) ) {
            $rel = ltrim( substr( $norm, strlen( $base_dir_normalized ) ), '/\\' );
        } else {
            $rel = ltrim( $norm, '/\\' );
        }
        $key = ( function_exists( 'pt_wuclean_normalize_uploads_attached_relpath' ) ? pt_wuclean_normalize_uploads_attached_relpath( $rel ) : ltrim( wp_normalize_path( $rel ), '/\\' ) );
        if ( '' !== $key && isset( $registered[$key] ) ) {
            $remove_paths[] = $norm;
        }
    }
    $remove_paths = array_values( array_unique( $remove_paths ) );
    if ( empty( $remove_paths ) ) {
        return 0;
    }
    pt_wuclean_image_scan_db_delete_orphaned_file_items_by_paths( $run_id, $remove_paths );
    return count( $remove_paths );
}

/**
 * Mark scan complete (status, counts, retention) after refinement finishes.
 *
 * @param int   $run_id   Run ID.
 * @param array $progress Progress array (may include refinement_* counters).
 * @return array Completion payload compatible with existing JS.
 */
function pt_wuclean_image_scan_engine_complete_run(  $run_id, array $progress = array()  ) {
    $run_id = absint( $run_id );
    if ( $run_id <= 0 ) {
        return array(
            'completed'  => true,
            'percentage' => 100,
            'message'    => __( 'Scan complete!', 'wuclean-database-cleaner-for-woocommerce' ),
        );
    }
    $run_row = pt_wuclean_image_scan_db_get_run_by_id( $run_id );
    if ( is_array( $run_row ) ) {
    }
    $refinement_count = ( isset( $progress['refinement_media_removed'] ) ? absint( $progress['refinement_media_removed'] ) : 0 );
    $orphaned_file_refinement_count = ( isset( $progress['refinement_orphan_removed'] ) ? absint( $progress['refinement_orphan_removed'] ) : 0 );
    $counts = pt_wuclean_image_scan_db_get_run_counts( $run_id );
    $counts['refinement_count'] = $refinement_count;
    $counts['orphaned_file_refinement_count'] = $orphaned_file_refinement_count;
    pt_wuclean_image_scan_db_update_status( $run_id, 'completed', $counts );
    pt_wuclean_image_scan_db_delete_completed_runs_except( $run_id );
    return array(
        'completed'                      => true,
        'percentage'                     => 100,
        'total_issues'                   => (int) ($counts['total'] ?? 0),
        'refinement_count'               => $refinement_count,
        'orphaned_file_refinement_count' => $orphaned_file_refinement_count,
        'message'                        => __( 'Scan complete!', 'wuclean-database-cleaner-for-woocommerce' ),
    );
}

/**
 * Enter refinement phase and return an in-progress AJAX payload.
 *
 * @param int   $run_id   Run ID.
 * @param array $progress Progress array.
 * @param array $settings Run settings snapshot (for step plan / scope).
 * @return array
 */
function pt_wuclean_image_scan_engine_begin_refinement_phase(  $run_id, array $progress, array $settings = array()  ) {
    $progress = pt_wuclean_image_scan_engine_init_refinement_progress( $run_id, $progress );
    pt_wuclean_image_scan_db_update_progress( $run_id, $progress );
    if ( pt_wuclean_image_scan_engine_get_refinement_total( $progress ) <= 0 ) {
        return pt_wuclean_image_scan_engine_complete_run( $run_id, $progress );
    }
    return pt_wuclean_image_scan_engine_build_refinement_progress_response( $run_id, $progress, $settings );
}

/**
 * Advance one refinement batch (media_library issues, then orphaned_file paths).
 *
 * @param int   $run_id     Run ID.
 * @param array $progress   Progress array.
 * @param int   $batch      Batch size.
 * @param float $started_at microtime( true ) batch start.
 * @param array $settings   Run settings snapshot (for step plan / scope).
 * @return array|true True when refinement is fully complete; array = in-progress payload.
 */
function pt_wuclean_image_scan_engine_advance_refinement_phase(
    $run_id,
    array $progress,
    $batch,
    $started_at,
    array $settings = array()
) {
    global $wpdb;
    $batch = max( 1, absint( $batch ) );
    $step = ( isset( $progress['refinement_step'] ) ? (string) $progress['refinement_step'] : 'media_library' );
    $offset = ( isset( $progress['refinement_offset'] ) ? absint( $progress['refinement_offset'] ) : 0 );
    $items_table = pt_wuclean_image_scan_db_items_table();
    $items_table_escaped = esc_sql( $items_table );
    if ( 'media_library' === $step ) {
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table from esc_sql().
        $unused_ids = $wpdb->get_col( $wpdb->prepare(
            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $items_table_escaped from esc_sql().
            "SELECT DISTINCT object_id FROM {$items_table_escaped} WHERE run_id = %d AND type = %s AND object_id > 0 ORDER BY object_id ASC LIMIT %d OFFSET %d",
            $run_id,
            'media_library',
            $batch,
            $offset
        ) );
        $unused_ids = array_values( array_filter( array_map( 'absint', ( is_array( $unused_ids ) ? $unused_ids : array() ) ) ) );
        if ( empty( $unused_ids ) ) {
            $progress['refinement_step'] = 'orphaned_files';
            $progress['refinement_offset'] = 0;
            pt_wuclean_image_scan_db_update_progress( $run_id, $progress );
            if ( pt_wuclean_image_scan_engine_is_budget_exceeded( $started_at ) ) {
                return pt_wuclean_image_scan_engine_build_refinement_progress_response( $run_id, $progress, $settings );
            }
            return pt_wuclean_image_scan_engine_advance_refinement_phase(
                $run_id,
                $progress,
                $batch,
                $started_at,
                $settings
            );
        }
        if ( pt_wuclean_image_scan_engine_is_budget_exceeded( $started_at ) ) {
            pt_wuclean_image_scan_db_update_progress( $run_id, $progress );
            return pt_wuclean_image_scan_engine_build_refinement_progress_response( $run_id, $progress, $settings );
        }
        $actually_used_ids = pt_wuclean_batch_refine_unused_images( $unused_ids );
        $actually_used_ids = ( is_array( $actually_used_ids ) ? array_values( array_filter( array_map( 'absint', $actually_used_ids ) ) ) : array() );
        if ( !empty( $actually_used_ids ) ) {
            pt_wuclean_image_scan_db_delete_media_library_items_by_ids( $run_id, $actually_used_ids );
            $progress['refinement_media_removed'] = ( isset( $progress['refinement_media_removed'] ) ? absint( $progress['refinement_media_removed'] ) + count( $actually_used_ids ) : count( $actually_used_ids ) );
        }
        $progress['refinement_offset'] = $offset + count( $unused_ids );
        pt_wuclean_image_scan_db_update_progress( $run_id, $progress );
        if ( count( $unused_ids ) < $batch || pt_wuclean_image_scan_engine_is_budget_exceeded( $started_at ) ) {
            if ( count( $unused_ids ) < $batch && !pt_wuclean_image_scan_engine_is_budget_exceeded( $started_at ) ) {
                $progress['refinement_step'] = 'orphaned_files';
                $progress['refinement_offset'] = 0;
                pt_wuclean_image_scan_db_update_progress( $run_id, $progress );
                return pt_wuclean_image_scan_engine_advance_refinement_phase(
                    $run_id,
                    $progress,
                    $batch,
                    $started_at,
                    $settings
                );
            }
            return pt_wuclean_image_scan_engine_build_refinement_progress_response( $run_id, $progress, $settings );
        }
        return pt_wuclean_image_scan_engine_build_refinement_progress_response( $run_id, $progress, $settings );
    }
    if ( 'orphaned_files' === $step ) {
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table from esc_sql().
        $paths = $wpdb->get_col( $wpdb->prepare(
            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $items_table_escaped from esc_sql().
            "SELECT DISTINCT path FROM {$items_table_escaped} WHERE run_id = %d AND type = %s AND path IS NOT NULL AND path <> '' ORDER BY path ASC LIMIT %d OFFSET %d",
            $run_id,
            'orphaned_file',
            $batch,
            $offset
        ) );
        $paths = ( is_array( $paths ) ? array_values( array_filter( array_map( 'strval', $paths ) ) ) : array() );
        if ( empty( $paths ) ) {
            return true;
        }
        if ( pt_wuclean_image_scan_engine_is_budget_exceeded( $started_at ) ) {
            pt_wuclean_image_scan_db_update_progress( $run_id, $progress );
            return pt_wuclean_image_scan_engine_build_refinement_progress_response( $run_id, $progress, $settings );
        }
        $removed = pt_wuclean_image_scan_engine_refine_orphaned_paths_chunk( $run_id, $paths );
        if ( $removed > 0 ) {
            $progress['refinement_orphan_removed'] = ( isset( $progress['refinement_orphan_removed'] ) ? absint( $progress['refinement_orphan_removed'] ) + $removed : $removed );
        }
        $progress['refinement_offset'] = $offset + count( $paths );
        pt_wuclean_image_scan_db_update_progress( $run_id, $progress );
        if ( count( $paths ) < $batch ) {
            return true;
        }
        return pt_wuclean_image_scan_engine_build_refinement_progress_response( $run_id, $progress, $settings );
    }
    return true;
}

/**
 * Localized media-library scan progress message.
 *
 * @param int $processed_media Processed image count.
 * @param int $total_images    Total images in library.
 * @return string
 */
function pt_wuclean_image_scan_engine_media_library_progress_message(  $processed_media, $total_images  ) {
    /* translators: %1$d: processed images, %2$d: total images */
    $format = __( 'Scanning Media Library... %1$d of %2$d images', 'wuclean-database-cleaner-for-woocommerce' );
    return sprintf( $format, absint( $processed_media ), absint( $total_images ) );
}

/**
 * Advance one batch for a running scan run.
 *
 * @param int    $run_id Run ID.
 * @param string $owner  'manual' or 'scheduled' (for lock/error messages).
 * @return array|WP_Error Response payload compatible with existing JS.
 */
function pt_wuclean_image_scan_engine_advance_run(  $run_id, $owner  ) {
    $run_id = absint( $run_id );
    $owner = ( is_string( $owner ) ? $owner : 'manual' );
    $latest = pt_wuclean_image_scan_db_get_latest_run();
    if ( !is_array( $latest ) || absint( $latest['id'] ?? 0 ) !== $run_id ) {
        return new WP_Error('wuclean_run_not_found', __( 'No active scan found.', 'wuclean-database-cleaner-for-woocommerce' ));
    }
    if ( (string) ($latest['status'] ?? '') !== 'running' ) {
        return new WP_Error('wuclean_run_not_running', __( 'Scan is not running.', 'wuclean-database-cleaner-for-woocommerce' ));
    }
    $progress = pt_wuclean_image_scan_db_decode_json_field( $latest['progress_json'] ?? '' );
    $phase = ( isset( $progress['phase'] ) ? (string) $progress['phase'] : 'media_library' );
    $settings = pt_wuclean_image_scan_db_decode_json_field( $latest['settings_json'] ?? '' );
    if ( !is_array( $settings ) ) {
        $settings = array();
    }
    $settings['scan_scope'] = pt_wuclean_image_scan_engine_normalize_scan_scope( ( isset( $settings['scan_scope'] ) ? (string) $settings['scan_scope'] : 'media_only' ) );
    $batch = ( isset( $progress['batch_size'] ) ? absint( $progress['batch_size'] ) : pt_wuclean_image_scan_resolve_batch_size( $settings, 'advance' ) );
    $started_at = microtime( true );
    if ( 'uploads_folder' === $phase && !pt_wuclean_image_scan_engine_combined_scan_allowed() ) {
        return pt_wuclean_image_scan_engine_begin_refinement_phase( $run_id, $progress, $settings );
    }
    // -------------------------------------------------------------------------
    // Phase 3: Refinement (chunked cross-check)
    // -------------------------------------------------------------------------
    if ( 'refinement' === $phase ) {
        $refinement_result = pt_wuclean_image_scan_engine_advance_refinement_phase(
            $run_id,
            $progress,
            $batch,
            $started_at,
            $settings
        );
        if ( true === $refinement_result ) {
            $run_row = pt_wuclean_image_scan_db_get_run_by_id( $run_id );
            $latest_progress = ( is_array( $run_row ) ? pt_wuclean_image_scan_db_decode_json_field( $run_row['progress_json'] ?? '' ) : $progress );
            return pt_wuclean_image_scan_engine_complete_run( $run_id, ( is_array( $latest_progress ) ? $latest_progress : $progress ) );
        }
        return $refinement_result;
    }
    // -------------------------------------------------------------------------
    // Phase 1: Media Library (cursor-based)
    // -------------------------------------------------------------------------
    if ( 'media_library' === $phase ) {
        $last_id = ( isset( $progress['last_attachment_id'] ) ? absint( $progress['last_attachment_id'] ) : 0 );
        $total_images = ( isset( $progress['total_images'] ) ? absint( $progress['total_images'] ) : 0 );
        $scan_scope = ( isset( $settings['scan_scope'] ) ? (string) $settings['scan_scope'] : 'media_only' );
        $image_ids = pt_wuclean_image_scan_engine_get_media_image_ids_after( $last_id, $batch );
        if ( empty( $image_ids ) ) {
            if ( 'media_and_uploads' === $scan_scope ) {
            }
            return pt_wuclean_image_scan_engine_begin_refinement_phase( $run_id, $progress, $settings );
        }
        if ( pt_wuclean_image_scan_engine_is_budget_exceeded( $started_at ) ) {
            pt_wuclean_image_scan_db_update_progress( $run_id, $progress );
            $processed_media = ( isset( $progress['processed_media'] ) ? absint( $progress['processed_media'] ) : 0 );
            return pt_wuclean_image_scan_engine_build_progress_response(
                $run_id,
                $progress,
                array(
                    'message' => pt_wuclean_image_scan_engine_media_library_progress_message( $processed_media, $total_images ),
                ),
                $settings
            );
        }
        // Build issues for this batch using the existing logic (issues only).
        $issues = pt_wuclean_image_scan_engine_process_media_library_batch( $image_ids, array() );
        if ( !empty( $issues ) ) {
            pt_wuclean_image_scan_db_insert_items( $run_id, $issues );
        }
        $progress['last_attachment_id'] = max( $image_ids );
        $progress['processed_media'] = ( isset( $progress['processed_media'] ) ? absint( $progress['processed_media'] ) + count( $image_ids ) : count( $image_ids ) );
        pt_wuclean_image_scan_db_update_progress( $run_id, $progress );
        if ( pt_wuclean_image_scan_engine_is_budget_exceeded( $started_at ) ) {
            return pt_wuclean_image_scan_engine_build_progress_response(
                $run_id,
                $progress,
                array(
                    'message' => pt_wuclean_image_scan_engine_media_library_progress_message( $progress['processed_media'], $total_images ),
                ),
                $settings
            );
        }
        return pt_wuclean_image_scan_engine_build_progress_response(
            $run_id,
            $progress,
            array(
                'message' => pt_wuclean_image_scan_engine_media_library_progress_message( $progress['processed_media'], $total_images ),
            ),
            $settings
        );
    }
    // -------------------------------------------------------------------------
    // Phase 2: Uploads folder (incremental enumeration)
    // -------------------------------------------------------------------------
    if ( 'uploads_folder' === $phase ) {
        return pt_wuclean_image_scan_engine_begin_refinement_phase( $run_id, $progress, $settings );
    }
    return new WP_Error('wuclean_invalid_phase', __( 'Invalid scan state.', 'wuclean-database-cleaner-for-woocommerce' ));
}

/**
 * Finalize a scan run (backward-compatible alias for completion after refinement).
 *
 * @param int $run_id Run ID.
 * @return array Completion payload compatible with existing JS.
 */
function pt_wuclean_image_scan_engine_finalize_run(  $run_id  ) {
    $run_id = absint( $run_id );
    $run_row = pt_wuclean_image_scan_db_get_run_by_id( $run_id );
    $progress = ( is_array( $run_row ) ? pt_wuclean_image_scan_db_decode_json_field( $run_row['progress_json'] ?? '' ) : array() );
    return pt_wuclean_image_scan_engine_complete_run( $run_id, ( is_array( $progress ) ? $progress : array() ) );
}

// -----------------------------------------------------------------------------
// Locking
// -----------------------------------------------------------------------------
/**
 * Acquire the image scan lock.
 *
 * @param string $owner 'manual' or 'scheduled'.
 * @return bool True if acquired (or already held by same owner), false otherwise.
 */
function pt_wuclean_image_scan_lock_acquire(  $owner  ) {
    $owner = ( is_string( $owner ) ? $owner : '' );
    if ( '' === $owner ) {
        return false;
    }
    $lock_key = 'pt_wuclean_image_scan_lock';
    $existing = get_transient( $lock_key );
    if ( is_array( $existing ) && !empty( $existing['owner'] ) ) {
        if ( $existing['owner'] === $owner ) {
            $existing['heartbeat'] = time();
            set_transient( $lock_key, $existing, 2 * HOUR_IN_SECONDS );
            return true;
        }
        // Stale lock recovery: allow takeover if heartbeat is too old.
        $heartbeat = ( isset( $existing['heartbeat'] ) ? absint( $existing['heartbeat'] ) : 0 );
        $now = time();
        $age = ( $heartbeat > 0 ? $now - $heartbeat : PHP_INT_MAX );
        $stale_ttl = (int) apply_filters( 'pt_wuclean_image_scan_lock_stale_ttl', 15 * MINUTE_IN_SECONDS );
        $stale_ttl = max( 60, $stale_ttl );
        if ( $age < $stale_ttl ) {
            // Another owner actively holds the lock.
            return false;
        }
    }
    $lock = array(
        'owner'     => $owner,
        'started'   => time(),
        'heartbeat' => time(),
    );
    return (bool) set_transient( $lock_key, $lock, 2 * HOUR_IN_SECONDS );
}

/**
 * Refresh the scan lock heartbeat.
 *
 * @param string $owner 'manual' or 'scheduled'.
 * @return void
 */
function pt_wuclean_image_scan_lock_touch(  $owner  ) {
    $lock_key = 'pt_wuclean_image_scan_lock';
    $existing = get_transient( $lock_key );
    if ( is_array( $existing ) && !empty( $existing['owner'] ) && $existing['owner'] === $owner ) {
        $existing['heartbeat'] = time();
        set_transient( $lock_key, $existing, 2 * HOUR_IN_SECONDS );
    }
}

/**
 * Release the image scan lock if owned.
 *
 * @param string $owner 'manual' or 'scheduled'.
 * @return void
 */
function pt_wuclean_image_scan_lock_release(  $owner  ) {
    $lock_key = 'pt_wuclean_image_scan_lock';
    $existing = get_transient( $lock_key );
    if ( is_array( $existing ) && !empty( $existing['owner'] ) && $existing['owner'] === $owner ) {
        delete_transient( $lock_key );
    }
}

// -----------------------------------------------------------------------------
// Media Library batching (cursor-based)
// -----------------------------------------------------------------------------
/**
 * Count total image attachments in the Media Library.
 *
 * @return int
 */
function pt_wuclean_image_scan_engine_get_total_media_images() {
    global $wpdb;
    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
    $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(ID) FROM {$wpdb->posts}\n\t\t\t WHERE post_type = 'attachment'\n\t\t\t AND post_status = 'inherit'\n\t\t\t AND post_mime_type LIKE %s", 'image/%' ) );
    return max( 0, $total );
}

/**
 * Get next batch of image attachment IDs after a given ID.
 *
 * @param int $after_id Last processed attachment ID.
 * @param int $limit    Batch size.
 * @return int[]
 */
function pt_wuclean_image_scan_engine_get_media_image_ids_after(  $after_id, $limit  ) {
    global $wpdb;
    $after_id = absint( $after_id );
    $limit = max( 1, absint( $limit ) );
    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
    $ids = $wpdb->get_col( $wpdb->prepare(
        "SELECT ID FROM {$wpdb->posts}\n\t\t\t WHERE post_type = 'attachment'\n\t\t\t AND post_status = 'inherit'\n\t\t\t AND post_mime_type LIKE %s\n\t\t\t AND ID > %d\n\t\t\t ORDER BY ID ASC\n\t\t\t LIMIT %d",
        'image/%',
        $after_id,
        $limit
    ) );
    if ( empty( $ids ) || !is_array( $ids ) ) {
        return array();
    }
    return array_values( array_filter( array_map( 'absint', $ids ) ) );
}

// -----------------------------------------------------------------------------
// Shared detection logic (bulk checks + issue building)
// -----------------------------------------------------------------------------
/**
 * Process a batch of Media Library attachment IDs and append issues for unused images.
 *
 * This mirrors the existing bulk-query approach from `images.php` to keep behavior consistent.
 *
 * @param int[] $image_ids   Attachment IDs to check.
 * @param array $issues_found Existing issues array to append to.
 * @return array Updated issues array.
 */
function pt_wuclean_image_scan_engine_process_media_library_batch(  array $image_ids, array $issues_found  ) {
    if ( empty( $image_ids ) ) {
        return $issues_found;
    }
    global $wpdb;
    // Exclude protected images (same behavior as existing code).
    $protected_images = pt_wuclean_get_protected_images();
    if ( !empty( $protected_images ) ) {
        $image_ids = array_values( array_diff( $image_ids, $protected_images ) );
    }
    if ( empty( $image_ids ) ) {
        return $issues_found;
    }
    $used_image_ids = array();
    $d_placeholders = implode( ', ', array_fill( 0, count( $image_ids ), '%d' ) );
    $s_placeholders = implode( ', ', array_fill( 0, count( $image_ids ), '%s' ) );
    $id_strings = array_map( static function ( $id ) {
        return (string) absint( $id );
    }, $image_ids );
    $st_status_parts = pt_wuclean_image_scan_sql_post_status_in_parts();
    $st_in_sql = $st_status_parts['in'];
    // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- placeholders from absint IDs; variadic args.
    // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $st_in_sql is SQL structure from fixed post statuses; image IDs use prepare placeholders.
    $featured_images = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT CAST(pm.meta_value AS UNSIGNED) FROM {$wpdb->postmeta} pm\n\t\t\t INNER JOIN {$wpdb->posts} p ON pm.post_id = p.ID\n\t\t\t WHERE pm.meta_key = '_thumbnail_id'\n\t\t\t AND p.post_status IN ({$st_in_sql})\n\t\t\t AND ( CAST(pm.meta_value AS UNSIGNED) IN ({$d_placeholders}) OR pm.meta_value IN ({$s_placeholders}) )", ...array_merge( $st_status_parts['statuses'], $image_ids, $id_strings ) ) );
    // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
    $used_image_ids = array_merge( $used_image_ids, $featured_images );
    // 2. Product galleries.
    $gallery_placeholders = array();
    $gallery_args = array();
    foreach ( $image_ids as $id ) {
        $gallery_placeholders[] = 'pm.meta_value LIKE %s';
        $gallery_args[] = '%' . $wpdb->esc_like( (string) $id ) . '%';
    }
    if ( !empty( $gallery_placeholders ) ) {
        $gallery_placeholders_str = implode( ' OR ', $gallery_placeholders );
        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $gallery_placeholders_str; variadic $gallery_args.
        // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $gallery_placeholders_str is SQL structure only; all dynamic values are passed via $gallery_args into prepare().
        $gallery_results = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT pm.meta_value FROM {$wpdb->postmeta} pm\n\t\t\t\t INNER JOIN {$wpdb->posts} p ON pm.post_id = p.ID\n\t\t\t\t WHERE pm.meta_key = '_product_image_gallery'\n\t\t\t\t AND p.post_type IN ('product', 'product_variation')\n\t\t\t\t AND p.post_status IN ({$st_in_sql})\n\t\t\t\t AND ({$gallery_placeholders_str})", ...array_merge( $st_status_parts['statuses'], $gallery_args ) ) );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
        foreach ( $gallery_results as $gallery_value ) {
            $gallery_ids = array_map( 'intval', explode( ',', (string) $gallery_value ) );
            foreach ( $gallery_ids as $gid ) {
                if ( in_array( $gid, $image_ids, true ) ) {
                    $used_image_ids[] = $gid;
                }
            }
        }
    }
    // 3. Term thumbnails.
    // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- placeholders from absint IDs.
    // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- placeholders; variadic args.
    $term_thumbnails = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT CAST(meta_value AS UNSIGNED) FROM {$wpdb->termmeta}\n\t\t\t WHERE meta_key = 'thumbnail_id'\n\t\t\t AND ( CAST(meta_value AS UNSIGNED) IN ({$d_placeholders}) OR meta_value IN ({$s_placeholders}) )", ...array_merge( $image_ids, $id_strings ) ) );
    // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
    $used_image_ids = array_merge( $used_image_ids, $term_thumbnails );
    // 4. Custom logo.
    $custom_logo_id = get_theme_mod( 'custom_logo' );
    if ( $custom_logo_id && in_array( absint( $custom_logo_id ), $image_ids, true ) ) {
        $used_image_ids[] = absint( $custom_logo_id );
    }
    // 5. Site icon.
    $site_icon_id = get_option( 'site_icon' );
    if ( $site_icon_id && in_array( absint( $site_icon_id ), $image_ids, true ) ) {
        $used_image_ids[] = absint( $site_icon_id );
    }
    // 6. WooCommerce catalog placeholder image (attachment ID in option).
    $wc_placeholder = absint( get_option( 'woocommerce_placeholder_image', 0 ) );
    if ( $wc_placeholder > 0 && in_array( $wc_placeholder, $image_ids, true ) ) {
        $used_image_ids[] = $wc_placeholder;
    }
    // 7. Images referenced in content/excerpt via wp-image-{id} (post types from pt_wuclean_image_scan_content_post_types()).
    $content_placeholders = array();
    $content_args = array();
    foreach ( $image_ids as $id ) {
        $content_placeholders[] = '(post_content LIKE %s OR post_excerpt LIKE %s)';
        $like_pattern = '%wp-image-' . intval( $id ) . '%';
        $content_args[] = $like_pattern;
        $content_args[] = $like_pattern;
    }
    if ( !empty( $content_placeholders ) ) {
        $placeholders_str = implode( ' OR ', $content_placeholders );
        $pt_in = pt_wuclean_image_scan_sql_post_type_in_parts();
        $in_sql = $pt_in['in'];
        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $placeholders_str; variadic $content_args.
        // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $placeholders_str is SQL structure only; all dynamic values are in prepare args.
        $content_rows = $wpdb->get_results( $wpdb->prepare( "SELECT post_content, post_excerpt FROM {$wpdb->posts}\n\t\t\t\t WHERE post_status IN ({$st_in_sql})\n\t\t\t\t AND post_type IN ({$in_sql})\n\t\t\t\t AND ({$placeholders_str})\n\t\t\t\t LIMIT 100", ...array_merge( $st_status_parts['statuses'], $pt_in['types'], $content_args ) ) );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
        if ( $content_rows ) {
            foreach ( $content_rows as $post_data ) {
                $post_content = ( isset( $post_data->post_content ) ? (string) $post_data->post_content : '' );
                $post_excerpt = ( isset( $post_data->post_excerpt ) ? (string) $post_data->post_excerpt : '' );
                $combined_content = $post_content . ' ' . $post_excerpt;
                if ( '' !== $combined_content && preg_match_all( '/wp-image-(\\d+)/i', $combined_content, $matches ) ) {
                    foreach ( $matches[1] as $found_id ) {
                        $found_id = intval( $found_id );
                        if ( in_array( $found_id, $image_ids, true ) ) {
                            $used_image_ids[] = $found_id;
                        }
                    }
                }
            }
        }
    }
    $used_image_ids = array_unique( array_map( 'absint', $used_image_ids ) );
    $unused_in_batch = array_diff( $image_ids, $used_image_ids );
    foreach ( $unused_in_batch as $unused_id ) {
        $unused_id = absint( $unused_id );
        if ( $unused_id <= 0 ) {
            continue;
        }
        $usage_check = pt_wuclean_is_attachment_used( $unused_id );
        if ( isset( $usage_check['used'] ) && true === $usage_check['used'] ) {
            continue;
        }
        $siblings = pt_wuclean_has_format_siblings( $unused_id );
        // Check for cropped children.
        $original_file = get_attached_file( $unused_id );
        $has_cropped_children = false;
        if ( $original_file ) {
            $filename = basename( $original_file );
            $filename_no_ext = pathinfo( $filename, PATHINFO_FILENAME );
            $has_cropped_children = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->postmeta} pm\n\t\t\t\t\t INNER JOIN {$wpdb->posts} p ON pm.post_id = p.ID\n\t\t\t\t\t WHERE pm.meta_key = '_wp_attached_file'\n\t\t\t\t\t AND pm.meta_value LIKE %s\n\t\t\t\t\t AND p.post_type = 'attachment'\n\t\t\t\t\t AND p.post_status = 'inherit'\n\t\t\t\t\t AND pm.post_id != %d", '%cropped-' . $wpdb->esc_like( $filename_no_ext ) . '%', $unused_id ) );
        }
        $issues_found[] = array(
            'id'      => $unused_id,
            'type'    => 'media_library',
            'status'  => 'unused',
            'issue'   => __( 'Media Library image not found in typical WooCommerce locations.', 'wuclean-database-cleaner-for-woocommerce' ),
            'data'    => array(
                'siblings'             => ( $siblings ? $siblings : null ),
                'has_cropped_children' => $has_cropped_children > 0,
            ),
            'subtype' => ( 0 < $has_cropped_children ? 'cropped' : null ),
            'warning' => ( 0 < $has_cropped_children ? __( 'Cropped Version Warning', 'wuclean-database-cleaner-for-woocommerce' ) : null ),
        );
    }
    return $issues_found;
}

/**
 * Which relative upload paths are registered in the Media Library (_wp_attached_file), using the index when available.
 *
 * @param string[] $relative_paths_list Relative paths under uploads (forward slashes, no leading slash).
 * @return array<string,true> Map of normalized relative path => true.
 */
function pt_wuclean_image_scan_engine_lookup_registered_relpaths_in_media_library(  array $relative_paths_list  ) {
    global $wpdb;
    $unique = array();
    foreach ( $relative_paths_list as $p ) {
        if ( !is_string( $p ) || '' === $p ) {
            continue;
        }
        $n = ( function_exists( 'pt_wuclean_normalize_uploads_attached_relpath' ) ? pt_wuclean_normalize_uploads_attached_relpath( $p ) : ltrim( wp_normalize_path( $p ), '/\\' ) );
        if ( '' !== $n ) {
            $unique[$n] = true;
        }
    }
    $list = array_keys( $unique );
    if ( empty( $list ) ) {
        return array();
    }
    $media_library_relpaths = array();
    if ( function_exists( 'pt_wuclean_attachment_index_lookup_relpaths' ) ) {
        $media_library_relpaths = pt_wuclean_attachment_index_lookup_relpaths( $list );
    }
    if ( !is_array( $media_library_relpaths ) ) {
        $media_library_relpaths = array();
    }
    if ( !empty( $media_library_relpaths ) ) {
        $normalized_index = array();
        foreach ( array_keys( $media_library_relpaths ) as $idx_key ) {
            $nk = ( function_exists( 'pt_wuclean_normalize_uploads_attached_relpath' ) ? pt_wuclean_normalize_uploads_attached_relpath( (string) $idx_key ) : ltrim( wp_normalize_path( (string) $idx_key ), '/\\' ) );
            if ( '' !== $nk ) {
                $normalized_index[$nk] = true;
            }
        }
        $media_library_relpaths = $normalized_index;
    }
    if ( !empty( $media_library_relpaths ) ) {
        $index_hit_paths = array_keys( $media_library_relpaths );
        $verified_hits = array();
        $chunk_size = 200;
        foreach ( array_chunk( $index_hit_paths, $chunk_size ) as $chunk ) {
            $placeholders = implode( ', ', array_fill( 0, count( $chunk ), '%s' ) );
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $placeholders from fixed count; values bound via prepare.
            $rows = $wpdb->get_col( $wpdb->prepare( "SELECT meta_value FROM {$wpdb->postmeta}\n\t\t\t\t\t WHERE meta_key = '_wp_attached_file'\n\t\t\t\t\t AND meta_value IN ({$placeholders})", ...$chunk ) );
            // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
            if ( is_array( $rows ) && !empty( $rows ) ) {
                foreach ( $rows as $relpath ) {
                    if ( is_string( $relpath ) && '' !== $relpath ) {
                        $key = ( function_exists( 'pt_wuclean_normalize_uploads_attached_relpath' ) ? pt_wuclean_normalize_uploads_attached_relpath( $relpath ) : ltrim( wp_normalize_path( $relpath ), '/\\' ) );
                        if ( '' !== $key ) {
                            $verified_hits[$key] = true;
                        }
                    }
                }
            }
        }
        if ( !empty( $verified_hits ) ) {
            $media_library_relpaths = array_intersect_key( $media_library_relpaths, $verified_hits );
        } else {
            $media_library_relpaths = array();
        }
    }
    $remaining_paths = $list;
    if ( !empty( $media_library_relpaths ) ) {
        $remaining_paths = array_values( array_diff( $list, array_keys( $media_library_relpaths ) ) );
    }
    if ( !empty( $remaining_paths ) ) {
        $chunk_size = 200;
        foreach ( array_chunk( $remaining_paths, $chunk_size ) as $chunk ) {
            $placeholders = implode( ', ', array_fill( 0, count( $chunk ), '%s' ) );
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- $placeholders from fixed count; values bound via prepare.
            $rows = $wpdb->get_col( $wpdb->prepare( "SELECT meta_value FROM {$wpdb->postmeta}\n\t\t\t\t\t WHERE meta_key = '_wp_attached_file'\n\t\t\t\t\t AND meta_value IN ({$placeholders})", ...$chunk ) );
            // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
            if ( is_array( $rows ) && !empty( $rows ) ) {
                foreach ( $rows as $relpath ) {
                    if ( is_string( $relpath ) && '' !== $relpath ) {
                        $key = ( function_exists( 'pt_wuclean_normalize_uploads_attached_relpath' ) ? pt_wuclean_normalize_uploads_attached_relpath( $relpath ) : ltrim( wp_normalize_path( $relpath ), '/\\' ) );
                        if ( '' !== $key ) {
                            $media_library_relpaths[$key] = true;
                        }
                    }
                }
            }
        }
    }
    return $media_library_relpaths;
}

/**
 * Delete orphaned_file scan rows whose filesystem path matches stored paths.
 *
 * @param int   $run_id Run ID.
 * @param array $paths  Normalized absolute paths stored in items.path.
 * @return void
 */
function pt_wuclean_image_scan_db_delete_orphaned_file_items_by_paths(  $run_id, array $paths  ) {
    global $wpdb;
    $run_id = absint( $run_id );
    if ( $run_id <= 0 || empty( $paths ) ) {
        return;
    }
    $paths = array_values( array_unique( array_filter( array_map( static function ( $p ) {
        return ( is_string( $p ) && '' !== $p ? wp_normalize_path( $p ) : '' );
    }, $paths ) ) ) );
    if ( empty( $paths ) ) {
        return;
    }
    $table = pt_wuclean_image_scan_db_items_table();
    $table_escaped = esc_sql( $table );
    foreach ( array_chunk( $paths, 150 ) as $chunk ) {
        $placeholders = implode( ', ', array_fill( 0, count( $chunk ), '%s' ) );
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Table from esc_sql(); IN placeholders; variadic paths.
        $wpdb->query( $wpdb->prepare( "DELETE FROM {$table_escaped} WHERE run_id = %d AND type = %s AND path IN ({$placeholders})", array_merge( array($run_id, 'orphaned_file'), $chunk ) ) );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
    }
}

/**
 * Re-check orphaned_file items: remove rows whose path is now present in _wp_attached_file (late imports / index lag).
 *
 * @param int $run_id Run ID.
 * @return int Number of rows removed.
 */
function pt_wuclean_image_scan_engine_refine_orphaned_file_items_for_run(  $run_id  ) {
    global $wpdb;
    $run_id = absint( $run_id );
    if ( $run_id <= 0 ) {
        return 0;
    }
    $table = pt_wuclean_image_scan_db_items_table();
    $table_escaped = esc_sql( $table );
    $batch = pt_wuclean_image_scan_resolve_batch_size( array(), 'refinement' );
    $offset = 0;
    $removed_total = 0;
    while ( true ) {
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table from esc_sql().
        $paths = $wpdb->get_col( $wpdb->prepare(
            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table_escaped from esc_sql().
            "SELECT DISTINCT path FROM {$table_escaped} WHERE run_id = %d AND type = %s AND path IS NOT NULL AND path <> '' ORDER BY path ASC LIMIT %d OFFSET %d",
            $run_id,
            'orphaned_file',
            $batch,
            $offset
        ) );
        $paths = ( is_array( $paths ) ? array_values( array_filter( array_map( 'strval', $paths ) ) ) : array() );
        if ( empty( $paths ) ) {
            break;
        }
        $removed_total += pt_wuclean_image_scan_engine_refine_orphaned_paths_chunk( $run_id, $paths );
        $offset += count( $paths );
        if ( count( $paths ) < $batch ) {
            break;
        }
    }
    return $removed_total;
}
