<?php

namespace WPSN\Modules\AdminLists;

use WPSN\Core\AbstractModule;
use WPSN\Core\Branding;
use WPSN\SEO\MetaKeys;
use WPSN\Modules\Revisions\RevisionsInstaller;
use WPSN\Modules\Revisions\RevisionsRepository;

defined('ABSPATH') || exit;

/**
 * Admin Lists UX
 *
 * Roadmap: native SEO columns + Quick Edit inside wp-admin post lists.
 *
 * Scope (v1):
 * - Columns: SEO Title, Meta description, Robots, Focus keyword
 * - Quick Edit: edit the same fields without opening the post
 * - Filters: quickly find posts missing overrides / noindex overrides
 */
final class AdminListsModule extends AbstractModule
{
    public function id(): string
    {
        return 'admin_lists';
    }

    public function name(): string
    {
        return __('Admin list UX', 'wp-seo-next');
    }

    public function description(): string
    {
        return __('Adds SEO columns, Quick Edit fields and simple SEO filters to wp-admin post lists.', 'wp-seo-next');
    }

    public function defaultEnabled(): bool
    {
        return true;
    }

    public function register(): void
    {
        if (!is_admin()) {
            return;
        }

        add_action('admin_init', [$this, 'registerColumns']);

        // Bulk actions (non-AJAX).
        add_action('admin_init', [$this, 'registerBulkActions']);
        add_action('admin_notices', [$this, 'renderBulkNotices']);

        // Quick Edit (posts list).
        add_action('quick_edit_custom_box', [$this, 'renderQuickEdit'], 10, 2);
        add_action('save_post', [$this, 'handleQuickEditSave'], 20, 2);

        // Bulk Edit (posts list) — uses WP core inline-save (admin-ajax) but no WPSN AJAX.
        add_action('bulk_edit_custom_box', [$this, 'renderBulkEdit'], 10, 2);
        add_action('save_post', [$this, 'handleBulkEditSave'], 21, 2);

        // Quick Edit (terms list).
        add_action('quick_edit_custom_box', [$this, 'renderTermQuickEdit'], 10, 2);
        add_action('edited_term', [$this, 'handleTermQuickEditSave'], 20, 3);

        add_action('admin_enqueue_scripts', [$this, 'enqueueAssets']);

        // Simple list filters + task work queues.
        add_action('restrict_manage_posts', [$this, 'renderFilters']);
        add_action('pre_get_posts', [$this, 'applyFilters']);
        add_action('pre_get_terms', [$this, 'applyTermQueues']);
        add_action('admin_notices', [$this, 'renderWorkQueueNotice']);

        // Deep queue workflow: keep context when entering native edit screens
        // and when saving/updating objects from a Tasks Hub work queue.
        add_filter('get_edit_post_link', [$this, 'filterQueueAwarePostEditLink'], 20, 3);
        add_filter('get_edit_term_link', [$this, 'filterQueueAwareTermEditLink'], 20, 3);
        add_filter('redirect_post_location', [$this, 'preserveQueueInPostRedirect'], 20, 2);
        add_filter('wp_redirect', [$this, 'preserveQueueInAdminRedirect'], 20, 2);
    }

    /**
     * Register columns for all supported public post types.
     */
    public function registerColumns(): void
    {
        $types = $this->supportedPostTypes();
        foreach ($types as $pt) {
            add_filter('manage_' . $pt . '_posts_columns', [$this, 'filterColumns'], 11);
            add_action('manage_' . $pt . '_posts_custom_column', [$this, 'renderColumn'], 10, 2);
        }

        // Term list columns (Categories/Tags/custom taxonomies).
        $taxes = $this->supportedTaxonomies();
        foreach ($taxes as $tax) {
            add_filter('manage_edit-' . $tax . '_columns', [$this, 'filterTermColumns'], 11);
            add_filter('manage_' . $tax . '_custom_column', [$this, 'renderTermColumn'], 10, 3);
        }
    }

    /**
     * @return string[]
     */
    private function supportedTaxonomies(): array
    {
        $taxes = function_exists('get_taxonomies')
            ? get_taxonomies(['public' => true, 'show_ui' => true], 'names')
            : [];

        if (!is_array($taxes)) {
            $taxes = [];
        }

        $out = [];
        foreach ($taxes as $tx) {
            $tx = is_string($tx) ? $tx : '';
            if ($tx === '') {
                continue;
            }
            // post_format is not really a content taxonomy users manage for SEO.
            if ($tx === 'post_format') {
                continue;
            }
            $out[] = $tx;
        }

        /**
         * Filter supported taxonomies for Admin Lists UX.
         *
         * @since 0.73.5
         *
         * @param string[] $out
         */
        $out = (array) apply_filters('wpsn/admin_lists/taxonomies', $out);

        // Ensure uniqueness.
        $out = array_values(array_unique(array_filter($out, static fn ($v) => is_string($v) && $v !== '')));

        return $out;
    }

    private function currentUserCanManageTaxonomy(string $taxonomy): bool
    {
        $taxonomy = (string) $taxonomy;
        if ($taxonomy === '') {
            return false;
        }

        // Best-effort capability mapping.
        if (function_exists('get_taxonomy')) {
            $tx = get_taxonomy($taxonomy);
            if ($tx && is_object($tx) && isset($tx->cap) && is_object($tx->cap)) {
                $cap = '';
                if (isset($tx->cap->manage_terms) && is_string($tx->cap->manage_terms)) {
                    $cap = $tx->cap->manage_terms;
                } elseif (isset($tx->cap->edit_terms) && is_string($tx->cap->edit_terms)) {
                    $cap = $tx->cap->edit_terms;
                }

                if ($cap !== '') {
                    return current_user_can($cap);
                }
            }
        }

        // Fallback for core taxonomies.
        return current_user_can('manage_categories');
    }

    /**
     * @param array<string,string> $cols
     * @return array<string,string>
     */
    public function filterTermColumns(array $cols): array
    {
        $insert = [
            'wpsn_seo_title' => __('SEO Title', 'wp-seo-next'),
            'wpsn_seo_description' => __('Meta description', 'wp-seo-next'),
            'wpsn_seo_robots' => __('Robots', 'wp-seo-next'),
            'wpsn_focus_keyword' => __('Focus keyword', 'wp-seo-next'),
        ];

        $out = [];
        $inserted = false;
        foreach ($cols as $k => $v) {
            $out[$k] = $v;

            if ($k === 'name' && !$inserted) {
                foreach ($insert as $ik => $iv) {
                    if (!isset($out[$ik])) {
                        $out[$ik] = $iv;
                    }
                }
                $inserted = true;
            }
        }

        if (!$inserted) {
            foreach ($insert as $ik => $iv) {
                if (!isset($out[$ik])) {
                    $out[$ik] = $iv;
                }
            }
        }

        return $out;
    }

    public function renderTermColumn(string $content, string $column, int $termId): string
    {
        if ($termId <= 0) {
            return $content;
        }

        $title = (string) get_term_meta($termId, MetaKeys::TITLE, true);
        $desc  = (string) get_term_meta($termId, MetaKeys::DESCRIPTION, true);
        $canon = (string) get_term_meta($termId, MetaKeys::CANONICAL, true);
        $rIndex = (string) get_term_meta($termId, MetaKeys::ROBOTS_INDEX, true);
        $rFollow = (string) get_term_meta($termId, MetaKeys::ROBOTS_FOLLOW, true);
        $focus = (string) get_term_meta($termId, MetaKeys::FOCUS_KEYWORD, true);

        $title = trim(wp_strip_all_tags($title));
        $desc = trim(wp_strip_all_tags($desc));
        $canon = trim(wp_strip_all_tags($canon));
        $rIndex = trim(wp_strip_all_tags($rIndex));
        $rFollow = trim(wp_strip_all_tags($rFollow));
        $focus = trim(wp_strip_all_tags($focus));

        if ($column === 'wpsn_seo_title') {
            $label = $title !== '' ? esc_html($this->truncate($title, 72)) : '<span style="color:#999;">—</span>';

            // Hidden data blob for Term Quick Edit JS.
            $label .= '<span class="wpsn-qe-data" style="display:none"'
                . ' data-title="' . esc_attr($title) . '"'
                . ' data-description="' . esc_attr($desc) . '"'
                . ' data-canonical="' . esc_attr($canon) . '"'
                . ' data-robots-index="' . esc_attr($rIndex) . '"'
                . ' data-robots-follow="' . esc_attr($rFollow) . '"'
                . ' data-focus-keyword="' . esc_attr($focus) . '"'
                . '></span>';

            return $label;
        }

        if ($column === 'wpsn_seo_description') {
            return $desc !== '' ? esc_html($this->truncate($desc, 84)) : '<span style="color:#999;">—</span>';
        }

        if ($column === 'wpsn_seo_robots') {
            $ri = $rIndex !== '' ? $rIndex : 'default';
            $rf = $rFollow !== '' ? $rFollow : 'default';
            $txt = $ri . ', ' . $rf;

            $style = '';
            if ($rIndex === 'noindex' || $rFollow === 'nofollow') {
                $style = 'color:#b32d2e; font-weight:600;';
            } elseif ($rIndex === '' && $rFollow === '') {
                $style = 'color:#666;';
            }

            return '<span style="' . esc_attr($style) . '">' . esc_html($txt) . '</span>';
        }

        if ($column === 'wpsn_focus_keyword') {
            return $focus !== '' ? esc_html($this->truncate($focus, 56)) : '<span style="color:#999;">—</span>';
        }

        return $content;
    }

    /**
     * @return string[]
     */
    private function supportedPostTypes(): array
    {
        $types = function_exists('get_post_types')
            ? get_post_types(['public' => true], 'names')
            : [];

        if (!is_array($types)) {
            $types = [];
        }

        $out = [];
        foreach ($types as $pt) {
            $pt = is_string($pt) ? $pt : '';
            if ($pt === '' || $pt === 'attachment') {
                continue;
            }
            $out[] = $pt;
        }

        /**
         * Filter supported post types for Admin Lists UX.
         *
         * @since 0.72.0
         *
         * @param string[] $out
         */
        $out = (array) apply_filters('wpsn/admin_lists/post_types', $out);

        // Ensure uniqueness.
        $out = array_values(array_unique(array_filter($out, static fn ($v) => is_string($v) && $v !== '')));

        return $out;
    }

    /**
     * @param array<string,string> $cols
     * @return array<string,string>
     */
    public function filterColumns(array $cols): array
    {
        $hasScore = array_key_exists('wpsn_seo_score', $cols);

        $insert = [
            'wpsn_seo_title' => __('SEO Title', 'wp-seo-next'),
            'wpsn_seo_description' => __('Meta description', 'wp-seo-next'),
            'wpsn_seo_robots' => __('Robots', 'wp-seo-next'),
            'wpsn_focus_keyword' => __('Focus keyword', 'wp-seo-next'),
        ];

        $out = [];
        $inserted = false;
        foreach ($cols as $k => $v) {
            $out[$k] = $v;

            if ($hasScore && $k === 'wpsn_seo_score' && !$inserted) {
                foreach ($insert as $ik => $iv) {
                    if (!isset($out[$ik])) {
                        $out[$ik] = $iv;
                    }
                }
                $inserted = true;
            }

            if (!$hasScore && $k === 'title' && !$inserted) {
                foreach ($insert as $ik => $iv) {
                    if (!isset($out[$ik])) {
                        $out[$ik] = $iv;
                    }
                }
                $inserted = true;
            }
        }

        // Fallback: append at the end.
        if (!$inserted) {
            foreach ($insert as $ik => $iv) {
                if (!isset($out[$ik])) {
                    $out[$ik] = $iv;
                }
            }
        }

        return $out;
    }

