<?php
/**
 * Tam Rank™ — fill the alt text of media that is ALREADY in the library.
 *
 * Auto-alt only ever fired on `add_attachment`, so somebody who installs the
 * plugin with three hundred images and no alt text gets nothing out of it. This
 * is the same fill, run over media that is already there.
 *
 * ─────────────────────────────────────────────────────────────────────────────
 * The risk here is not data loss — the guards make sure a filled field is never
 * touched. The risk is a bad pattern filling three hundred fields in one click,
 * after which they are no longer empty and the user gets to clean them out by
 * hand. So:
 *
 *   1. It is off. Its own switch, separate from auto-alt-on-upload, default off.
 *      While it is off the panel is hidden and the three AJAX routes refuse even
 *      with a valid nonce — they read the STORED setting, which is the only
 *      thing that ever decided this. The markup and the script ARE in the page
 *      while it is off, because the switch saves itself (see
 *      ajax_save_settings()) and reveals the panel without a reload; a hidden
 *      div and a nonce were never what was holding anybody back.
 *   2. Preview first. The user sees the actual values for their own library,
 *      out of the same decision function that does the writing, and only then
 *      confirms. The preview writes nothing whatsoever.
 *   3. What we wrote is marked, with the written value inside the marker. That
 *      marker is the only thing separating "TamRank put this here" from "the
 *      user typed this".
 *   4. Undo exists. It clears the fields we filled AND that still hold exactly
 *      what we left there. Anything edited since is left alone, for good.
 *   5. The panel says what is standing, not only what just happened. A finished
 *      run leaves a line and a counter behind that survive a reload, both fed by
 *      count_filled(), so "you can still undo this" is visible tomorrow and not
 *      just in the seconds after the click.
 *   6. When there is provably nothing to fill, Preview and Fill are off. That
 *      state is a COUNT, not a memory of the last run: count_fillable() asks the
 *      database how many images still have an empty alt field, so the buttons
 *      come back on by themselves the moment a new image arrives. Deliberately
 *      conservative — it does not ask whether the pattern would render anything
 *      for those images, because that is what the preview is for. One empty alt
 *      field anywhere is enough to leave the buttons alone.
 *
 * No cron, no activation hook, no background task: it runs when a human starts
 * it and confirms it, and never otherwise.
 * ─────────────────────────────────────────────────────────────────────────────
 *
 * @package TamRank
 */

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

class TamRank_Alt_Bulk
{
    /**
     * Holds the exact value the bulk action wrote into the alt field.
     *
     * Deliberately NOT the upload path's `_tamrank_alt_autofilled`: that one is
     * the deferred-fill marker and gets rewritten and dropped by the
     * auto-draft transition. Undo has to mean "undo the bulk run" and nothing
     * else, so it gets its own key. Cleared by uninstall.php with the rest of
     * the `_tamrank_*` post meta.
     */
    const META_BULK_FILLED = '_tamrank_alt_bulkfilled';

    /** Progress of the last run, so an interrupted run can be resumed. */
    const STATE_OPTION = 'tam_rank_alt_bulk_state';

    /** Nonce action shared by the three routes. */
    const NONCE = 'tamrank_alt_bulk';

    /**
     * Nonce action for the settings save behind the switch.
     *
     * Deliberately its OWN action rather than a fourth user of self::NONCE.
     * That nonce is the key to three routes that walk and write the media
     * library; the save route only writes one option. Sharing one token would
     * mean a page that may save a setting also carries the token for the run
     * route, and the guards would be the only thing left holding that apart.
     */
    const NONCE_SETTINGS = 'tamrank_alt_settings_save';

    /**
     * Attachments per batch.
     *
     * Each item costs one cached meta read plus at most one write; the queries
     * are per batch, not per item (see prime_batch()). 25 keeps a batch at a
     * handful of queries and well inside a default max_execution_time even on a
     * slow host, while still walking a 5.000-image library in 200 requests.
     */
    const BATCH_SIZE = 25;

    /** How many worked-out examples the preview shows. */
    const PREVIEW_SAMPLES = 12;

