<?php
if (!defined('ABSPATH')) exit;

require_once __DIR__ . '/trait-master-utilities.php';
require_once __DIR__ . '/trait-master-gutenberg.php';

class Calliope_SP_Master {
    use Calliope_SP_Master_Utilities;
    use Calliope_SP_Master_Gutenberg;
    /** @var string */
    private $option_group = 'calliope_sp_settings';

    public function __construct() {
        add_action('admin_menu', [$this, 'add_menu']);
        add_action('admin_init', [$this, 'maybe_restore_credentials_from_backup'], 0);
        add_action('admin_init', [$this, 'maybe_restore_connection_statuses'], 1);
        add_action('admin_init', [$this, 'maybe_store_connection_backup'], 999);
        add_action('admin_notices', [$this, 'maybe_render_restore_notice']);
        add_action('admin_init', [$this, 'maybe_handle_oauth_callbacks']);
        // Gutenberg sidebar + optional auto-post on publish
        add_action('init', [$this, 'register_gutenberg_meta']);
        add_action('enqueue_block_editor_assets', [$this, 'enqueue_editor_assets']);
        add_action('transition_post_status', [$this, 'maybe_autopost_from_gutenberg'], 10, 3);

        add_action('admin_init', [$this, 'register_settings']);
        add_action('admin_init', [$this, 'process_publication']);
        add_action('admin_init', [$this, 'maybe_purge_legacy_publish_jobs_on_upgrade'], 2);
        add_action('admin_init', [$this, 'maybe_recover_pending_publish_jobs'], 20);
        add_action('calliope_sp_process_publish_job', [$this, 'process_queued_publication'], 10, 1);
        add_action('wp_ajax_calliope_sp_run_publish_job', [$this, 'handle_async_publish_job']);
        add_action('wp_ajax_nopriv_calliope_sp_run_publish_job', [$this, 'handle_async_publish_job']);
        add_action('wp_ajax_calliope_sp_run_publish_job_step', [$this, 'handle_browser_publish_job_step']);
        add_action('admin_enqueue_scripts', [$this, 'enqueue_assets']);
        add_action('init', ['Calliope_SP_Media_Helper', 'maybe_serve_meta_static_proxy'], 0);
        add_action('rest_api_init', ['Calliope_SP_Media_Helper', 'register_rest_routes']);

        // Connections (save + test)
        add_action('admin_post_calliope_sp_disconnect_network', [$this, 'handle_disconnect_network']);
        add_action('admin_post_calliope_sp_test_telegram', [$this, 'handle_telegram_test_connection']);
        add_action('admin_post_calliope_sp_test_bluesky', [$this, 'handle_bluesky_test_connection']);
        add_action('admin_post_calliope_sp_test_mastodon', [$this, 'handle_mastodon_test_connection']);
        add_action('admin_post_calliope_sp_save_mastodon_settings', [$this, 'handle_mastodon_save_settings']);
        add_action('admin_post_calliope_sp_test_tumblr', [$this, 'handle_tumblr_test_connection']);
        add_action('admin_post_calliope_sp_save_tumblr_settings', [$this, 'handle_tumblr_save_settings']);
        add_action('admin_post_calliope_sp_test_pinterest', [$this, 'handle_pinterest_test_connection']);
        add_action('admin_post_calliope_sp_test_threads', [$this, 'handle_threads_test_connection']);
        add_action('admin_post_calliope_sp_test_linkedin', [$this, 'handle_linkedin_test_connection']);
        add_action('admin_post_calliope_sp_linkedin_oauth_start', [$this, 'handle_linkedin_oauth_start']);
        add_action('admin_post_calliope_sp_linkedin_oauth_callback', [$this, 'handle_linkedin_oauth_callback']);
        // Allow LinkedIn to return even if WP doesn't consider the user logged in (cross-site redirect).
        add_action('admin_post_nopriv_calliope_sp_linkedin_oauth_callback', [$this, 'handle_linkedin_oauth_callback']);
        add_action('admin_post_calliope_sp_linkedin_clear_tokens', [$this, 'handle_linkedin_clear_tokens']);
        add_action('admin_post_calliope_sp_linkedin_detect_profile_target', [$this, 'handle_linkedin_detect_profile_target']);
        add_action('admin_post_calliope_sp_linkedin_restore_page_target', [$this, 'handle_linkedin_restore_page_target']);

        // Instagram OAuth (auto token)
        add_action('admin_post_calliope_sp_instagram_oauth_start', [$this, 'handle_instagram_oauth_start']);
        add_action('admin_post_calliope_sp_instagram_callback', [$this, 'handle_instagram_oauth_callback']);
        // Allow Instagram to return even if the auth redirect does not preserve the wp-admin session.
        add_action('admin_post_nopriv_calliope_sp_instagram_callback', [$this, 'handle_instagram_oauth_callback']);
        // Public REST callback for Instagram OAuth (recommended: no query params)
        add_action('rest_api_init', [$this, 'register_instagram_rest_routes']);
        add_action('admin_post_calliope_sp_instagram_clear_tokens', [$this, 'handle_instagram_clear_tokens']);
        add_action('admin_post_calliope_sp_test_instagram', [$this, 'handle_instagram_test_connection']);

        // Facebook
        add_action('admin_post_calliope_sp_test_facebook', [$this, 'handle_facebook_test_connection']);
        add_action('admin_post_calliope_sp_diagnose_facebook', [$this, 'handle_facebook_token_diagnostics']);
        add_action('admin_post_calliope_sp_facebook_test_publish', [$this, 'handle_facebook_test_publish']);
        add_action('admin_post_calliope_sp_facebook_oauth_start', [$this, 'handle_facebook_oauth_start']);
        add_action('admin_post_calliope_sp_facebook_clear_tokens', [$this, 'handle_facebook_clear_tokens']);
        add_action('rest_api_init', [$this, 'register_facebook_rest_routes']);
        add_action('admin_init', [$this, 'migrate_facebook_pages_cache']);
        add_action('admin_init', [$this, 'maybe_migrate_legacy_facebook_options']);

        // Tumblr OAuth (auto tokens)
        add_action('admin_post_calliope_sp_tumblr_oauth_start', [$this, 'handle_tumblr_oauth_start']);
        add_action('admin_post_calliope_sp_tumblr_oauth_callback', [$this, 'handle_tumblr_oauth_callback']);
        add_action('admin_post_calliope_sp_tumblr_clear_tokens', [$this, 'handle_tumblr_clear_tokens']);

        // Mastodon OAuth (auto tokens)
        add_action('admin_post_calliope_sp_mastodon_oauth_start', [$this, 'handle_mastodon_oauth_start']);
        add_action('admin_post_calliope_sp_mastodon_oauth_callback', [$this, 'handle_mastodon_oauth_callback']);
        add_action('admin_post_calliope_sp_mastodon_clear_tokens', [$this, 'handle_mastodon_clear_tokens']);

        // Pinterest OAuth (auto tokens)
        add_action('admin_post_calliope_sp_pinterest_oauth_start', [$this, 'handle_pinterest_oauth_start']);
        add_action('admin_post_calliope_sp_pinterest_oauth_callback', [$this, 'handle_pinterest_oauth_callback']);
        add_action('admin_post_calliope_sp_pinterest_clear_tokens', [$this, 'handle_pinterest_clear_tokens']);
        add_action('admin_post_calliope_sp_pinterest_detect_board', [$this, 'handle_pinterest_detect_board']);

        // Threads OAuth (auto tokens)
        add_action('admin_post_calliope_sp_threads_oauth_start', [$this, 'handle_threads_oauth_start']);
        add_action('admin_post_calliope_sp_threads_oauth_callback', [$this, 'handle_threads_oauth_callback']);
        add_action('admin_post_calliope_sp_threads_clear_tokens', [$this, 'handle_threads_clear_tokens']);
        // Pinterest OAuth callback without action param (Pinterest disallows redirect URIs with params)
        add_action('init', [$this, 'maybe_handle_pinterest_oauth_callback']);

        // Public REST callback for LinkedIn OAuth (recommended)
        add_action('rest_api_init', [$this, 'register_linkedin_rest_routes']);

	    // Blogger (Google OAuth)
        add_action('admin_post_calliope_sp_test_blogger', [$this, 'handle_blogger_test_connection']);
        add_action('admin_post_calliope_sp_test_flipboard', [$this, 'handle_flipboard_test_connection']);
        add_action('admin_post_calliope_sp_blogger_oauth_start', [$this, 'handle_blogger_oauth_start']);
        add_action('admin_post_calliope_sp_blogger_clear_tokens', [$this, 'handle_blogger_clear_tokens']);
        add_action('rest_api_init', [$this, 'register_blogger_rest_routes']);

        // Logs
        add_action('admin_post_calliope_sp_clear_logs', [$this, 'handle_clear_logs']);
        add_action('admin_post_calliope_sp_test_meta_media_url', [$this, 'handle_meta_media_url_test']);
        add_action('admin_post_calliope_sp_save_cleanup_settings', [$this, 'handle_save_cleanup_settings']);
    }

    /**
     * Register REST routes used by OAuth callbacks.
     */
    public function register_instagram_rest_routes() {
        register_rest_route('calliope-social-publisher/v1', '/instagram/callback', [
            'methods'             => 'GET',
            'permission_callback' => function (\WP_REST_Request $request) {
                return $this->has_valid_oauth_rest_state($request, 'instagram');
            },
            'callback'            => [$this, 'handle_instagram_oauth_rest_callback'],
        ]);
    }

    /**
     * Blogger OAuth callback route.
     */
    public function register_blogger_rest_routes() {
        register_rest_route('calliope-social-publisher/v1', '/blogger/callback', [
            'methods'             => 'GET',
            'permission_callback' => function (\WP_REST_Request $request) {
                return $this->has_valid_oauth_rest_state($request, 'blogger');
            },
            'callback'            => [$this, 'handle_blogger_oauth_rest_callback'],
        ]);
    }

    /**
     * OAuth callback REST routes are public endpoints, but they are not open actions.
     * A valid provider state generated by an admin-initiated connection flow is required.
     *
     * @param \WP_REST_Request $request REST request.
     * @param string            $network Network key.
     * @return true|\WP_Error
     */
    private function has_valid_oauth_rest_state(\WP_REST_Request $request, $network) {
        $state = sanitize_text_field((string) $request->get_param('state'));

        if ($state === '') {
            return new \WP_Error(
                'calliope_sp_missing_oauth_state',
                __('Missing OAuth state.', 'calliope-social-publisher'),
                ['status' => 403]
            );
        }

        $is_valid = false;
        switch ((string) $network) {
            case 'facebook':
                $expected = (string) get_transient('calliope_sp_fb_oauth_state');
                $is_valid = $expected !== '' && hash_equals($expected, $state);
                break;
            case 'linkedin':
                $is_valid = is_array(get_transient('calliope_sp_linkedin_oauth_' . $state));
                break;
            case 'blogger':
                $is_valid = (bool) get_transient('calliope_sp_blogger_oauth_' . $state);
                break;
            case 'instagram':
                $is_valid = is_array(get_transient('calliope_sp_instagram_oauth_' . $state));
                break;
        }

        if (!$is_valid) {
            return new \WP_Error(
                'calliope_sp_invalid_oauth_state',
                __('Invalid or expired OAuth state.', 'calliope-social-publisher'),
                ['status' => 403]
            );
        }

        return true;
    }

    /**
     * Sanitize OAuth params received through a REST callback before passing them to
     * the shared callback handlers.
     *
     * @param \WP_REST_Request $request REST request.
     * @return array<string,string>
     */
    private function get_oauth_params_from_rest_request(\WP_REST_Request $request) {
        return [
            'code'              => sanitize_text_field((string) $request->get_param('code')),
            'state'             => sanitize_text_field((string) $request->get_param('state')),
            'error'             => sanitize_text_field((string) $request->get_param('error')),
            'error_code'        => sanitize_text_field((string) $request->get_param('error_code')),
            'error_description' => sanitize_text_field(wp_strip_all_tags((string) $request->get_param('error_description'))),
            'error_message'     => sanitize_text_field(wp_strip_all_tags((string) $request->get_param('error_message'))),
        ];
    }

    /**
     * Read a sanitized OAuth callback parameter from either explicit params or the
     * legacy admin-post query string. OAuth callbacks use provider state validation
     * instead of a WordPress nonce.
     *
     * @param array<string,string> $params Sanitized callback params.
     * @param string               $key    Parameter key.
     * @return string
     */
    private function get_oauth_callback_param(array $params, $key) {
        $key = (string) $key;
        if (array_key_exists($key, $params)) {
            return sanitize_text_field((string) $params[$key]);
        }

        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- OAuth callback params are validated with the provider state value in each callback handler.
        return isset($_GET[$key]) ? sanitize_text_field(wp_unslash((string) $_GET[$key])) : '';
    }


    /**
     * Return true only when an option has a real saved value.
     *
     * get_option() returns false for missing options, so comparing with !== ''
     * would incorrectly treat missing credentials as connected.
     *
     * @param string $option_name Option key.
     * @return bool
     */
    private function option_has_saved_value($option_name) {
        $value = get_option((string) $option_name, '');

        if (is_array($value)) {
            return !empty($value);
        }

        return trim((string) $value) !== '';
    }

    /**
     * Return true when every option in the list has a real saved value.
     *
     * @param string[] $option_names Option keys.
     * @return bool
     */
    private function all_options_have_saved_values(array $option_names) {
        if (empty($option_names)) {
            return false;
        }

        foreach ($option_names as $option_name) {
            if (!$this->option_has_saved_value($option_name)) {
                return false;
            }
        }

        return true;
    }

    /**
     * Return true when any option in the list has a real saved value.
     *
     * @param string[] $option_names Option keys.
     * @return bool
     */
    private function any_option_has_saved_value(array $option_names) {
        foreach ($option_names as $option_name) {
            if ($this->option_has_saved_value($option_name)) {
                return true;
            }
        }

        return false;
    }


    /**
     * Return true when Facebook has a real Page selected and a usable page token.
     * Having only a user token is not enough to publish to a Page.
     *
     * @return bool
     */
    private function facebook_has_ready_page_connection() {
        return $this->all_options_have_saved_values(['calliope_sp_fb_user_access_token', 'calliope_sp_fb_page_id', 'calliope_sp_fb_page_access_token']);
    }

    /**
     * Clear stale Facebook page selection data while keeping saved app credentials.
     *
     * @return void
     */
    private function clear_facebook_page_resolution_state() {
        delete_option('calliope_sp_fb_page_id');
        delete_option('calliope_sp_fb_page_access_token');
        delete_option('calliope_sp_fb_pages_last');
        delete_option('calliope_sp_fb_business_id');
        delete_option('calliope_sp_fb_businesses_last');
    }

    /**
     * Soft-migrate legacy Facebook option values into the approved Calliope option keys.
     * This does not restore old slugs or names; it only helps existing sites keep a working Page token
     * after the rebrand when the new option is still empty.
     *
     * @return void
     */
    public function maybe_migrate_legacy_facebook_options() {
        $legacy_map = [
            'sh_fb_page_id'            => 'calliope_sp_fb_page_id',
            'sh_fb_page_access_token'  => 'calliope_sp_fb_page_access_token',
            'sh_fb_user_access_token'  => 'calliope_sp_fb_user_access_token',
            'sh_fb_app_id'             => 'calliope_sp_fb_app_id',
            'sh_fb_app_secret'         => 'calliope_sp_fb_app_secret',
            'sh_fb_config_id'          => 'calliope_sp_fb_config_id',
        ];

        $migrated = [];
        foreach ($legacy_map as $legacy_key => $new_key) {
            $new_value = (string) get_option($new_key, '');
            $legacy_value = (string) get_option($legacy_key, '');
            if ($new_value !== '' || $legacy_value === '') {
                continue;
            }

            update_option($new_key, $legacy_value);
            $migrated[] = $new_key;
        }

        if (!empty($migrated)) {
            update_option('calliope_sp_fb_connection_status', $this->facebook_has_ready_page_connection() ? 'connected' : 'credentials_saved');
            if (class_exists('Calliope_SP_Logger')) {
                Calliope_SP_Logger::log('facebook', 'info', 'legacy_facebook_options_migrated: ' . implode(',', array_map('sanitize_key', $migrated)));
            }
        }
    }

    /**
     * Try several Meta edges to resolve Facebook Pages for the connected user.
     * This covers classic page ownership plus Business Portfolio assigned/client pages.
     *
     * @param string $user_token User access token.
     * @return array<int,array<string,string>>
     */
    private function resolve_facebook_pages_from_meta($user_token) {
        $user_token = (string) $user_token;
        if ($user_token === '') {
            return [];
        }

        $resolved_pages = [];
        $seen_page_ids = [];

        $append_pages = function ($pages) use (&$resolved_pages, &$seen_page_ids) {
            if (!is_array($pages)) {
                return;
            }

            foreach ($pages as $page) {
                $page_id = sanitize_text_field((string) ($page['id'] ?? ''));
                if ($page_id === '' || isset($seen_page_ids[$page_id])) {
                    continue;
                }

                $page_name = sanitize_text_field((string) ($page['name'] ?? $page_id));
                $resolved_pages[] = [
                    'id'   => $page_id,
                    'name' => $page_name,
                ];
                $seen_page_ids[$page_id] = true;
            }
        };

        $fetch_edge = function ($url, $label) use ($append_pages) {
            $response = wp_remote_get($url, ['timeout' => 60]);
            if (is_wp_error($response)) {
                if (class_exists('Calliope_SP_Logger')) {
                    Calliope_SP_Logger::log('facebook', 'warning', strtolower(str_replace(' ', '_', $label)) . '_failed: ' . $response->get_error_message());
                }
                return [];
            }

            $http_code = (int) wp_remote_retrieve_response_code($response);
            $body = (string) wp_remote_retrieve_body($response);
            $json = json_decode($body, true);
            $data = $json['data'] ?? [];

            if (class_exists('Calliope_SP_Logger')) {
                Calliope_SP_Logger::log('facebook', ($http_code >= 200 && $http_code < 300) ? 'info' : 'warning', $label . ' response code=' . $http_code . ' body=' . $body);
            }

            if ($http_code < 200 || $http_code >= 300) {
                return [];
            }

            if (!is_array($data) || empty($data)) {
                return [];
            }

            $append_pages($data);
            if (class_exists('Calliope_SP_Logger')) {
                Calliope_SP_Logger::log('facebook', 'info', 'pages_loaded: ' . $label . ' returned ' . count($data) . ' page(s).');
            }
            return $data;
        };

        $base_args = [
            'fields'       => 'id,name',
            'limit'        => 200,
            'access_token' => $user_token,
        ];

        // 1) Classic Pages edge.
        $fetch_edge(add_query_arg($base_args, 'https://graph.facebook.com/v20.0/me/accounts'), 'Pages');

        // 2) Pages assigned directly to the person through business assets.
        $fetch_edge(add_query_arg($base_args, 'https://graph.facebook.com/v20.0/me/assigned_pages'), 'Assigned pages');

        // 3) Business portfolios and their owned/client pages.
        $biz_response = wp_remote_get(add_query_arg([
            'fields'       => 'id,name',
            'access_token' => $user_token,
        ], 'https://graph.facebook.com/v20.0/me/businesses'), ['timeout' => 60]);

        if (!is_wp_error($biz_response)) {
            $biz_body = (string) wp_remote_retrieve_body($biz_response);
            $biz_json = json_decode($biz_body, true);
            $biz_data = $biz_json['data'] ?? [];
            update_option('calliope_sp_fb_businesses_last', wp_json_encode($biz_data));

            if (class_exists('Calliope_SP_Logger')) {
                Calliope_SP_Logger::log('facebook', 'info', 'Businesses response code=' . (int) wp_remote_retrieve_response_code($biz_response) . ' body=' . $biz_body);
            }

            if (is_array($biz_data) && !empty($biz_data)) {
                foreach ($biz_data as $biz) {
                    $biz_id = sanitize_text_field((string) ($biz['id'] ?? ''));
                    if ($biz_id === '') {
                        continue;
                    }

                    update_option('calliope_sp_fb_business_id', $biz_id);

                    $fetch_edge(add_query_arg($base_args, 'https://graph.facebook.com/v20.0/' . rawurlencode($biz_id) . '/owned_pages'), 'Owned pages');
                    $fetch_edge(add_query_arg($base_args, 'https://graph.facebook.com/v20.0/' . rawurlencode($biz_id) . '/client_pages'), 'Client pages');
                }
            }
        } elseif (class_exists('Calliope_SP_Logger')) {
            Calliope_SP_Logger::log('facebook', 'error', 'Businesses fetch HTTP error: ' . $biz_response->get_error_message());
        }

        return $resolved_pages;
    }


    /**
     * Option key used to keep a lightweight backup of saved connections.
     * This protects users from reconnecting everything if a host/plugin updater
     * replaces files in a way that drops runtime options unexpectedly.
     *
     * @return string
     */
    private function get_connection_backup_option_name() {
        return 'calliope_sp_connection_backup';
    }

    /**
     * Connection-related options that should survive updates/reinstalls.
     *
     * @return string[]
     */
    private function get_connection_backup_option_keys() {
        return [
            'calliope_sp_bsky_handle',
            'calliope_sp_bsky_password',
            'calliope_sp_bsky_connection_status',
            'calliope_sp_mstdn_instance',
            'calliope_sp_mstdn_token',
            'calliope_sp_mstdn_client_id',
            'calliope_sp_mstdn_client_secret',
            'calliope_sp_mstdn_client_instance',
            'calliope_sp_mstdn_connection_status',
            'calliope_sp_tumblr_consumer_key',
            'calliope_sp_tumblr_consumer_secret',
            'calliope_sp_tumblr_token',
            'calliope_sp_tumblr_token_secret',
            'calliope_sp_tumblr_blog_identifier',
            'calliope_sp_tumblr_connection_status',
            'calliope_sp_pinterest_app_id',
            'calliope_sp_pinterest_app_secret',
            'calliope_sp_pinterest_token',
            'calliope_sp_pinterest_refresh_token',
            'calliope_sp_pinterest_env',
            'calliope_sp_pinterest_board_id',
            'calliope_sp_pinterest_boards_last',
            'calliope_sp_pinterest_connection_status',
            'calliope_sp_threads_client_id',
            'calliope_sp_threads_client_secret',
            'calliope_sp_threads_access_token',
            'calliope_sp_threads_user_id',
            'calliope_sp_threads_expires_at',
            'calliope_sp_threads_connection_status',
            'calliope_sp_ig_app_id',
            'calliope_sp_ig_app_secret',
            'calliope_sp_ig_access_token',
            'calliope_sp_ig_expires_at',
            'calliope_sp_ig_page_id',
            'calliope_sp_ig_user_id',
            'calliope_sp_ig_username',
            'calliope_sp_ig_connection_status',
            'calliope_sp_li_client_id',
            'calliope_sp_li_client_secret',
            'calliope_sp_li_access_token',
            'calliope_sp_li_expires_at',
            'calliope_sp_li_person_id',
            'calliope_sp_li_connection_status',
            'calliope_sp_li_last_scope_list',
            'calliope_sp_li_last_target_kind',
            'calliope_sp_li_last_page_target',
            'calliope_sp_fb_app_id',
            'calliope_sp_fb_app_secret',
            'calliope_sp_fb_config_id',
            'calliope_sp_fb_user_access_token',
            'calliope_sp_fb_page_id',
            'calliope_sp_fb_page_access_token',
            'calliope_sp_fb_pages_last',
            'calliope_sp_fb_expires_at',
            'calliope_sp_fb_connection_status',
            'calliope_sp_fb_business_id',
            'calliope_sp_blogger_client_id',
            'calliope_sp_blogger_client_secret',
            'calliope_sp_blogger_access_token',
            'calliope_sp_blogger_refresh_token',
            'calliope_sp_blogger_expires_at',
            'calliope_sp_blogger_blog_id',
            'calliope_sp_blogger_blogs_last',
            'calliope_sp_blogger_connection_status',
            'calliope_sp_telegram_bot_token',
            'calliope_sp_telegram_chat_id',
            'calliope_sp_telegram_message_thread_id',
            'calliope_sp_telegram_connection_status',
            'calliope_sp_flipboard_feed_url',
            'calliope_sp_flipboard_enable_share_button',
        ];
    }

    /**
     * Core credential keys used to decide whether a restore is needed.
     *
     * @return string[]
     */
    private function get_primary_connection_option_keys() {
        return [
            'calliope_sp_bsky_handle',
            'calliope_sp_bsky_password',
            'calliope_sp_mstdn_token',
            'calliope_sp_tumblr_token',
            'calliope_sp_tumblr_token_secret',
            'calliope_sp_pinterest_token',
            'calliope_sp_threads_access_token',
            'calliope_sp_ig_access_token',
            'calliope_sp_li_access_token',
            'calliope_sp_fb_user_access_token',
            'calliope_sp_fb_page_access_token',
            'calliope_sp_blogger_access_token',
            'calliope_sp_blogger_refresh_token',
            'calliope_sp_telegram_bot_token',
            'calliope_sp_flipboard_feed_url',
        ];
    }