    public function renderColumn(string $column, int $postId): void
    {
        if ($postId <= 0) {
            echo '—';
            return;
        }

        $title = (string) get_post_meta($postId, MetaKeys::TITLE, true);
        $desc  = (string) get_post_meta($postId, MetaKeys::DESCRIPTION, true);
        $canon = (string) get_post_meta($postId, MetaKeys::CANONICAL, true);
        $rIndex = (string) get_post_meta($postId, MetaKeys::ROBOTS_INDEX, true);
        $rFollow = (string) get_post_meta($postId, MetaKeys::ROBOTS_FOLLOW, true);
        $focus = (string) get_post_meta($postId, MetaKeys::FOCUS_KEYWORD, true);

        $title = trim(wp_strip_all_tags($title));
        $desc = trim(wp_strip_all_tags($desc));
        $canon = trim(wp_strip_all_tags($canon));
        $rIndex = trim(wp_strip_all_tags($rIndex));
        $rFollow = trim(wp_strip_all_tags($rFollow));
        $focus = trim(wp_strip_all_tags($focus));

        if ($column === 'wpsn_seo_title') {
            $label = $title !== '' ? esc_html($this->truncate($title, 72)) : '<span style="color:#999;">—</span>';

            // Hidden data blob for Quick Edit JS.
            echo $label;
            echo '<span class="wpsn-qe-data" style="display:none"'
                . ' data-title="' . esc_attr($title) . '"'
                . ' data-description="' . esc_attr($desc) . '"'
                . ' data-canonical="' . esc_attr($canon) . '"'
                . ' data-robots-index="' . esc_attr($rIndex) . '"'
                . ' data-robots-follow="' . esc_attr($rFollow) . '"'
                . ' data-focus-keyword="' . esc_attr($focus) . '"'
                . '></span>';
            return;
        }

        if ($column === 'wpsn_seo_description') {
            echo $desc !== '' ? esc_html($this->truncate($desc, 84)) : '<span style="color:#999;">—</span>';
            return;
        }

        if ($column === 'wpsn_seo_robots') {
            $ri = $rIndex !== '' ? $rIndex : 'default';
            $rf = $rFollow !== '' ? $rFollow : 'default';
            $txt = $ri . ', ' . $rf;

            // Colorize noindex/nofollow.
            $style = '';
            if ($rIndex === 'noindex' || $rFollow === 'nofollow') {
                $style = 'color:#b32d2e; font-weight:600;';
            } elseif ($rIndex === '' && $rFollow === '') {
                $style = 'color:#666;';
            }

            echo '<span style="' . esc_attr($style) . '">' . esc_html($txt) . '</span>';
            return;
        }

        if ($column === 'wpsn_focus_keyword') {
            echo $focus !== '' ? esc_html($this->truncate($focus, 56)) : '<span style="color:#999;">—</span>';
            return;
        }

        echo '—';
    }

    private function truncate(string $s, int $maxChars): string
    {
        $s = trim($s);
        if ($s === '') {
            return '';
        }

        $max = max(8, (int) $maxChars);
        if (function_exists('mb_strlen') && function_exists('mb_substr')) {
            if (mb_strlen($s) > $max) {
                return rtrim(mb_substr($s, 0, $max - 1)) . '…';
            }
            return $s;
        }

        if (strlen($s) > $max) {
            return rtrim(substr($s, 0, $max - 1)) . '…';
        }

        return $s;
    }

    public function enqueueAssets(string $hook): void
    {
        if (!function_exists('get_current_screen')) {
            return;
        }

        $screen = get_current_screen();
        if (!$screen || !is_object($screen)) {
            return;
        }

        $base = (string) ($screen->base ?? '');
        $deps = ['jquery'];

        if ($base === 'edit') {
            $pt = (string) ($screen->post_type ?? '');
            if ($pt === '' || !in_array($pt, $this->supportedPostTypes(), true)) {
                return;
            }
            $deps[] = 'inline-edit-post';
        } elseif ($base === 'edit-tags') {
            $tax = (string) ($screen->taxonomy ?? '');
            if ($tax === '' || !in_array($tax, $this->supportedTaxonomies(), true)) {
                return;
            }
            $deps[] = 'inline-edit-tax';
        } elseif ($base === 'post') {
            $pt = (string) ($screen->post_type ?? '');
            if ($pt === '' || !in_array($pt, $this->supportedPostTypes(), true)) {
                return;
            }
        } elseif ($base === 'term') {
            $tax = (string) ($screen->taxonomy ?? '');
            if ($tax === '' || !in_array($tax, $this->supportedTaxonomies(), true)) {
                return;
            }
        } else {
            return;
        }

        wp_enqueue_script(
            'wpsn-admin-lists',
            WPSN_PLUGIN_URL . 'assets/admin/admin-lists.js',
            $deps,
            WPSN_VERSION,
            true
        );

        wp_add_inline_script('wpsn-admin-lists', 'window.WPSN_AdminLists=' . wp_json_encode([
            'nonce' => wp_create_nonce('wpsn_admin_lists_qe'),
            'brand' => wp_strip_all_tags(Branding::brand($this->settings)),
            'queuePreserve' => $this->queuePreserveArgsForScreen($screen),
        ]) . ';', 'before');
    }

    /**
     * @param mixed $screen
     * @return array<string,string>
     */
    private function queuePreserveArgsForScreen($screen): array
    {
        if (!$screen || !is_object($screen)) {
            return [];
        }

        $base = (string) ($screen->base ?? '');
        $out = [];

        if ($base === 'edit') {
            $postIds = isset($_GET['wpsn_post_ids']) ? (string) wp_unslash($_GET['wpsn_post_ids']) : '';
            $queueTitle = isset($_GET['wpsn_queue_title']) ? sanitize_text_field((string) wp_unslash($_GET['wpsn_queue_title'])) : '';
            $returnTo = isset($_GET['wpsn_return_to']) ? esc_url_raw((string) wp_unslash($_GET['wpsn_return_to'])) : '';

            if ($postIds !== '') {
                $out['wpsn_post_ids'] = $postIds;
            }
            if ($queueTitle !== '') {
                $out['wpsn_queue_title'] = $queueTitle;
            }
            if ($returnTo !== '') {
                $out['wpsn_return_to'] = $returnTo;
            }

            return $out;
        }

        if ($base === 'edit-tags') {
            $termIds = isset($_GET['wpsn_term_ids']) ? (string) wp_unslash($_GET['wpsn_term_ids']) : '';
            $queueTitle = isset($_GET['wpsn_queue_title']) ? sanitize_text_field((string) wp_unslash($_GET['wpsn_queue_title'])) : '';
            $returnTo = isset($_GET['wpsn_return_to']) ? esc_url_raw((string) wp_unslash($_GET['wpsn_return_to'])) : '';

            if ($termIds !== '') {
                $out['wpsn_term_ids'] = $termIds;
            }
            if ($queueTitle !== '') {
                $out['wpsn_queue_title'] = $queueTitle;
            }
            if ($returnTo !== '') {
                $out['wpsn_return_to'] = $returnTo;
            }
        }

        if ($base === 'post') {
            return self::sanitizeQueueContextArgs($_GET, 'post');
        }

        if ($base === 'term') {
            return self::sanitizeQueueContextArgs($_GET, 'term');
        }

        return $out;
    }

    public function renderQuickEdit(string $columnName, string $postType): void
    {
        if (!current_user_can('edit_posts')) {
            return;
        }

        if ($columnName !== 'wpsn_seo_title') {
            return;
        }

        if (!in_array($postType, $this->supportedPostTypes(), true)) {
            return;
        }

        $brand = Branding::labelWithBrand($this->settings, __('SEO', 'wp-seo-next'));

        echo '<fieldset class="inline-edit-col-right">';
        echo '<div class="inline-edit-col">';
        echo '<div class="inline-edit-group">';
        echo '<label class="alignleft">';
        echo '<span class="title" style="min-width: 140px; font-weight: 600;">' . esc_html($brand) . '</span>';
        echo '</label>';
        echo '</div>';

        echo '<div class="inline-edit-group">';

        // SEO Title
        echo '<label class="alignleft" style="display:block; margin: 4px 0;">';
        echo '<span class="title">' . esc_html__('SEO Title', 'wp-seo-next') . '</span>';
        echo '<span class="input-text-wrap"><input type="text" name="wpsn_seo_title" class="wpsn-qe-title" value="" /></span>';
        echo '</label>';

        // Meta description
        echo '<label class="alignleft" style="display:block; margin: 4px 0;">';
        echo '<span class="title">' . esc_html__('Meta description', 'wp-seo-next') . '</span>';
        echo '<span class="input-text-wrap"><textarea name="wpsn_seo_description" class="wpsn-qe-description" rows="3" style="width: 100%;"></textarea></span>';
        echo '</label>';

        // Focus keyword
        echo '<label class="alignleft" style="display:block; margin: 4px 0;">';
        echo '<span class="title">' . esc_html__('Focus keyword', 'wp-seo-next') . '</span>';
        echo '<span class="input-text-wrap"><input type="text" name="wpsn_focus_keyword" class="wpsn-qe-focus" value="" /></span>';
        echo '</label>';

        // Canonical
        echo '<label class="alignleft" style="display:block; margin: 4px 0;">';
        echo '<span class="title">' . esc_html__('Canonical URL', 'wp-seo-next') . '</span>';
        echo '<span class="input-text-wrap"><input type="text" name="wpsn_canonical" class="wpsn-qe-canonical" value="" /></span>';
        echo '</label>';

        // Robots
        echo '<div style="display:flex; gap: 12px; margin: 6px 0;">';
        echo '<label class="alignleft" style="flex: 1;">';
        echo '<span class="title">' . esc_html__('Robots: index', 'wp-seo-next') . '</span>';
        echo '<select name="wpsn_robots_index" class="wpsn-qe-robots-index">';
        echo '<option value="">' . esc_html__('Default', 'wp-seo-next') . '</option>';
        echo '<option value="index">index</option>';
        echo '<option value="noindex">noindex</option>';
        echo '</select>';
        echo '</label>';

        echo '<label class="alignleft" style="flex: 1;">';
        echo '<span class="title">' . esc_html__('Robots: follow', 'wp-seo-next') . '</span>';
        echo '<select name="wpsn_robots_follow" class="wpsn-qe-robots-follow">';
        echo '<option value="">' . esc_html__('Default', 'wp-seo-next') . '</option>';
        echo '<option value="follow">follow</option>';
        echo '<option value="nofollow">nofollow</option>';
        echo '</select>';
        echo '</label>';
        echo '</div>';

        echo '<input type="hidden" name="wpsn_admin_lists_qe_nonce" value="' . esc_attr(wp_create_nonce('wpsn_admin_lists_qe')) . '" />';

        echo '</div>'; // inline-edit-group
        echo '</div>'; // inline-edit-col
        echo '</fieldset>';
    }