    /** Cursor for the posts_where filter below. */
    private static $cursor = 0;

    public function __construct()
    {
        add_action('wp_ajax_tamrank_alt_bulk_preview', array($this, 'ajax_preview'));
        add_action('wp_ajax_tamrank_alt_bulk_run', array($this, 'ajax_run'));
        add_action('wp_ajax_tamrank_alt_bulk_undo', array($this, 'ajax_undo'));
        add_action('wp_ajax_tamrank_alt_settings_save', array($this, 'ajax_save_settings'));
        add_action('admin_enqueue_scripts', array($this, 'enqueue_assets'));
    }

    // -----------------------------------------------------------------------
    // The switch
    // -----------------------------------------------------------------------

    /**
     * Is the bulk action switched on?
     *
     * Reads its OWN key. `enabled` (auto-alt on upload) is not consulted here
     * and must never be: the two switches are independent in both directions.
     *
     * @return bool
     */
    public static function is_enabled()
    {
        $settings = TamRank_Alt_Templates::get_settings();

        return !empty($settings['bulk_enabled']);
    }

    /**
     * The alt pattern the bulk run fills from.
     *
     * @return string
     */
    public static function get_template()
    {
        $settings = TamRank_Alt_Templates::get_settings();

        return isset($settings['alt_template']) ? trim((string) $settings['alt_template']) : '';
    }

    // -----------------------------------------------------------------------
    // Assets
    // -----------------------------------------------------------------------

    /**
     * @param string $hook
     * @return void
     */
    public function enqueue_assets($hook)
    {
        // Only the TamRank settings page. The switch is NOT consulted here any
        // more: the script owns the switch itself now, so it has to exist while
        // the switch is off — otherwise flipping it on could never save. The
        // security property has not moved: the three walking routes still refuse
        // on the SAVED setting (see guard()), so a script and a nonce in the page
        // buy nobody anything while the setting says off.
        if (strpos((string) $hook, 'tam-rank-settings') === false) {
            return;
        }
        if (!current_user_can('manage_options')) {
            return;
        }

        // tam-rank-toast carries window.TamRank.confirm(), the branded dialog
        // this script asks for instead of the browser's own confirm box. It is
        // already enqueued on every TamRank page; declaring it here makes the
        // load order a guarantee rather than a coincidence.
        wp_enqueue_script(
            'tamrank-alt-bulk',
            TAM_RANK_PLUGIN_URL . 'assets/js/tamrank-alt-bulk.js',
            array('jquery', 'tam-rank-toast'),
            TAM_RANK_VERSION,
            true
        );

        wp_localize_script('tamrank-alt-bulk', 'tamrankAltBulk', array(
            'ajaxUrl'       => admin_url('admin-ajax.php'),
            'nonce'         => wp_create_nonce(self::NONCE),
            'settingsNonce' => wp_create_nonce(self::NONCE_SETTINGS),
            'batchSize'     => self::BATCH_SIZE,
            'i18n'          => array(
                'scanning'    => tamrank__('alt_bulk_scanning', 'Checking your media library…'),
                'previewNone' => tamrank__('alt_bulk_preview_none', 'Nothing to fill: every image either already has an alt text or produces no value from this pattern.'),
                'previewHead' => tamrank__('alt_bulk_preview_head', '%1$d of %2$d images would get an alt text. Nothing has been written yet.'),
                'previewSkip' => tamrank__('alt_bulk_preview_skipped', 'Left alone: %1$d already have an alt text, %2$d produce an empty value, %3$d belong to a post that has not been saved yet.'),
                'colImage'    => tamrank__('alt_bulk_col_image', 'Image'),
                'colValue'    => tamrank__('alt_bulk_col_value', 'Alt text that would be written'),
                'confirmRun'  => tamrank__('alt_bulk_confirm_run', 'Write this alt text to %d images? You can undo this afterwards.'),
                'confirmRunTitle' => tamrank__('alt_bulk_confirm_run_title', 'Fill in alt texts?'),
                'confirmRunLabel' => tamrank__('alt_bulk_confirm_run_label', 'Fill in'),
                'running'     => tamrank__('alt_bulk_running', 'Filling in… (%d done)'),
                'runDone'     => tamrank__('alt_bulk_run_done', 'Done — %d images filled in.'),
                // The toast that carries the good news out of the panel, so a
                // finished run is visible even when you have scrolled past it.
                'runToast'    => tamrank__('alt_bulk_run_toast', '%d alt texts filled in.'),
                'confirmUndo' => tamrank__('alt_bulk_confirm_undo', 'Empty the alt texts TamRank filled in? Anything you edited yourself stays as it is.'),
                'confirmUndoTitle' => tamrank__('alt_bulk_confirm_undo_title', 'Undo filled alt texts?'),
                'confirmUndoLabel' => tamrank__('alt_bulk_confirm_undo_label', 'Undo'),
                'cancel'      => tamrank__('cancel', 'Cancel'),
                'undoing'     => tamrank__('alt_bulk_undoing', 'Undoing… (%d done)'),
                'undoDone'    => tamrank__('alt_bulk_undo_done', 'Undone — %1$d emptied, %2$d left alone because you edited them.'),
                'error'       => tamrank__('alt_bulk_error', 'Something went wrong. Nothing more was changed; you can start again.'),
                'noTemplate'  => tamrank__('alt_bulk_no_template', 'Fill in an alt text pattern first.'),
                // The switch saves itself, so it has to say so. A control that
                // writes to the database in silence is its own surprise.
                'savedOn'     => tamrank__('alt_bulk_saved_on', 'Switched on and saved.'),
                'savedOff'    => tamrank__('alt_bulk_saved_off', 'Switched off and saved.'),
                'saveFailed'  => tamrank__('alt_bulk_save_failed', 'Could not save that. The switch has been put back the way it was.'),
                // Re-rendered client-side after a live save, after a run and
                // after an undo, from the freshly counted numbers the route
                // hands back. Same key, same number, in all three places.
                'undoBtn'     => tamrank__('alt_bulk_undo_btn', 'Undo (%d)'),
                'filled'      => tamrank__('alt_bulk_filled_notice', '%d images filled by TamRank — you can undo this.'),
                'resume'      => tamrank__('alt_bulk_resume_notice', 'A previous run stopped early after %d images. Starting it again picks up where it left off.'),
            ),
        ));
    }