    /**
     * Remove selected options from the automatic connection backup.
     *
     * This is important when an administrator intentionally disconnects a network,
     * clears tokens, or prepares the plugin for deletion. Otherwise, the safety
     * restore can bring old credentials back on the next admin request.
     *
     * @param string[] $option_names Option keys to remove from the backup.
     * @return void
     */
    private function remove_options_from_connection_backup(array $option_names) {
        $backup = get_option($this->get_connection_backup_option_name(), []);
        if (!is_array($backup) || empty($backup)) {
            return;
        }

        $changed = false;
        foreach ($option_names as $option_name) {
            if (array_key_exists($option_name, $backup)) {
                unset($backup[$option_name]);
                $changed = true;
            }
        }

        if ($changed) {
            update_option($this->get_connection_backup_option_name(), $backup, false);
        }
    }

    /**
     * Capture current saved connections so they can be restored after a bad update.
     */
    public function maybe_store_connection_backup() {
        if (!is_admin()) {
            return;
        }

        if (function_exists('wp_doing_ajax') && wp_doing_ajax()) {
            return;
        }

        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only request inspection used only to skip backup writes during internal actions.
        $action = isset($_REQUEST['action']) ? sanitize_key(wp_unslash($_REQUEST['action'])) : '';
        if ($action !== '' && strpos($action, 'calliope_sp_') === 0) {
            return;
        }

        $snapshot = [];
        foreach ($this->get_connection_backup_option_keys() as $option_name) {
            $value = get_option($option_name, null);
            if ($value === false || $value === null) {
                continue;
            }

            if (is_array($value)) {
                if (!empty($value)) {
                    $snapshot[$option_name] = $value;
                }
                continue;
            }

            if ($value === '') {
                continue;
            }

            $snapshot[$option_name] = $value;
        }

        $existing_backup = get_option($this->get_connection_backup_option_name(), []);
        $has_live_primary_credentials = false;
        foreach ($this->get_primary_connection_option_keys() as $option_name) {
            if (array_key_exists($option_name, $snapshot)) {
                $value = $snapshot[$option_name];
                if (is_array($value) ? !empty($value) : trim((string) $value) !== '') {
                    $has_live_primary_credentials = true;
                    break;
                }
            }
        }

        if (!$has_live_primary_credentials && is_array($existing_backup) && !empty($existing_backup)) {
            return;
        }

        update_option($this->get_connection_backup_option_name(), $snapshot, false);
    }

    /**
     * Restore saved credentials from the last good backup when the live options
     * unexpectedly disappear after an update.
     */
    public function maybe_restore_credentials_from_backup() {
        if (!is_admin()) {
            return;
        }

        $backup = get_option($this->get_connection_backup_option_name(), []);
        if (!is_array($backup) || empty($backup)) {
            return;
        }

        $restored_any = false;
        foreach ($backup as $option_name => $value) {
            if (!in_array($option_name, $this->get_connection_backup_option_keys(), true)) {
                continue;
            }

            $backup_has_value = is_array($value) ? !empty($value) : trim((string) $value) !== '';
            if (!$backup_has_value) {
                continue;
            }

            if ($this->option_has_saved_value($option_name)) {
                continue;
            }

            update_option($option_name, $value, false);
            $restored_any = true;
        }

        if ($restored_any) {
            set_transient('calliope_sp_restored_connections_notice', 1, MINUTE_IN_SECONDS * 10);
        }
    }

    /**
     * Show a short admin notice after an automatic restore.
     */
    public function maybe_render_restore_notice() {
        if (!current_user_can('manage_options')) {
            return;
        }

        if (!get_transient('calliope_sp_restored_connections_notice')) {
            return;
        }

        delete_transient('calliope_sp_restored_connections_notice');
        echo '<div class="notice notice-warning is-dismissible"><p>' . esc_html__('Calliope Social Publisher restored your saved connections automatically after an update/reinstall so you do not need to reconnect everything again.', 'calliope-social-publisher') . '</p></div>';
    }

    /**
     * Keep saved connections stable across plugin updates.
     * If tokens/credentials still exist, rebuild the UI status options instead of
     * making the user reconnect everything again after an update.
     */
    public function maybe_restore_connection_statuses() {
        $expected_statuses = [
            'calliope_sp_bsky_connection_status' => $this->all_options_have_saved_values(['calliope_sp_bsky_handle', 'calliope_sp_bsky_password']) ? 'connected' : 'not_connected',
            'calliope_sp_mstdn_connection_status' => $this->option_has_saved_value('calliope_sp_mstdn_token') ? 'connected' : ($this->option_has_saved_value('calliope_sp_mstdn_instance') ? 'credentials_saved' : 'not_connected'),
            'calliope_sp_tumblr_connection_status' => ($this->option_has_saved_value('calliope_sp_tumblr_token') && $this->option_has_saved_value('calliope_sp_tumblr_token_secret')) ? 'connected' : ($this->all_options_have_saved_values(['calliope_sp_tumblr_consumer_key', 'calliope_sp_tumblr_consumer_secret']) ? 'credentials_saved' : 'not_connected'),
            'calliope_sp_pinterest_connection_status' => $this->option_has_saved_value('calliope_sp_pinterest_token') ? 'connected' : ($this->all_options_have_saved_values(['calliope_sp_pinterest_app_id', 'calliope_sp_pinterest_app_secret']) ? 'credentials_saved' : 'not_connected'),
            'calliope_sp_threads_connection_status' => $this->option_has_saved_value('calliope_sp_threads_access_token') ? 'connected' : ($this->all_options_have_saved_values(['calliope_sp_threads_client_id', 'calliope_sp_threads_client_secret']) ? 'credentials_saved' : 'not_connected'),
            'calliope_sp_ig_connection_status' => $this->option_has_saved_value('calliope_sp_ig_access_token') ? 'connected' : ($this->all_options_have_saved_values(['calliope_sp_ig_app_id', 'calliope_sp_ig_app_secret']) ? 'credentials_saved' : 'not_connected'),
            'calliope_sp_blogger_connection_status' => $this->any_option_has_saved_value(['calliope_sp_blogger_access_token', 'calliope_sp_blogger_refresh_token']) ? 'connected' : ($this->all_options_have_saved_values(['calliope_sp_blogger_client_id', 'calliope_sp_blogger_client_secret']) ? 'credentials_saved' : 'not_connected'),
            'calliope_sp_telegram_connection_status' => $this->all_options_have_saved_values(['calliope_sp_telegram_bot_token', 'calliope_sp_telegram_chat_id']) ? 'connected' : ($this->option_has_saved_value('calliope_sp_telegram_bot_token') ? 'credentials_saved' : 'not_connected'),
            'calliope_sp_fb_connection_status' => $this->facebook_has_ready_page_connection() ? 'connected' : ($this->all_options_have_saved_values(['calliope_sp_fb_app_id', 'calliope_sp_fb_app_secret']) ? 'credentials_saved' : 'not_connected'),
        ];

        foreach ($expected_statuses as $status_option => $expected) {
            if (get_option($status_option, '') !== $expected) {
                update_option($status_option, $expected);
            }
        }

        $li_expected = $this->option_has_saved_value('calliope_sp_li_access_token') ? 'ready_to_publish' : 'not_connected';
        if (get_option('calliope_sp_li_connection_status', '') !== $li_expected) {
            update_option('calliope_sp_li_connection_status', $li_expected);
        }
    }

    /**
     * Disconnect a network and remove all of its saved credentials.
     */
    public function handle_disconnect_network() {
        if (!current_user_can('manage_options')) {
            wp_die(esc_html__('Unauthorized.', 'calliope-social-publisher'));
        }

        $network = isset($_GET['network']) ? sanitize_key(wp_unslash($_GET['network'])) : '';
        check_admin_referer('calliope_sp_disconnect_network_' . $network);

        $options_by_network = [
            'bluesky' => [
                'calliope_sp_bsky_handle',
                'calliope_sp_bsky_password',
                'calliope_sp_bsky_connection_status',
            ],
            'mastodon' => [
                'calliope_sp_mstdn_instance',
                'calliope_sp_mstdn_token',
                'calliope_sp_mstdn_client_id',
                'calliope_sp_mstdn_client_secret',
                'calliope_sp_mstdn_client_instance',
                'calliope_sp_mstdn_connection_status',
            ],
            'tumblr' => [
                'calliope_sp_tumblr_consumer_key',
                'calliope_sp_tumblr_consumer_secret',
                'calliope_sp_tumblr_token',
                'calliope_sp_tumblr_token_secret',
                'calliope_sp_tumblr_blog_identifier',
                'calliope_sp_tumblr_connection_status',
            ],
            'pinterest' => [
                'calliope_sp_pinterest_app_id',
                'calliope_sp_pinterest_app_secret',
                'calliope_sp_pinterest_token',
                'calliope_sp_pinterest_refresh_token',
                'calliope_sp_pinterest_env',
                'calliope_sp_pinterest_board_id',
                'calliope_sp_pinterest_boards_last',
                'calliope_sp_pinterest_connection_status',
            ],
            'threads' => [
                'calliope_sp_threads_client_id',
                'calliope_sp_threads_client_secret',
                'calliope_sp_threads_access_token',
                'calliope_sp_threads_user_id',
                'calliope_sp_threads_expires_at',
                'calliope_sp_threads_connection_status',
            ],
            'instagram' => [
                'calliope_sp_ig_app_id',
                'calliope_sp_ig_app_secret',
                'calliope_sp_ig_access_token',
                'calliope_sp_ig_expires_at',
                'calliope_sp_ig_page_id',
                'calliope_sp_ig_user_id',
                'calliope_sp_ig_connection_status',
            ],
            'linkedin' => [
                'calliope_sp_li_client_id',
                'calliope_sp_li_client_secret',
                'calliope_sp_li_access_token',
                'calliope_sp_li_expires_at',
                'calliope_sp_li_person_id',
                'calliope_sp_li_connection_status',
                'calliope_sp_li_last_scope_list',
                'calliope_sp_li_last_target_kind',
                'calliope_sp_li_last_page_target',
            ],
            'blogger' => [
                'calliope_sp_blogger_client_id',
                'calliope_sp_blogger_client_secret',
                'calliope_sp_blogger_access_token',
                'calliope_sp_blogger_refresh_token',
                'calliope_sp_blogger_expires_at',
                'calliope_sp_blogger_blog_id',
                'calliope_sp_blogger_blogs_last',
                'calliope_sp_blogger_connection_status',
            ],
            'telegram' => [
                'calliope_sp_telegram_bot_token',
                'calliope_sp_telegram_chat_id',
                'calliope_sp_telegram_message_thread_id',
                'calliope_sp_telegram_connection_status',
            ],
            'facebook' => [
                'calliope_sp_fb_app_id',
                'calliope_sp_fb_app_secret',
                'calliope_sp_fb_config_id',
                'calliope_sp_fb_user_access_token',
                'calliope_sp_fb_page_id',
                'calliope_sp_fb_page_access_token',
                'calliope_sp_fb_pages_last',
                'calliope_sp_fb_business_id',
                'calliope_sp_fb_businesses_last',
                'calliope_sp_fb_expires_at',
                'calliope_sp_fb_connection_status',
                // Legacy Facebook option names are cleared too, but the plugin keeps the new slug/name.
                'sh_fb_page_id',
                'sh_fb_page_access_token',
                'sh_fb_user_access_token',
                'sh_fb_pages_last',
            ],
        ];

        if (empty($options_by_network[$network])) {
            $this->redirect_msg('error', __('Unknown network.', 'calliope-social-publisher'), 'connections');
        }

        foreach ($options_by_network[$network] as $option_name) {
            delete_option($option_name);
        }

        $this->remove_options_from_connection_backup($options_by_network[$network]);

        if ($network === 'linkedin' && class_exists('Calliope_SP_LinkedIn')) {
            $this->refresh_linkedin_connection_status();
        }

        $this->redirect_msg('success', __('Network disconnected and saved credentials removed.', 'calliope-social-publisher'), 'connections', $network);
    }

    /**
     * Facebook OAuth callback route.
     */
    public function register_facebook_rest_routes() {
        register_rest_route('calliope-social-publisher/v1', '/facebook/callback', [
            'methods'             => 'GET',
            'permission_callback' => function (\WP_REST_Request $request) {
                return $this->has_valid_oauth_rest_state($request, 'facebook');
            },
            'callback'            => [$this, 'handle_facebook_oauth_rest_callback'],
        ]);
    }

    /**
     * Keep the cached Facebook pages list free of page access tokens.
     * Old installs may still have raw /me/accounts payloads stored.
     */
    public function migrate_facebook_pages_cache() {
        $pages_raw = get_option('calliope_sp_fb_pages_last', '');
        if (!is_string($pages_raw) || $pages_raw === '') {
            return;
        }

        $sanitized_json = $this->sanitize_facebook_pages_cache_setting($pages_raw);
        if ($sanitized_json !== $pages_raw) {
            update_option('calliope_sp_fb_pages_last', $sanitized_json);
        }
    }

    /**
     * Store only the fields the UI actually needs.
     * Never persist page access tokens in the cached page selector list.
     *
     * @param mixed $pages
     * @return array<int, array{id:string,name:string}>
     */
    private function sanitize_facebook_pages_list($pages) {
        if (!is_array($pages)) {
            return [];
        }

        $sanitized = [];
        $seen_ids = [];
        foreach ($pages as $page) {
            if (!is_array($page)) {
                continue;
            }

            $raw_id = isset($page['id']) ? $page['id'] : ($page['page_id'] ?? '');
            $id = sanitize_text_field(wp_unslash((string) $raw_id));
            if ($id === '' || isset($seen_ids[$id])) {
                continue;
            }

            $name = isset($page['name']) ? sanitize_text_field(wp_unslash((string) $page['name'])) : $id;

            $sanitized[] = [
                'id'   => $id,
                'name' => $name !== '' ? $name : $id,
            ];
            $seen_ids[$id] = true;
        }

        return $sanitized;
    }

    /**
     * Resolve a Page access token on demand from the selected page ID.
     * This avoids storing duplicated page tokens inside the cached pages list.
     *
     * @param string $page_id
     * @param string $user_token
     * @return string
     */
    private function fetch_facebook_page_access_token($page_id, $user_token) {
        $page_id = sanitize_text_field((string) $page_id);
        $user_token = (string) $user_token;

        if ($page_id === '' || $user_token === '') {
            return '';
        }

        $response = wp_remote_get(add_query_arg([
            'fields'       => 'access_token',
            'access_token' => $user_token,
        ], 'https://graph.facebook.com/v20.0/' . rawurlencode($page_id)), ['timeout' => 60]);

        if (is_wp_error($response)) {
            if (class_exists('Calliope_SP_Logger')) {
                Calliope_SP_Logger::log('facebook', 'error', 'Page token fetch HTTP error: ' . $response->get_error_message());
            }
            return '';
        }

        $body = (string) wp_remote_retrieve_body($response);
        $json = json_decode($body, true);
        $page_token = (string) ($json['access_token'] ?? '');

        if (class_exists('Calliope_SP_Logger')) {
            Calliope_SP_Logger::log('facebook', 'info', 'Page token response code=' . (int) wp_remote_retrieve_response_code($response) . ' body=' . $body);
        }

        return $page_token;
    }

    /**
     * Pick the first Facebook Page that can actually return a publishable Page token.
     * The preferred page is tested first, then the remaining resolved pages are tried.
     *
     * @param array<int,array{id:string,name:string}> $pages
     * @param string                                   $preferred_page_id
     * @param string                                   $user_token
     * @return array{id:string,name:string,token:string}
     */
    private function resolve_facebook_publishable_page(array $pages, $preferred_page_id, $user_token) {
        $preferred_page_id = sanitize_text_field((string) $preferred_page_id);
        $ordered_pages = [];

        foreach ($pages as $page) {
            if (!is_array($page) || empty($page['id'])) {
                continue;
            }

            $page_id = sanitize_text_field((string) $page['id']);
            $page_name = sanitize_text_field((string) ($page['name'] ?? $page_id));

            if ($page_id === '') {
                continue;
            }

            if ($preferred_page_id !== '' && $page_id === $preferred_page_id) {
                array_unshift($ordered_pages, [
                    'id'   => $page_id,
                    'name' => $page_name !== '' ? $page_name : $page_id,
                ]);
            } else {
                $ordered_pages[] = [
                    'id'   => $page_id,
                    'name' => $page_name !== '' ? $page_name : $page_id,
                ];
            }
        }

        foreach ($ordered_pages as $page) {
            $page_token = $this->fetch_facebook_page_access_token($page['id'], $user_token);
            if ($page_token === '') {
                continue;
            }

            return [
                'id'    => $page['id'],
                'name'  => $page['name'],
                'token' => $page_token,
            ];
        }

        return [
            'id'    => '',
            'name'  => '',
            'token' => '',
        ];
    }

    /**
     * LinkedIn OAuth callback route.
     * Using REST avoids issues with /wp-admin callbacks being blocked/looped by login or caching.
     */
    public function register_linkedin_rest_routes() {
        register_rest_route('calliope-social-publisher/v1', '/linkedin/callback', [
            'methods'             => 'GET',
            'permission_callback' => function (\WP_REST_Request $request) {
                return $this->has_valid_oauth_rest_state($request, 'linkedin');
            },
            'callback'            => [$this, 'handle_linkedin_oauth_rest_callback'],
        ]);
    }

    public function handle_linkedin_oauth_rest_callback(\WP_REST_Request $request) {
        $params = $this->get_oauth_params_from_rest_request($request);
        $this->handle_linkedin_oauth_callback($params);

        // If the handler redirected, this won't be used, but return a harmless OK for completeness.
        return new \WP_REST_Response(['ok' => true], 200);
    }

    public function handle_blogger_oauth_rest_callback(\WP_REST_Request $request) {
        $params = $this->get_oauth_params_from_rest_request($request);
        if (!empty($params['error'])) {
            $pretty = !empty($params['error_description']) ? urldecode($params['error_description']) : $params['error'];
            $pretty = trim(wp_strip_all_tags($pretty));
            $this->redirect_msg('error', 'Blogger OAuth: ' . $pretty, 'connections', 'blogger');
        }

        $this->handle_blogger_oauth_callback($params);

        return new \WP_REST_Response(['ok' => true], 200);
    }

    /**
     * REST callback entry-point for Instagram OAuth.
     * We keep the legacy admin-post callback too, but this avoids query params in redirect URIs.
     */
    
    public function handle_facebook_oauth_rest_callback(\WP_REST_Request $request) {
        $params = $this->get_oauth_params_from_rest_request($request);

        // Verbose debug to diagnose cases where the dialog completes but tokens are not stored.
        $dbg = [
            'ts'    => time(),
            'step'  => 'rest_callback',
            'error' => $params['error'],
            'error_description' => $params['error_description'],
            'code_present' => $params['code'] !== '',
            'state' => $params['state'],
        ];
        update_option('calliope_sp_fb_last_oauth_debug', wp_json_encode($dbg));
        if (class_exists('Calliope_SP_Logger')) {
            Calliope_SP_Logger::log('facebook', 'info', 'OAuth callback hit: ' . wp_json_encode($dbg));
        }

        if (!empty($params['error'])) {
            $pretty = !empty($params['error_description']) ? urldecode($params['error_description']) : $params['error'];
            $pretty = trim(wp_strip_all_tags($pretty));
            if (class_exists('Calliope_SP_Logger')) {
                Calliope_SP_Logger::log('facebook', 'error', 'OAuth error: ' . $pretty);
            }
            $this->redirect_msg('error', 'Facebook OAuth: ' . $pretty, 'connections', 'facebook');
        }

        $this->handle_facebook_oauth_callback($params);

        return new \WP_REST_Response(['ok' => true], 200);
    }

    public function handle_facebook_oauth_start() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Unauthorized.', 'calliope-social-publisher'));
        check_admin_referer('calliope_sp_fb_oauth_start_action', 'calliope_sp_fb_oauth_start_nonce');

        $app_id = sanitize_text_field((string) get_option('calliope_sp_fb_app_id'));
        if (!$app_id) {
            $this->redirect_msg('error', __('Facebook: Missing App ID.', 'calliope-social-publisher'), 'connections', 'facebook');
        }

        $state = wp_generate_password(24, false, false);
        set_transient('calliope_sp_fb_oauth_state', $state, 15 * MINUTE_IN_SECONDS);

        // Store a small trace for troubleshooting.
        update_option('calliope_sp_fb_last_oauth_debug', wp_json_encode([
            'ts'   => time(),
            'step' => 'start',
            'has_config_id' => (bool) get_option('calliope_sp_fb_config_id'),
        ]));

        if (class_exists('Calliope_SP_Logger')) {
            Calliope_SP_Logger::log('facebook', 'info', 'OAuth start: state set (15min).');
        }

        $redirect_uri = $this->get_facebook_redirect_uri();

        // IMPORTANT: Meta 'Facebook Login for Business' may require a Login Configuration (config_id).
        // If you have a config_id, permissions are chosen in Meta Dashboard and passing pages_* scopes here can trigger
        // 'Invalid Scopes' during the dialog. In that case we request only basic scopes here and rely on config_id.
        $config_id = sanitize_text_field((string) get_option('calliope_sp_fb_config_id'));

        // With Facebook Login for Business (config_id), Meta may reject even valid classic scopes (including `email`).
        // We keep the dialog minimal here and rely on the Login Configuration + subsequent API calls.
        $scopes = ['public_profile'];
        if (!$config_id) {
            // Classic flow (no config_id): request Page permissions directly.
            $scopes = [
                'pages_show_list',
                'pages_read_engagement',
                'pages_manage_posts',
                'pages_manage_metadata',
                'business_management',
            ];
        }

        $args = [
            'client_id'     => $app_id,
            'redirect_uri'  => $redirect_uri,
            'state'         => $state,
            'response_type' => 'code',
            'scope'         => implode(',', $scopes),
            'auth_type'     => 'rerequest',
        ];

        if (!empty($config_id)) {
            $args['config_id'] = $config_id;
        }

        $url = add_query_arg($args, 'https://www.facebook.com/v20.0/dialog/oauth');