    /**
     * Bulk Edit UI (Posts list).
     *
     * Note: WP bulk edit uses the same inline-save endpoint as Quick Edit.
     * We do NOT add any custom WPSN AJAX here.
     */
    public function renderBulkEdit(string $columnName, string $postType): void
    {
        if (!current_user_can('edit_posts')) {
            return;
        }

        // Only render once per row: reuse our primary column as the anchor.
        if ($columnName !== 'wpsn_seo_title') {
            return;
        }

        if (!in_array($postType, $this->supportedPostTypes(), true)) {
            return;
        }

        $brand = Branding::labelWithBrand($this->settings, __('SEO', 'wp-seo-next'));

        echo '<fieldset class="inline-edit-col-right">';
        echo '<div class="inline-edit-col">';
        echo '<div class="inline-edit-group">';
        echo '<label class="alignleft">';
        echo '<span class="title" style="min-width: 140px; font-weight: 600;">' . esc_html($brand) . '</span>';
        echo '</label>';
        echo '</div>';

        echo '<div class="inline-edit-group">';
        echo '<p class="description" style="margin: 0 0 8px;">' . esc_html__('Bulk Edit: check a field to update it. Leave unchecked to keep current values.', 'wp-seo-next') . '</p>';

        // SEO Title
        echo '<label class="alignleft" style="display:block; margin: 6px 0;">';
        echo '<input type="checkbox" name="wpsn_bulk_update_title" value="1" style="margin-right:6px;" />';
        echo '<span class="title">' . esc_html__('SEO Title', 'wp-seo-next') . '</span>';
        echo '<span class="input-text-wrap"><input type="text" name="wpsn_bulk_seo_title" value="" /></span>';
        echo '</label>';

        // Meta description
        echo '<label class="alignleft" style="display:block; margin: 6px 0;">';
        echo '<input type="checkbox" name="wpsn_bulk_update_description" value="1" style="margin-right:6px;" />';
        echo '<span class="title">' . esc_html__('Meta description', 'wp-seo-next') . '</span>';
        echo '<span class="input-text-wrap"><textarea name="wpsn_bulk_seo_description" rows="3" style="width: 100%;"></textarea></span>';
        echo '</label>';

        // Focus keyword
        echo '<label class="alignleft" style="display:block; margin: 6px 0;">';
        echo '<input type="checkbox" name="wpsn_bulk_update_focus" value="1" style="margin-right:6px;" />';
        echo '<span class="title">' . esc_html__('Focus keyword', 'wp-seo-next') . '</span>';
        echo '<span class="input-text-wrap"><input type="text" name="wpsn_bulk_focus_keyword" value="" /></span>';
        echo '</label>';

        // Canonical
        echo '<label class="alignleft" style="display:block; margin: 6px 0;">';
        echo '<input type="checkbox" name="wpsn_bulk_update_canonical" value="1" style="margin-right:6px;" />';
        echo '<span class="title">' . esc_html__('Canonical URL', 'wp-seo-next') . '</span>';
        echo '<span class="input-text-wrap"><input type="text" name="wpsn_bulk_canonical" value="" /></span>';
        echo '</label>';

        // Robots
        echo '<div style="display:flex; gap: 12px; margin: 8px 0;">';
        echo '<label class="alignleft" style="flex: 1;">';
        echo '<input type="checkbox" name="wpsn_bulk_update_robots_index" value="1" style="margin-right:6px;" />';
        echo '<span class="title">' . esc_html__('Robots: index', 'wp-seo-next') . '</span>';
        echo '<select name="wpsn_bulk_robots_index">';
        echo '<option value="">' . esc_html__('Default', 'wp-seo-next') . '</option>';
        echo '<option value="index">index</option>';
        echo '<option value="noindex">noindex</option>';
        echo '</select>';
        echo '</label>';

        echo '<label class="alignleft" style="flex: 1;">';
        echo '<input type="checkbox" name="wpsn_bulk_update_robots_follow" value="1" style="margin-right:6px;" />';
        echo '<span class="title">' . esc_html__('Robots: follow', 'wp-seo-next') . '</span>';
        echo '<select name="wpsn_bulk_robots_follow">';
        echo '<option value="">' . esc_html__('Default', 'wp-seo-next') . '</option>';
        echo '<option value="follow">follow</option>';
        echo '<option value="nofollow">nofollow</option>';
        echo '</select>';
        echo '</label>';
        echo '</div>';

        echo '<input type="hidden" name="wpsn_admin_lists_bulk_nonce" value="' . esc_attr(wp_create_nonce('wpsn_admin_lists_bulk')) . '" />';

        echo '</div>'; // inline-edit-group
        echo '</div>'; // inline-edit-col
        echo '</fieldset>';
    }

    /**
     * Term Quick Edit UI (Categories/Tags/custom taxonomies).
     */
    public function renderTermQuickEdit(string $columnName, string $taxonomy): void
    {
        if (!function_exists('get_current_screen')) {
            return;
        }

        $screen = get_current_screen();
        if (!$screen || !is_object($screen) || (string) ($screen->base ?? '') !== 'edit-tags') {
            return;
        }

        if ($columnName !== 'wpsn_seo_title') {
            return;
        }

        $taxonomy = (string) $taxonomy;
        if ($taxonomy === '' || !in_array($taxonomy, $this->supportedTaxonomies(), true)) {
            return;
        }

        if (!$this->currentUserCanManageTaxonomy($taxonomy)) {
            return;
        }

        $brand = Branding::labelWithBrand($this->settings, __('SEO', 'wp-seo-next'));

        echo '<fieldset class="inline-edit-col-right">';
        echo '<div class="inline-edit-col">';
        echo '<div class="inline-edit-group">';
        echo '<label class="alignleft">';
        echo '<span class="title" style="min-width: 140px; font-weight: 600;">' . esc_html($brand) . '</span>';
        echo '</label>';
        echo '</div>';

        echo '<div class="inline-edit-group">';

        // SEO Title
        echo '<label class="alignleft" style="display:block; margin: 4px 0;">';
        echo '<span class="title">' . esc_html__('SEO Title', 'wp-seo-next') . '</span>';
        echo '<span class="input-text-wrap"><input type="text" name="wpsn_seo_title" class="wpsn-qe-title" value="" /></span>';
        echo '</label>';

        // Meta description
        echo '<label class="alignleft" style="display:block; margin: 4px 0;">';
        echo '<span class="title">' . esc_html__('Meta description', 'wp-seo-next') . '</span>';
        echo '<span class="input-text-wrap"><textarea name="wpsn_seo_description" class="wpsn-qe-description" rows="3" style="width: 100%;"></textarea></span>';
        echo '</label>';

        // Focus keyword
        echo '<label class="alignleft" style="display:block; margin: 4px 0;">';
        echo '<span class="title">' . esc_html__('Focus keyword', 'wp-seo-next') . '</span>';
        echo '<span class="input-text-wrap"><input type="text" name="wpsn_focus_keyword" class="wpsn-qe-focus" value="" /></span>';
        echo '</label>';

        // Canonical
        echo '<label class="alignleft" style="display:block; margin: 4px 0;">';
        echo '<span class="title">' . esc_html__('Canonical URL', 'wp-seo-next') . '</span>';
        echo '<span class="input-text-wrap"><input type="text" name="wpsn_canonical" class="wpsn-qe-canonical" value="" /></span>';
        echo '</label>';

        // Robots
        echo '<div style="display:flex; gap: 12px; margin: 6px 0;">';
        echo '<label class="alignleft" style="flex: 1;">';
        echo '<span class="title">' . esc_html__('Robots: index', 'wp-seo-next') . '</span>';
        echo '<select name="wpsn_robots_index" class="wpsn-qe-robots-index">';
        echo '<option value="">' . esc_html__('Default', 'wp-seo-next') . '</option>';
        echo '<option value="index">index</option>';
        echo '<option value="noindex">noindex</option>';
        echo '</select>';
        echo '</label>';

        echo '<label class="alignleft" style="flex: 1;">';
        echo '<span class="title">' . esc_html__('Robots: follow', 'wp-seo-next') . '</span>';
        echo '<select name="wpsn_robots_follow" class="wpsn-qe-robots-follow">';
        echo '<option value="">' . esc_html__('Default', 'wp-seo-next') . '</option>';
        echo '<option value="follow">follow</option>';
        echo '<option value="nofollow">nofollow</option>';
        echo '</select>';
        echo '</label>';
        echo '</div>';

        echo '<input type="hidden" name="wpsn_admin_lists_term_qe_nonce" value="' . esc_attr(wp_create_nonce('wpsn_admin_lists_term_qe')) . '" />';

        echo '</div>'; // inline-edit-group
        echo '</div>'; // inline-edit-col
        echo '</fieldset>';
    }