    // -----------------------------------------------------------------------
    // AJAX
    // -----------------------------------------------------------------------

    /**
     * Nonce, capability and switch — in that order, on every single route.
     *
     * A hidden button is not security: the routes are always registered and
     * always refuse while the switch is off, so calling them straight with a
     * valid nonce gets a 403 as well.
     *
     * @return void
     */
    private function guard()
    {
        check_ajax_referer(self::NONCE, 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(array(
                'message' => tamrank__('error_permission_denied', 'You do not have permission for this action.'),
                'code'    => 'permission_denied',
            ), 403);
        }

        if (!self::is_enabled()) {
            wp_send_json_error(array(
                'message' => tamrank__('alt_bulk_disabled', 'Filling existing media is switched off.'),
                'code'    => 'bulk_disabled',
            ), 403);
        }
    }

    /**
     * Save the alt settings from the switch, without a form round-trip.
     *
     * WHY THE WHOLE BLOCK AND NOT JUST THE FLAG: the panel that appears here
     * previews what the alt pattern would write. Save only `bulk_enabled` and
     * somebody can type a new pattern, flip the switch, and get a preview
     * computed from the pattern that is still IN THE DATABASE while a different
     * one sits on their screen. The preview would be lying. So the four fields
     * travel together, exactly as they stand in the form at that moment.
     *
     * Same guard order as the three routes above — nonce, capability, then do
     * the work — but deliberately NOT guard() itself: that one refuses on the
     * saved switch, which is the very thing this route exists to change.
     *
     * @return void
     */
    public function ajax_save_settings()
    {
        check_ajax_referer(self::NONCE_SETTINGS, 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(array(
                'message' => tamrank__('error_permission_denied', 'You do not have permission for this action.'),
                'code'    => 'permission_denied',
            ), 403);
        }

        // Read the four fields one by one rather than taking the array whole:
        // that way each value is unslashed and sanitized at the point it comes
        // out of the request, and a key nobody asked for cannot ride along. The
        // whitelist that decides what actually gets stored is still
        // sanitize_settings() below — this is the doorstep, not the decision.
        $posted = array(
            // phpcs:disable WordPress.Security.NonceVerification.Missing -- check_ajax_referer() ran above.
            'enabled'        => isset($_POST['settings']['enabled']) ? sanitize_text_field(wp_unslash($_POST['settings']['enabled'])) : '',
            'bulk_enabled'   => isset($_POST['settings']['bulk_enabled']) ? sanitize_text_field(wp_unslash($_POST['settings']['bulk_enabled'])) : '',
            'alt_template'   => isset($_POST['settings']['alt_template']) ? sanitize_text_field(wp_unslash($_POST['settings']['alt_template'])) : '',
            'title_template' => isset($_POST['settings']['title_template']) ? sanitize_text_field(wp_unslash($_POST['settings']['title_template'])) : '',
            // phpcs:enable WordPress.Security.NonceVerification.Missing
        );

        // The one sanitizer, the same one register_setting() calls. Anything
        // that is not in its whitelist never reaches the option.
        $clean = TamRank_Alt_Templates::sanitize_settings($posted);

        update_option(TamRank_Alt_Templates::OPTION, $clean, false);

        // Read back rather than echo what we sent: what the panel shows has to
        // be what the database holds, filters and all.
        $stored = TamRank_Alt_Templates::get_settings();
        $state  = self::get_panel_state();

        wp_send_json_success(array(
            'bulk_enabled'   => !empty($stored['bulk_enabled']),
            'enabled'        => !empty($stored['enabled']),
            'alt_template'   => (string) $stored['alt_template'],
            'title_template' => (string) $stored['title_template'],
            // Freshly counted, not the numbers the page was rendered with: a
            // library with markers in it must not show "Undo (0)" after a live
            // switch-on, and a library with nothing left to fill must not open
            // with a live Preview button.
            'filled'         => $state['filled'],
            'fillable'       => $state['fillable'],
            'resume'         => $state['resume'],
        ));
    }