        $this->safe_redirect($url);
    }

    private function get_facebook_redirect_uri() {
        return esc_url_raw(rest_url('calliope-social-publisher/v1/facebook/callback'));
    }

    /**
     * Prefer the REST callback when it is a clean path. If WordPress exposes the REST API
     * through query parameters (for example ?rest_route=...), fall back to the legacy
     * admin-post callback because Meta often rejects those redirect URIs for Instagram OAuth.
     *
     * @return string
     */
    private function get_instagram_redirect_uri() {
        $rest_callback = esc_url_raw(rest_url('calliope-social-publisher/v1/instagram/callback'));
        $rest_query = wp_parse_url($rest_callback, PHP_URL_QUERY);

        if (is_string($rest_query) && $rest_query !== '') {
            return esc_url_raw(add_query_arg('action', 'calliope_sp_instagram_callback', admin_url('admin-post.php')));
        }

        return $rest_callback;
    }

    public function handle_facebook_oauth_callback($params = []) {
        $params = is_array($params) ? $params : [];
        $code  = $this->get_oauth_callback_param($params, 'code');
        $state = $this->get_oauth_callback_param($params, 'state');

        $expected = (string) get_transient('calliope_sp_fb_oauth_state');
        $dbg = [
            'ts' => time(),
            'step' => 'callback_start',
            'code_present' => (bool) $code,
            'state_present' => (bool) $state,
            'expected_state_present' => (bool) $expected,
            'state_match' => ($expected && $state) ? (bool) hash_equals($expected, $state) : false,
        ];
        update_option('calliope_sp_fb_last_oauth_debug', wp_json_encode($dbg));
        if (class_exists('Calliope_SP_Logger')) {
            Calliope_SP_Logger::log('facebook', 'info', 'OAuth callback start: ' . wp_json_encode($dbg));
        }

        if (!$code) {
            if (class_exists('Calliope_SP_Logger')) {
                $safe_params = [
                    'code'  => $code !== '',
                    'state' => $state !== '',
                    'error' => $this->get_oauth_callback_param($params, 'error'),
                ];
                Calliope_SP_Logger::log('facebook', 'error', 'OAuth callback missing code. Params: ' . wp_json_encode($safe_params));
            }
            $this->redirect_msg('error', __('Facebook OAuth: the code parameter was not received (authorization did not complete or was blocked). Check Logs.', 'calliope-social-publisher'), 'connections', 'facebook');
        }
        if (!$expected || !$state || !hash_equals($expected, $state)) {
            if (class_exists('Calliope_SP_Logger')) {
                Calliope_SP_Logger::log('facebook', 'error', 'OAuth invalid state. expected=' . ($expected ? 'set' : 'missing') . ' got=' . ($state ? 'set' : 'missing'));
            }
            $this->redirect_msg('error', __('Facebook OAuth: Invalid state. Please try again.', 'calliope-social-publisher'), 'connections', 'facebook');
        }

        $app_id = sanitize_text_field((string) get_option('calliope_sp_fb_app_id'));
        $app_secret = sanitize_text_field((string) get_option('calliope_sp_fb_app_secret'));
        if (!$app_id || !$app_secret) {
            $this->redirect_msg('error', __('Facebook: Missing App ID/Secret.', 'calliope-social-publisher'), 'connections', 'facebook');
        }

        $redirect_uri = $this->get_facebook_redirect_uri();

        $token_url = add_query_arg([
            'client_id'     => $app_id,
            'client_secret' => $app_secret,
            'redirect_uri'  => $redirect_uri,
            'code'          => $code,
        ], 'https://graph.facebook.com/v20.0/oauth/access_token');

        $res = wp_remote_get($token_url, ['timeout' => 60]);
        if (is_wp_error($res)) {
            if (class_exists('Calliope_SP_Logger')) {
                Calliope_SP_Logger::log('facebook', 'error', 'Token HTTP error: ' . $res->get_error_message());
            }
            $this->redirect_msg('error', 'Facebook OAuth: ' . $res->get_error_message(), 'connections', 'facebook');
        }
        $body = (string) wp_remote_retrieve_body($res);
        $json = json_decode($body, true);
        $user_token = (string)($json['access_token'] ?? '');

        if (class_exists('Calliope_SP_Logger')) {
            Calliope_SP_Logger::log('facebook', 'info', 'Token response code=' . (int) wp_remote_retrieve_response_code($res) . ' body=' . $body);
        }

        if (!$user_token) {
            if (class_exists('Calliope_SP_Logger')) {
                Calliope_SP_Logger::log('facebook', 'error', 'No access_token in response.');
            }
            $this->redirect_msg('error', 'Facebook OAuth: could not obtain access token.', 'connections', 'facebook');
        }

        // Long-lived exchange (best-effort)
        $ll_url = add_query_arg([
            'grant_type'        => 'fb_exchange_token',
            'client_id'         => $app_id,
            'client_secret'     => $app_secret,
            'fb_exchange_token' => $user_token,
        ], 'https://graph.facebook.com/v20.0/oauth/access_token');

        $res2 = wp_remote_get($ll_url, ['timeout' => 60]);
        if (!is_wp_error($res2)) {
            $body2 = (string) wp_remote_retrieve_body($res2);
            $json2 = json_decode($body2, true);
            if (!empty($json2['access_token'])) {
                $user_token = (string)$json2['access_token'];
                if (!empty($json2['expires_in'])) {
                    update_option('calliope_sp_fb_expires_at', time() + (int)$json2['expires_in']);
                }
            }
        }

        update_option('calliope_sp_fb_user_access_token', $user_token);
        if (class_exists('Calliope_SP_Logger')) {
            Calliope_SP_Logger::log('facebook', 'info', 'token_received: Facebook user token stored.');
        }

        // Start every Facebook login with a clean page-resolution state so stale data
        // from an older token does not keep the UI green incorrectly.
        $this->clear_facebook_page_resolution_state();
        update_option('calliope_sp_fb_connection_status', 'credentials_saved');

        $resolved_pages = $this->resolve_facebook_pages_from_meta($user_token);
        $sanitized_pages = $this->sanitize_facebook_pages_list($resolved_pages);
        if (class_exists('Calliope_SP_Logger')) {
            Calliope_SP_Logger::log('facebook', 'info', 'pages_loaded: resolved ' . count($sanitized_pages) . ' unique page(s).');
        }

        if (empty($sanitized_pages)) {
            $this->clear_facebook_page_resolution_state();
            update_option('calliope_sp_fb_connection_status', 'credentials_saved');
            $this->redirect_msg('warning', __('Facebook connected to Meta, but no Facebook Pages were returned. Check your selected Pages, Business Portfolio assets, and Login Configuration page permissions.', 'calliope-social-publisher'), 'connections', 'facebook');
        }

        update_option('calliope_sp_fb_pages_last', wp_json_encode($sanitized_pages));

        $preferred_page_id = sanitize_text_field((string) get_option('calliope_sp_fb_page_id'));
        $publishable_page = $this->resolve_facebook_publishable_page($sanitized_pages, $preferred_page_id, $user_token);
        $selected_page = (string) ($publishable_page['id'] ?? '');
        $selected_page_name = (string) ($publishable_page['name'] ?? $selected_page);
        $page_token = (string) ($publishable_page['token'] ?? '');

        if (!$selected_page || !$page_token) {
            delete_option('calliope_sp_fb_page_id');
            delete_option('calliope_sp_fb_page_access_token');
            update_option('calliope_sp_fb_connection_status', 'credentials_saved');
            if (class_exists('Calliope_SP_Logger')) {
                Calliope_SP_Logger::log('facebook', 'warning', 'page_token_saved_failed: no publishable Page token could be resolved from the available page list.');
            }
            $this->redirect_msg('warning', __('Facebook connected to Meta, but Calliope Social Publisher could not resolve a Page access token yet. Choose a Page and reconnect if needed.', 'calliope-social-publisher'), 'connections', 'facebook');
        }

        update_option('calliope_sp_fb_page_id', $selected_page);
        update_option('calliope_sp_fb_page_access_token', $page_token);
        update_option('calliope_sp_fb_connection_status', 'connected');
        if (class_exists('Calliope_SP_Logger')) {
            Calliope_SP_Logger::log('facebook', 'info', 'page_token_saved: ' . $selected_page_name . ' (' . $selected_page . ').');
            Calliope_SP_Logger::log('facebook', 'info', 'connected_ready_to_publish: Facebook Page connection is ready.');
        }

        $this->redirect_msg(
            'success',
            sprintf(
                /* translators: %s: Facebook page name. */
                __('Facebook connected to page: %s', 'calliope-social-publisher'),
                $selected_page_name
            ),
            'connections',
            'facebook'
        );
    }

    public function handle_facebook_clear_tokens() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Unauthorized.', 'calliope-social-publisher'));
        check_admin_referer('calliope_sp_fb_clear_action', 'calliope_sp_fb_clear_nonce');

        $cleared_options = [
            'calliope_sp_fb_user_access_token',
            'calliope_sp_fb_page_access_token',
            'calliope_sp_fb_page_id',
            'calliope_sp_fb_pages_last',
            'calliope_sp_fb_expires_at',
            'calliope_sp_fb_business_id',
            'calliope_sp_fb_businesses_last',
            'sh_fb_user_access_token',
            'sh_fb_page_access_token',
            'sh_fb_page_id',
            'sh_fb_pages_last',
        ];

        foreach ($cleared_options as $option_name) {
            delete_option($option_name);
        }

        $this->remove_options_from_connection_backup($cleared_options);
        update_option('calliope_sp_fb_connection_status', $this->all_options_have_saved_values(['calliope_sp_fb_app_id', 'calliope_sp_fb_app_secret']) ? 'credentials_saved' : 'not_connected');

        $this->redirect_msg('success', __('Facebook tokens cleared.', 'calliope-social-publisher'), 'connections', 'facebook');
    }

    /**
     * Build a compact human-readable summary from Facebook token diagnostics.
     *
     * @param array<string,mixed> $diagnostic
     * @return string
     */
    private function summarize_facebook_diagnostic($diagnostic) {
        $page_name = (string) ($diagnostic['page_name'] ?? '');
        $page_id = (string) ($diagnostic['page_id'] ?? '');
        $missing = isset($diagnostic['missing_required_scopes']) && is_array($diagnostic['missing_required_scopes']) ? $diagnostic['missing_required_scopes'] : [];
        $checked = !empty($diagnostic['debug_token_checked']);

        $message = $page_name !== ''
            ? sprintf(
                /* translators: %s: Facebook page name. */
                __('Facebook connected to page: %s', 'calliope-social-publisher'),
                $page_name
            )
            : __('Facebook connection is ready to publish.', 'calliope-social-publisher');

        if ($page_id !== '') {
            $message .= ' Page ID: ' . $page_id . '.';
        }

        $granted = isset($diagnostic['granted_scopes']) && is_array($diagnostic['granted_scopes']) ? $diagnostic['granted_scopes'] : [];
        if ($checked && !empty($granted)) {
            $message .= empty($missing)
                ? ' Token permissions look OK: pages_manage_posts, pages_read_engagement, pages_show_list.'
                : ' Missing/unknown permissions: ' . implode(', ', array_map('sanitize_key', $missing)) . '.';
        } elseif ($checked) {
            $message .= ' Page lookup works. debug_token responded, but Meta did not return scopes for this token.';
        } else {
            $message .= ' Page lookup works. Token scopes could not be fully checked with debug_token.';
        }

        return $message;
    }

    public function handle_facebook_test_connection() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Unauthorized.', 'calliope-social-publisher'));
        check_admin_referer('calliope_sp_test_facebook_action', 'calliope_sp_test_facebook_nonce');

        $app_id = sanitize_text_field(wp_unslash($_POST['calliope_sp_fb_app_id'] ?? ''));
        $app_secret = sanitize_text_field(wp_unslash($_POST['calliope_sp_fb_app_secret'] ?? ''));
        $config_id = sanitize_text_field(wp_unslash($_POST['calliope_sp_fb_config_id'] ?? ''));
        $page_id = sanitize_text_field(wp_unslash($_POST['calliope_sp_fb_page_id'] ?? ''));

        update_option('calliope_sp_fb_app_id', $app_id);
        if ($app_secret !== '') {
            update_option('calliope_sp_fb_app_secret', sanitize_text_field($app_secret));
        }
        if ($config_id !== '') {
            update_option('calliope_sp_fb_config_id', $config_id);
        }
        if ($page_id !== '') {
            update_option('calliope_sp_fb_page_id', $page_id);
        }

        $saved_secret = (string) get_option('calliope_sp_fb_app_secret', '');
        $has_saved_token = $this->facebook_has_ready_page_connection();

        if ($has_saved_token) {
            $fb = new Calliope_SP_Facebook();
            $res = method_exists($fb, 'diagnose_page_token') ? $fb->diagnose_page_token() : $fb->test_connection();

            if (is_wp_error($res)) {
                $error_code = $res->get_error_code();
                $status = in_array($error_code, ['facebook_missing_publish_permission', 'facebook_action_restricted', 'facebook_publish_restricted'], true) ? 'warning' : 'error';

                // Keep the Page as connected when Meta says the token/page exists but cannot publish yet.
                if (in_array($error_code, ['facebook_missing_publish_permission', 'facebook_action_restricted', 'facebook_publish_restricted'], true)) {
                    update_option('calliope_sp_fb_connection_status', 'connected');
                } else {
                    update_option('calliope_sp_fb_connection_status', 'error');
                }

                if (class_exists('Calliope_SP_Logger')) {
                    Calliope_SP_Logger::log('facebook', $status, $res->get_error_message());
                }

                $this->redirect_msg($status, 'Facebook: ' . $res->get_error_message(), 'connections', 'facebook');
            }

            update_option('calliope_sp_fb_connection_status', 'connected');
            if (class_exists('Calliope_SP_Logger')) {
                Calliope_SP_Logger::log('facebook', 'info', 'Diagnostics OK: ' . wp_json_encode($res, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
            }

            $message = is_array($res) ? $this->summarize_facebook_diagnostic($res) : __('Facebook connection is ready to publish.', 'calliope-social-publisher');
            $this->redirect_msg('success', $message, 'connections', 'facebook');
        }

        update_option('calliope_sp_fb_connection_status', ($app_id !== '' && $saved_secret !== '') ? 'credentials_saved' : 'not_connected');
        $this->redirect_msg('success', __('Credentials saved. Now click Connect Facebook.', 'calliope-social-publisher'), 'connections', 'facebook');
    }

    public function handle_facebook_token_diagnostics() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Unauthorized.', 'calliope-social-publisher'));
        check_admin_referer('calliope_sp_diagnose_facebook_action', 'calliope_sp_diagnose_facebook_nonce');

        $fb = new Calliope_SP_Facebook();
        $res = method_exists($fb, 'diagnose_page_token') ? $fb->diagnose_page_token() : $fb->test_connection();

        if (is_wp_error($res)) {
            $error_code = $res->get_error_code();
            $status = in_array($error_code, ['facebook_missing_publish_permission', 'facebook_action_restricted', 'facebook_publish_restricted'], true) ? 'warning' : 'error';

            if (in_array($error_code, ['facebook_missing_publish_permission', 'facebook_action_restricted', 'facebook_publish_restricted'], true)) {
                update_option('calliope_sp_fb_connection_status', 'connected');
            }

            if (class_exists('Calliope_SP_Logger')) {
                Calliope_SP_Logger::log('facebook', $status, 'Diagnostics failed: ' . $res->get_error_message());
            }

            $this->redirect_msg($status, 'Facebook diagnostics: ' . $res->get_error_message(), 'connections', 'facebook');
        }

        update_option('calliope_sp_fb_connection_status', 'connected');
        $message = is_array($res) ? $this->summarize_facebook_diagnostic($res) : __('Facebook connection is ready to publish.', 'calliope-social-publisher');
        $this->redirect_msg('success', 'Facebook diagnostics OK. ' . $message, 'connections', 'facebook');
    }

    public function handle_facebook_test_publish() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Unauthorized.', 'calliope-social-publisher'));
        check_admin_referer('calliope_sp_facebook_test_publish_action', 'calliope_sp_facebook_test_publish_nonce');

        $mode = sanitize_key(wp_unslash($_POST['calliope_sp_fb_test_mode'] ?? 'text'));
        if (!in_array($mode, ['text', 'link', 'image'], true)) {
            $mode = 'text';
        }

        $fb = new Calliope_SP_Facebook();
        $result = method_exists($fb, 'run_publish_test') ? $fb->run_publish_test($mode) : new WP_Error('facebook_test_unavailable', 'Facebook test publishing is unavailable.');

        if (is_wp_error($result)) {
            $normalized = $this->normalize_publish_result('facebook', $result);
            $classified = class_exists('Calliope_SP_Publish_Helper') ? Calliope_SP_Publish_Helper::classify_result($normalized) : [];
            $status = (($classified['summary_status'] ?? '') === 'warning') ? 'warning' : 'error';
            $message = (string) ($classified['user_message'] ?? $result->get_error_message());

            if (class_exists('Calliope_SP_Logger')) {
                Calliope_SP_Logger::log('facebook', $status, wp_json_encode($normalized, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
            }

            $this->redirect_msg($status, 'Facebook ' . $mode . ' test failed: ' . $message, 'connections', 'facebook');
        }

        update_option('calliope_sp_fb_connection_status', 'connected');
        $this->redirect_msg('success', 'Facebook ' . $mode . ' test published successfully. You can delete the test post from the Page.', 'connections', 'facebook');
    }

    public function handle_instagram_oauth_rest_callback(\WP_REST_Request $request) {
        $params = $this->get_oauth_params_from_rest_request($request);

        // If Meta sends an error, avoid fatal screens and show a clean message in WP admin.
        if (!empty($params['error_code']) || !empty($params['error_message'])) {
            $pretty = !empty($params['error_message']) ? urldecode($params['error_message']) : ('Error code: ' . $params['error_code']);
            $pretty = trim(wp_strip_all_tags($pretty));
            $this->redirect_msg('error', 'Instagram OAuth: ' . $pretty, 'connections', 'instagram');
        }

        $this->handle_instagram_oauth_callback($params);

        // Fallback (should rarely reach here because handler redirects).
        return new \WP_REST_Response(['ok' => true], 200);
    }

    public function add_menu() {
        add_menu_page(
            'Calliope Social Publisher',
            'Calliope Social Publisher',
            'manage_options',
            'calliope-social-publisher',
            [$this, 'render_page'],
            CALLIOPE_SP_URL . 'assets/images/menu-icon.png'
        );
    }

    private function get_asset_version($relative_path) {
        $asset_path = CALLIOPE_SP_PATH . ltrim((string) $relative_path, '/\\');

        return file_exists($asset_path) ? (string) filemtime($asset_path) : CALLIOPE_SP_VERSION;
    }

    public function enqueue_assets($hook) {
        if ($hook !== 'toplevel_page_calliope-social-publisher') {
            return;
        }

        wp_enqueue_media();

        wp_enqueue_style('calliope-sp-admin-style', CALLIOPE_SP_URL . 'assets/css/admin-style.css', [], $this->get_asset_version('assets/css/admin-style.css'));
        wp_enqueue_script('calliope-sp-editor', CALLIOPE_SP_URL . 'assets/js/image-editor.js', ['jquery'], $this->get_asset_version('assets/js/image-editor.js'), true);
        wp_enqueue_script('calliope-sp-admin-tabs', CALLIOPE_SP_URL . 'assets/js/admin-tabs.js', ['jquery'], $this->get_asset_version('assets/js/admin-tabs.js'), true);
        wp_enqueue_script('calliope-sp-net-facebook', CALLIOPE_SP_URL . 'assets/js/net-facebook.js', [], $this->get_asset_version('assets/js/net-facebook.js'), true);

        wp_localize_script('calliope-sp-editor', 'CALLIOPE_SP_ADMIN_I18N', [
            'selectImage'          => __('Select image', 'calliope-social-publisher'),
            'pasteLinkFirst'       => __('Paste a link to share first.', 'calliope-social-publisher'),
            'imageLoadFailed'      => __('The selected image could not be loaded.', 'calliope-social-publisher'),
            'formatOriginal'       => __('Original', 'calliope-social-publisher'),
            'formatSquare'         => __('Square 1:1', 'calliope-social-publisher'),
            'formatPortrait'       => __('Portrait 4:5', 'calliope-social-publisher'),
            'formatLandscape'      => __('Landscape 16:9', 'calliope-social-publisher'),
            'hashtagsHelp'         => __('You can separate hashtags with spaces, commas, semicolons, or line breaks. They will be normalized automatically.', 'calliope-social-publisher'),
            'hashtagsPreviewLabel' => __('Normalized hashtags preview:', 'calliope-social-publisher'),
        ]);

        wp_localize_script('calliope-sp-admin-tabs', 'CALLIOPE_SP_JOB_RUNNER', [
            'ajaxUrl'       => admin_url('admin-ajax.php'),
            'nonce'         => wp_create_nonce('calliope_sp_job_runner'),
            'jobs'          => $this->get_browser_publish_runner_jobs(3),
            'networkLabels' => [
                'bsky'      => __('Bluesky', 'calliope-social-publisher'),
                'bluesky'   => __('Bluesky', 'calliope-social-publisher'),
                'mastodon'  => __('Mastodon', 'calliope-social-publisher'),
                'tumblr'    => __('Tumblr', 'calliope-social-publisher'),
                'pinterest' => __('Pinterest', 'calliope-social-publisher'),
                'threads'   => __('Threads', 'calliope-social-publisher'),
                'instagram' => __('Instagram', 'calliope-social-publisher'),
                'linkedin'  => __('LinkedIn', 'calliope-social-publisher'),
                'blogger'   => __('Blogger', 'calliope-social-publisher'),
                'telegram'  => __('Telegram', 'calliope-social-publisher'),
                'facebook'  => __('Facebook', 'calliope-social-publisher'),
            ],
            'i18n'          => [
                'starting'         => __('Starting background publication…', 'calliope-social-publisher'),
                'running'          => __('Publishing in safe steps…', 'calliope-social-publisher'),
                'runningNetwork'   => __('Publishing %s…', 'calliope-social-publisher'),
                'completedNetwork' => __('%s published.', 'calliope-social-publisher'),
                'failedNetwork'    => __('%s failed. Continuing with the next network…', 'calliope-social-publisher'),
                'done'             => __('Publication finished. Refreshing logs…', 'calliope-social-publisher'),
                'doneWithErrors'   => __('Publication finished with some errors. Refreshing logs…', 'calliope-social-publisher'),
                'failed'           => __('The browser worker could not continue. Refresh this page and check Logs.', 'calliope-social-publisher'),
                'progressTitle'    => __('Publication in progress', 'calliope-social-publisher'),
                'progressHelp'     => __('Keep this tab open until the process finishes.', 'calliope-social-publisher'),
                'progressCount'    => __('%1$d of %2$d networks processed', 'calliope-social-publisher'),
                'waiting'          => __('Waiting…', 'calliope-social-publisher'),
            ],
        ]);
    }

    public function register_settings() {
        $text_options = [
            'calliope_sp_bsky_handle',
            'calliope_sp_mstdn_client_id',
            'calliope_sp_tumblr_consumer_key',
            'calliope_sp_tumblr_blog_identifier',
            'calliope_sp_pinterest_app_id',
            'calliope_sp_pinterest_board_id',
            'calliope_sp_threads_client_id',
            'calliope_sp_threads_user_id',
            'calliope_sp_ig_app_id',
            'calliope_sp_ig_page_id',
            'calliope_sp_ig_user_id',
            'calliope_sp_li_client_id',
            'calliope_sp_li_person_id',
            'calliope_sp_fb_app_id',
            'calliope_sp_fb_config_id',
            'calliope_sp_fb_page_id',
            'calliope_sp_blogger_client_id',
            'calliope_sp_blogger_blog_id',
            'calliope_sp_telegram_chat_id',
        ];

        foreach ($text_options as $option_name) {
            register_setting(
                $this->option_group,
                $option_name,
                [
                    'type'              => 'string',
                    'sanitize_callback' => [$this, 'sanitize_text_setting'],
                ]
            );
        }

        $secret_options = [
            'calliope_sp_bsky_password',
            'calliope_sp_mstdn_token',
            'calliope_sp_mstdn_client_secret',
            'calliope_sp_tumblr_consumer_secret',
            'calliope_sp_tumblr_token',
            'calliope_sp_tumblr_token_secret',
            'calliope_sp_pinterest_app_secret',
            'calliope_sp_pinterest_token',
            'calliope_sp_pinterest_refresh_token',
            'calliope_sp_threads_client_secret',
            'calliope_sp_threads_access_token',
            'calliope_sp_ig_app_secret',
            'calliope_sp_ig_access_token',
            'calliope_sp_li_client_secret',
            'calliope_sp_li_access_token',
            'calliope_sp_fb_app_secret',
            'calliope_sp_fb_user_access_token',
            'calliope_sp_fb_page_access_token',
            'calliope_sp_blogger_client_secret',
            'calliope_sp_blogger_access_token',
            'calliope_sp_blogger_refresh_token',
            'calliope_sp_telegram_bot_token',
        ];

        foreach ($secret_options as $option_name) {
            register_setting(
                $this->option_group,
                $option_name,
                [
                    'type'              => 'string',
                    'sanitize_callback' => [$this, 'sanitize_secret_setting'],
                ]
            );
        }

        $status_options = [
            'calliope_sp_bsky_connection_status',
            'calliope_sp_mstdn_connection_status',
            'calliope_sp_tumblr_connection_status',
            'calliope_sp_pinterest_connection_status',
            'calliope_sp_threads_connection_status',
            'calliope_sp_ig_connection_status',
            'calliope_sp_li_connection_status',
            'calliope_sp_fb_connection_status',
            'calliope_sp_blogger_connection_status',
            'calliope_sp_telegram_connection_status',
        ];

        foreach ($status_options as $option_name) {
            register_setting(
                $this->option_group,
                $option_name,
                [
                    'type'              => 'string',
                    'sanitize_callback' => [$this, 'sanitize_connection_status_setting'],
                ]
            );
        }

        $array_options = [
            'calliope_sp_pinterest_boards_last' => [$this, 'sanitize_pinterest_boards_cache_setting'],
            'calliope_sp_blogger_blogs_last'    => [$this, 'sanitize_blogger_blogs_cache_setting'],
            'calliope_sp_debug_logs'            => [$this, 'sanitize_debug_logs_setting'],
        ];

        foreach ($array_options as $option_name => $sanitize_callback) {
            register_setting(
                $this->option_group,
                $option_name,
                [
                    'type'              => 'array',
                    'sanitize_callback' => $sanitize_callback,
                ]
            );
        }

        $integer_options = [
            'calliope_sp_threads_expires_at',
            'calliope_sp_ig_expires_at',
            'calliope_sp_li_expires_at',
            'calliope_sp_fb_expires_at',
            'calliope_sp_blogger_expires_at',
            'calliope_sp_telegram_message_thread_id',
        ];

        foreach ($integer_options as $option_name) {
            register_setting(
                $this->option_group,
                $option_name,
                [
                    'type'              => 'integer',
                    'sanitize_callback' => [$this, 'sanitize_absint_setting'],
                ]
            );
        }

        register_setting(
            $this->option_group,
            'calliope_sp_mstdn_instance',
            [
                'type'              => 'string',
                'sanitize_callback' => [$this, 'sanitize_instance_setting'],
            ]
        );

        register_setting(
            $this->option_group,
            'calliope_sp_mstdn_client_instance',
            [
                'type'              => 'string',
                'sanitize_callback' => [$this, 'sanitize_instance_setting'],
            ]
        );

        register_setting(
            $this->option_group,
            'calliope_sp_pinterest_env',
            [
                'type'              => 'string',
                'sanitize_callback' => [$this, 'sanitize_pinterest_env_setting'],
            ]
        );

        register_setting(
            $this->option_group,
            'calliope_sp_fb_pages_last',
            [
                'type'              => 'string',
                'sanitize_callback' => [$this, 'sanitize_facebook_pages_cache_setting'],
            ]
        );

        register_setting(
            $this->option_group,
            'calliope_sp_flipboard_feed_url',
            [
                'type'              => 'string',
                'sanitize_callback' => [$this, 'sanitize_url_setting'],
            ]
        );

        register_setting(
            $this->option_group,
            'calliope_sp_flipboard_enable_share_button',
            [
                'type'              => 'boolean',
                'sanitize_callback' => [$this, 'sanitize_boolean_setting'],
            ]
        );
    }

    /**
     * Sanitize a plain text setting.
     *
     * @param mixed $value Raw option value.
     * @return string
     */
    public function sanitize_text_setting($value) {
        if (is_array($value)) {
            return '';
        }

        return sanitize_text_field(wp_unslash((string) $value));
    }

    /**
     * Sanitize credential-like settings without forcing URL/email-specific rules.
     *
     * @param mixed $value Raw option value.
     * @return string
     */
    public function sanitize_secret_setting($value) {
        if (is_array($value)) {
            return '';
        }

        return trim(sanitize_textarea_field(wp_unslash((string) $value)));
    }

    /**
     * Sanitize URL settings.
     *
     * @param mixed $value Raw option value.
     * @return string
     */
    public function sanitize_url_setting($value) {
        if (is_array($value)) {
            return '';
        }

        return esc_url_raw(wp_unslash((string) $value));
    }

    /**
     * Sanitize a Mastodon instance/domain setting.
     *
     * @param mixed $value Raw option value.
     * @return string
     */
    public function sanitize_instance_setting($value) {
        if (is_array($value)) {
            return '';
        }

        return $this->normalize_instance(wp_unslash((string) $value));
    }

    /**
     * Sanitize numeric timestamp settings.
     *
     * @param mixed $value Raw option value.
     * @return int
     */
    public function sanitize_absint_setting($value) {
        if (is_array($value)) {
            return 0;
        }

        return absint($value);
    }

    /**
     * Sanitize checkbox/boolean settings.
     *
     * @param mixed $value Raw option value.
     * @return bool
     */
    public function sanitize_boolean_setting($value) {
        if (is_array($value)) {
            return false;
        }

        if (is_string($value)) {
            $value = wp_unslash($value);
        }

        return rest_sanitize_boolean($value);
    }

    /**
     * Sanitize a connection status option.
     *
     * @param mixed $value Raw option value.
     * @return string
     */
    public function sanitize_connection_status_setting($value) {
        if (is_array($value)) {
            return 'not_connected';
        }

        $value   = sanitize_key(wp_unslash((string) $value));
        $allowed = [
            'not_connected',
            'credentials_saved',
            'connected',
            'ready_to_publish',
            'error',
        ];

        return in_array($value, $allowed, true) ? $value : 'not_connected';
    }

    /**
     * Sanitize Pinterest environment.
     *
     * @param mixed $value Raw option value.
     * @return string
     */
    public function sanitize_pinterest_env_setting($value) {
        if (is_array($value)) {
            return 'production';
        }

        $value = sanitize_key(wp_unslash((string) $value));
        return in_array($value, ['production', 'sandbox'], true) ? $value : 'production';
    }

    /**
     * Normalize a raw Settings API value into an array.
     *
     * @param mixed $value Raw option value.
     * @return array
     */
    private function normalize_setting_array_input($value) {
        if (is_string($value)) {
            $decoded = json_decode(wp_unslash($value), true);
            return is_array($decoded) ? $decoded : [];
        }

        return is_array($value) ? $value : [];
    }

    /**
     * Sanitize the Facebook pages selector cache and always return safe JSON.
     *
     * @param mixed $value Raw option value.
     * @return string
     */
    public function sanitize_facebook_pages_cache_setting($value) {
        $pages = $this->normalize_setting_array_input($value);
        return wp_json_encode($this->sanitize_facebook_pages_list($pages));
    }

    /**
     * Sanitize the cached Pinterest boards list for the admin helper UI.
     *
     * @param mixed $value Raw value.
     * @return array<int, array{id:string,name:string,privacy:string}>
     */
    public function sanitize_pinterest_boards_cache_setting($value) {
        $boards = $this->normalize_setting_array_input($value);
        $sanitized = [];
        $seen_ids = [];

        foreach ($boards as $board) {
            if (!is_array($board)) {
                continue;
            }

            $id = isset($board['id']) ? sanitize_text_field(wp_unslash((string) $board['id'])) : '';
            if ($id === '' || isset($seen_ids[$id])) {
                continue;
            }

            $name = isset($board['name']) ? sanitize_text_field(wp_unslash((string) $board['name'])) : $id;
            $privacy = isset($board['privacy']) ? sanitize_text_field(wp_unslash((string) $board['privacy'])) : '';

            $sanitized[] = [
                'id'      => $id,
                'name'    => $name !== '' ? $name : $id,
                'privacy' => $privacy,
            ];
            $seen_ids[$id] = true;
        }

        return $sanitized;
    }

    /**
     * Sanitize the cached Blogger blog list for the admin helper UI.
     *
     * @param mixed $value Raw value.
     * @return array<int, array{id:string,name:string,url:string}>
     */
    public function sanitize_blogger_blogs_cache_setting($value) {
        $blogs = $this->normalize_setting_array_input($value);
        $sanitized = [];
        $seen_ids = [];

        foreach ($blogs as $blog) {
            if (!is_array($blog)) {
                continue;
            }

            $id = isset($blog['id']) ? sanitize_text_field(wp_unslash((string) $blog['id'])) : '';
            if ($id === '' || isset($seen_ids[$id])) {
                continue;
            }

            $name = isset($blog['name']) ? sanitize_text_field(wp_unslash((string) $blog['name'])) : $id;
            $url = isset($blog['url']) ? esc_url_raw(wp_unslash((string) $blog['url'])) : '';

            $sanitized[] = [
                'id'   => $id,
                'name' => $name !== '' ? $name : $id,
                'url'  => $url,
            ];
            $seen_ids[$id] = true;
        }

        return $sanitized;
    }

    /**
     * Sanitize the bounded debug log cache used by the admin screen.
     *
     * @param mixed $value Raw value.
     * @return array<int, array{date:string,network:string,status:string,message:string}>
     */
    public function sanitize_debug_logs_setting($value) {
        $logs = $this->normalize_setting_array_input($value);
        $sanitized = [];

        foreach ($logs as $log) {
            if (!is_array($log)) {
                continue;
            }

            $message = isset($log['message']) ? wp_unslash((string) $log['message']) : '';
            if (class_exists('Calliope_SP_Logger') && method_exists('Calliope_SP_Logger', 'sanitize_message_for_storage')) {
                $message = Calliope_SP_Logger::sanitize_message_for_storage($message);
            } else {
                $message = sanitize_textarea_field(wp_strip_all_tags($message));
            }

            $sanitized[] = [
                'date'    => isset($log['date']) ? sanitize_text_field(wp_unslash((string) $log['date'])) : '',
                'network' => isset($log['network']) ? sanitize_key(wp_unslash((string) $log['network'])) : '',
                'status'  => isset($log['status']) ? sanitize_key(wp_unslash((string) $log['status'])) : '',
                'message' => $message,
            ];
        }

        return array_slice($sanitized, 0, 50);
    }

    private function sanitize_image_data($value) {
        $value = is_string($value) ? trim((string) $value) : '';

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

        if (preg_match('#^(data:image/[a-zA-Z0-9.+-]+;base64,)(.*)$#s', $value, $matches)) {
            $prefix = sanitize_text_field((string) $matches[1]);
            $payload = preg_replace('/\s+/', '', (string) $matches[2]);
            $payload = preg_replace('/[^A-Za-z0-9+\/=]/', '', (string) $payload);
            $data = $prefix . $payload;
            return strlen($data) <= 2500000 ? $data : '';
        }

        $value = preg_replace('/\s+/', '', (string) $value);
        $value = preg_replace('/[^A-Za-z0-9+\/=]/', '', (string) $value);

        return is_string($value) && strlen($value) <= 2500000 ? $value : '';
    }
    private function get_publish_guard_option_name($type, $hash) {
        $type = sanitize_key((string) $type);
        if ($type === '') {
            $type = 'publish';
        }

        return 'calliope_sp_' . $type . '_' . hash('sha256', (string) $hash);
    }

    private function get_publish_job_option_name($job_id) {
        return $this->get_publish_guard_option_name('publish_job', sanitize_key((string) $job_id));
    }

    private function build_manual_publish_fingerprint(array $data, array $selected) {
        $selected = $this->sanitize_gutenberg_destinations_meta($selected);
        sort($selected, SORT_STRING);

        $compact_data = $data;
        // Do not include the prepared image bytes, crop format, or alt text in the
        // manual idempotency key. Re-cropping the same post must not create a new
        // publish attempt that can duplicate Threads, Tumblr, Mastodon, etc.
        unset($compact_data['image_b64'], $compact_data['image_format'], $compact_data['alt_text']);

        return hash('sha256', wp_json_encode([
            'payload'      => $this->normalize_publish_fingerprint_payload($compact_data),
            'destinations' => $selected,
        ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
    }

    private function reserve_manual_publish_attempt($fingerprint, $ttl = 7200) {
        $fingerprint = (string) $fingerprint;
        if ($fingerprint === '') {
            return false;
        }

        $option_name = $this->get_publish_guard_option_name('manual_attempt', $fingerprint);
        $now = time();
        $existing = get_option($option_name, null);

        if (is_array($existing)) {
            $created = isset($existing['created']) ? (int) $existing['created'] : 0;
            if ($created > 0 && ($now - $created) < (int) $ttl) {
                return false;
            }
            delete_option($option_name);
        } elseif ($existing !== null && $existing !== false) {
            delete_option($option_name);
        }

        return add_option($option_name, [
            'created'     => $now,
            'created_at'  => current_time('mysql'),
            'fingerprint' => $fingerprint,
        ], '', 'no');
    }

    private function reserve_publish_execution($fingerprint, $ttl = 21600) {
        $fingerprint = (string) $fingerprint;
        if ($fingerprint === '') {
            return true;
        }

        $option_name = $this->get_publish_guard_option_name('publish_execution', $fingerprint);
        $now = time();
        $existing = get_option($option_name, null);

        if (is_array($existing)) {
            $created = isset($existing['created']) ? (int) $existing['created'] : 0;
            if ($created > 0 && ($now - $created) < (int) $ttl) {
                return false;
            }
            delete_option($option_name);
        } elseif ($existing !== null && $existing !== false) {
            delete_option($option_name);
        }

        return add_option($option_name, [
            'created'     => $now,
            'created_at'  => current_time('mysql'),
            'fingerprint' => $fingerprint,
        ], '', 'no');
    }

    private function release_manual_publish_attempt($fingerprint) {
        $fingerprint = (string) $fingerprint;
        if ($fingerprint === '') {
            return;
        }

        delete_option($this->get_publish_guard_option_name('manual_attempt', $fingerprint));
    }

    private function release_publish_execution($fingerprint) {
        $fingerprint = (string) $fingerprint;
        if ($fingerprint === '') {
            return;
        }

        delete_option($this->get_publish_guard_option_name('publish_execution', $fingerprint));
    }

    private function normalize_publish_fingerprint_payload(array $payload) {
        $normalized = [];
        foreach (['title', 'content', 'link', 'hashtags'] as $key) {
            $value = isset($payload[$key]) ? (string) $payload[$key] : '';
            $value = wp_strip_all_tags($value);
            $value = preg_replace('/\s+/u', ' ', $value);
            $value = trim((string) $value);

            if ($key === 'link') {
                $value = esc_url_raw($value);
                $value = untrailingslashit($value);
            }

            if ($key === 'hashtags' && function_exists('mb_strtolower')) {
                $value = mb_strtolower($value, 'UTF-8');
            } elseif ($key === 'hashtags') {
                $value = strtolower($value);
            }

            $normalized[$key] = $value;
        }

        return $normalized;
    }

    private function build_network_publish_fingerprint($network, array $payload) {
        $network = sanitize_key((string) $network);
        if ($network === '') {
            $network = 'unknown';
        }

        $compact_payload = $payload;
        unset($compact_payload['image_b64'], $compact_payload['image_format'], $compact_payload['alt_text']);

        return hash('sha256', wp_json_encode([
            'network' => $network,
            'payload' => $this->normalize_publish_fingerprint_payload($compact_payload),
        ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
    }

    private function get_network_publish_option_name($network, $fingerprint) {
        return $this->get_publish_guard_option_name('network_attempt_' . sanitize_key((string) $network), (string) $fingerprint);
    }

    private function reserve_network_publish_attempt($network, $fingerprint, $ttl = 21600) {
        $network = sanitize_key((string) $network);
        $fingerprint = (string) $fingerprint;
        if ($network === '' || $fingerprint === '') {
            return true;
        }

        $option_name = $this->get_network_publish_option_name($network, $fingerprint);
        $now = time();
        $existing = get_option($option_name, null);

        if (is_array($existing)) {
            $created = isset($existing['created']) ? (int) $existing['created'] : 0;
            if ($created > 0 && ($now - $created) < (int) $ttl) {
                return false;
            }
            delete_option($option_name);
        } elseif ($existing !== null && $existing !== false) {
            delete_option($option_name);
        }

        return add_option($option_name, [
            'status'      => 'running',
            'created'     => $now,
            'created_at'  => current_time('mysql'),
            'network'     => $network,
            'fingerprint' => $fingerprint,
        ], '', 'no');
    }

    private function mark_network_publish_success($network, $fingerprint, array $result = []) {
        $network = sanitize_key((string) $network);
        $fingerprint = (string) $fingerprint;
        if ($network === '' || $fingerprint === '') {
            return;
        }

        update_option($this->get_network_publish_option_name($network, $fingerprint), [
            'status'       => 'sent',
            'created'      => time(),
            'created_at'   => current_time('mysql'),
            'network'      => $network,
            'fingerprint'  => $fingerprint,
            'external_id'  => isset($result['id']) && is_scalar($result['id']) ? sanitize_text_field((string) $result['id']) : '',
            'result_code'  => isset($result['error_code']) && is_scalar($result['error_code']) ? sanitize_key((string) $result['error_code']) : '',
        ], 'no');
    }

    private function release_network_publish_attempt($network, $fingerprint) {
        $network = sanitize_key((string) $network);
        $fingerprint = (string) $fingerprint;
        if ($network === '' || $fingerprint === '') {
            return;
        }

        delete_option($this->get_network_publish_option_name($network, $fingerprint));
    }

    private function reserve_publish_job_execution($job_id, $ttl = 1800) {
        $job_id = sanitize_key((string) $job_id);
        if ($job_id === '') {
            return false;
        }

        $option_name = $this->get_publish_guard_option_name('job_execution', $job_id);
        $now = time();
        $existing = get_option($option_name, null);

        if (is_array($existing)) {
            $created = isset($existing['created']) ? (int) $existing['created'] : 0;
            if ($created > 0 && ($now - $created) < (int) $ttl) {
                return false;
            }
            delete_option($option_name);
        } elseif ($existing !== null && $existing !== false) {
            delete_option($option_name);
        }

        return add_option($option_name, [
            'created'    => $now,
            'created_at' => current_time('mysql'),
            'job_id'     => $job_id,
        ], '', 'no');
    }

    private function release_publish_job_execution($job_id) {
        $job_id = sanitize_key((string) $job_id);
        if ($job_id === '') {
            return;
        }

        delete_option($this->get_publish_guard_option_name('job_execution', $job_id));
    }

    private function get_publish_job_token($job_id, array $job) {
        $job_id = sanitize_key((string) $job_id);
        $fingerprint = isset($job['fingerprint']) ? (string) $job['fingerprint'] : '';
        $created = isset($job['created']) ? (string) $job['created'] : '';

        return hash_hmac('sha256', $job_id . '|' . $fingerprint . '|' . $created, wp_salt('auth'));
    }

    private function validate_publish_job_token($job_id, $token, array $job) {
        $token = is_string($token) ? trim($token) : '';
        if ($token === '') {
            return false;
        }

        return hash_equals($this->get_publish_job_token($job_id, $job), $token);
    }

    private function maybe_cancel_legacy_stale_publish_job($job_id, $option_name, array $job) {
        $version = isset($job['plugin_version']) ? (string) $job['plugin_version'] : '';
        $created = isset($job['created']) ? (int) $job['created'] : 0;
        $age = $created > 0 ? (time() - $created) : 0;

        $current_version = defined('CALLIOPE_SP_VERSION') ? (string) CALLIOPE_SP_VERSION : '1.4.12';
        if (($version === '' || version_compare($version, $current_version, '<')) && $age > 10) {
            if (!empty($job['fingerprint'])) {
                $this->release_manual_publish_attempt((string) $job['fingerprint']);
            }
            delete_option($option_name);
            Calliope_SP_Logger::log(
                'publisher',
                'warning',
                wp_json_encode([
                    'job_id'  => sanitize_key((string) $job_id),
                    'summary' => __('A stale queued publication from an older background worker was canceled to prevent delayed duplicate posts. Please publish it again.', 'calliope-social-publisher'),
                ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
            );

            return true;
        }

        return false;
    }

    private function dispatch_publish_job($job_id, array $job) {
        $job_id = sanitize_key((string) $job_id);
        if ($job_id === '') {
            return new WP_Error('invalid_job', __('Invalid publication job.', 'calliope-social-publisher'));
        }

        $option_name = $this->get_publish_job_option_name($job_id);
        $stored_job = get_option($option_name, []);
        if (is_array($stored_job) && !empty($stored_job)) {
            $job = $stored_job;
        }

        $job['last_dispatch'] = time();
        $job['last_dispatch_at'] = current_time('mysql');
        $job['dispatch_attempts'] = isset($job['dispatch_attempts']) ? ((int) $job['dispatch_attempts'] + 1) : 1;
        update_option($option_name, $job, 'no');

        $response = wp_remote_post(admin_url('admin-ajax.php'), [
            'timeout'     => 0.75,
            'blocking'    => false,
            'redirection' => 0,
            'sslverify'   => apply_filters('https_local_ssl_verify', false),
            'body'        => [
                'action' => 'calliope_sp_run_publish_job',
                'job_id' => $job_id,
                'token'  => $this->get_publish_job_token($job_id, $job),
            ],
        ]);

        if (is_wp_error($response)) {
            Calliope_SP_Logger::log(
                'publisher',
                'warning',
                wp_json_encode([
                    'job_id'  => $job_id,
                    'summary' => __('The immediate background worker could not be started. WordPress cron will retry the queued publication.', 'calliope-social-publisher'),
                    'error'   => $response->get_error_message(),
                ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
            );
        } else {
            Calliope_SP_Logger::log(
                'publisher',
                'warning',
                wp_json_encode([
                    'job_id'  => $job_id,
                    'summary' => __('Background publication worker dispatched.', 'calliope-social-publisher'),
                ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
            );
        }

        return $response;
    }

    public function handle_async_publish_job() {
        $job_id = isset($_REQUEST['job_id']) ? sanitize_key(wp_unslash((string) $_REQUEST['job_id'])) : '';
        $token = isset($_REQUEST['token']) ? sanitize_text_field(wp_unslash((string) $_REQUEST['token'])) : '';

        if ($job_id === '') {
            status_header(400);
            wp_die('missing job');
        }

        $option_name = $this->get_publish_job_option_name($job_id);
        $job = get_option($option_name, []);
        if (!is_array($job) || empty($job) || !$this->validate_publish_job_token($job_id, $token, $job)) {
            status_header(403);
            wp_die('forbidden');
        }

        if (function_exists('ignore_user_abort')) {
            @ignore_user_abort(true);
        }
        if (function_exists('set_time_limit')) {
            @set_time_limit(300);
        }

        $this->process_queued_publication($job_id);
        wp_die('ok');
    }

    private function get_pending_publish_jobs($limit = 5) {
        global $wpdb;

        $limit = max(1, min(20, (int) $limit));
        $like = $wpdb->esc_like('calliope_sp_publish_job_') . '%';
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s ORDER BY option_id ASC LIMIT %d",
                $like,
                $limit
            )
        );

        $jobs = [];
        foreach ((array) $rows as $row) {
            $job = maybe_unserialize($row->option_value);
            if (!is_array($job) || empty($job['id'])) {
                continue;
            }

            $jobs[] = [
                'option_name' => (string) $row->option_name,
                'job_id'      => sanitize_key((string) $job['id']),
                'job'         => $job,
            ];
        }

        return $jobs;
    }


    private function get_browser_publish_runner_jobs($limit = 3) {
        $jobs = [];

        foreach ($this->get_pending_publish_jobs($limit) as $entry) {
            $job = $entry['job'];
            $job_id = $entry['job_id'];
            $status = isset($job['status']) ? sanitize_key((string) $job['status']) : 'queued';

            if (in_array($status, ['completed', 'canceled'], true)) {
                continue;
            }

            if ($this->maybe_cancel_legacy_stale_publish_job($job_id, $entry['option_name'], $job)) {
                continue;
            }

            $selected = isset($job['selected']) ? $this->sanitize_gutenberg_destinations_meta((array) $job['selected']) : [];
            $results = (isset($job['results']) && is_array($job['results'])) ? $job['results'] : [];

            $jobs[] = [
                'job_id'       => $job_id,
                'token'        => $this->get_publish_job_token($job_id, $job),
                'status'       => $status,
                'destinations' => array_values($selected),
                'completed'    => array_values(array_intersect(array_keys($results), $selected)),
            ];
        }

        return $jobs;
    }

    public function handle_browser_publish_job_step() {
        if (!current_user_can('manage_options')) {
            wp_send_json_error([
                'message' => __('Not allowed.', 'calliope-social-publisher'),
            ], 403);
        }

        $nonce = isset($_POST['nonce']) ? sanitize_text_field(wp_unslash((string) $_POST['nonce'])) : '';
        if (!wp_verify_nonce($nonce, 'calliope_sp_job_runner')) {
            wp_send_json_error([
                'message' => __('Security check failed.', 'calliope-social-publisher'),
            ], 403);
        }

        $job_id = isset($_POST['job_id']) ? sanitize_key(wp_unslash((string) $_POST['job_id'])) : '';
        $token = isset($_POST['token']) ? sanitize_text_field(wp_unslash((string) $_POST['token'])) : '';

        if ($job_id === '') {
            wp_send_json_error([
                'message' => __('Missing publication job.', 'calliope-social-publisher'),
            ], 400);
        }

        $option_name = $this->get_publish_job_option_name($job_id);
        $job = get_option($option_name, []);
        if (!is_array($job) || empty($job) || !$this->validate_publish_job_token($job_id, $token, $job)) {
            wp_send_json_error([
                'message' => __('The queued publication is no longer available or the token is invalid.', 'calliope-social-publisher'),
            ], 404);
        }

        $result = $this->process_queued_publication_step($job_id);
        if (is_wp_error($result)) {
            wp_send_json_error([
                'message'    => $result->get_error_message(),
                'error_code' => $result->get_error_code(),
            ], 500);
        }

        wp_send_json_success($result);
    }

    private function process_queued_publication_step($job_id) {
        $job_id = sanitize_key((string) $job_id);
        if ($job_id === '') {
            return new WP_Error('invalid_job', __('Invalid publication job.', 'calliope-social-publisher'));
        }

        $option_name = $this->get_publish_job_option_name($job_id);
        $job = get_option($option_name, []);
        if (!is_array($job) || empty($job['data']) || empty($job['selected'])) {
            return [
                'done'    => true,
                'missing' => true,
                'message' => __('The queued publication is no longer available.', 'calliope-social-publisher'),
            ];
        }

        if ($this->maybe_cancel_legacy_stale_publish_job($job_id, $option_name, $job)) {
            return [
                'done'     => true,
                'canceled' => true,
                'message'  => __('A stale queued publication was canceled to prevent duplicates.', 'calliope-social-publisher'),
            ];
        }

        $status = isset($job['status']) ? sanitize_key((string) $job['status']) : 'queued';
        if ($status === 'completed') {
            return [
                'done'    => true,
                'message' => __('Publication already completed.', 'calliope-social-publisher'),
            ];
        }

        if (!$this->reserve_publish_job_execution($job_id, 5 * MINUTE_IN_SECONDS)) {
            return [
                'done'    => false,
                'running' => true,
                'message' => __('The queued publication is already running.', 'calliope-social-publisher'),
            ];
        }

        try {
            $fingerprint = isset($job['fingerprint']) ? (string) $job['fingerprint'] : '';
            if ($fingerprint !== '' && empty($job['publish_execution_reserved'])) {
                if (!$this->reserve_publish_execution($fingerprint)) {
                    Calliope_SP_Logger::log(
                        'publisher',
                        'warning',
                        wp_json_encode([
                            'job_id'      => $job_id,
                            'summary'     => __('Queued duplicate skipped before contacting social networks.', 'calliope-social-publisher'),
                            'fingerprint' => substr($fingerprint, 0, 12),
                        ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
                    );

                    delete_option($option_name);
                    $this->release_publish_job_execution($job_id);

                    return [
                        'done'      => true,
                        'duplicate' => true,
                        'message'   => __('Duplicate queued publication skipped.', 'calliope-social-publisher'),
                    ];
                }

                $job['publish_execution_reserved'] = 1;
            }

            $selected = $this->sanitize_gutenberg_destinations_meta((array) $job['selected']);
            $results = (isset($job['results']) && is_array($job['results'])) ? $job['results'] : [];
            $results = array_intersect_key($results, array_flip($selected));

            if ($status !== 'running') {
                $job['status'] = 'running';
                $job['started'] = time();
                $job['started_at'] = current_time('mysql');

                Calliope_SP_Logger::log(
                    'publisher',
                    'warning',
                    wp_json_encode([
                        'job_id'       => $job_id,
                        'summary'      => __('Browser publication worker started.', 'calliope-social-publisher'),
                        'destinations' => array_values($selected),
                    ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
                );
            }

            $next_network = '';
            foreach ($selected as $network) {
                if (!isset($results[$network])) {
                    $next_network = sanitize_key((string) $network);
                    break;
                }
            }

            if ($next_network === '') {
                return $this->complete_publish_job_from_step_results($job_id, $option_name, $job, $selected, $results);
            }

            Calliope_SP_Logger::log(
                'publisher',
                'info',
                wp_json_encode([
                    'job_id'  => $job_id,
                    'summary' => sprintf(
                        /* translators: %s: social network key. */
                        __('Publishing next network: %s', 'calliope-social-publisher'),
                        $next_network
                    ),
                ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
            );

            $network_result = $this->run_single_network_publish($next_network, (array) $job['data']);
            $results[$next_network] = $network_result;

            $single_report = class_exists('Calliope_SP_Publish_Helper')
                ? Calliope_SP_Publish_Helper::build_report([$next_network => $network_result])
                : $this->build_publish_report_from_results([$next_network => $network_result]);
            $single_result = $single_report['results'][$next_network] ?? $network_result;
            $single_status = !empty($single_result['ok']) ? 'success' : (((string) ($single_result['ui_status'] ?? '') === 'warning') ? 'warning' : 'error');

            Calliope_SP_Logger::log(
                $next_network,
                $single_status,
                wp_json_encode($single_result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
            );

            $job['results'] = $results;
            $job['last_network'] = $next_network;
            $job['last_step'] = time();
            $job['last_step_at'] = current_time('mysql');
            update_option($option_name, $job, 'no');

            $step_response = [
                'done'      => false,
                'processed' => $next_network,
                'ok'        => !empty($single_result['ok']),
                'status'    => $single_status,
                'message'   => (string) ($single_result['user_message'] ?? $single_result['short_message'] ?? ''),
                'remaining' => array_values(array_diff($selected, array_keys($results))),
            ];

            if (count($results) >= count($selected)) {
                $completed_response = $this->complete_publish_job_from_step_results($job_id, $option_name, $job, $selected, $results);
                if (is_array($completed_response)) {
                    return array_merge($step_response, $completed_response, [
                        'done'      => true,
                        'processed' => $next_network,
                        'ok'        => !empty($single_result['ok']),
                        'status'    => $single_status,
                        'message'   => (string) ($completed_response['message'] ?? $step_response['message']),
                    ]);
                }

                return $completed_response;
            }

            $this->release_publish_job_execution($job_id);

            return $step_response;
        } catch (Throwable $e) {
            $this->release_publish_job_execution($job_id);
            return new WP_Error('publish_step_failed', $e->getMessage());
        }
    }

    private function complete_publish_job_from_step_results($job_id, $option_name, array $job, array $selected, array $results) {
        $report = class_exists('Calliope_SP_Publish_Helper')
            ? Calliope_SP_Publish_Helper::build_report($results)
            : $this->build_publish_report_from_results($results);

        $this->log_publish_report('publisher', $selected, $report, [
            'job_id'      => $job_id,
            'queued'      => true,
            'browser_run' => true,
            'fingerprint' => substr((string) ($job['fingerprint'] ?? ''), 0, 12),
        ]);

        $job['status'] = 'completed';
        $job['completed'] = time();
        $job['completed_at'] = current_time('mysql');
        $job['report'] = $report;
        $job['results'] = $results;
        update_option($option_name, $job, 'no');

        delete_option($option_name);
        wp_clear_scheduled_hook('calliope_sp_process_publish_job', [$job_id]);
        $this->release_publish_job_execution($job_id);

        if (empty($report['success_count']) && !empty($job['fingerprint'])) {
            $this->release_manual_publish_attempt((string) $job['fingerprint']);
            $this->release_publish_execution((string) $job['fingerprint']);
        }

        Calliope_SP_Logger::log(
            'publisher',
            !empty($report['all_ok']) ? 'success' : (((string) ($report['notice_type'] ?? '') === 'warning') ? 'warning' : 'error'),
            wp_json_encode([
                'job_id'       => $job_id,
                'summary'      => (string) ($report['summary'] ?? __('Queued publication completed.', 'calliope-social-publisher')),
                'all_ok'       => !empty($report['all_ok']),
                'partial'      => !empty($report['partial']),
                'success_count'=> isset($report['success_count']) ? (int) $report['success_count'] : 0,
                'failed_count' => isset($report['failed_count']) ? (int) $report['failed_count'] : 0,
                'browser_run'  => true,
            ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
        );

        return [
            'done'    => true,
            'report'  => $report,
            'message' => (string) ($report['summary'] ?? __('Queued publication completed.', 'calliope-social-publisher')),
        ];
    }

    public function maybe_purge_legacy_publish_jobs_on_upgrade() {
        if (!is_admin() || !current_user_can('manage_options')) {
            return;
        }

        $current_version = defined('CALLIOPE_SP_VERSION') ? (string) CALLIOPE_SP_VERSION : '1.4.12';
        $cleanup_option = 'calliope_sp_publish_queue_cleanup_version';
        if ((string) get_option($cleanup_option, '') === $current_version) {
            return;
        }

        foreach ($this->get_pending_publish_jobs(20) as $entry) {
            $job = $entry['job'];
            $job_id = $entry['job_id'];
            $option_name = $entry['option_name'];
            $job_version = isset($job['plugin_version']) ? (string) $job['plugin_version'] : '';

            if ($job_version !== $current_version) {
                if (!empty($job['fingerprint'])) {
                    $this->release_manual_publish_attempt((string) $job['fingerprint']);
                    $this->release_publish_job_execution($job_id);
                }
                wp_clear_scheduled_hook('calliope_sp_process_publish_job', [$job_id]);
                delete_option($option_name);

                Calliope_SP_Logger::log(
                    'publisher',
                    'warning',
                    wp_json_encode([
                        'job_id'  => $job_id,
                        'summary' => __('A queued publication from an older plugin version was canceled during the safety upgrade to prevent delayed duplicate posts. Please publish it again if needed.', 'calliope-social-publisher'),
                    ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
                );
            }
        }

        update_option($cleanup_option, $current_version, false);
    }

    public function maybe_recover_pending_publish_jobs() {
        if (!is_admin() || !current_user_can('manage_options')) {
            return;
        }

        $page = isset($_GET['page']) ? sanitize_key(wp_unslash((string) $_GET['page'])) : '';
        if ($page !== 'calliope-social-publisher') {
            return;
        }

        foreach ($this->get_pending_publish_jobs(5) as $entry) {
            $job = $entry['job'];
            $job_id = $entry['job_id'];
            $option_name = $entry['option_name'];

            $this->maybe_cancel_legacy_stale_publish_job($job_id, $option_name, $job);
        }
    }

    private function queue_manual_publication(array $data, array $selected, $fingerprint) {
        $job_id = str_replace('-', '', wp_generate_uuid4());
        if ($job_id === '') {
            $job_id = hash('sha256', $fingerprint . '|' . microtime(true) . '|' . wp_rand());
        }

        $job = [
            'id'             => $job_id,
            'fingerprint'    => (string) $fingerprint,
            'data'           => $data,
            'selected'       => array_values($this->sanitize_gutenberg_destinations_meta($selected)),
            'status'         => 'queued',
            'created'        => time(),
            'created_at'     => current_time('mysql'),
            'created_by'     => get_current_user_id(),
            'plugin_version' => defined('CALLIOPE_SP_VERSION') ? CALLIOPE_SP_VERSION : '1.4.12',
        ];

        $option_name = $this->get_publish_job_option_name($job_id);
        if (!add_option($option_name, $job, '', 'no')) {
            return new WP_Error('queue_failed', __('The publication could not be queued. Please try again.', 'calliope-social-publisher'));
        }

        // Do not publish inside the admin request and do not rely on a hidden loopback
        // request. The redirected admin page starts a signed browser worker that runs
        // the job one network at a time. This avoids 504/QUIC errors and prevents
        // delayed hidden duplicates from stale WP-Cron/loopback jobs.
        return $job_id;
    }

    public function process_queued_publication($job_id) {
        $job_id = sanitize_key((string) $job_id);
        if ($job_id === '') {
            return;
        }

        $option_name = $this->get_publish_job_option_name($job_id);
        $job = get_option($option_name, []);
        if (!is_array($job) || empty($job['data']) || empty($job['selected'])) {
            return;
        }

        if ($this->maybe_cancel_legacy_stale_publish_job($job_id, $option_name, $job)) {
            return;
        }

        $status = isset($job['status']) ? sanitize_key((string) $job['status']) : 'queued';
        if ($status === 'completed') {
            return;
        }

        if ($status === 'running') {
            $started = isset($job['started']) ? (int) $job['started'] : 0;
            if ($started > 0 && (time() - $started) < 20 * MINUTE_IN_SECONDS) {
                return;
            }
        }

        if (!$this->reserve_publish_job_execution($job_id)) {
            Calliope_SP_Logger::log(
                'publisher',
                'warning',
                wp_json_encode([
                    'job_id'  => $job_id,
                    'summary' => __('Queued publication skipped because the same job is already running.', 'calliope-social-publisher'),
                ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
            );
            return;
        }

        $fingerprint = isset($job['fingerprint']) ? (string) $job['fingerprint'] : '';
        if ($fingerprint !== '' && !$this->reserve_publish_execution($fingerprint)) {
            Calliope_SP_Logger::log(
                'publisher',
                'warning',
                wp_json_encode([
                    'job_id'      => $job_id,
                    'summary'     => __('Queued duplicate skipped before contacting social networks.', 'calliope-social-publisher'),
                    'fingerprint' => substr($fingerprint, 0, 12),
                ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
            );
            delete_option($option_name);
            $this->release_publish_job_execution($job_id);
            return;
        }

        $job['status'] = 'running';
        $job['started'] = time();
        $job['started_at'] = current_time('mysql');
        update_option($option_name, $job, 'no');

        Calliope_SP_Logger::log(
            'publisher',
            'warning',
            wp_json_encode([
                'job_id'       => $job_id,
                'summary'      => __('Queued publication started.', 'calliope-social-publisher'),
                'destinations' => array_values((array) $job['selected']),
            ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
        );

        $report = $this->run_publish_pipeline((array) $job['data'], (array) $job['selected'], 'publisher', [
            'job_id'      => $job_id,
            'queued'      => true,
            'fingerprint' => substr((string) ($job['fingerprint'] ?? ''), 0, 12),
        ]);

        if (is_wp_error($report)) {
            $report = $this->build_publish_error_report($report->get_error_message(), (string) $report->get_error_code());
            $this->log_publish_report('publisher', (array) $job['selected'], $report, [
                'job_id' => $job_id,
                'queued' => true,
            ]);
        }

        $job['status'] = 'completed';
        $job['completed'] = time();
        $job['completed_at'] = current_time('mysql');
        $job['report'] = $report;
        update_option($option_name, $job, 'no');

        // The log stores the final report. Remove the queue payload afterwards so large
        // base64 image data does not remain in wp_options longer than needed.
        delete_option($option_name);
        wp_clear_scheduled_hook('calliope_sp_process_publish_job', [$job_id]);
        $this->release_publish_job_execution($job_id);

        Calliope_SP_Logger::log(
            'publisher',
            !empty($report['all_ok']) ? 'success' : (((string) ($report['notice_type'] ?? '') === 'warning') ? 'warning' : 'error'),
            wp_json_encode([
                'job_id'       => $job_id,
                'summary'      => (string) ($report['summary'] ?? __('Queued publication completed.', 'calliope-social-publisher')),
                'all_ok'       => !empty($report['all_ok']),
                'partial'      => !empty($report['partial']),
                'success_count'=> isset($report['success_count']) ? (int) $report['success_count'] : 0,
                'failed_count' => isset($report['failed_count']) ? (int) $report['failed_count'] : 0,
            ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
        );
    }

    public function process_publication() {
        if (!isset($_POST['calliope_sp_publish']) || !isset($_POST['calliope_sp_nonce_publish'])) {
            return;
        }
        if (!current_user_can('manage_options')) {
            wp_die(esc_html__('Not allowed.', 'calliope-social-publisher'));
        }
        if (!wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['calliope_sp_nonce_publish'])), 'calliope_sp_action_publish')) {
            wp_die(esc_html__('Security check failed.', 'calliope-social-publisher'));
        }

        $cropped_image_data = isset($_POST['calliope_sp_cropped_image_data']) ? sanitize_textarea_field(wp_unslash($_POST['calliope_sp_cropped_image_data'])) : '';
        $sanitized_image_data = $this->sanitize_image_data($cropped_image_data);
        if (trim((string) $cropped_image_data) !== '' && $sanitized_image_data === '') {
            $this->redirect_msg('error', __('The selected image is too large or could not be prepared safely. Please choose a smaller image and try again.', 'calliope-social-publisher'), 'dashboard');
        }

        $data = [
            'title'        => sanitize_text_field(wp_unslash($_POST['calliope_sp_title'] ?? '')),
            'content'      => sanitize_textarea_field(wp_unslash($_POST['calliope_sp_content'] ?? '')),
            'link'         => esc_url_raw(wp_unslash($_POST['calliope_sp_link'] ?? '')),
            'hashtags'     => sanitize_text_field(wp_unslash($_POST['calliope_sp_hashtags'] ?? '')),
            'alt_text'     => sanitize_text_field(wp_unslash($_POST['calliope_sp_alt_text'] ?? '')),
            'image_b64'    => $sanitized_image_data,
            'image_format' => sanitize_key(wp_unslash($_POST['calliope_sp_image_format'] ?? 'original')),
        ];

        $selected = [];
        $checkbox_map = $this->get_publish_checkbox_map();

        foreach ($checkbox_map as $field => $network) {
            $is_selected = isset($_POST[$field]) ? sanitize_text_field(wp_unslash((string) $_POST[$field])) : '';
            if ($is_selected !== '') {
                $selected[] = $network;
            }
        }

        $selected = $this->sanitize_gutenberg_destinations_meta($selected);
        if (empty($selected)) {
            $this->redirect_msg('error', __('You did not select any destination.', 'calliope-social-publisher'), 'dashboard');
        }

        $fingerprint = $this->build_manual_publish_fingerprint($data, $selected);
        if (!$this->reserve_manual_publish_attempt($fingerprint)) {
            Calliope_SP_Logger::log(
                'publisher',
                'warning',
                wp_json_encode([
                    'summary'      => __('Duplicate publication blocked.', 'calliope-social-publisher'),
                    'destinations' => $selected,
                    'fingerprint'  => substr($fingerprint, 0, 12),
                ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
            );

            $this->redirect_msg(
                'warning',
                __('This same publication was already sent or queued recently, so it was blocked to avoid duplicate posts. Change the content/image or wait a while before trying again.', 'calliope-social-publisher'),
                'dashboard'
            );
        }

        $queued = $this->queue_manual_publication($data, $selected, $fingerprint);
        if (is_wp_error($queued)) {
            $this->release_manual_publish_attempt($fingerprint);
            $this->redirect_msg('error', $queued->get_error_message(), 'dashboard');
        }

        Calliope_SP_Logger::log(
            'publisher',
            'success',
            wp_json_encode([
                'job_id'       => (string) $queued,
                'summary'      => __('Publication queued. The browser worker will publish it in safe one-network steps to avoid timeouts.', 'calliope-social-publisher'),
                'destinations' => $selected,
            ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
        );

        $this->redirect_msg(
            'success',
            __('Publication queued. Keep this admin page open for a moment while the browser worker publishes it in safe steps. Duplicate protection is active.', 'calliope-social-publisher'),
            'dashboard',
            '',
            ['calliope_sp_clear_image' => '1']
        );
    }

    public function render_page() {
        if (!current_user_can('manage_options')) {
            wp_die(esc_html__('Not allowed.', 'calliope-social-publisher'));
        }
        include CALLIOPE_SP_PATH . 'views/admin-main.php';
    }

    // =============================
    // Connection handlers
    // =============================

    public function handle_telegram_test_connection() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Unauthorized.', 'calliope-social-publisher'));
        check_admin_referer('calliope_sp_test_telegram_action', 'calliope_sp_test_telegram_nonce');

        $chat_id = sanitize_text_field(wp_unslash($_POST['calliope_sp_telegram_chat_id'] ?? ''));
        $token = sanitize_textarea_field(wp_unslash($_POST['calliope_sp_telegram_bot_token'] ?? ''));
        $thread_id = absint(wp_unslash($_POST['calliope_sp_telegram_message_thread_id'] ?? 0));

        if (class_exists('Calliope_SP_Telegram')) {
            $telegram_for_normalization = new Calliope_SP_Telegram();
            $chat_id = $telegram_for_normalization->normalize_chat_id($chat_id);
        }

        update_option('calliope_sp_telegram_chat_id', $chat_id);
        update_option('calliope_sp_telegram_message_thread_id', $thread_id);
        if ($token !== '') {
            update_option('calliope_sp_telegram_bot_token', trim($token));
        }

        $saved_token = (string) get_option('calliope_sp_telegram_bot_token', '');
        if ($saved_token === '' || $chat_id === '') {
            update_option('calliope_sp_telegram_connection_status', $saved_token !== '' ? 'credentials_saved' : 'not_connected');
            $this->redirect_msg('error', __('Telegram: add the Bot Token and Group/Channel ID.', 'calliope-social-publisher'), 'connections', 'telegram');
        }

        $telegram = new Calliope_SP_Telegram();
        $test = $telegram->test_connection();
        update_option('calliope_sp_telegram_connection_status', is_wp_error($test) ? 'error' : 'connected');

        $this->redirect_msg(
            is_wp_error($test) ? 'error' : 'success',
            is_wp_error($test) ? ('Telegram: ' . $test->get_error_message()) : __('Telegram connected.', 'calliope-social-publisher'),
            'connections',
            'telegram'
        );
    }

    public function handle_bluesky_test_connection() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Unauthorized.', 'calliope-social-publisher'));
        check_admin_referer('calliope_sp_test_bluesky_action', 'calliope_sp_test_bluesky_nonce');

        $handle = sanitize_text_field(wp_unslash($_POST['calliope_sp_bsky_handle'] ?? ''));
        $password = sanitize_text_field(wp_unslash($_POST['calliope_sp_bsky_password'] ?? ''));

        update_option('calliope_sp_bsky_handle', $handle);
        if ($password !== '') {
            update_option('calliope_sp_bsky_password', sanitize_text_field($password));
        }

        $bsky = new Calliope_SP_Bluesky();
        $test = $bsky->test_connection();
        update_option('calliope_sp_bsky_connection_status', is_wp_error($test) ? 'error' : 'connected');

        $this->redirect_msg(
            is_wp_error($test) ? 'error' : 'success',
            is_wp_error($test) ? ('Bluesky: ' . $test->get_error_message()) : __('Bluesky connected.', 'calliope-social-publisher'),
            'connections'
        );
    }

    public function handle_mastodon_test_connection() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Unauthorized.', 'calliope-social-publisher'));
        check_admin_referer('calliope_sp_test_mastodon_action', 'calliope_sp_test_mastodon_nonce');

        $instance = $this->normalize_instance(sanitize_text_field(wp_unslash($_POST['calliope_sp_mstdn_instance'] ?? '')));
        $token = sanitize_text_field(wp_unslash($_POST['calliope_sp_mstdn_token'] ?? ''));

        update_option('calliope_sp_mstdn_instance', $this->normalize_instance($instance));
        if ($token !== '') {
            update_option('calliope_sp_mstdn_token', sanitize_text_field($token));
        }

        $mstdn = new Calliope_SP_Mastodon();
        $test = $mstdn->test_connection();
        update_option('calliope_sp_mstdn_connection_status', is_wp_error($test) ? 'error' : 'connected');

        $this->redirect_msg(
            is_wp_error($test) ? 'error' : 'success',
            is_wp_error($test) ? ('Mastodon: ' . $test->get_error_message()) : __('Mastodon connected.', 'calliope-social-publisher'),
            'connections'
        );
    }

    public function handle_mastodon_save_settings() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Unauthorized.', 'calliope-social-publisher'));
        check_admin_referer('calliope_sp_save_mastodon_settings_action', 'calliope_sp_save_mastodon_settings_nonce');

        $instance = $this->normalize_instance(sanitize_text_field(wp_unslash($_POST['calliope_sp_mstdn_instance'] ?? '')));
        $token = sanitize_text_field(wp_unslash($_POST['calliope_sp_mstdn_token'] ?? ''));

        update_option('calliope_sp_mstdn_instance', $instance);
        if ($token !== '') {
            update_option('calliope_sp_mstdn_token', $token);
        }

        $saved_token = (string) get_option('calliope_sp_mstdn_token', '');
        if ($instance !== '' && $saved_token !== '') {
            $mstdn = new Calliope_SP_Mastodon();
            $test = $mstdn->test_connection();
            update_option('calliope_sp_mstdn_connection_status', is_wp_error($test) ? 'error' : 'connected');

            $this->redirect_msg(
                is_wp_error($test) ? 'error' : 'success',
                is_wp_error($test) ? ('Mastodon: ' . $test->get_error_message()) : __('Settings saved and Mastodon is connected.', 'calliope-social-publisher'),
                'connections'
            );
        }

        update_option('calliope_sp_mstdn_connection_status', $instance !== '' ? 'credentials_saved' : 'not_connected');

        $this->redirect_msg(
            'success',
            __('Credentials saved. Now click Connect with Mastodon.', 'calliope-social-publisher'),
            'connections'
        );
    }

    public function handle_tumblr_test_connection() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Unauthorized.', 'calliope-social-publisher'));
        check_admin_referer('calliope_sp_test_tumblr_action', 'calliope_sp_test_tumblr_nonce');

        $ck = sanitize_text_field(wp_unslash($_POST['calliope_sp_tumblr_consumer_key'] ?? ''));
        $cs = sanitize_text_field(wp_unslash($_POST['calliope_sp_tumblr_consumer_secret'] ?? ''));
        $tk = sanitize_text_field(wp_unslash($_POST['calliope_sp_tumblr_token'] ?? ''));
        $ts = sanitize_text_field(wp_unslash($_POST['calliope_sp_tumblr_token_secret'] ?? ''));
        $blog = sanitize_text_field(wp_unslash($_POST['calliope_sp_tumblr_blog_identifier'] ?? ''));

        update_option('calliope_sp_tumblr_consumer_key', $ck);
        if ($cs !== '') update_option('calliope_sp_tumblr_consumer_secret', $cs);
        if ($tk !== '') update_option('calliope_sp_tumblr_token', $tk);
        if ($ts !== '') update_option('calliope_sp_tumblr_token_secret', $ts);
        update_option('calliope_sp_tumblr_blog_identifier', $blog);

        $tumblr = new Calliope_SP_Tumblr();
        $test = $tumblr->test_connection();
        update_option('calliope_sp_tumblr_connection_status', is_wp_error($test) ? 'error' : 'connected');

        $this->redirect_msg(
            is_wp_error($test) ? 'error' : 'success',
            is_wp_error($test) ? ('Tumblr: ' . $test->get_error_message()) : __('Tumblr connected.', 'calliope-social-publisher'),
            'connections'
        );
    }

    public function handle_tumblr_save_settings() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Unauthorized.', 'calliope-social-publisher'));
        check_admin_referer('calliope_sp_save_tumblr_settings_action', 'calliope_sp_save_tumblr_settings_nonce');

        $ck = sanitize_text_field(wp_unslash($_POST['calliope_sp_tumblr_consumer_key'] ?? ''));
        $cs = sanitize_text_field(wp_unslash($_POST['calliope_sp_tumblr_consumer_secret'] ?? ''));
        $blog = sanitize_text_field(wp_unslash($_POST['calliope_sp_tumblr_blog_identifier'] ?? ''));

        update_option('calliope_sp_tumblr_consumer_key', $ck);
        if ($cs !== '') {
            update_option('calliope_sp_tumblr_consumer_secret', $cs);
        }
        update_option('calliope_sp_tumblr_blog_identifier', $blog);

        $saved_token = (string) get_option('calliope_sp_tumblr_token', '');
        $saved_token_secret = (string) get_option('calliope_sp_tumblr_token_secret', '');
        $saved_consumer_secret = (string) get_option('calliope_sp_tumblr_consumer_secret', '');

        if ($ck !== '' && $saved_consumer_secret !== '' && $saved_token !== '' && $saved_token_secret !== '') {
            $tumblr = new Calliope_SP_Tumblr();
            $test = $tumblr->test_connection();
            update_option('calliope_sp_tumblr_connection_status', is_wp_error($test) ? 'error' : 'connected');

            $this->redirect_msg(
                is_wp_error($test) ? 'error' : 'success',
                is_wp_error($test) ? ('Tumblr: ' . $test->get_error_message()) : __('Settings saved and Tumblr is connected.', 'calliope-social-publisher'),
                'connections'
            );
        }

        update_option('calliope_sp_tumblr_connection_status', ($ck !== '' && $saved_consumer_secret !== '') ? 'credentials_saved' : 'not_connected');

        $this->redirect_msg(
            'success',
            __('Credentials saved. Now click Connect with Tumblr.', 'calliope-social-publisher'),
            'connections'
        );
    }

    public function handle_pinterest_test_connection() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Unauthorized.', 'calliope-social-publisher'));
        check_admin_referer('calliope_sp_test_pinterest_action', 'calliope_sp_test_pinterest_nonce');

        $app_id = sanitize_text_field(wp_unslash($_POST['calliope_sp_pinterest_app_id'] ?? ''));
        $app_secret = sanitize_text_field(wp_unslash($_POST['calliope_sp_pinterest_app_secret'] ?? ''));

        $token = sanitize_text_field(wp_unslash($_POST['calliope_sp_pinterest_token'] ?? ''));
        $env = sanitize_key(wp_unslash($_POST['calliope_sp_pinterest_env'] ?? 'production'));
        $env = in_array($env, ['production', 'sandbox'], true) ? $env : 'production';
        $board = sanitize_text_field(wp_unslash($_POST['calliope_sp_pinterest_board_id'] ?? ''));

        if ($app_id !== '') update_option('calliope_sp_pinterest_app_id', $app_id);
        if ($app_secret !== '') update_option('calliope_sp_pinterest_app_secret', $app_secret);

        if ($token !== '') update_option('calliope_sp_pinterest_token', sanitize_text_field($token));
        update_option('calliope_sp_pinterest_env', $env);
        update_option('calliope_sp_pinterest_board_id', $board);

        $saved_secret = (string) get_option('calliope_sp_pinterest_app_secret', '');
        $saved_token = (string) get_option('calliope_sp_pinterest_token', '');

        if ($saved_token !== '') {
            $pin = new Calliope_SP_Pinterest();
            $test = $pin->test_connection();
            update_option('calliope_sp_pinterest_connection_status', is_wp_error($test) ? 'error' : 'connected');

            $this->redirect_msg(
                is_wp_error($test) ? 'error' : 'success',
                is_wp_error($test) ? ('Pinterest: ' . $test->get_error_message()) : __('Pinterest connected.', 'calliope-social-publisher'),
                'connections'
            );
        }

        update_option('calliope_sp_pinterest_connection_status', ($app_id !== '' && $saved_secret !== '') ? 'credentials_saved' : 'not_connected');

        $this->redirect_msg(
            'success',
            __('Credentials saved. Now click Connect with Pinterest.', 'calliope-social-publisher'),
            'connections'
        );
    }

    // =============================
    // Tumblr OAuth (auto token)
    // =============================

    public function handle_tumblr_clear_tokens() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Unauthorized.', 'calliope-social-publisher'));
        check_admin_referer('calliope_sp_tumblr_clear_tokens_action');

        $cleared_options = [
            'calliope_sp_tumblr_token',
            'calliope_sp_tumblr_token_secret',
        ];

        foreach ($cleared_options as $option_name) {
            delete_option($option_name);
        }

        $this->remove_options_from_connection_backup($cleared_options);
        update_option('calliope_sp_tumblr_connection_status', $this->all_options_have_saved_values(['calliope_sp_tumblr_consumer_key', 'calliope_sp_tumblr_consumer_secret']) ? 'credentials_saved' : 'not_connected');

        $this->redirect_msg('success', __('Tumblr tokens cleared.', 'calliope-social-publisher'), 'connections');
    }

    public function handle_tumblr_oauth_start() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Unauthorized.', 'calliope-social-publisher'));
        check_admin_referer('calliope_sp_tumblr_oauth_start_action');

        $ck = get_option('calliope_sp_tumblr_consumer_key');
        $cs = get_option('calliope_sp_tumblr_consumer_secret');

        if (!$ck || !$cs) {
            $this->redirect_msg('error', __('Tumblr: missing Consumer Key/Secret.', 'calliope-social-publisher'), 'connections');
        }

        // Nonce used to reduce forged callback attempts.
        $state_nonce = wp_create_nonce('calliope_sp_tumblr_oauth_state');
        $callback = add_query_arg([
            'action' => 'calliope_sp_tumblr_oauth_callback',
            'calliope_sp_state' => $state_nonce,
        ], admin_url('admin-post.php'));

        $tumblr = new Calliope_SP_Tumblr();
        $resp = $tumblr->oauth_request_token($ck, $cs, $callback);

        if (is_wp_error($resp)) {
            $this->redirect_msg('error', 'Tumblr OAuth: ' . $resp->get_error_message(), 'connections');
        }

        $oauth_token = $resp['oauth_token'] ?? '';
        $oauth_secret = $resp['oauth_token_secret'] ?? '';

        if (!$oauth_token || !$oauth_secret) {
            $this->redirect_msg('error', __('Tumblr OAuth: invalid response (request token).', 'calliope-social-publisher'), 'connections');
        }

        set_transient('calliope_sp_tumblr_req_secret_' . $oauth_token, $oauth_secret, 15 * MINUTE_IN_SECONDS);

        $this->safe_redirect('https://www.tumblr.com/oauth/authorize?oauth_token=' . rawurlencode($oauth_token));
    }

    public function handle_tumblr_oauth_callback() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Unauthorized.', 'calliope-social-publisher'));

        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Tumblr OAuth callback state is verified against a stored nonce.
        $state = sanitize_text_field(wp_unslash($_GET['calliope_sp_state'] ?? ''));
        if (!$state || !wp_verify_nonce($state, 'calliope_sp_tumblr_oauth_state')) {
            $this->redirect_msg('error', __('Tumblr OAuth: invalid state. Please connect again.', 'calliope-social-publisher'), 'connections');
        }

        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Tumblr OAuth callback parameters come from the provider.
        $oauth_token = sanitize_text_field(wp_unslash($_GET['oauth_token'] ?? ''));
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Tumblr OAuth callback parameters come from the provider.
        $oauth_verifier = sanitize_text_field(wp_unslash($_GET['oauth_verifier'] ?? ''));

        if (!$oauth_token || !$oauth_verifier) {
            $this->redirect_msg('error', __('Tumblr OAuth: missing callback parameters.', 'calliope-social-publisher'), 'connections');
        }

        $ck = get_option('calliope_sp_tumblr_consumer_key');
        $cs = get_option('calliope_sp_tumblr_consumer_secret');
        if (!$ck || !$cs) {
            $this->redirect_msg('error', __('Tumblr: missing Consumer Key/Secret.', 'calliope-social-publisher'), 'connections');
        }

        $req_secret = get_transient('calliope_sp_tumblr_req_secret_' . $oauth_token);
        if (!$req_secret) {
            $this->redirect_msg('error', __('Tumblr OAuth: session expired. Please connect again.', 'calliope-social-publisher'), 'connections');
        }

        $tumblr = new Calliope_SP_Tumblr();
        $resp = $tumblr->oauth_access_token($ck, $cs, $oauth_token, $oauth_verifier, $req_secret);

        if (is_wp_error($resp)) {
            $this->redirect_msg('error', 'Tumblr OAuth: ' . $resp->get_error_message(), 'connections');
        }

        $access_token = $resp['oauth_token'] ?? '';
        $access_secret = $resp['oauth_token_secret'] ?? '';

        if (!$access_token || !$access_secret) {
            $this->redirect_msg('error', __('Tumblr OAuth: invalid response (access token).', 'calliope-social-publisher'), 'connections');
        }

        update_option('calliope_sp_tumblr_token', sanitize_text_field($access_token));
        update_option('calliope_sp_tumblr_token_secret', sanitize_text_field($access_secret));
        delete_transient('calliope_sp_tumblr_req_secret_' . $oauth_token);

        $test = $tumblr->test_connection();
        update_option('calliope_sp_tumblr_connection_status', is_wp_error($test) ? 'error' : 'connected');

        $this->redirect_msg(
            is_wp_error($test) ? 'error' : 'success',
            is_wp_error($test) ? ('Tumblr: ' . $test->get_error_message()) : __('Tumblr connected.', 'calliope-social-publisher'),
            'connections'
        );
    }

    // =============================
    // Mastodon OAuth (auto token)
    // =============================

    public function handle_mastodon_clear_tokens() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Unauthorized.', 'calliope-social-publisher'));
        check_admin_referer('calliope_sp_mastodon_clear_tokens_action');

        $cleared_options = [
            'calliope_sp_mstdn_token',
            'calliope_sp_mstdn_client_id',
            'calliope_sp_mstdn_client_secret',
            'calliope_sp_mstdn_client_instance',
        ];

        foreach ($cleared_options as $option_name) {
            delete_option($option_name);
        }

        $this->remove_options_from_connection_backup($cleared_options);
        update_option('calliope_sp_mstdn_connection_status', $this->option_has_saved_value('calliope_sp_mstdn_instance') ? 'credentials_saved' : 'not_connected');

        $this->redirect_msg('success', __('Mastodon credentials cleared.', 'calliope-social-publisher'), 'connections');
    }

    public function handle_mastodon_oauth_start() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Unauthorized.', 'calliope-social-publisher'));
        check_admin_referer('calliope_sp_mastodon_oauth_start_action');

        $instance = $this->normalize_instance(get_option('calliope_sp_mstdn_instance'));
        if (!$instance) {
            $this->redirect_msg('error', __('Mastodon: enter your server first (for example, mastodon.social) and try again.', 'calliope-social-publisher'), 'connections');
        }

        $callback = admin_url('admin-post.php?action=calliope_sp_mastodon_oauth_callback');

        // Store state -> instance for the callback.
        $state = wp_generate_password(24, false, false);
        set_transient('calliope_sp_mastodon_oauth_' . $state, [
            'instance' => $instance,
            'callback' => $callback,
            'created'  => time(),
        ], 15 * MINUTE_IN_SECONDS);

        // Register the app if needed.
        $client_id = get_option('calliope_sp_mstdn_client_id');
        $client_secret = get_option('calliope_sp_mstdn_client_secret');
        $client_instance = $this->normalize_instance(get_option('calliope_sp_mstdn_client_instance'));

        if ($client_instance !== $instance) {
            $client_id = '';
            $client_secret = '';
            delete_option('calliope_sp_mstdn_client_id');
            delete_option('calliope_sp_mstdn_client_secret');
        }

        if (!$client_id || !$client_secret) {
            $body = [
                'client_name'   => 'Calliope Social Publisher (WordPress)',
                'redirect_uris' => $callback,
                'scopes'        => 'read:accounts write:statuses write:media',
                'website'       => get_site_url(),
            ];

            $res = wp_remote_post('https://' . $instance . '/api/v1/apps', [
                'headers' => [
                    'Content-Type' => 'application/json',
                    'Accept'       => 'application/json',
                ],
                'body' => wp_json_encode($body),
                'timeout' => 30,
            ]);

            if (is_wp_error($res)) {
                $this->redirect_msg('error', 'Mastodon OAuth: ' . $res->get_error_message(), 'connections');
            }

            $code = (int) wp_remote_retrieve_response_code($res);
            $raw = (string) wp_remote_retrieve_body($res);
            $json = json_decode($raw, true);

            if ($code < 200 || $code >= 300 || !is_array($json) || empty($json['client_id']) || empty($json['client_secret'])) {
                $this->redirect_msg('error', 'Mastodon OAuth: the app could not be registered on the instance (HTTP ' . $code . ').', 'connections');
            }

            $client_id = sanitize_text_field($json['client_id']);
            $client_secret = sanitize_text_field($json['client_secret']);

            update_option('calliope_sp_mstdn_client_id', $client_id);
            update_option('calliope_sp_mstdn_client_secret', $client_secret);
            update_option('calliope_sp_mstdn_client_instance', $instance);
        }

        $auth_url = add_query_arg([
            'response_type' => 'code',
            'client_id'     => $client_id,
            'redirect_uri'  => $callback,
            'scope'         => 'read:accounts write:statuses write:media',
            'state'         => $state,
        ], 'https://' . $instance . '/oauth/authorize');

        $this->safe_redirect($auth_url);
    }

    public function handle_mastodon_oauth_callback() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Unauthorized.', 'calliope-social-publisher'));

        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- OAuth callback parameters are validated with the provider state value.
        $code = sanitize_text_field(wp_unslash($_GET['code'] ?? ''));
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- OAuth callback parameters are validated with the provider state value.
        $state = sanitize_text_field(wp_unslash($_GET['state'] ?? ''));

        if (!$code || !$state) {
            $this->redirect_msg('error', 'Mastodon OAuth: missing callback parameters.', 'connections');
        }

        $state_data = get_transient('calliope_sp_mastodon_oauth_' . $state);
        if (!is_array($state_data) || empty($state_data['instance']) || empty($state_data['callback'])) {
            $this->redirect_msg('error', 'Mastodon OAuth: session expired. Please connect again.', 'connections');
        }

        $instance = $this->normalize_instance($state_data['instance']);
        $callback = (string) $state_data['callback'];

        $client_id = get_option('calliope_sp_mstdn_client_id');
        $client_secret = get_option('calliope_sp_mstdn_client_secret');

        if (!$client_id || !$client_secret) {
            $this->redirect_msg('error', 'Mastodon OAuth: missing app credentials (client_id/secret).', 'connections');
        }

        $res = wp_remote_post('https://' . $instance . '/oauth/token', [
            'headers' => [
                'Accept' => 'application/json',
            ],
            'body' => [
                'grant_type'    => 'authorization_code',
                'code'          => $code,
                'client_id'     => $client_id,
                'client_secret' => $client_secret,
                'redirect_uri'  => $callback,
                'scope'         => 'read:accounts write:statuses write:media',
            ],
            'timeout' => 30,
        ]);

        if (is_wp_error($res)) {
            $this->redirect_msg('error', 'Mastodon OAuth: ' . $res->get_error_message(), 'connections');
        }

        $http = (int) wp_remote_retrieve_response_code($res);
        $raw = (string) wp_remote_retrieve_body($res);
        $json = json_decode($raw, true);

        if ($http < 200 || $http >= 300 || !is_array($json) || empty($json['access_token'])) {
            $this->redirect_msg('error', 'Mastodon OAuth: could not obtain access_token (HTTP ' . $http . ').', 'connections');
        }

        $access_token = sanitize_text_field($json['access_token']);
        update_option('calliope_sp_mstdn_instance', $this->normalize_instance($instance));
        update_option('calliope_sp_mstdn_token', $access_token);
        delete_transient('calliope_sp_mastodon_oauth_' . $state);

        $mstdn = new Calliope_SP_Mastodon();
        $test = $mstdn->test_connection();
        update_option('calliope_sp_mstdn_connection_status', is_wp_error($test) ? 'error' : 'connected');

        $this->redirect_msg(
            is_wp_error($test) ? 'error' : 'success',
            is_wp_error($test) ? ('Mastodon: ' . $test->get_error_message()) : __('Mastodon connected.', 'calliope-social-publisher'),
            'connections'
        );
    }

    // =============================
    // Pinterest OAuth (auto token)
    // =============================

    public function handle_pinterest_clear_tokens() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Unauthorized.', 'calliope-social-publisher'));
        check_admin_referer('calliope_sp_pinterest_clear_tokens_action');

        $cleared_options = [
            'calliope_sp_pinterest_token',
            'calliope_sp_pinterest_refresh_token',
            'calliope_sp_pinterest_boards_last',
        ];

        foreach ($cleared_options as $option_name) {
            delete_option($option_name);
        }

        $this->remove_options_from_connection_backup($cleared_options);
        update_option('calliope_sp_pinterest_connection_status', $this->all_options_have_saved_values(['calliope_sp_pinterest_app_id', 'calliope_sp_pinterest_app_secret']) ? 'credentials_saved' : 'not_connected');

        $this->redirect_msg('success', __('Pinterest tokens cleared.', 'calliope-social-publisher'), 'connections');
    }

    public function handle_pinterest_oauth_start() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Unauthorized.', 'calliope-social-publisher'));
        check_admin_referer('calliope_sp_pinterest_oauth_start_action');

        $app_id = get_option('calliope_sp_pinterest_app_id');
        $app_secret = get_option('calliope_sp_pinterest_app_secret');

        if (!$app_id || !$app_secret) {
            $this->redirect_msg('error', __('Pinterest: missing App ID / App Secret.', 'calliope-social-publisher'), 'connections');
        }

        $callback = admin_url('admin-post.php');

        $state = wp_generate_password(24, false, false);
        set_transient('calliope_sp_pinterest_oauth_' . $state, [
            'callback' => $callback,
            'created'  => time(),
        ], 15 * MINUTE_IN_SECONDS);

        // Important: scopes must be a subset of what is configured in Pinterest
        $scope = 'pins:read boards:read user_accounts:read pins:write boards:write';

        $auth_url = add_query_arg([
            'response_type' => 'code',
            'client_id'     => $app_id,
            'redirect_uri'  => $callback,
            'scope'         => $scope,
            'state'         => $state,
        ], 'https://www.pinterest.com/oauth/');

        $this->safe_redirect($auth_url);
    }

    public function handle_pinterest_oauth_callback() {
        // Note: this callback can be reached without an active WP session (browser-dependent).
        // The real validation is the transient-stored state value.
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- OAuth callback parameters are validated with the provider state value.
        $code = sanitize_text_field(wp_unslash($_GET['code'] ?? ''));
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- OAuth callback parameters are validated with the provider state value.
        $state = sanitize_text_field(wp_unslash($_GET['state'] ?? ''));

        if (!$code || !$state) {
            $this->redirect_msg('error', __('Pinterest OAuth: missing callback parameters.', 'calliope-social-publisher'), 'connections');
        }

        $state_data = get_transient('calliope_sp_pinterest_oauth_' . $state);
        if (!is_array($state_data) || empty($state_data['callback'])) {
            $this->redirect_msg('error', __('Pinterest OAuth: session expired. Please connect again.', 'calliope-social-publisher'), 'connections');
        }

        $callback = (string) $state_data['callback'];

        $app_id = get_option('calliope_sp_pinterest_app_id');
        $app_secret = get_option('calliope_sp_pinterest_app_secret');
        if (!$app_id || !$app_secret) {
            $this->redirect_msg('error', __('Pinterest: missing App ID / App Secret.', 'calliope-social-publisher'), 'connections');
        }

        $env = get_option('calliope_sp_pinterest_env', 'production');
        $env = in_array($env, ['production', 'sandbox'], true) ? $env : 'production';
        $base = ($env === 'sandbox') ? 'https://api-sandbox.pinterest.com/v5' : 'https://api.pinterest.com/v5';

        $res = wp_remote_post($base . '/oauth/token', [
            'headers' => [
                'Authorization' => $this->safe_basic_auth_header($app_id, $app_secret),
                'Content-Type'  => 'application/x-www-form-urlencoded',
                'Accept'        => 'application/json',
            ],
            'body' => [
                'grant_type'   => 'authorization_code',
                'code'         => $code,
                'redirect_uri' => $callback,
            ],
            'timeout' => 30,
        ]);

        if (is_wp_error($res)) {
            $this->redirect_msg('error', 'Pinterest OAuth: ' . $res->get_error_message(), 'connections');
        }

        $http = (int) wp_remote_retrieve_response_code($res);
        $raw = (string) wp_remote_retrieve_body($res);
        $json = json_decode($raw, true);

        if ($http < 200 || $http >= 300 || !is_array($json) || empty($json['access_token'])) {
            // Avoid showing the full body because it may include extra hints. The code alone is enough.
            $this->redirect_msg('error', 'Pinterest OAuth: could not obtain access_token (HTTP ' . $http . ').', 'connections');
        }

        update_option('calliope_sp_pinterest_token', sanitize_text_field($json['access_token']));
        if (!empty($json['refresh_token'])) {
            update_option('calliope_sp_pinterest_refresh_token', sanitize_text_field($json['refresh_token']));
        } elseif (!empty($json['continuous_refresh_token'])) {
            update_option('calliope_sp_pinterest_refresh_token', sanitize_text_field($json['continuous_refresh_token']));
        }

        delete_transient('calliope_sp_pinterest_oauth_' . $state);

        $saved_secret = (string) get_option('calliope_sp_pinterest_app_secret', '');
        $saved_token = (string) get_option('calliope_sp_pinterest_token', '');

        if ($saved_token !== '') {
            $pin = new Calliope_SP_Pinterest();
            $test = $pin->test_connection();
            update_option('calliope_sp_pinterest_connection_status', is_wp_error($test) ? 'error' : 'connected');

            $this->redirect_msg(
                is_wp_error($test) ? 'error' : 'success',
                is_wp_error($test) ? ('Pinterest: ' . $test->get_error_message()) : __('Pinterest connected.', 'calliope-social-publisher'),
                'connections'
            );
        }

        update_option('calliope_sp_pinterest_connection_status', ($app_id !== '' && $saved_secret !== '') ? 'credentials_saved' : 'not_connected');

        $this->redirect_msg(
            'success',
            __('Credentials saved. Now click Connect with Pinterest.', 'calliope-social-publisher'),
            'connections'
        );
    }

    

/**
 * Pinterest does NOT allow redirect URIs containing query parameters.
 * So we register redirect URI as /wp-admin/admin-post.php (no ?action=...).
 * Pinterest will append ?code=...&state=...
 */
public function maybe_handle_pinterest_oauth_callback() {
    /*
     * Legacy router for OAuth callbacks.
     *
     * Pinterest does NOT allow redirect URIs with query parameters, so we register:
     *   /wp-admin/admin-post.php
     * and the provider returns with ?code=...&state=...
     *
     * Originally this handler routed ALL callbacks to Pinterest, which broke other
     * integrations (e.g., Threads). Now we simply delegate to the generic router,
     * which dispatches by looking up the stored transient for the returned state.
     */
    if (!is_admin()) {
        return;
    }
    global $pagenow;
    if (empty($pagenow) || $pagenow !== 'admin-post.php') {
        return;
    }
    // If action is set, let WordPress route it normally.
    // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only action check for bare OAuth callbacks before WordPress routes admin-post actions.
    if (!empty($_REQUEST['action'])) {
        return;
    }
    // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- OAuth callbacks do not use WordPress nonces.
    if (empty($_GET['code']) || empty($_GET['state'])) {
        return;
    }

    $this->maybe_handle_oauth_callbacks();
}

public function handle_pinterest_detect_board() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Unauthorized.', 'calliope-social-publisher'));
        // URL uses wp_nonce_url(..., 'calliope_sp_pinterest_detect_board_action') which stores nonce in _wpnonce
        check_admin_referer('calliope_sp_pinterest_detect_board_action');

        $pin = new Calliope_SP_Pinterest();
        $res = $pin->detect_board_id();

        if (is_wp_error($res)) {
            // Keep status as-is; just show message
            $this->redirect_msg('error', 'Pinterest: ' . $res->get_error_message(), 'connections');
        }

        // If detect succeeded, it's either the board id or already set.
        $board_id = (string) $res;
        if ($board_id) {
            update_option('calliope_sp_pinterest_board_id', sanitize_text_field($board_id));
        }

        $test = $pin->test_connection();
        update_option('calliope_sp_pinterest_connection_status', is_wp_error($test) ? 'error' : 'connected');

        $this->redirect_msg(
            is_wp_error($test) ? 'error' : 'success',
            is_wp_error($test)
                ? ('Pinterest: ' . $test->get_error_message())
                : (__('Board ID detected.', 'calliope-social-publisher') . ' (' . $board_id . ')'),
            'connections'
        );
    }

    // =============================
    // Threads
    // =============================

    /**
     * Handle OAuth callbacks that come back to /wp-admin/admin-post.php WITHOUT query params (no action=...).
     * Some platforms require "redirect_uri" without params, so we route by state prefix and transient.
     */
    public function maybe_handle_oauth_callbacks() {
        if (!is_admin()) return;
        if (!isset($GLOBALS['pagenow']) || $GLOBALS['pagenow'] !== 'admin-post.php') return;

        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- OAuth callback routing reads the action only to avoid double handling.
        $action = isset($_REQUEST['action']) ? sanitize_key((string) $_REQUEST['action']) : '';
        if ($action) return; // normal admin_post_* handler will run

        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- OAuth callback parameters are validated with the provider state value.
        $code  = sanitize_text_field(wp_unslash($_GET['code'] ?? ''));
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- OAuth callback parameters are validated with the provider state value.
        $state = sanitize_text_field(wp_unslash($_GET['state'] ?? ''));
        if (!$code || !$state) return;

        // Pinterest
        if (get_transient('calliope_sp_pinterest_oauth_' . $state)) {
            $this->handle_pinterest_oauth_callback();
            exit;
        }

        // Threads
        if (get_transient('calliope_sp_threads_oauth_' . $state)) {
            $this->handle_threads_oauth_callback();
            exit;
        }

        // Tumblr
        if (get_transient('calliope_sp_tumblr_oauth_' . $state)) {
            $this->handle_tumblr_oauth_callback();
            exit;
        }

        // Mastodon
        if (get_transient('calliope_sp_mastodon_oauth_' . $state)) {
            $this->handle_mastodon_oauth_callback();
            exit;
        }

        // Threads
        if (get_transient('calliope_sp_threads_oauth_' . $state)) {
            $this->handle_threads_oauth_callback();
            exit;
        }

        // Instagram
        if (get_transient('calliope_sp_instagram_oauth_' . $state)) {
            $this->handle_instagram_oauth_callback();
            exit;
        }
    }

    public function handle_threads_test_connection() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Unauthorized.', 'calliope-social-publisher'));
        check_admin_referer('calliope_sp_test_threads_action', 'calliope_sp_test_threads_nonce');

        $client_id = sanitize_text_field(wp_unslash($_POST['calliope_sp_threads_client_id'] ?? ''));
        $client_secret = sanitize_text_field(wp_unslash($_POST['calliope_sp_threads_client_secret'] ?? ''));

        update_option('calliope_sp_threads_client_id', $client_id);
        if ($client_secret !== '') {
            update_option('calliope_sp_threads_client_secret', sanitize_text_field($client_secret));
        }

        $saved_secret = (string) get_option('calliope_sp_threads_client_secret', '');
        update_option('calliope_sp_threads_connection_status', ($client_id !== '' && $saved_secret !== '') ? 'credentials_saved' : 'not_connected');

        $this->redirect_msg(
            'success',
            __('Credentials saved. Now click Connect with Threads.', 'calliope-social-publisher'),
            'connections',
            'threads'
        );
    }

    public function handle_threads_clear_tokens() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Unauthorized.', 'calliope-social-publisher'));
        check_admin_referer('calliope_sp_threads_clear_tokens_action');

        $cleared_options = [
            'calliope_sp_threads_access_token',
            'calliope_sp_threads_user_id',
            'calliope_sp_threads_expires_at',
        ];

        foreach ($cleared_options as $option_name) {
            delete_option($option_name);
        }

        $this->remove_options_from_connection_backup($cleared_options);
        update_option('calliope_sp_threads_connection_status', $this->all_options_have_saved_values(['calliope_sp_threads_client_id', 'calliope_sp_threads_client_secret']) ? 'credentials_saved' : 'not_connected');

        $this->redirect_msg('success', __('Threads tokens cleared.', 'calliope-social-publisher'), 'connections');
    }

    public function handle_threads_oauth_start() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Unauthorized.', 'calliope-social-publisher'));
        check_admin_referer('calliope_sp_threads_oauth_start_action');

        $client_id     = get_option('calliope_sp_threads_client_id');
        $client_secret = get_option('calliope_sp_threads_client_secret');

        if (!$client_id || !$client_secret) {
            $this->redirect_msg('error', __('Threads: missing App ID / App Secret.', 'calliope-social-publisher'), 'connections');
        }

        // IMPORTANT: redirect_uri without params (Meta dashboard rejects params sometimes)
        $callback = admin_url('admin-post.php');

        $state = wp_generate_password(24, false, false);
        set_transient('calliope_sp_threads_oauth_' . $state, [
            'callback' => $callback,
            'created'  => time(),
        ], 15 * MINUTE_IN_SECONDS);

        $scopes = 'threads_basic,threads_content_publish';
        $auth_url = add_query_arg([
            'client_id'     => $client_id,
            'redirect_uri'  => $callback,
            'response_type' => 'code',
            'scope'         => $scopes,
            'state'         => $state,
        ], 'https://threads.net/oauth/authorize');

        $this->safe_redirect($auth_url);
    }

    public function handle_threads_oauth_callback() {
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- OAuth callback parameters are validated with the provider state value.
        $code  = sanitize_text_field(wp_unslash($_GET['code'] ?? ''));
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- OAuth callback parameters are validated with the provider state value.
        $state = sanitize_text_field(wp_unslash($_GET['state'] ?? ''));

        if (!$code || !$state) {
            $this->redirect_msg('error', __('Threads OAuth: missing callback parameters.', 'calliope-social-publisher'), 'connections', 'threads');
        }

        $state_data = get_transient('calliope_sp_threads_oauth_' . $state);
        if (!is_array($state_data) || empty($state_data['callback'])) {
            $this->redirect_msg('error', __('Threads OAuth: session expired. Please connect again.', 'calliope-social-publisher'), 'connections', 'threads');
        }

        $callback = (string) $state_data['callback'];
        $client_id     = get_option('calliope_sp_threads_client_id');
        $client_secret = get_option('calliope_sp_threads_client_secret');

        if (!$client_id || !$client_secret) {
            $this->redirect_msg('error', 'Threads: missing App ID / App Secret.', 'connections', 'threads');
        }

        // 1) Exchange code for short-lived token
        $token_res = wp_remote_post('https://graph.threads.net/oauth/access_token', [
            'timeout' => 45,
            'headers' => [
                'Accept' => 'application/json',
            ],
            'body' => [
                'client_id'     => $client_id,
                'client_secret' => $client_secret,
                'grant_type'    => 'authorization_code',
                'redirect_uri'  => $callback,
                'code'          => $code,
            ],
        ]);

        if (is_wp_error($token_res)) {
            if (class_exists('Calliope_SP_Logger')) Calliope_SP_Logger::log('threads', 'error', 'OAuth token error: ' . $token_res->get_error_message());
            $this->redirect_msg('error', 'Threads OAuth: ' . $token_res->get_error_message(), 'connections', 'threads');
        }

        $http = (int) wp_remote_retrieve_response_code($token_res);
        $raw  = (string) wp_remote_retrieve_body($token_res);
        $json = json_decode($raw, true);

        if (class_exists('Calliope_SP_Logger')) {
            $lvl = ($http >= 200 && $http < 300) ? 'info' : 'error';
            Calliope_SP_Logger::log('threads', $lvl, 'OAuth exchange code HTTP ' . $http . ' | ' . mb_substr($raw, 0, 1200));
        }

        if ($http < 200 || $http >= 300 || !is_array($json) || empty($json['access_token'])) {
            $msg = is_array($json) ? ($json['error_message'] ?? $json['error']['message'] ?? $json['message'] ?? '') : '';
            if (!$msg) $msg = 'Could not obtain the access token.';
            delete_transient('calliope_sp_threads_oauth_' . $state);
            $this->redirect_msg('error', 'Threads OAuth: ' . $msg, 'connections', 'threads');
        }

        $short_token = sanitize_text_field($json['access_token']);
        $user_id     = !empty($json['user_id']) ? sanitize_text_field((string) $json['user_id']) : '';
        $expires_in  = !empty($json['expires_in']) ? (int) $json['expires_in'] : 0;

        // 2) Exchange short-lived for long-lived token (60d, refreshable)
        $ll_url = add_query_arg([
            'grant_type'    => 'th_exchange_token',
            'client_secret' => $client_secret,
            'access_token'  => $short_token,
        ], 'https://graph.threads.net/access_token');

        $ll_res = wp_remote_get($ll_url, ['timeout' => 45]);

        // Some clients send this as POST. If GET fails, try POST.
        if (!is_wp_error($ll_res)) {
            $ll_http = (int) wp_remote_retrieve_response_code($ll_res);
            if ($ll_http < 200 || $ll_http >= 300) {
                $ll_res = wp_remote_post('https://graph.threads.net/access_token', [
                    'timeout' => 45,
                    'headers' => ['Accept' => 'application/json'],
                    'body' => [
                        'grant_type'    => 'th_exchange_token',
                        'client_secret' => $client_secret,
                        'access_token'  => $short_token,
                    ],
                ]);
            }
        }

        if (is_wp_error($ll_res)) {
            if (class_exists('Calliope_SP_Logger')) Calliope_SP_Logger::log('threads', 'error', 'Long-lived token error: ' . $ll_res->get_error_message());
            delete_transient('calliope_sp_threads_oauth_' . $state);
            $this->redirect_msg('error', 'Threads OAuth: ' . $ll_res->get_error_message(), 'connections', 'threads');
        }

        $ll_http = (int) wp_remote_retrieve_response_code($ll_res);
        $ll_raw  = (string) wp_remote_retrieve_body($ll_res);
        $ll_json = json_decode($ll_raw, true);

        if (class_exists('Calliope_SP_Logger')) {
            $lvl = ($ll_http >= 200 && $ll_http < 300) ? 'info' : 'error';
            Calliope_SP_Logger::log('threads', $lvl, 'Long-lived exchange HTTP ' . $ll_http . ' | ' . mb_substr($ll_raw, 0, 1200));
        }

        if ($ll_http >= 200 && $ll_http < 300 && is_array($ll_json) && !empty($ll_json['access_token'])) {
            $access_token = sanitize_text_field($ll_json['access_token']);
            $ll_expires_in = !empty($ll_json['expires_in']) ? (int) $ll_json['expires_in'] : 0;

            update_option('calliope_sp_threads_access_token', $access_token);
            if ($user_id) update_option('calliope_sp_threads_user_id', $user_id);

            if ($ll_expires_in > 0) {
                update_option('calliope_sp_threads_expires_at', time() + $ll_expires_in);
            } elseif ($expires_in > 0) {
                update_option('calliope_sp_threads_expires_at', time() + $expires_in);
            } else {
                update_option('calliope_sp_threads_expires_at', 0);
            }
        } else {
            // Fallback: store short-lived to allow testing, but recommend long-lived.
            update_option('calliope_sp_threads_access_token', $short_token);
            if ($user_id) update_option('calliope_sp_threads_user_id', $user_id);
            if ($expires_in > 0) update_option('calliope_sp_threads_expires_at', time() + $expires_in);
        }

        delete_transient('calliope_sp_threads_oauth_' . $state);

        $threads = new Calliope_SP_Threads();
        $test = $threads->test_connection();
        update_option('calliope_sp_threads_connection_status', is_wp_error($test) ? 'error' : 'connected');

        $this->redirect_msg(
            is_wp_error($test) ? 'error' : 'success',
            is_wp_error($test) ? ('Threads: ' . $test->get_error_message()) : __('Threads connected.', 'calliope-social-publisher'),
            'connections', 'threads');
    }

    // =============================
    // LinkedIn
    // =============================

    private function refresh_linkedin_connection_status($li = null) {
        if (!($li instanceof Calliope_SP_LinkedIn)) {
            $li = new Calliope_SP_LinkedIn();
        }

        $state = $li->get_connection_state();
        update_option('calliope_sp_li_connection_status', $state['key']);

        return $state;
    }

    private function get_linkedin_notice_type($status_key) {
        if (in_array((string) $status_key, ['ready_to_publish', 'credentials_saved'], true)) {
            return 'success';
        }

        if (strpos((string) $status_key, 'connected_') === 0 || strpos((string) $status_key, 'token_') === 0) {
            return 'warning';
        }

        return 'error';
    }

    private function log_linkedin_oauth_event($level, $message, $context = []) {
        if (!class_exists('Calliope_SP_Logger')) {
            return;
        }

        $payload = is_array($context) && !empty($context)
            ? $message . ' | ' . wp_json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
            : $message;

        Calliope_SP_Logger::log('linkedin', $level, $payload);
    }

    private function get_linkedin_oauth_error_message($error, $description, $state_data = []) {
        $callback = !empty($state_data['callback']) ? (string) $state_data['callback'] : (new Calliope_SP_LinkedIn())->get_redirect_uri();
        $target_kind = !empty($state_data['target_kind']) ? (string) $state_data['target_kind'] : '';
        $pretty = $description !== '' ? urldecode($description) : $error;
        $pretty = trim(wp_strip_all_tags($pretty));

        if ($pretty === '') {
            $pretty = $error;
        }

        if (stripos($error, 'access_denied') !== false || stripos($pretty, 'denied') !== false || stripos($pretty, 'cancel') !== false) {
            return __('LinkedIn authorization was cancelled or denied. Approve the requested permissions and try again.', 'calliope-social-publisher');
        }

        if (stripos($error, 'scope') !== false || stripos($pretty, 'scope') !== false || stripos($pretty, 'permission') !== false) {
            if ($target_kind === 'organization' || $target_kind === 'invalid_company_url') {
                return __('This LinkedIn setup is limited to personal profiles. Company pages need a different app and are not supported in this connection.', 'calliope-social-publisher');
            }

            return __('LinkedIn did not grant the requested permissions for this personal profile connection. Check that the app can request openid, profile, email, and w_member_social in LinkedIn Developers, then try again.', 'calliope-social-publisher');
        }

        if (stripos($pretty, 'redirect_uri') !== false) {
            return sprintf(
                /* translators: %s: Redirect URI callback URL. */
                __('LinkedIn rejected the Redirect URI. Verify this exact Redirect URI in LinkedIn Developers: %s', 'calliope-social-publisher'),
                esc_html($callback)
            );
        }

        return $pretty;
    }

    private function get_linkedin_missing_code_message($state_data = []) {
        $callback = !empty($state_data['callback']) ? (string) $state_data['callback'] : (new Calliope_SP_LinkedIn())->get_redirect_uri();
        $target_kind = !empty($state_data['target_kind']) ? (string) $state_data['target_kind'] : '';

        if ($target_kind === 'organization' || $target_kind === 'invalid_company_url') {
            return __('This LinkedIn setup is limited to personal profiles. Company pages need a different LinkedIn app and are not supported in this connection.', 'calliope-social-publisher');
        }
        return sprintf(
            /* translators: %s: Redirect URI callback URL. */
            __('LinkedIn returned to WordPress without an authorization code. Verify the Redirect URI exactly and confirm that the app can request openid, profile, email, and w_member_social in LinkedIn Developers: %s', 'calliope-social-publisher'),
            esc_html($callback)
        );
    }

    private function extract_linkedin_remote_error_message($http, $json, $raw) {
        $candidates = [];

        if (is_array($json)) {
            foreach (['error_description', 'message', 'error', 'status'] as $key) {
                if (!empty($json[$key]) && is_scalar($json[$key])) {
                    $candidates[] = (string) $json[$key];
                }
            }
        }

        $message = trim(wp_strip_all_tags(implode(' | ', array_unique(array_filter($candidates)))));
        if ($message === '') {
            $message = trim(wp_strip_all_tags((string) $raw));
        }

        if ($message === '') {
            $message = sprintf(
                /* translators: %d: HTTP status code returned by LinkedIn. */
                __('LinkedIn returned HTTP %d while creating the access token.', 'calliope-social-publisher'),
                (int) $http
            );
        }

        if (strtolower($message) === 'scope') {
            return __('LinkedIn rejected the requested scope for this connection.', 'calliope-social-publisher');
        }

        return $message;
    }

    public function handle_linkedin_test_connection() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Unauthorized.', 'calliope-social-publisher'));
        check_admin_referer('calliope_sp_test_linkedin_action', 'calliope_sp_test_linkedin_nonce');

        $client_id     = sanitize_text_field(wp_unslash($_POST['calliope_sp_li_client_id'] ?? ''));
        $client_secret = sanitize_text_field(wp_unslash($_POST['calliope_sp_li_client_secret'] ?? ''));
        $token         = sanitize_text_field(wp_unslash($_POST['calliope_sp_li_access_token'] ?? ''));
        $publish_target = sanitize_text_field(wp_unslash($_POST['calliope_sp_li_person_id'] ?? ''));

        update_option('calliope_sp_li_client_id', $client_id);
        if ($client_secret !== '') {
            update_option('calliope_sp_li_client_secret', sanitize_text_field($client_secret));
        }
        if ($token !== '') {
            update_option('calliope_sp_li_access_token', sanitize_text_field($token));
            update_option('calliope_sp_li_expires_at', 0);
        }

        update_option('calliope_sp_li_person_id', $publish_target);

        $li = new Calliope_SP_LinkedIn();
        $state = $this->refresh_linkedin_connection_status($li);

        $this->redirect_msg(
            $this->get_linkedin_notice_type($state['key']),
            $state['message'],
            'connections',
            'linkedin'
        );
    }

    public function handle_linkedin_clear_tokens() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Unauthorized.', 'calliope-social-publisher'));
        check_admin_referer('calliope_sp_linkedin_clear_tokens_action');

        $cleared_options = [
            'calliope_sp_li_access_token',
            'calliope_sp_li_expires_at',
            'calliope_sp_li_last_scope_list',
            'calliope_sp_li_last_target_kind',
            'calliope_sp_li_last_page_target',
        ];

        foreach ($cleared_options as $option_name) {
            delete_option($option_name);
        }

        $this->remove_options_from_connection_backup($cleared_options);
        $this->refresh_linkedin_connection_status();

        $this->redirect_msg('success', __('LinkedIn token cleared. Click Connect with LinkedIn to create a new token.', 'calliope-social-publisher'), 'connections', 'linkedin');
    }

    public function handle_linkedin_detect_profile_target() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Unauthorized.', 'calliope-social-publisher'));
        check_admin_referer('calliope_sp_linkedin_detect_profile_target_action');

        $li = new Calliope_SP_LinkedIn();
        $token_check = $li->test_connection();

        if (is_wp_error($token_check)) {
            $this->redirect_msg('error', $token_check->get_error_message(), 'connections', 'linkedin');
        }

        $resolved = $li->fetch_profile_person_urn();
        if (is_wp_error($resolved) || empty($resolved)) {
            $message = __('LinkedIn could not detect a reusable personal profile author ID for this app or token yet.', 'calliope-social-publisher');
            if (is_wp_error($resolved) && $resolved->get_error_message() !== '') {
                $message .= ' ' . $resolved->get_error_message();
            }

            $this->redirect_msg('error', $message, 'connections', 'linkedin');
        }

        update_option('calliope_sp_li_person_id', sanitize_text_field($resolved));
        $state = $this->refresh_linkedin_connection_status($li);

        $this->redirect_msg(
            $this->get_linkedin_notice_type($state['key']),
            __('LinkedIn profile target detected. You can now test posting as the connected member profile.', 'calliope-social-publisher'),
            'connections',
            'linkedin'
        );
    }

    public function handle_linkedin_restore_page_target() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Unauthorized.', 'calliope-social-publisher'));
        check_admin_referer('calliope_sp_linkedin_restore_page_target_action');

        $saved_page_target = (string) get_option('calliope_sp_li_last_page_target', '');
        if ($saved_page_target === '') {
            $this->redirect_msg('error', __('There is no saved LinkedIn Page target to restore yet.', 'calliope-social-publisher'), 'connections', 'linkedin');
        }

        update_option('calliope_sp_li_person_id', sanitize_text_field($saved_page_target));
        $state = $this->refresh_linkedin_connection_status(new Calliope_SP_LinkedIn());

        $this->redirect_msg(
            $this->get_linkedin_notice_type($state['key']),
            __('Saved LinkedIn Page target restored.', 'calliope-social-publisher'),
            'connections',
            'linkedin'
        );
    }

    public function handle_linkedin_oauth_start() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Unauthorized.', 'calliope-social-publisher'));
        check_admin_referer('calliope_sp_linkedin_oauth_start_action');

        $client_id     = get_option('calliope_sp_li_client_id');
        $client_secret = get_option('calliope_sp_li_client_secret');

        if (!$client_id || !$client_secret) {
            $this->redirect_msg('error', __('LinkedIn: missing Client ID / Client Secret.', 'calliope-social-publisher'), 'connections', 'linkedin');
        }

        $li = new Calliope_SP_LinkedIn();
        $callback = $li->get_redirect_uri();
        $target = $li->resolve_publish_target();
        $scope_mode = 'member';
        $scope_list = $li->get_oauth_scope_list_for_target($target, $scope_mode);

        $state = wp_generate_password(24, false, false);
        set_transient('calliope_sp_linkedin_oauth_' . $state, [
            'callback' => $callback,
            'created' => time(),
            'scope_list' => $scope_list,
            'scope_mode' => $scope_mode,
            'target_kind' => (string) ($target['kind'] ?? ''),
            'target_raw' => (string) ($target['raw'] ?? ''),
        ], 15 * MINUTE_IN_SECONDS);

        $this->log_linkedin_oauth_event('info', 'OAuth start', [
            'callback' => $callback,
            'scope_list' => $scope_list,
            'scope_mode' => $scope_mode,
            'target_kind' => (string) ($target['kind'] ?? 'none'),
            'target_present' => !empty($target['kind']) && $target['kind'] !== 'none',
        ]);

        $auth_args = [
            'response_type' => 'code',
            'client_id'     => $client_id,
            'redirect_uri'  => $callback,
            'state'         => $state,
        ];

        if (!empty($scope_list)) {
            $auth_args['scope'] = implode(' ', $scope_list);
        }

        $auth_url = 'https://www.linkedin.com/oauth/v2/authorization?' . http_build_query($auth_args, '', '&', PHP_QUERY_RFC3986);

        $this->safe_redirect($auth_url);
    }

    public function handle_linkedin_oauth_callback($params = []) {
        $params = is_array($params) ? $params : [];
        $error = $this->get_oauth_callback_param($params, 'error');
        $description = $this->get_oauth_callback_param($params, 'error_description');
        $code = $this->get_oauth_callback_param($params, 'code');
        $state = $this->get_oauth_callback_param($params, 'state');
        $state_data = $state ? get_transient('calliope_sp_linkedin_oauth_' . $state) : false;

        $this->log_linkedin_oauth_event('info', 'OAuth callback received', [
            'error' => $error,
            'error_description' => $description,
            'code_present' => $code !== '',
            'state_present' => $state !== '',
            'state_known' => is_array($state_data),
            // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Server variables are logged for OAuth troubleshooting only.
            'request_uri' => isset($_SERVER['REQUEST_URI']) ? sanitize_text_field(wp_unslash((string) $_SERVER['REQUEST_URI'])) : '',
            // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Server variables are logged for OAuth troubleshooting only.
            'query_string' => isset($_SERVER['QUERY_STRING']) ? sanitize_text_field(wp_unslash((string) $_SERVER['QUERY_STRING'])) : '',
        ]);

        if ($error) {
            $message = $this->get_linkedin_oauth_error_message($error, $description, is_array($state_data) ? $state_data : []);
            if ($state) {
                delete_transient('calliope_sp_linkedin_oauth_' . $state);
            }
            $this->refresh_linkedin_connection_status();
            $this->redirect_msg('error', 'LinkedIn OAuth: ' . $message, 'connections', 'linkedin');
        }

        if (!$state) {
            $this->refresh_linkedin_connection_status();
            $this->redirect_msg('error', __('LinkedIn OAuth: the state parameter is missing. Start the connection again from this plugin.', 'calliope-social-publisher'), 'connections', 'linkedin');
        }

        if (!is_array($state_data) || empty($state_data['callback'])) {
            $this->refresh_linkedin_connection_status();
            $this->redirect_msg('error', __('LinkedIn OAuth: session expired or callback data was lost. Start the LinkedIn connection again from this plugin.', 'calliope-social-publisher'), 'connections', 'linkedin');
        }

        if (!$code) {
            delete_transient('calliope_sp_linkedin_oauth_' . $state);
            $this->refresh_linkedin_connection_status();
            $this->redirect_msg('error', 'LinkedIn OAuth: ' . $this->get_linkedin_missing_code_message($state_data), 'connections', 'linkedin');
        }

        $client_id = get_option('calliope_sp_li_client_id');
        $client_secret = get_option('calliope_sp_li_client_secret');
        $li = new Calliope_SP_LinkedIn();
        $callback = (string) $state_data['callback'];

        if (!$client_id || !$client_secret) {
            delete_transient('calliope_sp_linkedin_oauth_' . $state);
            $this->refresh_linkedin_connection_status($li);
            $this->redirect_msg('error', __('LinkedIn: missing Client ID / Client Secret.', 'calliope-social-publisher'), 'connections', 'linkedin');
        }

        $token_res = wp_remote_post('https://www.linkedin.com/oauth/v2/accessToken', [
            'timeout' => 45,
            'headers' => ['Accept' => 'application/json'],
            'body' => [
                'grant_type'    => 'authorization_code',
                'code'          => $code,
                'redirect_uri'  => $callback,
                'client_id'     => $client_id,
                'client_secret' => $client_secret,
            ],
        ]);

        if (is_wp_error($token_res)) {
            $this->log_linkedin_oauth_event('error', 'OAuth token HTTP error', [
                'message' => $token_res->get_error_message(),
            ]);
            delete_transient('calliope_sp_linkedin_oauth_' . $state);
            $this->refresh_linkedin_connection_status($li);
            $this->redirect_msg('error', 'LinkedIn OAuth: ' . $token_res->get_error_message(), 'connections', 'linkedin');
        }

        $http = (int) wp_remote_retrieve_response_code($token_res);
        $raw  = (string) wp_remote_retrieve_body($token_res);
        $json = json_decode($raw, true);

        if ($http < 200 || $http >= 300 || !is_array($json) || empty($json['access_token'])) {
            $remote_message = $this->extract_linkedin_remote_error_message($http, $json, $raw);
            $target_kind = !empty($state_data['target_kind']) ? (string) $state_data['target_kind'] : '';
            $scope_mode = !empty($state_data['scope_mode']) ? (string) $state_data['scope_mode'] : 'member';

            if (($target_kind === 'organization' || $target_kind === 'invalid_company_url') && (stripos($remote_message, 'scope') !== false || stripos($remote_message, 'permission') !== false)) {
                $remote_message = __('This LinkedIn setup is limited to personal profiles. Company pages need a different LinkedIn app and are not supported in this connection.', 'calliope-social-publisher');
            } elseif (stripos($remote_message, 'redirect_uri') !== false) {
                $remote_message = sprintf(
                    /* translators: %s: Redirect URI callback URL. */
                    __('LinkedIn rejected the Redirect URI during the token exchange. Verify this exact Redirect URI in LinkedIn Developers: %s', 'calliope-social-publisher'),
                    esc_html($callback)
                );
            }

            $this->log_linkedin_oauth_event('error', 'OAuth token exchange failed', [
                'http' => $http,
                'message' => $remote_message,
            ]);
            delete_transient('calliope_sp_linkedin_oauth_' . $state);
            $this->refresh_linkedin_connection_status($li);
            $this->redirect_msg('error', 'LinkedIn OAuth: ' . $remote_message, 'connections', 'linkedin');
        }

        update_option('calliope_sp_li_access_token', sanitize_text_field($json['access_token']));
        update_option('calliope_sp_li_last_scope_list', array_values(array_filter(array_map('sanitize_text_field', (array) ($state_data['scope_list'] ?? [])))));
        update_option('calliope_sp_li_last_target_kind', sanitize_text_field((string) ($state_data['target_kind'] ?? '')));
        if (!empty($json['expires_in'])) {
            update_option('calliope_sp_li_expires_at', time() + (int) $json['expires_in']);
        } else {
            update_option('calliope_sp_li_expires_at', 0);
        }

        if (!$li->get_person_id()) {
            $resolved = $li->fetch_profile_person_urn();
            if (!is_wp_error($resolved) && $resolved) {
                update_option('calliope_sp_li_person_id', sanitize_text_field($resolved));
                $this->log_linkedin_oauth_event('info', 'Profile target detected automatically');
            } elseif (is_wp_error($resolved)) {
                $this->log_linkedin_oauth_event('info', 'Profile target was not detected automatically', [
                    'message' => $resolved->get_error_message(),
                ]);
            }
        }

        delete_transient('calliope_sp_linkedin_oauth_' . $state);
        $state = $this->refresh_linkedin_connection_status($li);

        $this->redirect_msg(
            $this->get_linkedin_notice_type($state['key']),
            $state['message'],
            'connections',
            'linkedin'
        );
    }

	// =============================
	// Blogger (Google OAuth)
	// =============================

    public function handle_blogger_test_connection() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Not allowed.', 'calliope-social-publisher'));
        check_admin_referer('calliope_sp_test_blogger_action', 'calliope_sp_test_blogger_nonce');

        $client_id = sanitize_text_field(wp_unslash($_POST['calliope_sp_blogger_client_id'] ?? ''));
        $client_secret = sanitize_text_field(wp_unslash($_POST['calliope_sp_blogger_client_secret'] ?? ''));
        $blog_id = sanitize_text_field(wp_unslash($_POST['calliope_sp_blogger_blog_id'] ?? ''));

        update_option('calliope_sp_blogger_client_id', $client_id);
        if ($client_secret !== '') {
            update_option('calliope_sp_blogger_client_secret', sanitize_text_field($client_secret));
        }
        if ($blog_id) {
            update_option('calliope_sp_blogger_blog_id', $blog_id);
        }

        $saved_secret = (string) get_option('calliope_sp_blogger_client_secret', '');
        $has_saved_token = $this->any_option_has_saved_value(['calliope_sp_blogger_access_token', 'calliope_sp_blogger_refresh_token']);

        if ($has_saved_token) {
            $bg = new Calliope_SP_Blogger();
            $test = $bg->test_connection();
            update_option('calliope_sp_blogger_connection_status', is_wp_error($test) ? 'error' : 'connected');

            $this->redirect_msg(
                is_wp_error($test) ? 'error' : 'success',
                is_wp_error($test) ? ('Blogger: ' . $test->get_error_message()) : __('Blogger connected.', 'calliope-social-publisher'),
                'connections', 'blogger'
            );
        }

        update_option('calliope_sp_blogger_connection_status', ($client_id !== '' && $saved_secret !== '') ? 'credentials_saved' : 'not_connected');

        $this->redirect_msg(
            'success',
            __('Credentials saved. Now click Connect with Blogger.', 'calliope-social-publisher'),
            'connections',
            'blogger'
        );
    }

    public function handle_blogger_clear_tokens() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Unauthorized.', 'calliope-social-publisher'));
        check_admin_referer('calliope_sp_blogger_clear_tokens_action');

        $cleared_options = [
            'calliope_sp_blogger_access_token',
            'calliope_sp_blogger_refresh_token',
            'calliope_sp_blogger_expires_at',
            'calliope_sp_blogger_blogs_last',
        ];

        foreach ($cleared_options as $option_name) {
            delete_option($option_name);
        }

        $this->remove_options_from_connection_backup($cleared_options);
        update_option('calliope_sp_blogger_connection_status', $this->all_options_have_saved_values(['calliope_sp_blogger_client_id', 'calliope_sp_blogger_client_secret']) ? 'credentials_saved' : 'not_connected');

        $this->redirect_msg('success', __('Blogger tokens cleared.', 'calliope-social-publisher'), 'connections', 'blogger');
    }

    public function handle_blogger_oauth_start() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Unauthorized.', 'calliope-social-publisher'));
        check_admin_referer('calliope_sp_blogger_oauth_start_action');

        $client_id = get_option('calliope_sp_blogger_client_id');
        $secret    = get_option('calliope_sp_blogger_client_secret');
        if (!$client_id || !$secret) {
            $this->redirect_msg('error', __('Blogger: missing Client ID / Client Secret.', 'calliope-social-publisher'), 'connections', 'blogger');
        }

        $bg = new Calliope_SP_Blogger();
        $state = wp_generate_password(24, false, false);
        set_transient('calliope_sp_blogger_oauth_' . $state, ['created' => time()], 15 * MINUTE_IN_SECONDS);

        $this->safe_redirect($bg->build_auth_url($state));
    }

    public function handle_blogger_oauth_callback($params = []) {
        $params = is_array($params) ? $params : [];
        $code  = $this->get_oauth_callback_param($params, 'code');
        $state = $this->get_oauth_callback_param($params, 'state');

        if (!$code || !$state) {
            $this->redirect_msg('error', __('Blogger OAuth: missing callback parameters.', 'calliope-social-publisher'), 'connections', 'blogger');
        }

        if (!get_transient('calliope_sp_blogger_oauth_' . $state)) {
            $this->redirect_msg('error', __('Blogger OAuth: session expired. Please connect again.', 'calliope-social-publisher'), 'connections', 'blogger');
        }

        $bg = new Calliope_SP_Blogger();
        $ex = $bg->exchange_code($code);
        if (is_wp_error($ex)) {
            if (class_exists('Calliope_SP_Logger')) Calliope_SP_Logger::log('blogger', 'error', 'OAuth exchange error: ' . $ex->get_error_message());
            $this->redirect_msg('error', 'Blogger OAuth: ' . $ex->get_error_message(), 'connections', 'blogger');
        }

        // Pull blogs list for UI.
        $test = $bg->test_connection();
        update_option('calliope_sp_blogger_connection_status', is_wp_error($test) ? 'error' : 'connected');

        delete_transient('calliope_sp_blogger_oauth_' . $state);

        $this->redirect_msg(
            is_wp_error($test) ? 'error' : 'success',
            is_wp_error($test) ? ('Blogger: ' . $test->get_error_message()) : (__('Blogger connected.', 'calliope-social-publisher') . ' (' . __('blogs detected', 'calliope-social-publisher') . ')'),
            'connections', 'blogger'
        );
    }

    // =============================
    // Instagram (image only)
    // =============================

    public function handle_instagram_test_connection() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Not allowed.', 'calliope-social-publisher'));
        check_admin_referer('calliope_sp_test_instagram_action', 'calliope_sp_test_instagram_nonce');

        $app_id = sanitize_text_field(wp_unslash($_POST['calliope_sp_ig_app_id'] ?? ''));
        $app_secret = sanitize_text_field(wp_unslash($_POST['calliope_sp_ig_app_secret'] ?? ''));

        update_option('calliope_sp_ig_app_id', $app_id);
        if ($app_secret !== '') {
            update_option('calliope_sp_ig_app_secret', sanitize_text_field($app_secret));
        }

        $saved_secret = (string) get_option('calliope_sp_ig_app_secret', '');
        $has_saved_token = $this->option_has_saved_value('calliope_sp_ig_access_token');

        if ($has_saved_token) {
            $ig = new Calliope_SP_Instagram();
            $test = $ig->test_connection();
            update_option('calliope_sp_ig_connection_status', is_wp_error($test) ? 'error' : 'connected');

            $this->redirect_msg(
                is_wp_error($test) ? 'error' : 'success',
                is_wp_error($test) ? ('Instagram: ' . $test->get_error_message()) : __('Instagram connected.', 'calliope-social-publisher'),
                'connections', 'instagram'
            );
        }

        update_option('calliope_sp_ig_connection_status', ($app_id !== '' && $saved_secret !== '') ? 'credentials_saved' : 'not_connected');

        $this->redirect_msg(
            'success',
            __('Credentials saved. Now click Connect with Instagram.', 'calliope-social-publisher'),
            'connections',
            'instagram'
        );
    }

    public function handle_instagram_clear_tokens() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Not allowed.', 'calliope-social-publisher'));
        check_admin_referer('calliope_sp_instagram_clear_tokens_action');

        $cleared_options = [
            'calliope_sp_ig_access_token',
            'calliope_sp_ig_expires_at',
            'calliope_sp_ig_page_id',
            'calliope_sp_ig_user_id',
        ];

        foreach ($cleared_options as $option_name) {
            delete_option($option_name);
        }

        $this->remove_options_from_connection_backup($cleared_options);
        update_option('calliope_sp_ig_connection_status', $this->all_options_have_saved_values(['calliope_sp_ig_app_id', 'calliope_sp_ig_app_secret']) ? 'credentials_saved' : 'not_connected');

        $this->redirect_msg('success', __('Instagram: tokens cleared', 'calliope-social-publisher') . ' ✅', 'connections', 'instagram');
    }

    public function handle_instagram_oauth_start() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Not allowed.', 'calliope-social-publisher'));
        check_admin_referer('calliope_sp_instagram_oauth_start_action');

        $app_id = (string) get_option('calliope_sp_ig_app_id');
        $app_secret = (string) get_option('calliope_sp_ig_app_secret');
        if (!$app_id || !$app_secret) {
            $this->redirect_msg('error', __('Instagram: missing App ID / App Secret.', 'calliope-social-publisher'), 'connections', 'instagram');
        }

        // Prefer the REST callback, but avoid query-style REST URLs because Meta may reject them.
        $callback = $this->get_instagram_redirect_uri();
        $state = wp_generate_password(24, false, false);
        set_transient('calliope_sp_instagram_oauth_' . $state, [
            'callback' => $callback,
            'created'  => time(),
        ], 15 * MINUTE_IN_SECONDS);

        // Instagram Business Login OAuth
        // NOTE: This is NOT the Facebook Login dialog.
        // The Instagram API setup (Instagram Business Login) uses instagram.com OAuth endpoint.
        $scopes = implode(',', [
            'instagram_business_basic',
            'instagram_business_manage_messages',
            'instagram_business_manage_comments',
            'instagram_business_content_publish',
            'instagram_business_manage_insights',
        ]);

        $auth_url = add_query_arg([
            'force_reauth'  => 'true',
            'client_id'     => $app_id,
            'redirect_uri'  => $callback,
            'response_type' => 'code',
            'scope'         => $scopes,
            'state'         => $state,
        ], 'https://www.instagram.com/oauth/authorize');

        $this->safe_redirect($auth_url);
    }

    public function handle_instagram_oauth_callback($params = []) {
        $params = is_array($params) ? $params : [];
        $code  = $this->get_oauth_callback_param($params, 'code');
        $state = $this->get_oauth_callback_param($params, 'state');

        if (!$code || !$state) {
            $this->redirect_msg('error', __('Instagram OAuth: missing callback parameters.', 'calliope-social-publisher'), 'connections', 'instagram');
        }

        $state_data = get_transient('calliope_sp_instagram_oauth_' . $state);
        if (!is_array($state_data) || empty($state_data['callback'])) {
            $this->redirect_msg('error', __('Instagram OAuth: session expired. Please connect again.', 'calliope-social-publisher'), 'connections', 'instagram');
        }

        $callback = (string) $state_data['callback'];
        $app_id = (string) get_option('calliope_sp_ig_app_id');
        $app_secret = (string) get_option('calliope_sp_ig_app_secret');
        if (!$app_id || !$app_secret) {
            $this->redirect_msg('error', __('Instagram: missing App ID / App Secret.', 'calliope-social-publisher'), 'connections', 'instagram');
        }

        // 1) Code -> access token (Instagram OAuth)
        $token_res = wp_remote_post('https://api.instagram.com/oauth/access_token', [
            'timeout' => 45,
            'body'    => [
                'client_id'     => $app_id,
                'client_secret' => $app_secret,
                'grant_type'    => 'authorization_code',
                'redirect_uri'  => $callback,
                'code'          => $code,
            ],
        ]);
        if (is_wp_error($token_res)) {
            if (class_exists('Calliope_SP_Logger')) Calliope_SP_Logger::log('instagram', 'error', 'OAuth token error: ' . $token_res->get_error_message());
            delete_transient('calliope_sp_instagram_oauth_' . $state);
            $this->redirect_msg('error', 'Instagram OAuth: ' . $token_res->get_error_message(), 'connections', 'instagram');
        }

        $http = (int) wp_remote_retrieve_response_code($token_res);
        $raw  = (string) wp_remote_retrieve_body($token_res);
        $json = json_decode($raw, true);

        if (class_exists('Calliope_SP_Logger')) {
            $lvl = ($http >= 200 && $http < 300) ? 'info' : 'error';
            Calliope_SP_Logger::log('instagram', $lvl, 'OAuth exchange code HTTP ' . $http . ' | ' . mb_substr($raw, 0, 1200));
        }

        if ($http < 200 || $http >= 300 || !is_array($json) || empty($json['access_token'])) {
            $msg = is_array($json) ? ($json['error']['message'] ?? $json['message'] ?? '') : '';
            if (!$msg) $msg = 'Could not obtain access token.';
            delete_transient('calliope_sp_instagram_oauth_' . $state);
            $this->redirect_msg('error', 'Instagram OAuth: ' . $msg, 'connections', 'instagram');
        }

        $access_token = sanitize_text_field((string)$json['access_token']);
        $expires_in = !empty($json['expires_in']) ? (int)$json['expires_in'] : 0;

        // 1b) Exchange for long-lived token (recommended for production)
        $long_res = wp_remote_get(add_query_arg([
            'grant_type'    => 'ig_exchange_token',
            'client_secret' => $app_secret,
            'access_token'  => $access_token,
        ], 'https://graph.instagram.com/access_token'), [
            'timeout' => 45,
        ]);

        if (!is_wp_error($long_res)) {
            $long_http = (int) wp_remote_retrieve_response_code($long_res);
            $long_raw  = (string) wp_remote_retrieve_body($long_res);
            $long_json = json_decode($long_raw, true);

            if ($long_http >= 200 && $long_http < 300 && is_array($long_json) && !empty($long_json['access_token'])) {
                $access_token = sanitize_text_field((string) $long_json['access_token']);
                $expires_in   = !empty($long_json['expires_in']) ? (int) $long_json['expires_in'] : 0;
                if (class_exists('Calliope_SP_Logger')) Calliope_SP_Logger::log('instagram', 'info', 'Long-lived exchange HTTP ' . $long_http . ' | ' . $long_raw);
            } else {
                if (class_exists('Calliope_SP_Logger')) Calliope_SP_Logger::log('instagram', 'warning', 'Long-lived exchange HTTP ' . $long_http . ' | ' . $long_raw);
            }
        } else {
            if (class_exists('Calliope_SP_Logger')) Calliope_SP_Logger::log('instagram', 'warning', 'Long-lived exchange error: ' . $long_res->get_error_message());
        }
        $ig_user_id = !empty($json['user_id']) ? sanitize_text_field((string)$json['user_id']) : '';

        update_option('calliope_sp_ig_access_token', $access_token);
        update_option('calliope_sp_ig_expires_at', $expires_in > 0 ? (time() + $expires_in) : 0);
        if ($ig_user_id) update_option('calliope_sp_ig_user_id', $ig_user_id);

        // 3) Auto-detect page + IG user id
        $ig = new Calliope_SP_Instagram();
        $test = $ig->test_connection();
        update_option('calliope_sp_ig_connection_status', is_wp_error($test) ? 'error' : 'connected');

        delete_transient('calliope_sp_instagram_oauth_' . $state);

        $this->redirect_msg(
            is_wp_error($test) ? 'error' : 'success',
            is_wp_error($test) ? ('Instagram: ' . $test->get_error_message()) : __('Instagram connected.', 'calliope-social-publisher'),
            'connections', 'instagram'
        );
    }
    // =============================
    // Logs
    // =============================

    public function handle_clear_logs() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Unauthorized.', 'calliope-social-publisher'));
        check_admin_referer('calliope_sp_clear_logs_action', 'calliope_sp_clear_logs_nonce');

        update_option('calliope_sp_debug_logs', []);
        $this->redirect_msg('success', __('Logs cleared', 'calliope-social-publisher') . ' ✅', 'logs');
    }

    public function handle_meta_media_url_test() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Unauthorized.', 'calliope-social-publisher'));
        check_admin_referer('calliope_sp_test_meta_media_url_action');

        $network = sanitize_key(wp_unslash($_REQUEST['calliope_sp_meta_media_network'] ?? 'instagram'));
        if (!in_array($network, ['instagram', 'threads'], true)) {
            $network = 'instagram';
        }

        if (!class_exists('Calliope_SP_Media_Helper')) {
            $this->redirect_msg('error', __('Meta media URL test is unavailable.', 'calliope-social-publisher'), 'logs');
        }

        $sample = Calliope_SP_Media_Helper::create_sample_jpeg_data_url();
        if (is_wp_error($sample)) {
            if (class_exists('Calliope_SP_Logger')) {
                Calliope_SP_Logger::log($network, 'error', 'Meta media URL test failed: ' . $sample->get_error_message());
            }
            $this->redirect_msg('error', 'Meta media URL test failed: ' . $sample->get_error_message(), 'logs');
        }

        $asset = Calliope_SP_Media_Helper::create_public_image_from_b64(
            (string) $sample,
            Calliope_SP_Media_Helper::get_meta_static_image_args($network)
        );

        if (is_wp_error($asset)) {
            if (class_exists('Calliope_SP_Logger')) {
                Calliope_SP_Logger::log($network, 'error', wp_json_encode([
                    'message'       => 'Meta media URL test failed while preparing the sample JPEG.',
                    'error_code'    => $asset->get_error_code(),
                    'error_message' => $asset->get_error_message(),
                    'error_data'    => $asset->get_error_data(),
                ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
            }
            $this->redirect_msg('error', 'Meta media URL test failed: ' . $asset->get_error_message(), 'logs');
        }

        if (class_exists('Calliope_SP_Logger')) {
            Calliope_SP_Logger::log($network, 'info', wp_json_encode([
                'message' => 'Manual Meta media URL test completed. Open the generated URL in a browser and compare these headers with Meta errors if publishing still fails.',
                'generated_url' => (string) ($asset['url'] ?? ''),
                'passthrough_url' => (string) ($asset['passthrough_url'] ?? ''),
                'meta_static_url' => (string) ($asset['meta_static_url'] ?? ''),
                'static_url' => (string) ($asset['static_url'] ?? ''),
                'url_strategy' => (string) ($asset['url_strategy'] ?? ''),
                'local_path' => (string) ($asset['local_path'] ?? ''),
                'file_exists' => (bool) ($asset['file_exists'] ?? false),
                'file_readable' => (bool) ($asset['file_readable'] ?? false),
                'filesize' => (int) ($asset['filesize'] ?? 0),
                'mime' => (string) ($asset['mime'] ?? ''),
                'width' => (int) ($asset['width'] ?? 0),
                'height' => (int) ($asset['height'] ?? 0),
                'first_12_bytes_hex' => (string) ($asset['first_12_bytes_hex'] ?? ''),
                'sha1' => (string) ($asset['sha1'] ?? ''),
                'http_head_status' => (int) ($asset['http_head_status'] ?? 0),
                'http_get_status' => (int) ($asset['http_get_status'] ?? 0),
                'http_content_type' => (string) ($asset['http_content_type'] ?? ''),
                'http_content_length' => (string) ($asset['http_content_length'] ?? ''),
                'http_accept_ranges' => (string) ($asset['http_accept_ranges'] ?? ''),
                'http_cache_control' => (string) ($asset['http_cache_control'] ?? ''),
                'http_x_content_type_options' => (string) ($asset['http_x_content_type_options'] ?? ''),
                'http_server' => (string) ($asset['http_server'] ?? ''),
                'http_via' => (string) ($asset['http_via'] ?? ''),
                'http_cf_cache_status' => (string) ($asset['http_cf_cache_status'] ?? ''),
                'http_x_litespeed_cache' => (string) ($asset['http_x_litespeed_cache'] ?? ''),
                'http_x_hcdn_cache_status' => (string) ($asset['http_x_hcdn_cache_status'] ?? ''),
                'http_x_hcdn_request_id' => (string) ($asset['http_x_hcdn_request_id'] ?? ''),
                'body_starts_with_jpeg' => (bool) ($asset['body_starts_with_jpeg'] ?? false),
                'body_detected_mime' => (string) ($asset['body_detected_mime'] ?? ''),
                'body_magic' => (string) ($asset['body_magic'] ?? ''),
                'body_matches_local' => (bool) ($asset['body_matches_local'] ?? false),
                'body_size_matches_local' => (bool) ($asset['body_size_matches_local'] ?? false),
                'body_magic_matches_local' => (bool) ($asset['body_magic_matches_local'] ?? false),
                'http_content_length_matches_local' => (bool) ($asset['http_content_length_matches_local'] ?? false),
            ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
        }

        $label = $network === 'threads' ? __('Threads', 'calliope-social-publisher') : __('Instagram', 'calliope-social-publisher');
        $this->redirect_msg('success', sprintf(__('%s Meta media URL test completed. See Logs for the generated URL and headers.', 'calliope-social-publisher'), $label), 'logs');
    }

    public function handle_save_cleanup_settings() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Unauthorized.', 'calliope-social-publisher'));
        check_admin_referer('calliope_sp_save_cleanup_settings_action', 'calliope_sp_save_cleanup_settings_nonce');

        $enabled = !empty(sanitize_text_field(wp_unslash($_POST['calliope_sp_cleanup_on_delete'] ?? ''))) ? '1' : '0';
        update_option('calliope_sp_cleanup_on_delete', $enabled, false);

        $message = ($enabled === '1')
            ? __('Calliope Social Publisher will delete its saved data when the plugin is deleted.', 'calliope-social-publisher')
            : __('Calliope Social Publisher will keep its saved data when the plugin is deleted.', 'calliope-social-publisher');

        $this->redirect_msg('success', $message, 'logs');
    }

    // =============================
    // Flipboard (RSS helper)
    // =============================

    public function handle_flipboard_test_connection() {
        if (!current_user_can('manage_options')) wp_die(esc_html__('Not allowed.', 'calliope-social-publisher'));
        check_admin_referer('calliope_sp_test_flipboard_action', 'calliope_sp_test_flipboard_nonce');

        $feed_url = esc_url_raw(wp_unslash($_POST['calliope_sp_flipboard_feed_url'] ?? ''));
        $enable_share = !empty(sanitize_text_field(wp_unslash($_POST['calliope_sp_flipboard_enable_share_button'] ?? ''))) ? '1' : '0';

        update_option('calliope_sp_flipboard_feed_url', $feed_url);
        update_option('calliope_sp_flipboard_enable_share_button', $enable_share);

        $fb = new Calliope_SP_Flipboard();
        $report = $fb->test_feed($feed_url ? $feed_url : $fb->get_feed_url());

        if (is_wp_error($report)) {
            Calliope_SP_Logger::log('flipboard', 'error', 'Flipboard RSS check failed: ' . $report->get_error_message());
            $this->redirect_msg('error', 'Flipboard: ' . $report->get_error_message(), 'connections', 'flipboard');
        }

        $msg = 'Flipboard RSS OK ✅ — items: ' . intval($report['items_found']) . '.';
        if (!empty($report['latest_title'])) {
            $msg .= ' Latest: ' . $report['latest_title'];
        }
        $msg .= $report['has_images'] ? ' (includes images ✅)' : ' (no images detected ⚠️)';

        Calliope_SP_Logger::log('flipboard', 'success', 'Flipboard RSS check: ' . wp_json_encode($report));
        $this->redirect_msg('success', $msg, 'connections', 'flipboard');
    }

/**
 * Gutenberg integration: sidebar fields stored in post meta + optional auto-post on publish.
 */

}