    /**
     * Save handler for Quick Edit fields.
     */
    public function handleQuickEditSave(int $postId, \WP_Post $post): void
    {
        if ($postId <= 0) {
            return;
        }

        // Only act when our Quick Edit fields were present.
        if (!isset($_POST['wpsn_admin_lists_qe_nonce'])) {
            return;
        }

        if (wp_is_post_autosave($postId) || wp_is_post_revision($postId)) {
            return;
        }

        if (!current_user_can('edit_post', $postId)) {
            return;
        }

        $nonce = (string) wp_unslash($_POST['wpsn_admin_lists_qe_nonce']);
        if (!wp_verify_nonce($nonce, 'wpsn_admin_lists_qe')) {
            return;
        }

        // Supported post types only.
        $pt = (string) ($post->post_type ?? '');
        if ($pt === '' || !in_array($pt, $this->supportedPostTypes(), true)) {
            return;
        }

        $title = isset($_POST['wpsn_seo_title']) ? sanitize_text_field((string) wp_unslash($_POST['wpsn_seo_title'])) : '';
        $descRaw = isset($_POST['wpsn_seo_description']) ? (string) wp_unslash($_POST['wpsn_seo_description']) : '';
        $desc  = function_exists('sanitize_textarea_field')
            ? sanitize_textarea_field($descRaw)
            : sanitize_text_field($descRaw);
        $focus = isset($_POST['wpsn_focus_keyword']) ? sanitize_text_field((string) wp_unslash($_POST['wpsn_focus_keyword'])) : '';
        $canon = isset($_POST['wpsn_canonical']) ? trim((string) wp_unslash($_POST['wpsn_canonical'])) : '';
        $canon = $canon !== '' ? esc_url_raw($canon) : '';

        $ri = isset($_POST['wpsn_robots_index']) ? sanitize_key((string) wp_unslash($_POST['wpsn_robots_index'])) : '';
        $rf = isset($_POST['wpsn_robots_follow']) ? sanitize_key((string) wp_unslash($_POST['wpsn_robots_follow'])) : '';

        if (!in_array($ri, ['', 'index', 'noindex'], true)) {
            $ri = '';
        }
        if (!in_array($rf, ['', 'follow', 'nofollow'], true)) {
            $rf = '';
        }

        // Unified revision snapshot (Quick Edit).
        $trackedKeys = [
            MetaKeys::TITLE,
            MetaKeys::DESCRIPTION,
            MetaKeys::FOCUS_KEYWORD,
            MetaKeys::CANONICAL,
            MetaKeys::ROBOTS_INDEX,
            MetaKeys::ROBOTS_FOLLOW,
        ];

        $beforeMeta = [];
        foreach ($trackedKeys as $k) {
            $exists = function_exists('metadata_exists') ? metadata_exists('post', $postId, $k) : true;
            $beforeMeta[$k] = $exists ? (string) get_post_meta($postId, $k, true) : null;
        }

        $afterMeta = [
            MetaKeys::TITLE => trim($title) !== '' ? $title : null,
            MetaKeys::DESCRIPTION => trim($desc) !== '' ? $desc : null,
            MetaKeys::FOCUS_KEYWORD => trim($focus) !== '' ? $focus : null,
            MetaKeys::CANONICAL => trim($canon) !== '' ? $canon : null,
            MetaKeys::ROBOTS_INDEX => $ri !== '' ? $ri : null,
            MetaKeys::ROBOTS_FOLLOW => $rf !== '' ? $rf : null,
        ];

        $this->setPostMeta($postId, MetaKeys::TITLE, $title);
        $this->setPostMeta($postId, MetaKeys::DESCRIPTION, $desc);
        $this->setPostMeta($postId, MetaKeys::FOCUS_KEYWORD, $focus);
        $this->setPostMeta($postId, MetaKeys::CANONICAL, $canon);
        $this->setPostMeta($postId, MetaKeys::ROBOTS_INDEX, $ri);
        $this->setPostMeta($postId, MetaKeys::ROBOTS_FOLLOW, $rf);

        // Record revision only if something actually changed.
        if ($beforeMeta !== $afterMeta) {
            RevisionsInstaller::maybeInstall();
            $revRepo = new RevisionsRepository($this->container->get('logger'));
            $revRepo->insert([
                'object_type' => 'post',
                'object_id' => $postId,
                'object_subtype' => $pt,
                'action' => 'update',
                'source' => 'admin_lists',
                'actor_user_id' => (int) get_current_user_id(),
                'before' => ['meta' => $beforeMeta],
                'after' => ['meta' => $afterMeta],
                'meta' => [
                    'context' => 'quick_edit',
                ],
            ]);

            // Notify dependent modules (analysis, sitemap cache, etc.).
            $this->container->get('events')->dispatch('seo/meta/updated', [$postId, 'post']);
        }
    }

    /**
     * Save handler for WP Bulk Edit fields.
     *
     * Trigger: wp-admin edit.php bulk action "Edit" → inline-save.
     */
    public function handleBulkEditSave(int $postId, \WP_Post $post): void
    {
        if ($postId <= 0) {
            return;
        }

        if (!isset($_POST['wpsn_admin_lists_bulk_nonce'])) {
            return;
        }

        if (wp_is_post_autosave($postId) || wp_is_post_revision($postId)) {
            return;
        }

        if (!current_user_can('edit_post', $postId)) {
            return;
        }

        $nonce = (string) wp_unslash($_POST['wpsn_admin_lists_bulk_nonce']);
        if (!wp_verify_nonce($nonce, 'wpsn_admin_lists_bulk')) {
            return;
        }

        // Supported post types only.
        $pt = (string) ($post->post_type ?? '');
        if ($pt === '' || !in_array($pt, $this->supportedPostTypes(), true)) {
            return;
        }

        // Only apply checked fields.
        $doTitle = isset($_POST['wpsn_bulk_update_title']);
        $doDesc  = isset($_POST['wpsn_bulk_update_description']);
        $doFocus = isset($_POST['wpsn_bulk_update_focus']);
        $doCanon = isset($_POST['wpsn_bulk_update_canonical']);
        $doRi    = isset($_POST['wpsn_bulk_update_robots_index']);
        $doRf    = isset($_POST['wpsn_bulk_update_robots_follow']);

        if (!$doTitle && !$doDesc && !$doFocus && !$doCanon && !$doRi && !$doRf) {
            return;
        }

        $tracked = [];
        if ($doTitle) {
            $tracked[] = MetaKeys::TITLE;
        }
        if ($doDesc) {
            $tracked[] = MetaKeys::DESCRIPTION;
        }
        if ($doFocus) {
            $tracked[] = MetaKeys::FOCUS_KEYWORD;
        }
        if ($doCanon) {
            $tracked[] = MetaKeys::CANONICAL;
        }
        if ($doRi) {
            $tracked[] = MetaKeys::ROBOTS_INDEX;
        }
        if ($doRf) {
            $tracked[] = MetaKeys::ROBOTS_FOLLOW;
        }

        $beforeMeta = [];
        foreach ($tracked as $k) {
            $exists = function_exists('metadata_exists') ? metadata_exists('post', $postId, $k) : true;
            $beforeMeta[$k] = $exists ? (string) get_post_meta($postId, $k, true) : null;
        }

        // Values.
        $title = '';
        if ($doTitle) {
            $title = isset($_POST['wpsn_bulk_seo_title']) ? sanitize_text_field((string) wp_unslash($_POST['wpsn_bulk_seo_title'])) : '';
        }

        $desc = '';
        if ($doDesc) {
            $descRaw = isset($_POST['wpsn_bulk_seo_description']) ? (string) wp_unslash($_POST['wpsn_bulk_seo_description']) : '';
            $desc = function_exists('sanitize_textarea_field')
                ? sanitize_textarea_field($descRaw)
                : sanitize_text_field($descRaw);
        }

        $focus = '';
        if ($doFocus) {
            $focus = isset($_POST['wpsn_bulk_focus_keyword']) ? sanitize_text_field((string) wp_unslash($_POST['wpsn_bulk_focus_keyword'])) : '';
        }

        $canon = '';
        if ($doCanon) {
            $canon = isset($_POST['wpsn_bulk_canonical']) ? trim((string) wp_unslash($_POST['wpsn_bulk_canonical'])) : '';
            $canon = $canon !== '' ? esc_url_raw($canon) : '';
        }

        $ri = '';
        if ($doRi) {
            $ri = isset($_POST['wpsn_bulk_robots_index']) ? sanitize_key((string) wp_unslash($_POST['wpsn_bulk_robots_index'])) : '';
            if (!in_array($ri, ['', 'index', 'noindex'], true)) {
                $ri = '';
            }
        }

        $rf = '';
        if ($doRf) {
            $rf = isset($_POST['wpsn_bulk_robots_follow']) ? sanitize_key((string) wp_unslash($_POST['wpsn_bulk_robots_follow'])) : '';
            if (!in_array($rf, ['', 'follow', 'nofollow'], true)) {
                $rf = '';
            }
        }

        // Apply.
        if ($doTitle) {
            $this->setPostMeta($postId, MetaKeys::TITLE, $title);
        }
        if ($doDesc) {
            $this->setPostMeta($postId, MetaKeys::DESCRIPTION, $desc);
        }
        if ($doFocus) {
            $this->setPostMeta($postId, MetaKeys::FOCUS_KEYWORD, $focus);
        }
        if ($doCanon) {
            $this->setPostMeta($postId, MetaKeys::CANONICAL, $canon);
        }
        if ($doRi) {
            $this->setPostMeta($postId, MetaKeys::ROBOTS_INDEX, $ri);
        }
        if ($doRf) {
            $this->setPostMeta($postId, MetaKeys::ROBOTS_FOLLOW, $rf);
        }

        $afterMeta = [];
        foreach ($tracked as $k) {
            $exists = function_exists('metadata_exists') ? metadata_exists('post', $postId, $k) : true;
            $afterMeta[$k] = $exists ? (string) get_post_meta($postId, $k, true) : null;
        }

        if ($beforeMeta === $afterMeta) {
            return;
        }

        RevisionsInstaller::maybeInstall();
        $revRepo = new RevisionsRepository($this->container->get('logger'));
        $revRepo->insert([
            'object_type' => 'post',
            'object_id' => $postId,
            'object_subtype' => $pt,
            'action' => 'update',
            'source' => 'admin_lists',
            'actor_user_id' => (int) get_current_user_id(),
            'before' => ['meta' => $beforeMeta],
            'after' => ['meta' => $afterMeta],
            'meta' => [
                'context' => 'bulk_edit',
            ],
        ]);

        $this->container->get('events')->dispatch('seo/meta/updated', [$postId, 'post']);
    }

    private function setPostMeta(int $postId, string $key, string $value): void
    {
        $value = trim($value);
        if ($value === '') {
            delete_post_meta($postId, $key);
            return;
        }
        update_post_meta($postId, $key, $value);
    }

    private function setTermMeta(int $termId, string $key, string $value): void
    {
        $value = trim($value);
        if ($value === '') {
            delete_term_meta($termId, $key);
            return;
        }
        update_term_meta($termId, $key, $value);
    }