    /** The cursor the caller sent. @return int */
    private function read_cursor()
    {
        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- guard() ran check_ajax_referer() before this is ever called.
        return isset($_POST['after_id']) ? max(0, (int) $_POST['after_id']) : 0;
    }

    /**
     * Dry run: report what WOULD happen. Writes nothing.
     *
     * @return void
     */
    public function ajax_preview()
    {
        $this->guard();

        $template = self::get_template();
        if ($template === '') {
            wp_send_json_error(array(
                'message' => tamrank__('alt_bulk_no_template', 'Fill in an alt text pattern first.'),
                'code'    => 'no_template',
            ), 400);
        }

        $result = $this->walk_batch($this->read_cursor(), $template, false);

        wp_send_json_success($result);
    }

    /**
     * The real run: fill, mark, and remember how far we got.
     *
     * @return void
     */
    public function ajax_run()
    {
        $this->guard();

        $template = self::get_template();
        if ($template === '') {
            wp_send_json_error(array(
                'message' => tamrank__('alt_bulk_no_template', 'Fill in an alt text pattern first.'),
                'code'    => 'no_template',
            ), 400);
        }

        $cursor = $this->read_cursor();
        $result = $this->walk_batch($cursor, $template, true);

        // Progress survives a closed tab, a timeout or a browser crash: the
        // cursor is an attachment ID, so resuming picks up exactly where this
        // stopped without re-reading what was already walked.
        $state = ($cursor === 0) ? array('scanned' => 0, 'filled' => 0) : $this->get_state();
        $this->save_state(array(
            'after_id' => $result['next_after_id'],
            'scanned'  => (int) (isset($state['scanned']) ? $state['scanned'] : 0) + $result['scanned'],
            'filled'   => (int) (isset($state['filled']) ? $state['filled'] : 0) + $result['filled'],
            'done'     => (bool) $result['done'],
        ));

        // On the closing batch only, hand back how much there is to undo NOW.
        // The walk's own `filled` counts what THIS run wrote, which is not the
        // same thing the moment an earlier run left markers behind: those images
        // carry an alt text, so this run skips them as occupied and never counts
        // them. The Undo button and the persistent line both name the whole set,
        // so they read it from here rather than adding up batches in the browser
        // — one count, from the same response, on the one batch that can afford
        // an extra query.
        // `fillable` rides along on that same closing batch, for the same
        // reason: a run that just filled the last empty alt field has to be
        // able to switch its own buttons off without a reload.
        if ($result['done']) {
            $result['marked']   = self::count_filled();
            $result['fillable'] = self::count_fillable();
        }

        wp_send_json_success($result);
    }