    /**
     * Save handler for Term Quick Edit fields.
     */
    public function handleTermQuickEditSave(int $termId, int $ttId, string $taxonomy): void
    {
        if ($termId <= 0) {
            return;
        }

        if (!isset($_POST['wpsn_admin_lists_term_qe_nonce'])) {
            return;
        }

        if (!current_user_can('edit_term', $termId)) {
            return;
        }

        $nonce = (string) wp_unslash($_POST['wpsn_admin_lists_term_qe_nonce']);
        if (!wp_verify_nonce($nonce, 'wpsn_admin_lists_term_qe')) {
            return;
        }

        $taxonomy = (string) $taxonomy;
        if ($taxonomy === '' || !in_array($taxonomy, $this->supportedTaxonomies(), true)) {
            return;
        }

        $title = isset($_POST['wpsn_seo_title']) ? sanitize_text_field((string) wp_unslash($_POST['wpsn_seo_title'])) : '';
        $descRaw = isset($_POST['wpsn_seo_description']) ? (string) wp_unslash($_POST['wpsn_seo_description']) : '';
        $desc  = function_exists('sanitize_textarea_field')
            ? sanitize_textarea_field($descRaw)
            : sanitize_text_field($descRaw);
        $focus = isset($_POST['wpsn_focus_keyword']) ? sanitize_text_field((string) wp_unslash($_POST['wpsn_focus_keyword'])) : '';
        $canon = isset($_POST['wpsn_canonical']) ? trim((string) wp_unslash($_POST['wpsn_canonical'])) : '';
        $canon = $canon !== '' ? esc_url_raw($canon) : '';

        $ri = isset($_POST['wpsn_robots_index']) ? sanitize_key((string) wp_unslash($_POST['wpsn_robots_index'])) : '';
        $rf = isset($_POST['wpsn_robots_follow']) ? sanitize_key((string) wp_unslash($_POST['wpsn_robots_follow'])) : '';

        if (!in_array($ri, ['', 'index', 'noindex'], true)) {
            $ri = '';
        }
        if (!in_array($rf, ['', 'follow', 'nofollow'], true)) {
            $rf = '';
        }

        $trackedKeys = [
            MetaKeys::TITLE,
            MetaKeys::DESCRIPTION,
            MetaKeys::FOCUS_KEYWORD,
            MetaKeys::CANONICAL,
            MetaKeys::ROBOTS_INDEX,
            MetaKeys::ROBOTS_FOLLOW,
        ];

        $beforeMeta = [];
        foreach ($trackedKeys as $k) {
            $exists = function_exists('metadata_exists') ? metadata_exists('term', $termId, $k) : true;
            $beforeMeta[$k] = $exists ? (string) get_term_meta($termId, $k, true) : null;
        }

        $afterMeta = [
            MetaKeys::TITLE => trim($title) !== '' ? $title : null,
            MetaKeys::DESCRIPTION => trim($desc) !== '' ? $desc : null,
            MetaKeys::FOCUS_KEYWORD => trim($focus) !== '' ? $focus : null,
            MetaKeys::CANONICAL => trim($canon) !== '' ? $canon : null,
            MetaKeys::ROBOTS_INDEX => $ri !== '' ? $ri : null,
            MetaKeys::ROBOTS_FOLLOW => $rf !== '' ? $rf : null,
        ];

        $this->setTermMeta($termId, MetaKeys::TITLE, $title);
        $this->setTermMeta($termId, MetaKeys::DESCRIPTION, $desc);
        $this->setTermMeta($termId, MetaKeys::FOCUS_KEYWORD, $focus);
        $this->setTermMeta($termId, MetaKeys::CANONICAL, $canon);
        $this->setTermMeta($termId, MetaKeys::ROBOTS_INDEX, $ri);
        $this->setTermMeta($termId, MetaKeys::ROBOTS_FOLLOW, $rf);

        if ($beforeMeta !== $afterMeta) {
            RevisionsInstaller::maybeInstall();
            $revRepo = new RevisionsRepository($this->container->get('logger'));
            $revRepo->insert([
                'object_type' => 'term',
                'object_id' => $termId,
                'object_subtype' => $taxonomy,
                'action' => 'update',
                'source' => 'admin_lists',
                'actor_user_id' => (int) get_current_user_id(),
                'before' => ['meta' => $beforeMeta],
                'after' => ['meta' => $afterMeta],
                'meta' => [
                    'context' => 'quick_edit',
                ],
            ]);

            $this->container->get('events')->dispatch('seo/meta/updated', [$termId, 'term']);
        }
    }

    /**
     * Register WPSN bulk actions for post and term list screens.
     */
    public function registerBulkActions(): void
    {
        // Posts.
        foreach ($this->supportedPostTypes() as $pt) {
            add_filter('bulk_actions-edit-' . $pt, function (array $actions) {
                $actions['wpsn_set_noindex'] = __('WPSN: Set noindex', 'wp-seo-next');
                $actions['wpsn_set_index'] = __('WPSN: Set index', 'wp-seo-next');
                $actions['wpsn_clear_seo_overrides'] = __('WPSN: Clear SEO overrides', 'wp-seo-next');
                return $actions;
            }, 20);

            add_filter('handle_bulk_actions-edit-' . $pt, function (string $redirectTo, string $doAction, array $postIds) use ($pt) {
                return $this->handlePostBulkAction($redirectTo, $doAction, $postIds, $pt);
            }, 20, 3);
        }

        // Terms.
        foreach ($this->supportedTaxonomies() as $tax) {
            add_filter('bulk_actions-edit-' . $tax, function (array $actions) use ($tax) {
                if (!$this->currentUserCanManageTaxonomy((string) $tax)) {
                    return $actions;
                }
                $actions['wpsn_set_noindex'] = __('WPSN: Set noindex', 'wp-seo-next');
                $actions['wpsn_set_index'] = __('WPSN: Set index', 'wp-seo-next');
                $actions['wpsn_clear_seo_overrides'] = __('WPSN: Clear SEO overrides', 'wp-seo-next');
                return $actions;
            }, 20);

            add_filter('handle_bulk_actions-edit-' . $tax, function (string $redirectTo, string $doAction, array $termIds) use ($tax) {
                return $this->handleTermBulkAction($redirectTo, $doAction, $termIds, $tax);
            }, 20, 3);
        }
    }

    private function preserveWorkQueueRedirectArgs(string $redirectTo): string
    {
        $args = [];

        $postIds = isset($_REQUEST['wpsn_post_ids']) ? (string) wp_unslash($_REQUEST['wpsn_post_ids']) : '';
        if ($postIds !== '') {
            $args['wpsn_post_ids'] = $postIds;
        }

        $termIds = isset($_REQUEST['wpsn_term_ids']) ? (string) wp_unslash($_REQUEST['wpsn_term_ids']) : '';
        if ($termIds !== '') {
            $args['wpsn_term_ids'] = $termIds;
        }

        $queueTitle = isset($_REQUEST['wpsn_queue_title']) ? sanitize_text_field((string) wp_unslash($_REQUEST['wpsn_queue_title'])) : '';
        if ($queueTitle !== '') {
            $args['wpsn_queue_title'] = $queueTitle;
        }

        $returnTo = isset($_REQUEST['wpsn_return_to']) ? esc_url_raw((string) wp_unslash($_REQUEST['wpsn_return_to'])) : '';
        if ($returnTo !== '') {
            $args['wpsn_return_to'] = $returnTo;
        }

        return !empty($args) ? add_query_arg($args, $redirectTo) : $redirectTo;
    }

    private function handlePostBulkAction(string $redirectTo, string $doAction, array $postIds, string $postType): string
    {
        $redirectTo = $this->preserveWorkQueueRedirectArgs($redirectTo);
        $doAction = sanitize_key($doAction);
        if (!in_array($doAction, ['wpsn_set_noindex', 'wpsn_set_index', 'wpsn_clear_seo_overrides'], true)) {
            return $redirectTo;
        }

        if (!current_user_can('edit_posts')) {
            return $redirectTo;
        }

        $postType = (string) $postType;
        $done = 0;

        foreach ($postIds as $id) {
            $id = (int) $id;
            if ($id <= 0 || !current_user_can('edit_post', $id)) {
                continue;
            }

            $changed = $this->applyPostBulkChange($id, $postType, $doAction);
            if ($changed) {
                $done++;
            }
        }

        if ($done > 0) {
            $redirectTo = add_query_arg([
                'wpsn_bulk_action' => $doAction,
                'wpsn_bulk_done' => $done,
            ], $redirectTo);
        }

        return $redirectTo;
    }

    private function applyPostBulkChange(int $postId, string $postType, string $doAction): bool
    {
        $trackedKeys = [
            MetaKeys::TITLE,
            MetaKeys::DESCRIPTION,
            MetaKeys::FOCUS_KEYWORD,
            MetaKeys::CANONICAL,
            MetaKeys::ROBOTS_INDEX,
            MetaKeys::ROBOTS_FOLLOW,
            MetaKeys::OG_TITLE,
            MetaKeys::OG_DESCRIPTION,
            MetaKeys::OG_IMAGE,
            MetaKeys::TWITTER_TITLE,
            MetaKeys::TWITTER_DESCRIPTION,
            MetaKeys::TWITTER_IMAGE,
        ];

        $beforeMeta = [];
        foreach ($trackedKeys as $k) {
            $exists = function_exists('metadata_exists') ? metadata_exists('post', $postId, $k) : true;
            $beforeMeta[$k] = $exists ? (string) get_post_meta($postId, $k, true) : null;
        }

        $afterMeta = $beforeMeta;

        if ($doAction === 'wpsn_set_noindex') {
            $afterMeta[MetaKeys::ROBOTS_INDEX] = 'noindex';
            $this->setPostMeta($postId, MetaKeys::ROBOTS_INDEX, 'noindex');
        } elseif ($doAction === 'wpsn_set_index') {
            $afterMeta[MetaKeys::ROBOTS_INDEX] = 'index';
            $this->setPostMeta($postId, MetaKeys::ROBOTS_INDEX, 'index');
        } elseif ($doAction === 'wpsn_clear_seo_overrides') {
            foreach ($trackedKeys as $k) {
                $afterMeta[$k] = null;
                delete_post_meta($postId, $k);
            }
        }

        if ($beforeMeta === $afterMeta) {
            return false;
        }

        RevisionsInstaller::maybeInstall();
        $revRepo = new RevisionsRepository($this->container->get('logger'));
        $revRepo->insert([
            'object_type' => 'post',
            'object_id' => $postId,
            'object_subtype' => $postType,
            'action' => 'update',
            'source' => 'admin_lists',
            'actor_user_id' => (int) get_current_user_id(),
            'before' => ['meta' => $beforeMeta],
            'after' => ['meta' => $afterMeta],
            'meta' => [
                'context' => 'bulk_action',
                'bulk_action' => $doAction,
            ],
        ]);

        $this->container->get('events')->dispatch('seo/meta/updated', [$postId, 'post']);
        return true;
    }

    private function handleTermBulkAction(string $redirectTo, string $doAction, array $termIds, string $taxonomy): string
    {
        $redirectTo = $this->preserveWorkQueueRedirectArgs($redirectTo);
        $doAction = sanitize_key($doAction);
        if (!in_array($doAction, ['wpsn_set_noindex', 'wpsn_set_index', 'wpsn_clear_seo_overrides'], true)) {
            return $redirectTo;
        }

        $taxonomy = (string) $taxonomy;
        if (!$this->currentUserCanManageTaxonomy($taxonomy)) {
            return $redirectTo;
        }
        $done = 0;

        foreach ($termIds as $id) {
            $id = (int) $id;
            if ($id <= 0 || !current_user_can('edit_term', $id)) {
                continue;
            }

            $changed = $this->applyTermBulkChange($id, $taxonomy, $doAction);
            if ($changed) {
                $done++;
            }
        }

        if ($done > 0) {
            $redirectTo = add_query_arg([
                'wpsn_bulk_action' => $doAction,
                'wpsn_bulk_done' => $done,
            ], $redirectTo);
        }

        return $redirectTo;
    }

    private function applyTermBulkChange(int $termId, string $taxonomy, string $doAction): bool
    {
        $trackedKeys = [
            MetaKeys::TITLE,
            MetaKeys::DESCRIPTION,
            MetaKeys::FOCUS_KEYWORD,
            MetaKeys::CANONICAL,
            MetaKeys::ROBOTS_INDEX,
            MetaKeys::ROBOTS_FOLLOW,
            MetaKeys::OG_TITLE,
            MetaKeys::OG_DESCRIPTION,
            MetaKeys::OG_IMAGE,
            MetaKeys::TWITTER_TITLE,
            MetaKeys::TWITTER_DESCRIPTION,
            MetaKeys::TWITTER_IMAGE,
        ];

        $beforeMeta = [];
        foreach ($trackedKeys as $k) {
            $exists = function_exists('metadata_exists') ? metadata_exists('term', $termId, $k) : true;
            $beforeMeta[$k] = $exists ? (string) get_term_meta($termId, $k, true) : null;
        }
        $afterMeta = $beforeMeta;

        if ($doAction === 'wpsn_set_noindex') {
            $afterMeta[MetaKeys::ROBOTS_INDEX] = 'noindex';
            $this->setTermMeta($termId, MetaKeys::ROBOTS_INDEX, 'noindex');
        } elseif ($doAction === 'wpsn_set_index') {
            $afterMeta[MetaKeys::ROBOTS_INDEX] = 'index';
            $this->setTermMeta($termId, MetaKeys::ROBOTS_INDEX, 'index');
        } elseif ($doAction === 'wpsn_clear_seo_overrides') {
            foreach ($trackedKeys as $k) {
                $afterMeta[$k] = null;
                delete_term_meta($termId, $k);
            }
        }

        if ($beforeMeta === $afterMeta) {
            return false;
        }

        RevisionsInstaller::maybeInstall();
        $revRepo = new RevisionsRepository($this->container->get('logger'));
        $revRepo->insert([
            'object_type' => 'term',
            'object_id' => $termId,
            'object_subtype' => $taxonomy,
            'action' => 'update',
            'source' => 'admin_lists',
            'actor_user_id' => (int) get_current_user_id(),
            'before' => ['meta' => $beforeMeta],
            'after' => ['meta' => $afterMeta],
            'meta' => [
                'context' => 'bulk_action',
                'bulk_action' => $doAction,
            ],
        ]);

        $this->container->get('events')->dispatch('seo/meta/updated', [$termId, 'term']);
        return true;
    }

    public function renderBulkNotices(): void
    {
        if (!is_admin()) {
            return;
        }

        $done = isset($_GET['wpsn_bulk_done']) ? (int) $_GET['wpsn_bulk_done'] : 0;
        $action = isset($_GET['wpsn_bulk_action']) ? sanitize_key((string) wp_unslash($_GET['wpsn_bulk_action'])) : '';

        if ($done <= 0 || $action === '') {
            return;
        }

        $label = '';
        if ($action === 'wpsn_set_noindex') {
            $label = __('WPSN bulk action applied: set noindex.', 'wp-seo-next');
        } elseif ($action === 'wpsn_set_index') {
            $label = __('WPSN bulk action applied: set index.', 'wp-seo-next');
        } elseif ($action === 'wpsn_clear_seo_overrides') {
            $label = __('WPSN bulk action applied: cleared SEO overrides.', 'wp-seo-next');
        }

        if ($label === '') {
            return;
        }

        echo '<div class="notice notice-success is-dismissible"><p>';
        echo esc_html($label . ' ' . sprintf(__('Items updated: %d', 'wp-seo-next'), $done));
        echo '</p></div>';
    }

    public function renderFilters(): void
    {
        if (!function_exists('get_current_screen')) {
            return;
        }

        $screen = get_current_screen();
        if (!$screen || !is_object($screen) || (string) ($screen->base ?? '') !== 'edit') {
            return;
        }

        $pt = (string) ($screen->post_type ?? '');
        if ($pt === '' || !in_array($pt, $this->supportedPostTypes(), true)) {
            return;
        }

        $val = isset($_GET['wpsn_seo_filter']) ? sanitize_key((string) wp_unslash($_GET['wpsn_seo_filter'])) : '';
        $queueIdsRaw = isset($_GET['wpsn_post_ids']) ? (string) wp_unslash($_GET['wpsn_post_ids']) : '';
        $queueTitle = isset($_GET['wpsn_queue_title']) ? sanitize_text_field((string) wp_unslash($_GET['wpsn_queue_title'])) : '';
        $returnTo = isset($_GET['wpsn_return_to']) ? esc_url_raw((string) wp_unslash($_GET['wpsn_return_to'])) : '';

        if ($queueIdsRaw !== '') {
            echo '<input type="hidden" name="wpsn_post_ids" value="' . esc_attr($queueIdsRaw) . '" />';
        }
        if ($queueTitle !== '') {
            echo '<input type="hidden" name="wpsn_queue_title" value="' . esc_attr($queueTitle) . '" />';
        }
        if ($returnTo !== '') {
            echo '<input type="hidden" name="wpsn_return_to" value="' . esc_attr($returnTo) . '" />';
        }

        echo '<label class="screen-reader-text" for="wpsn-seo-filter">' . esc_html__('SEO filter', 'wp-seo-next') . '</label>';
        echo '<select name="wpsn_seo_filter" id="wpsn-seo-filter" style="margin-left:6px;">';
        echo '<option value="">' . esc_html__('SEO: All', 'wp-seo-next') . '</option>';
        echo '<option value="missing_title"' . selected($val, 'missing_title', false) . '>' . esc_html__('SEO: Missing custom title', 'wp-seo-next') . '</option>';
        echo '<option value="missing_description"' . selected($val, 'missing_description', false) . '>' . esc_html__('SEO: Missing custom description', 'wp-seo-next') . '</option>';
        echo '<option value="missing_focus"' . selected($val, 'missing_focus', false) . '>' . esc_html__('SEO: Missing focus keyword', 'wp-seo-next') . '</option>';
        echo '<option value="noindex"' . selected($val, 'noindex', false) . '>' . esc_html__('SEO: Noindex override', 'wp-seo-next') . '</option>';
        echo '<option value="no_social_image"' . selected($val, 'no_social_image', false) . '>' . esc_html__('SEO: No social image (OG/featured)', 'wp-seo-next') . '</option>';
        echo '<option value="dup_title"' . selected($val, 'dup_title', false) . '>' . esc_html__('SEO: Duplicate titles (latest audit)', 'wp-seo-next') . '</option>';
        echo '<option value="dup_description"' . selected($val, 'dup_description', false) . '>' . esc_html__('SEO: Duplicate descriptions (latest audit)', 'wp-seo-next') . '</option>';
        echo '</select>';

        if ($returnTo !== '') {
            echo '<a class="button" style="margin-left:6px;" href="' . esc_url($returnTo) . '">' . esc_html__('Back to Tasks', 'wp-seo-next') . '</a>';
        }
    }

    public function applyFilters(\WP_Query $query): void
    {
        if (!is_admin() || !$query->is_main_query()) {
            return;
        }

        if (!function_exists('get_current_screen')) {
            return;
        }

        $screen = get_current_screen();
        if (!$screen || !is_object($screen) || (string) ($screen->base ?? '') !== 'edit') {
            return;
        }

        $pt = (string) ($screen->post_type ?? '');
        if ($pt === '' || !in_array($pt, $this->supportedPostTypes(), true)) {
            return;
        }

        $queueVars = self::buildPostQueueQueryVars(isset($_GET['wpsn_post_ids']) ? (string) wp_unslash($_GET['wpsn_post_ids']) : '');
        foreach ($queueVars as $key => $value) {
            $query->set($key, $value);
        }

        $val = isset($_GET['wpsn_seo_filter']) ? sanitize_key((string) wp_unslash($_GET['wpsn_seo_filter'])) : '';
        if ($val === '') {
            return;
        }

        // Duplicate filters are audit-backed (no heavy runtime scanning).
        if (in_array($val, ['dup_title', 'dup_description'], true)) {
            $scanId = $this->getLatestCompletedAuditScanId();
            if ($scanId <= 0) {
                $this->noticeAuditNeeded();
                return;
            }

            $this->applyDuplicateJoinFilter($query, $val, $scanId, $pt);
            return;
        }

        $metaQuery = $query->get('meta_query');
        if (!is_array($metaQuery)) {
            $metaQuery = [];
        }

        $add = self::buildMetaQueryForFilter($val);
        if ($add === []) {
            return;
        }

        $metaQuery[] = $add;
        $query->set('meta_query', $metaQuery);
    }

    /**
     * Applies Tasks Hub term queues to the native edit-tags.php list table only.
     *
     * @param mixed $query
     */
    public function applyTermQueues($query): void
    {
        if (!is_admin() || !($query instanceof \WP_Term_Query) || !function_exists('get_current_screen')) {
            return;
        }

        $screen = get_current_screen();
        if (!$screen || !is_object($screen) || (string) ($screen->base ?? '') !== 'edit-tags') {
            return;
        }

        $taxonomy = sanitize_key((string) ($screen->taxonomy ?? ''));
        if ($taxonomy === '' || !in_array($taxonomy, $this->supportedTaxonomies(), true)) {
            return;
        }

        $queueVars = self::buildTermQueueQueryVars(
            isset($_GET['wpsn_term_ids']) ? (string) wp_unslash($_GET['wpsn_term_ids']) : '',
            $taxonomy
        );
        if ($queueVars === []) {
            return;
        }

        $queryVars = isset($query->query_vars) && is_array($query->query_vars) ? $query->query_vars : [];
        $fields = isset($queryVars['fields']) ? (string) $queryVars['fields'] : '';
        $isCountQuery = $fields === 'count';
        $isListTableQuery = array_key_exists('page', $queryVars);
        if (!$isListTableQuery && !$isCountQuery) {
            return;
        }

        $queryTaxonomies = [];
        if (isset($queryVars['taxonomy'])) {
            $queryTaxonomies = array_values(array_filter((array) $queryVars['taxonomy'], static function ($value): bool {
                return is_string($value) && $value !== '';
            }));
        }

        if (!empty($queryTaxonomies) && !in_array($taxonomy, $queryTaxonomies, true)) {
            return;
        }

        foreach ($queueVars as $key => $value) {
            $query->query_vars[$key] = $value;
        }
    }

    /**
     * Pure helper for unit tests: returns a meta_query clause for a given filter.
     *
     * @return array<string,mixed>
     */
    public static function buildMetaQueryForFilter(string $filter): array
    {
        $filter = sanitize_key($filter);

        if ($filter === 'missing_title') {
            return [
                'relation' => 'OR',
                [
                    'key' => MetaKeys::TITLE,
                    'compare' => 'NOT EXISTS',
                ],
                [
                    'key' => MetaKeys::TITLE,
                    'value' => '',
                    'compare' => '=',
                ],
            ];
        }

        if ($filter === 'missing_description') {
            return [
                'relation' => 'OR',
                [
                    'key' => MetaKeys::DESCRIPTION,
                    'compare' => 'NOT EXISTS',
                ],
                [
                    'key' => MetaKeys::DESCRIPTION,
                    'value' => '',
                    'compare' => '=',
                ],
            ];
        }

        if ($filter === 'missing_focus') {
            return [
                'relation' => 'OR',
                [
                    'key' => MetaKeys::FOCUS_KEYWORD,
                    'compare' => 'NOT EXISTS',
                ],
                [
                    'key' => MetaKeys::FOCUS_KEYWORD,
                    'value' => '',
                    'compare' => '=',
                ],
            ];
        }

        if ($filter === 'noindex') {
            return [
                'key' => MetaKeys::ROBOTS_INDEX,
                'value' => 'noindex',
                'compare' => '=',
            ];
        }

        if ($filter === 'no_social_image') {
            // No OG override AND no featured image.
            return [
                'relation' => 'AND',
                [
                    'relation' => 'OR',
                    [
                        'key' => MetaKeys::OG_IMAGE,
                        'compare' => 'NOT EXISTS',
                    ],
                    [
                        'key' => MetaKeys::OG_IMAGE,
                        'value' => '',
                        'compare' => '=',
                    ],
                ],
                [
                    'relation' => 'OR',
                    [
                        'key' => '_thumbnail_id',
                        'compare' => 'NOT EXISTS',
                    ],
                    [
                        'key' => '_thumbnail_id',
                        'value' => '',
                        'compare' => '=',
                    ],
                ],
            ];
        }

        return [];
    }