    /**
     * Undo: empty the fields we filled and that still hold what we wrote.
     *
     * @return void
     */
    public function ajax_undo()
    {
        $this->guard();

        $wpdb_queries = $GLOBALS['wpdb']->num_queries;
        $started      = microtime(true);

        $ids = $this->query_marked_ids($this->read_cursor(), self::BATCH_SIZE);
        $this->prime_batch($ids);

        $cleared = 0;
        $kept    = 0;
        $last_id = 0;

        foreach ($ids as $id) {
            $last_id = $id;

            $marker  = (string) get_post_meta($id, self::META_BULK_FILLED, true);
            $current = (string) get_post_meta($id, '_wp_attachment_image_alt', true);

            if ($current === $marker) {
                // Still byte-for-byte ours: put the field back the way we found
                // it, which was empty.
                delete_post_meta($id, '_wp_attachment_image_alt');
                $cleared++;
            } else {
                // Edited since the run. It is the user's text now; leave it.
                $kept++;
            }

            // Either way we let go: the marker has done its job.
            delete_post_meta($id, self::META_BULK_FILLED);
        }

        $done = count($ids) < self::BATCH_SIZE;
        if ($done) {
            delete_option(self::STATE_OPTION);
        }

        $payload = array(
            'scanned'       => count($ids),
            'cleared'       => $cleared,
            'kept'          => $kept,
            'next_after_id' => $last_id > 0 ? $last_id : $this->read_cursor(),
            'done'          => $done,
            'queries'       => $GLOBALS['wpdb']->num_queries - $wpdb_queries,
            'ms'            => (int) round((microtime(true) - $started) * 1000),
        );

        // Same closing count as the run route, in the other direction. Every
        // marker this walk saw has been let go — the ones we emptied and the
        // ones you had edited alike — so a completed undo lands on 0 and the
        // panel says so without being told twice. And an undo puts alt fields
        // back to empty, so it is the one route that can hand Preview and Fill
        // their work back: `fillable` travels with it.
        if ($done) {
            $payload['marked']   = self::count_filled();
            $payload['fillable'] = self::count_fillable();
        }

        wp_send_json_success($payload);
    }

    // -----------------------------------------------------------------------
    // The walk
    // -----------------------------------------------------------------------