    /**
     * Pure helper for queue-style admin list filters.
     *
     * @param mixed $raw
     * @return array<int,int>
     */
    public static function normalizeQueueIds($raw, int $limit = 200): array
    {
        if (is_array($raw)) {
            $raw = implode(',', array_map('strval', $raw));
        }

        preg_match_all('/-?\d+/', (string) $raw, $matches);
        $parts = isset($matches[0]) && is_array($matches[0]) ? $matches[0] : [];
        $out = [];

        foreach ($parts as $part) {
            $id = (int) $part;
            if ($id <= 0 || isset($out[$id])) {
                continue;
            }

            $out[$id] = $id;
            if (count($out) >= max(1, $limit)) {
                break;
            }
        }

        return array_values($out);
    }

    /**
     * Pure helper for wp-admin edit.php work queues.
     *
     * @return array<string,mixed>
     */
    public static function buildPostQueueQueryVars(string $rawIds): array
    {
        $ids = self::normalizeQueueIds($rawIds);
        if (empty($ids)) {
            return [];
        }

        return [
            'post__in' => $ids,
            'orderby' => 'post__in',
            'ignore_sticky_posts' => 1,
        ];
    }

    /**
     * Pure helper for wp-admin edit-tags.php work queues.
     *
     * @return array<string,mixed>
     */
    public static function buildTermQueueQueryVars(string $rawIds, string $taxonomy): array
    {
        $taxonomy = sanitize_key($taxonomy);
        $ids = self::normalizeQueueIds($rawIds);
        if ($taxonomy === '' || empty($ids)) {
            return [];
        }

        return [
            'taxonomy' => [$taxonomy],
            'include' => $ids,
            'orderby' => 'include',
            'hide_empty' => 0,
        ];
    }

    /**
     * Pure helper: sanitize queue context args from request/URL/form payloads.
     *
     * @param array<string,mixed> $request
     * @return array<string,string>
     */
    public static function sanitizeQueueContextArgs(array $request, string $kind): array
    {
        $kind = sanitize_key($kind);
        $idsKey = $kind === 'term' ? 'wpsn_term_ids' : 'wpsn_post_ids';
        $idsRaw = $request[$idsKey] ?? '';
        if (function_exists('wp_unslash')) {
            $idsRaw = wp_unslash($idsRaw);
        }
        $ids = self::normalizeQueueIds($idsRaw);
        if (empty($ids)) {
            return [];
        }

        $out = [
            $idsKey => implode(',', $ids),
        ];

        $titleRaw = $request['wpsn_queue_title'] ?? '';
        if (function_exists('wp_unslash')) {
            $titleRaw = wp_unslash($titleRaw);
        }
        $title = sanitize_text_field((string) $titleRaw);
        if ($title !== '') {
            $out['wpsn_queue_title'] = $title;
        }

        $returnToRaw = $request['wpsn_return_to'] ?? '';
        if (function_exists('wp_unslash')) {
            $returnToRaw = wp_unslash($returnToRaw);
        }
        $returnTo = esc_url_raw((string) $returnToRaw);
        if ($returnTo !== '') {
            $out['wpsn_return_to'] = $returnTo;
        }

        return $out;
    }

    /**
     * Pure helper for queue-aware Previous/Next navigation on native admin edit screens.
     *
     * @param mixed $rawIds
     * @return array{ids:array<int,int>,total:int,current_id:int,position:int,contains_current:bool,prev_id:int,next_id:int}
     */
    public static function buildQueueNavigation($rawIds, int $currentId): array
    {
        $ids = self::normalizeQueueIds($rawIds);
        $total = count($ids);
        $position = 0;
        $prevId = 0;
        $nextId = 0;
        $containsCurrent = false;

        if ($currentId > 0 && !empty($ids)) {
            $index = array_search($currentId, $ids, true);
            if (is_int($index) && isset($ids[$index])) {
                $containsCurrent = true;
                $position = $index + 1;
                if ($index > 0 && isset($ids[$index - 1])) {
                    $prevId = (int) $ids[$index - 1];
                }
                if (isset($ids[$index + 1])) {
                    $nextId = (int) $ids[$index + 1];
                }
            }
        }

        return [
            'ids' => $ids,
            'total' => $total,
            'current_id' => $currentId,
            'position' => $position,
            'contains_current' => $containsCurrent,
            'prev_id' => $prevId,
            'next_id' => $nextId,
        ];
    }

    /**
     * @return array<string,string>
     */
    private function currentListScreenQueueContext(string $kind): array
    {
        if (!is_admin() || !function_exists('get_current_screen')) {
            return [];
        }

        $screen = get_current_screen();
        if (!$screen || !is_object($screen)) {
            return [];
        }

        $kind = sanitize_key($kind);
        $base = (string) ($screen->base ?? '');

        if ($kind === 'post') {
            if ($base !== 'edit') {
                return [];
            }

            $postType = (string) ($screen->post_type ?? '');
            if ($postType === '' || !in_array($postType, $this->supportedPostTypes(), true)) {
                return [];
            }

            return self::sanitizeQueueContextArgs($_GET, 'post');
        }

        if ($kind === 'term') {
            if ($base !== 'edit-tags') {
                return [];
            }

            $taxonomy = (string) ($screen->taxonomy ?? '');
            if ($taxonomy === '' || !in_array($taxonomy, $this->supportedTaxonomies(), true)) {
                return [];
            }

            return self::sanitizeQueueContextArgs($_GET, 'term');
        }

        return [];
    }

    /**
     * @param mixed $location
     * @param mixed $postId
     * @param mixed $context
     * @return mixed
     */
    public function filterQueueAwarePostEditLink($location, $postId, $context = null)
    {
        if (!is_string($location) || $location === '') {
            return $location;
        }

        $queueArgs = $this->currentListScreenQueueContext('post');
        if ($queueArgs === []) {
            return $location;
        }

        return add_query_arg($queueArgs, $location);
    }

    /**
     * @param mixed $location
     * @param mixed $termId
     * @param mixed $taxonomy
     * @return mixed
     */
    public function filterQueueAwareTermEditLink($location, $termId, $taxonomy = null)
    {
        if (!is_string($location) || $location === '') {
            return $location;
        }

        $queueArgs = $this->currentListScreenQueueContext('term');
        if ($queueArgs === []) {
            return $location;
        }

        return add_query_arg($queueArgs, $location);
    }

    /**
     * @param mixed $location
     * @param mixed $postId
     * @return mixed
     */
    public function preserveQueueInPostRedirect($location, $postId)
    {
        if (!is_string($location) || $location === '') {
            return $location;
        }

        $queueArgs = self::sanitizeQueueContextArgs($_POST, 'post');
        if ($queueArgs === []) {
            return $location;
        }

        return add_query_arg($queueArgs, $location);
    }

    /**
     * @param mixed $location
     * @param mixed $status
     * @return mixed
     */
    public function preserveQueueInAdminRedirect($location, $status)
    {
        if (!is_admin() || !is_string($location) || $location === '') {
            return $location;
        }

        $queueArgs = self::sanitizeQueueContextArgs($_POST, 'term');
        if ($queueArgs === []) {
            return $location;
        }

        $path = (string) wp_parse_url($location, PHP_URL_PATH);
        $basename = $path !== '' ? basename($path) : '';
        if (!in_array($basename, ['edit-tags.php', 'term.php'], true)) {
            return $location;
        }

        $location = add_query_arg($queueArgs, $location);

        $postType = isset($_POST['post_type']) ? sanitize_key((string) $_POST['post_type']) : '';
        if ($postType !== '') {
            $location = add_query_arg(['post_type' => $postType], $location);
        }

        return $location;
    }

    private function preferredPostTypeForTaxonomy(string $taxonomy): string
    {
        $taxonomy = sanitize_key($taxonomy);
        if ($taxonomy === '' || !function_exists('get_taxonomy')) {
            return '';
        }

        $obj = get_taxonomy($taxonomy);
        if (!$obj || !is_object($obj) || !isset($obj->object_type) || !is_array($obj->object_type)) {
            return '';
        }

        $types = array_values(array_filter(array_map(static function ($value): string {
            return sanitize_key((string) $value);
        }, $obj->object_type), static function (string $value): bool {
            return $value !== '';
        }));

        if (empty($types)) {
            return '';
        }

        if (count($types) === 1) {
            return $types[0] !== 'post' ? $types[0] : '';
        }

        if (!in_array('post', $types, true)) {
            return $types[0];
        }

        return '';
    }

    /**
     * @param array<string,string> $queueArgs
     */
    private function queueListUrlForPost(int $postId, array $queueArgs): string
    {
        $args = $queueArgs;
        $postType = $postId > 0 && function_exists('get_post_type') ? sanitize_key((string) get_post_type($postId)) : '';
        if ($postType !== '' && $postType !== 'post') {
            $args['post_type'] = $postType;
        }

        return add_query_arg($args, admin_url('edit.php'));
    }

    /**
     * @param array<string,string> $queueArgs
     */
    private function queueEditUrlForPost(int $postId, array $queueArgs): string
    {
        $args = array_merge([
            'post' => $postId,
            'action' => 'edit',
        ], $queueArgs);

        return add_query_arg($args, admin_url('post.php'));
    }

    /**
     * @param array<string,string> $queueArgs
     */
    private function queueListUrlForTerm(string $taxonomy, array $queueArgs): string
    {
        $taxonomy = sanitize_key($taxonomy);
        $args = array_merge(['taxonomy' => $taxonomy], $queueArgs);

        $postType = isset($_GET['post_type']) ? sanitize_key((string) wp_unslash($_GET['post_type'])) : '';
        if ($postType === '') {
            $postType = $this->preferredPostTypeForTaxonomy($taxonomy);
        }
        if ($postType !== '' && $postType !== 'post') {
            $args['post_type'] = $postType;
        }

        return add_query_arg($args, admin_url('edit-tags.php'));
    }

    /**
     * @param array<string,string> $queueArgs
     */
    private function queueEditUrlForTerm(int $termId, string $taxonomy, array $queueArgs): string
    {
        $taxonomy = sanitize_key($taxonomy);
        $args = array_merge([
            'taxonomy' => $taxonomy,
            'tag_ID' => $termId,
        ], $queueArgs);

        $postType = isset($_GET['post_type']) ? sanitize_key((string) wp_unslash($_GET['post_type'])) : '';
        if ($postType === '') {
            $postType = $this->preferredPostTypeForTaxonomy($taxonomy);
        }
        if ($postType !== '' && $postType !== 'post') {
            $args['post_type'] = $postType;
        }

        return add_query_arg($args, admin_url('term.php'));
    }