    /**
     * Walk one batch of attachments and either report or write.
     *
     * Preview and run are the SAME walk with one boolean between them, so the
     * values on screen cannot drift away from the values that get written.
     *
     * @param int    $after_id Cursor: only attachments with a higher ID.
     * @param string $template
     * @param bool   $write    false = dry run, nothing is written.
     * @return array
     */
    private function walk_batch($after_id, $template, $write)
    {
        $queries_before = $GLOBALS['wpdb']->num_queries;
        $started        = microtime(true);

        $ids = $this->query_batch_ids($after_id, self::BATCH_SIZE);
        $this->prime_batch($ids);

        $filled  = 0;
        $skipped = array('occupied' => 0, 'empty_result' => 0, 'unsaved_parent' => 0, 'other' => 0);
        $samples = array();
        $last_id = 0;

        foreach ($ids as $id) {
            $last_id = $id;
            $plan    = self::plan_one($id, $template);

            if ($plan['status'] !== TamRank_Alt_Templates::PLAN_FILL) {
                $key = isset($skipped[$plan['status']]) ? $plan['status'] : 'other';
                $skipped[$key]++;
                continue;
            }

            $filled++;

            if (count($samples) < self::PREVIEW_SAMPLES) {
                $samples[] = array(
                    'id'    => $id,
                    'name'  => wp_basename((string) get_post_meta($id, '_wp_attached_file', true)),
                    'value' => $plan['value'],
                );
            }

            if ($write) {
                update_post_meta($id, '_wp_attachment_image_alt', $plan['value']);
                // Read the value back rather than trusting what we sent in: a
                // filter on the way into the database would otherwise leave a
                // marker that disagrees with the field it has to prove.
                update_post_meta(
                    $id,
                    self::META_BULK_FILLED,
                    (string) get_post_meta($id, '_wp_attachment_image_alt', true)
                );
            }
        }

        return array(
            'scanned'       => count($ids),
            'filled'        => $filled,
            'skipped'       => $skipped,
            'samples'       => $samples,
            // A short batch means the library ran out: nothing is left behind
            // it, because the cursor only ever moves forward through IDs.
            'next_after_id' => $last_id > 0 ? $last_id : (int) $after_id,
            'done'          => count($ids) < self::BATCH_SIZE,
            'queries'       => $GLOBALS['wpdb']->num_queries - $queries_before,
            'ms'            => (int) round((microtime(true) - $started) * 1000),
        );
    }

    /**
     * The verdict for one attachment, plus the one policy the bulk run adds.
     *
     * The decision itself comes from TamRank_Alt_Templates::plan_alt_fill() —
     * the very function the upload path writes from — so the guards and the
     * pattern rendering exist exactly once.
     *
     * What is added here is a single refusal: an image hanging off a post that
     * has never been saved belongs to the upload path, not to us. Its
     * {post_title} and {category} are still empty, so we would write half a
     * value and make it permanent, and the deferred fill would then find an
     * occupied field and let go. Those images are minutes old and get filled by
     * the upload path the moment their post is saved.
     *
     * @param int    $attachment_id
     * @param string $template
     * @return array Plan, with 'unsaved_parent' as a possible status.
     */
    public static function plan_one($attachment_id, $template)
    {
        $plan = TamRank_Alt_Templates::plan_alt_fill($attachment_id, $template);

        if ($plan['status'] === TamRank_Alt_Templates::PLAN_FILL && !empty($plan['unsaved_parent'])) {
            $plan['status'] = 'unsaved_parent';
        }

        return $plan;
    }

    // -----------------------------------------------------------------------
    // Queries
    // -----------------------------------------------------------------------

    /**
     * Cursor paging: every image attachment with an ID above the cursor.
     *
     * An OFFSET would be wrong here. The set shifts under a run — somebody
     * uploads, somebody deletes — and every shift either skips an item or hands
     * one out twice. An ID cursor cannot: it only moves forward, and an
     * interrupted run resumes at the exact attachment it stopped at.
     *
     * @param int $after_id
     * @param int $limit
     * @return int[]
     */
    private function query_batch_ids($after_id, $limit)
    {
        self::$cursor = max(0, (int) $after_id);

        add_filter('posts_where', array(__CLASS__, 'filter_cursor_where'));
        $ids = get_posts(array(
            'post_type'              => 'attachment',
            'post_status'            => 'inherit',
            'post_mime_type'         => 'image',
            'posts_per_page'         => (int) $limit,
            'orderby'                => 'ID',
            'order'                  => 'ASC',
            'fields'                 => 'ids',
            'no_found_rows'          => true,
            'ignore_sticky_posts'    => true,
            // get_posts() suppresses filters by default, which would drop the
            // cursor clause silently and walk the library from the top forever.
            'suppress_filters'       => false,
            'update_post_term_cache' => false,
            'update_post_meta_cache' => false,
        ));
        remove_filter('posts_where', array(__CLASS__, 'filter_cursor_where'));

        return array_map('intval', (array) $ids);
    }