    public function renderWorkQueueNotice(): void
    {
        if (!is_admin() || !function_exists('get_current_screen')) {
            return;
        }

        $screen = get_current_screen();
        if (!$screen || !is_object($screen)) {
            return;
        }

        $base = (string) ($screen->base ?? '');
        $title = isset($_GET['wpsn_queue_title']) ? sanitize_text_field((string) wp_unslash($_GET['wpsn_queue_title'])) : '';
        if ($title === '') {
            $title = __('Tasks Hub work queue', 'wp-seo-next');
        }

        $returnTo = isset($_GET['wpsn_return_to']) ? esc_url_raw((string) wp_unslash($_GET['wpsn_return_to'])) : '';

        if ($base === 'edit') {
            $ids = self::normalizeQueueIds(isset($_GET['wpsn_post_ids']) ? (string) wp_unslash($_GET['wpsn_post_ids']) : '');
            if (empty($ids)) {
                return;
            }

            echo '<div class="notice notice-info"><p><strong>' . esc_html($title) . '</strong> — ' . esc_html(sprintf(
                /* translators: %d: number of queued posts */
                __('Showing %d queued posts in the native admin list. Use WPSN columns, Quick Edit, and bulk SEO actions here.', 'wp-seo-next'),
                count($ids)
            )) . '</p>';
            if ($returnTo !== '') {
                echo '<p><a class="button button-secondary" href="' . esc_url($returnTo) . '">' . esc_html__('Back to Tasks', 'wp-seo-next') . '</a></p>';
            }
            echo '</div>';
            return;
        }

        if ($base === 'post') {
            $queueArgs = self::sanitizeQueueContextArgs($_GET, 'post');
            if ($queueArgs === []) {
                return;
            }

            $postId = isset($_GET['post']) ? (int) wp_unslash($_GET['post']) : 0;
            if ($postId <= 0 && isset($_GET['post_ID'])) {
                $postId = (int) wp_unslash($_GET['post_ID']);
            }

            $nav = self::buildQueueNavigation($queueArgs['wpsn_post_ids'] ?? '', $postId);
            if ((int) ($nav['total'] ?? 0) <= 0) {
                return;
            }

            $summary = !empty($nav['contains_current'])
                ? sprintf(
                    /* translators: 1: position in queue 2: total queued items */
                    __('Editing item %1$d of %2$d in this work queue. Use Previous / Next to move through the queue without losing Tasks Hub context.', 'wp-seo-next'),
                    (int) ($nav['position'] ?? 0),
                    (int) ($nav['total'] ?? 0)
                )
                : sprintf(
                    /* translators: %d: total queued items */
                    __('This edit screen is attached to a Tasks Hub work queue with %d queued items.', 'wp-seo-next'),
                    (int) ($nav['total'] ?? 0)
                );

            echo '<div class="notice notice-info"><p><strong>' . esc_html($title) . '</strong> — ' . esc_html($summary) . '</p>';
            echo '<p style="display:flex; gap:6px; flex-wrap:wrap;">';

            $queueListUrl = $this->queueListUrlForPost($postId, $queueArgs);
            echo '<a class="button button-secondary" href="' . esc_url($queueListUrl) . '">' . esc_html__('Back to queue list', 'wp-seo-next') . '</a>';

            $returnTo = isset($queueArgs['wpsn_return_to']) ? (string) $queueArgs['wpsn_return_to'] : '';
            if ($returnTo !== '') {
                echo '<a class="button button-secondary" href="' . esc_url($returnTo) . '">' . esc_html__('Back to Tasks', 'wp-seo-next') . '</a>';
            }

            $prevId = (int) ($nav['prev_id'] ?? 0);
            if ($prevId > 0) {
                echo '<a class="button" href="' . esc_url($this->queueEditUrlForPost($prevId, $queueArgs)) . '">' . esc_html__('Previous in queue', 'wp-seo-next') . '</a>';
            }

            $nextId = (int) ($nav['next_id'] ?? 0);
            if ($nextId > 0) {
                echo '<a class="button button-primary" href="' . esc_url($this->queueEditUrlForPost($nextId, $queueArgs)) . '">' . esc_html__('Next in queue', 'wp-seo-next') . '</a>';
            }

            echo '</p></div>';
            return;
        }

        if ($base !== 'edit-tags') {
            if ($base !== 'term') {
                return;
            }

            $queueArgs = self::sanitizeQueueContextArgs($_GET, 'term');
            if ($queueArgs === []) {
                return;
            }

            $taxonomy = sanitize_key((string) ($screen->taxonomy ?? ''));
            if ($taxonomy === '') {
                $taxonomy = isset($_GET['taxonomy']) ? sanitize_key((string) wp_unslash($_GET['taxonomy'])) : '';
            }

            $termId = isset($_GET['tag_ID']) ? (int) wp_unslash($_GET['tag_ID']) : 0;
            $nav = self::buildQueueNavigation($queueArgs['wpsn_term_ids'] ?? '', $termId);
            if ($taxonomy === '' || (int) ($nav['total'] ?? 0) <= 0) {
                return;
            }

            $summary = !empty($nav['contains_current'])
                ? sprintf(
                    /* translators: 1: position in queue 2: total queued items */
                    __('Editing item %1$d of %2$d in this term queue. Use Previous / Next to keep working through the queued taxonomy items.', 'wp-seo-next'),
                    (int) ($nav['position'] ?? 0),
                    (int) ($nav['total'] ?? 0)
                )
                : sprintf(
                    /* translators: %d: total queued items */
                    __('This term editor is attached to a Tasks Hub queue with %d queued items.', 'wp-seo-next'),
                    (int) ($nav['total'] ?? 0)
                );

            echo '<div class="notice notice-info"><p><strong>' . esc_html($title) . '</strong> — ' . esc_html($summary) . '</p>';
            echo '<p style="display:flex; gap:6px; flex-wrap:wrap;">';

            $queueListUrl = $this->queueListUrlForTerm($taxonomy, $queueArgs);
            echo '<a class="button button-secondary" href="' . esc_url($queueListUrl) . '">' . esc_html__('Back to queue list', 'wp-seo-next') . '</a>';

            $returnTo = isset($queueArgs['wpsn_return_to']) ? (string) $queueArgs['wpsn_return_to'] : '';
            if ($returnTo !== '') {
                echo '<a class="button button-secondary" href="' . esc_url($returnTo) . '">' . esc_html__('Back to Tasks', 'wp-seo-next') . '</a>';
            }

            $prevId = (int) ($nav['prev_id'] ?? 0);
            if ($prevId > 0) {
                echo '<a class="button" href="' . esc_url($this->queueEditUrlForTerm($prevId, $taxonomy, $queueArgs)) . '">' . esc_html__('Previous in queue', 'wp-seo-next') . '</a>';
            }

            $nextId = (int) ($nav['next_id'] ?? 0);
            if ($nextId > 0) {
                echo '<a class="button button-primary" href="' . esc_url($this->queueEditUrlForTerm($nextId, $taxonomy, $queueArgs)) . '">' . esc_html__('Next in queue', 'wp-seo-next') . '</a>';
            }

            echo '</p></div>';
            return;
        }

        $ids = self::normalizeQueueIds(isset($_GET['wpsn_term_ids']) ? (string) wp_unslash($_GET['wpsn_term_ids']) : '');
        if (empty($ids)) {
            return;
        }

        echo '<div class="notice notice-info"><p><strong>' . esc_html($title) . '</strong> — ' . esc_html(sprintf(
            /* translators: %d: number of queued terms */
            __('Showing %d queued terms in the native taxonomy list. Use WPSN columns, Quick Edit, and bulk SEO actions here.', 'wp-seo-next'),
            count($ids)
        )) . '</p>';
        if ($returnTo !== '') {
            echo '<p><a class="button button-secondary" href="' . esc_url($returnTo) . '">' . esc_html__('Back to Tasks', 'wp-seo-next') . '</a></p>';
        }
        echo '</div>';
    }

    private function noticeAuditNeeded(): void
    {
        add_action('admin_notices', static function () {
            if (!is_admin()) {
                return;
            }
            echo '<div class="notice notice-warning is-dismissible"><p>';
            echo esc_html__('This filter requires a completed WPSN Audit scan. Run Audit first to populate duplicate keys.', 'wp-seo-next');
            echo '</p></div>';
        });
    }

    private function getLatestCompletedAuditScanId(): int
    {
        global $wpdb;

        $ver = (string) get_option('wpsn_audit_db_version', '');
        if ($ver === '') {
            return 0;
        }

        $table = $wpdb->prefix . 'wpsn_audit_scans';
        // Hardening: option may exist on some hosts even if table creation failed.
        $exists = (string) $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table));
        if ($exists !== $table) {
            return 0;
        }

        $sql = "SELECT id FROM {$table} WHERE status = 'completed' ORDER BY id DESC LIMIT 1";
        $id = (int) $wpdb->get_var($sql);
        return $id > 0 ? $id : 0;
    }

    private function applyDuplicateJoinFilter(\WP_Query $query, string $mode, int $scanId, string $postType): void
    {
        global $wpdb;

        $mode = sanitize_key($mode);
        if (!in_array($mode, ['dup_title', 'dup_description'], true)) {
            return;
        }

        $scanId = (int) $scanId;
        if ($scanId <= 0) {
            return;
        }

        $postType = (string) $postType;
        $scanTable = '';
        $keyField  = '';
        if ($mode === 'dup_title') {
            $scanTable = $wpdb->prefix . 'wpsn_audit_scan_titles';
            $keyField  = 'title_key';
        } else {
            $scanTable = $wpdb->prefix . 'wpsn_audit_scan_descriptions';
            $keyField  = 'description_key';
        }

        $exists = (string) $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $scanTable));
        if ($exists !== $scanTable) {
            $this->noticeAuditNeeded();
            return;
        }

        // Modify main query clauses with safe joins.
        $fn = function (array $clauses, \WP_Query $q) use (&$fn, $query, $wpdb, $scanTable, $keyField, $scanId, $postType): array {
            if ($q !== $query) {
                return $clauses;
            }

            // One-shot.
            remove_filter('posts_clauses', $fn, 10);

            $postsTable = $wpdb->posts;

            $scanAlias = 'wpsn_scan';
            $dupAlias = 'wpsn_dup';

            // Duplicates within current post type (more usable on edit.php).
            $ptSafe = esc_sql($postType);
            $scanIdInt = (int) $scanId;

            $sub = "(SELECT {$keyField} FROM {$scanTable} WHERE scan_id = {$scanIdInt} AND post_type = '{$ptSafe}' GROUP BY {$keyField} HAVING COUNT(*) > 1)";

            $clauses['join'] .= " INNER JOIN {$scanTable} {$scanAlias} ON ({$scanAlias}.post_id = {$postsTable}.ID AND {$scanAlias}.scan_id = {$scanIdInt} AND {$scanAlias}.post_type = '{$ptSafe}')";
            $clauses['join'] .= " INNER JOIN {$sub} {$dupAlias} ON ({$dupAlias}.{$keyField} = {$scanAlias}.{$keyField})";

            // Prevent duplicates if WP adds additional joins.
            $clauses['groupby'] = $postsTable . '.ID';

            return $clauses;
        };

        add_filter('posts_clauses', $fn, 10, 2);
    }
}