    /**
     * Attachments carrying our marker, same cursor paging.
     *
     * @param int $after_id
     * @param int $limit
     * @return int[]
     */
    private function query_marked_ids($after_id, $limit)
    {
        self::$cursor = max(0, (int) $after_id);

        add_filter('posts_where', array(__CLASS__, 'filter_cursor_where'));
        $ids = get_posts(array(
            'post_type'              => 'attachment',
            'post_status'            => 'inherit',
            'posts_per_page'         => (int) $limit,
            'orderby'                => 'ID',
            'order'                  => 'ASC',
            'fields'                 => 'ids',
            'no_found_rows'          => true,
            'ignore_sticky_posts'    => true,
            'suppress_filters'       => false,
            'update_post_term_cache' => false,
            'update_post_meta_cache' => false,
            // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- keyed on our own indexed meta_key, batched, admin-initiated only.
            'meta_query'             => array(
                array('key' => self::META_BULK_FILLED, 'compare' => 'EXISTS'),
            ),
        ));
        remove_filter('posts_where', array(__CLASS__, 'filter_cursor_where'));

        return array_map('intval', (array) $ids);
    }

    /**
     * The cursor clause. Public because add_filter() needs to reach it.
     *
     * @param string $where
     * @return string
     */
    public static function filter_cursor_where($where)
    {
        global $wpdb;

        return $where . $wpdb->prepare(" AND {$wpdb->posts}.ID > %d", self::$cursor);
    }

    /**
     * One round of cache priming for the whole batch — the answer to N+1.
     *
     * Without this, every item costs a post read, a meta read, a parent read
     * and a term read. With it, a batch of 25 costs four queries in total no
     * matter how many items are in it, and everything the walk touches after
     * this point comes out of the object cache.
     *
     * @param int[] $ids
     * @return void
     */
    private function prime_batch($ids)
    {
        if (empty($ids)) {
            return;
        }

        // Attachment rows + all of their post meta: 2 queries.
        _prime_post_caches($ids, false, true);

        // {post_title} and {category} read the parent post. Prime the distinct
        // parents in one go — rows and term relationships — instead of letting
        // render_template() fetch them one attachment at a time.
        $parents = array();
        foreach ($ids as $id) {
            $parent = (int) get_post_field('post_parent', $id);
            if ($parent > 0) {
                $parents[$parent] = $parent;
            }
        }

        if (!empty($parents)) {
            _prime_post_caches(array_values($parents), true, false);
        }
    }

    // -----------------------------------------------------------------------
    // Resume state + the numbers the settings page shows
    // -----------------------------------------------------------------------

    /**
     * @return array
     */
    public static function get_state()
    {
        $state = get_option(self::STATE_OPTION, array());

        return is_array($state) ? $state : array();
    }

    /**
     * @param array $state
     * @return void
     */
    private function save_state($state)
    {
        update_option(self::STATE_OPTION, array(
            'after_id' => (int) $state['after_id'],
            'scanned'  => (int) $state['scanned'],
            'filled'   => (int) $state['filled'],
            'done'     => !empty($state['done']),
            'updated'  => time(),
        ), false);
    }

    /**
     * Is there an interrupted run waiting to be picked up?
     *
     * @return array|null The state, or null when there is nothing to resume.
     */
    public static function get_resumable_state()
    {
        $state = self::get_state();

        if (empty($state) || !empty($state['done']) || empty($state['after_id'])) {
            return null;
        }

        return $state;
    }

    /**
     * How many images still have an EMPTY alt field — i.e. is there anything
     * for a run to try at all.
     *
     * Not to be confused with count_filled() next door: that one counts what
     * this plugin already wrote (how much there is to undo), this one counts
     * what is still open (whether Preview and Fill have any work).
     *
     * DELIBERATELY CONSERVATIVE. It does not render the pattern and it does not
     * look at the parent post, so an image whose pattern would come out empty
     * still counts here. Over-counting keeps the buttons ON and costs nothing
     * but a preview that honestly reports zero; under-counting would switch off
     * a button that had work to do. The preview stays the precise instrument;
     * this count only answers "is there an empty field anywhere".
     *
     * Conservative is not the same as wrong, though. "Empty" here has to mean
     * exactly what plan_alt_fill() means by it (`$current !== ''`), and a plain
     * `meta_value = ''` did not: wp_postmeta ships in a PAD SPACE collation
     * (utf8mb4_unicode_520_ci), which reads a space, three spaces, an NBSP and
     * a zero-width space as equal to ''. Those fields are occupied, the walk
     * leaves them alone — so the count could never reach zero and the panel
     * kept offering work that no run would ever do. The BINARY cast below makes
     * the comparison byte-for-byte, which is the same test PHP performs.
     *
     * Same shape and same index as count_filled(): one meta-keyed WP_Query with
     * `posts_per_page => 1`, read off found_posts. The OR turns both clauses
     * into LEFT JOINs, and WP closes that with `GROUP BY wp_posts.ID` — so an
     * attachment carrying two empty alt rows is still counted once. Measured,
     * not assumed: see [3c] in docs/alt-bulk-harness.php.
     *
     * @return int
     */
    public static function count_fillable()
    {
        $query = new WP_Query(array(
            'post_type'              => 'attachment',
            'post_status'            => 'inherit',
            // The walk only ever visits images (see query_batch_ids()), so a
            // PDF with no alt text must not hold these buttons open.
            'post_mime_type'         => 'image',
            'posts_per_page'         => 1,
            'fields'                 => 'ids',
            'ignore_sticky_posts'    => true,
            'update_post_term_cache' => false,
            'update_post_meta_cache' => false,
            // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- keyed on the core alt meta_key, which is indexed; one count on the settings screen and one on the closing batch of a walk.
            'meta_query'             => array(
                'relation' => 'OR',
                // Never uploaded with an alt text: the row is simply absent.
                array('key' => '_wp_attachment_image_alt', 'compare' => 'NOT EXISTS'),
                // Emptied again, by a user or by our own undo: the row is there
                // and holds nothing. `type => BINARY` makes WP cast the column
                // before comparing, so the collation's space padding cannot
                // turn a whitespace-only alt into an empty one (see above).
                array('key' => '_wp_attachment_image_alt', 'value' => '', 'compare' => '=', 'type' => 'BINARY'),
            ),
        ));

        return (int) $query->found_posts;
    }

    /**
     * How many attachments currently carry our marker — i.e. how much there is
     * to undo. One query, only on the settings page.
     *
     * @return int
     */
    public static function count_filled()
    {
        $query = new WP_Query(array(
            'post_type'              => 'attachment',
            'post_status'            => 'inherit',
            'posts_per_page'         => 1,
            'fields'                 => 'ids',
            'ignore_sticky_posts'    => true,
            'update_post_term_cache' => false,
            'update_post_meta_cache' => false,
            // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- keyed on our own indexed meta_key; one count on the settings screen.
            'meta_query'             => array(
                array('key' => self::META_BULK_FILLED, 'compare' => 'EXISTS'),
            ),
        ));

        return (int) $query->found_posts;
    }

    /**
     * Everything the panel needs to show numbers that are true right now.
     *
     * One function, two callers: the settings page renders from it, and the
     * save route hands the same values back so the panel that appears without a
     * page load carries the same numbers a reload would have shown.
     *
     * @return array {filled:int, fillable:int, resume:array|null}
     */
    public static function get_panel_state()
    {
        $resume = self::get_resumable_state();

        return array(
            'filled' => self::count_filled(),
            // Whether Preview and Fill have anything to do. Rendered into the
            // page and re-sent by every route that can change it, so page load
            // and live update land on the same state.
            'fillable' => self::count_fillable(),
            'resume' => is_array($resume)
                ? array(
                    'after_id' => (int) $resume['after_id'],
                    'filled'   => isset($resume['filled']) ? (int) $resume['filled'] : 0,
                )
                : null,
        );
    }
}
