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

/**
 * Google Blogger integration using OAuth 2.0 and Blogger API v3.
 *
 * Connection readiness is local and marker-backed. Remote validation only occurs
 * during explicit administrator actions; publishing may renew an eligible token just in time before preparing content.
 */
class Calliope_SP_Blogger {
    const VALIDATION_SCHEMA = 'blogger-validation-v1';
    const VALIDATION_OPTION = 'calliope_sp_blogger_validation';
    const REQUIRED_SCOPE = 'https://www.googleapis.com/auth/blogger';
    const MAX_BLOG_ID_LENGTH = 64;
    const MAX_TOKEN_LENGTH = 8192;
    const MAX_IDENTITY_LENGTH = 256;
    const MAX_CACHE_ITEMS = 100;
    const MAX_ACCESS_TOKEN_LIFETIME = 31536000;
    const MAX_CLOCK_SKEW = 300;
    const MAX_IMAGE_ITEMS = 10;

    private $auth_base = 'https://accounts.google.com/o/oauth2/v2/auth';
    private $token_url = 'https://oauth2.googleapis.com/token';
    private $api_base  = 'https://www.googleapis.com/blogger/v3';

    public function get_client_id() { return $this->normalize_application_credential(get_option('calliope_sp_blogger_client_id', '')); }
    public function get_client_secret() { return $this->normalize_application_credential(get_option('calliope_sp_blogger_client_secret', '')); }
    public function get_access_token() { return $this->normalize_oauth_token(get_option('calliope_sp_blogger_access_token', '')); }
    public function get_refresh_token() { return $this->normalize_oauth_token(get_option('calliope_sp_blogger_refresh_token', '')); }
    public function get_expires_at() { return $this->normalize_timestamp_option(get_option('calliope_sp_blogger_expires_at', 0)); }
    public function get_blog_id() { return $this->normalize_blog_id(get_option('calliope_sp_blogger_blog_id', '')); }
    public function get_redirect_uri() { return rest_url('calliope-social-publisher/v1/blogger/callback'); }

    /**
     * Normalize an opaque OAuth token without casting or silently changing it.
     *
     * Leading or trailing whitespace is rejected because it is part of the
     * credential material and must never be trimmed into a different token.
     */
    public function normalize_oauth_token($value) {
        if (!is_string($value) || $value === '' || strlen($value) > self::MAX_TOKEN_LENGTH) return '';
        if (trim($value) !== $value || preg_match('/[\x00-\x1F\x7F]/', $value)) return '';
        return $value;
    }

    /** Local, side-effect-free connection state. */
    public function get_connection_state() {
        if (!class_exists('Calliope_SP_Connection_State')) {
            return new WP_Error('blogger_connection_contract_missing', 'Blogger connection state contract is unavailable.');
        }

        $client_id = $this->get_client_id();
        $client_secret = $this->get_client_secret();
        $raw_access_token = get_option('calliope_sp_blogger_access_token', '');
        $raw_refresh_token = get_option('calliope_sp_blogger_refresh_token', '');
        $access_token = $this->normalize_oauth_token($raw_access_token);
        $refresh_token = $this->normalize_oauth_token($raw_refresh_token);
        $access_token_corrupt = $this->has_option_material($raw_access_token) && $access_token === '';
        $refresh_token_corrupt = $this->has_option_material($raw_refresh_token) && $refresh_token === '';
        $expires_at = $this->get_expires_at();
        $blog_id = $this->get_blog_id();
        $scopes = $this->normalize_scopes(get_option('calliope_sp_blogger_granted_scopes', []));

        $has_client_id = $client_id !== '';
        $has_client_secret = $client_secret !== '';
        $has_access = $access_token !== '';
        $has_refresh = $refresh_token !== '';
        $has_any = $has_client_id || $has_client_secret || $has_access || $has_refresh
            || $access_token_corrupt || $refresh_token_corrupt
            || $blog_id !== '' || is_array(get_option(self::VALIDATION_OPTION, null));

        if (!$has_any) {
            return $this->create_state([
                'status' => 'not_configured', 'health' => 'unknown',
                'is_configured' => false, 'is_authenticated' => false, 'is_identity_known' => false,
                'has_target' => false, 'has_permission' => false, 'evidence_level' => 'E0',
                'message' => __('Enter Blogger OAuth application credentials.', 'calliope-social-publisher'),
                'action' => __('Save credentials', 'calliope-social-publisher'),
                'connection_method' => 'oauth2',
            ]);
        }

        if ($has_client_id xor $has_client_secret) {
            return $this->create_state([
                'status' => 'configuration_error', 'health' => 'invalid',
                'is_configured' => true, 'is_authenticated' => false, 'is_identity_known' => false,
                'has_target' => false, 'has_permission' => false, 'evidence_level' => 'E1',
                'message' => __('Blogger Client ID and Client Secret must both be configured.', 'calliope-social-publisher'),
                'action' => __('Complete the configuration', 'calliope-social-publisher'),
                'connection_method' => 'oauth2',
            ]);
        }

        if (!$has_client_id || !$has_client_secret) {
            return $this->create_state([
                'status' => 'configuration_error', 'health' => 'invalid',
                'is_configured' => true, 'is_authenticated' => false, 'is_identity_known' => false,
                'has_target' => false, 'has_permission' => false, 'evidence_level' => 'E1',
                'message' => __('Blogger tokens cannot be used without the matching OAuth application.', 'calliope-social-publisher'),
                'action' => __('Reconnect with Blogger', 'calliope-social-publisher'),
                'connection_method' => 'oauth2',
            ]);
        }

        if ($access_token_corrupt || $refresh_token_corrupt) {
            return $this->create_state([
                'status' => 'refresh_required', 'health' => 'invalid',
                'is_configured' => true, 'is_authenticated' => true, 'is_identity_known' => false,
                'has_target' => false, 'has_permission' => false, 'evidence_level' => 'E2',
                'message' => __('Blogger needs a complete offline OAuth session. Reconnect the Google account.', 'calliope-social-publisher'),
                'action' => __('Reconnect with Blogger', 'calliope-social-publisher'),
                'connection_method' => 'oauth2',
            ]);
        }

        if (!$has_access && !$has_refresh) {
            return $this->create_state([
                'status' => 'credentials_saved', 'health' => 'unknown',
                'is_configured' => true, 'is_authenticated' => false, 'is_identity_known' => false,
                'has_target' => false, 'has_permission' => false, 'evidence_level' => 'E1',
                'message' => __('Blogger application credentials are saved. Connect the Google account.', 'calliope-social-publisher'),
                'action' => __('Connect with Blogger', 'calliope-social-publisher'),
                'connection_method' => 'oauth2',
            ]);
        }

        if (!$has_access || !$has_refresh) {
            return $this->create_state([
                'status' => 'refresh_required', 'health' => 'invalid',
                'is_configured' => true, 'is_authenticated' => true, 'is_identity_known' => false,
                'has_target' => false, 'has_permission' => false, 'evidence_level' => 'E2',
                'message' => __('Blogger needs a complete offline OAuth session. Reconnect the Google account.', 'calliope-social-publisher'),
                'action' => __('Reconnect with Blogger', 'calliope-social-publisher'),
                'expires_at' => $expires_at, 'connection_method' => 'oauth2',
            ]);
        }

        if ($expires_at <= 0) {
            return $this->create_state([
                'status' => 'refresh_required', 'health' => 'invalid',
                'is_configured' => true, 'is_authenticated' => true, 'is_identity_known' => false,
                'has_target' => false, 'has_permission' => false, 'evidence_level' => 'E2',
                'message' => __('Blogger access token expiry is unknown. Reconnect before publishing.', 'calliope-social-publisher'),
                'action' => __('Reconnect with Blogger', 'calliope-social-publisher'),
                'connection_method' => 'oauth2',
            ]);
        }

        // Google access tokens normally expire after about one hour, but a valid
        // offline OAuth session remains publishable because publish() renews the
        // access token just in time with the saved refresh token. Do not make the
        // destination disappear from the publisher merely because the short-lived
        // access token reached its expiry time.
        $access_token_needs_refresh = $expires_at <= time();

        if (!$access_token_needs_refresh && !$this->is_future_expiry_plausible($expires_at)) {
            return $this->create_state([
                'status' => 'refresh_required', 'health' => 'invalid',
                'is_configured' => true, 'is_authenticated' => true, 'is_identity_known' => false,
                'has_target' => false, 'has_permission' => false, 'evidence_level' => 'E2',
                'message' => __('Blogger needs a complete offline OAuth session. Reconnect the Google account.', 'calliope-social-publisher'),
                'action' => __('Reconnect with Blogger', 'calliope-social-publisher'),
                'connection_method' => 'oauth2',
            ]);
        }

        $application_fingerprint = $this->application_fingerprint($client_id, $client_secret);
        $credentials_fingerprint = $this->credentials_fingerprint($client_id, $client_secret, $access_token, $refresh_token);
        $target_fingerprint = $blog_id !== '' ? $this->target_fingerprint($blog_id) : '';
        $credential_marker = $this->get_validation_marker_for_credentials($application_fingerprint, $credentials_fingerprint);
        if (is_array($credential_marker)
            && !$this->is_marker_lifecycle_consistent($credential_marker, $expires_at, !$access_token_needs_refresh)
            && !(!$access_token_needs_refresh && $this->is_unverified_degraded_marker_lifecycle_safe($credential_marker, $expires_at))) {
            return $this->create_state([
                'status' => 'refresh_required', 'health' => 'invalid',
                'is_configured' => true, 'is_authenticated' => true, 'is_identity_known' => false,
                'has_target' => false, 'has_permission' => false, 'evidence_level' => 'E2',
                'message' => __('Blogger needs a complete offline OAuth session. Reconnect the Google account.', 'calliope-social-publisher'),
                'action' => __('Reconnect with Blogger', 'calliope-social-publisher'),
                'expires_at' => $expires_at, 'connection_method' => 'oauth2',
            ]);
        }
        $target_marker = $blog_id !== ''
            ? $this->get_matching_validation_marker($application_fingerprint, $credentials_fingerprint, $target_fingerprint)
            : null;
        $marker = is_array($target_marker) ? $target_marker : $credential_marker;

        if (is_array($marker) && $marker['health'] === 'invalid') {
            $status_map = [
                'auth' => 'token_invalid', 'application' => 'configuration_error',
                'identity' => 'identity_missing', 'target' => 'target_missing', 'permission' => 'permission_missing',
            ];
            $status = $status_map[$marker['failure_kind']] ?? 'connected_unverified';
            $identity_known = $marker['identity_fingerprint'] !== '' && $marker['validated_at'] > 0
                && !in_array($status, ['token_invalid', 'configuration_error', 'identity_missing'], true);
            $has_target = $status === 'permission_missing' && is_array($target_marker)
                && $target_marker['resolved_blog_id'] !== '' && $target_marker['target_identity_fingerprint'] !== '';
            $evidence = $identity_known ? ($has_target ? 'E4' : 'E3') : ($status === 'configuration_error' ? 'E1' : 'E2');
            $messages = [
                'token_invalid' => __('The Blogger access token is invalid or was revoked.', 'calliope-social-publisher'),
                'configuration_error' => __('The Blogger OAuth application no longer matches this connection.', 'calliope-social-publisher'),
                'identity_missing' => __('Blogger could not confirm the connected account identity.', 'calliope-social-publisher'),
                'target_missing' => __('The selected Blogger blog is unavailable.', 'calliope-social-publisher'),
                'permission_missing' => __('The connected Google account cannot publish to the selected Blogger blog.', 'calliope-social-publisher'),
                'connected_unverified' => __('Verify Blogger again before publishing.', 'calliope-social-publisher'),
            ];
            return $this->create_state([
                'status' => $status, 'health' => $status === 'connected_unverified' ? 'unknown' : 'invalid',
                'is_configured' => true, 'is_authenticated' => true, 'is_identity_known' => $identity_known,
                'has_target' => $has_target, 'has_permission' => false, 'evidence_level' => $evidence,
                'message' => $messages[$status], 'action' => __('Reconnect with Blogger', 'calliope-social-publisher'),
                'validated_at' => $identity_known ? $marker['validated_at'] : 0,
                'expires_at' => $expires_at, 'connection_method' => 'oauth2',
            ]);
        }

        $identity_known = is_array($credential_marker)
            && $credential_marker['identity_fingerprint'] !== ''
            && $credential_marker['validated_at'] > 0;

        if ($blog_id === '') {
            if ($identity_known) {
                $blogs_cache = get_option('calliope_sp_blogger_blogs_last', null);
                $has_verified_empty_blog_list = is_array($blogs_cache) && count($blogs_cache) === 0;
                return $this->create_state([
                    'status' => 'target_missing', 'health' => 'invalid',
                    'is_configured' => true, 'is_authenticated' => true, 'is_identity_known' => true,
                    'has_target' => false, 'has_permission' => false, 'evidence_level' => 'E3',
                    'message' => $has_verified_empty_blog_list
                        ? __('The Google account connected successfully, but no Blogger blogs were found for which it is an author or administrator. Open Blogger with this same Google account or reconnect using the account that owns the blog.', 'calliope-social-publisher')
                        : __('Select a Blogger blog and verify the connection before publishing.', 'calliope-social-publisher'),
                    'action' => $has_verified_empty_blog_list
                        ? __('Reconnect with Blogger', 'calliope-social-publisher')
                        : __('Save and test', 'calliope-social-publisher'),
                    'validated_at' => $credential_marker['validated_at'], 'expires_at' => $expires_at,
                    'connection_method' => 'oauth2',
                ]);
            }
            return $this->create_state([
                'status' => 'connected_unverified', 'health' => is_array($credential_marker) && $credential_marker['health'] === 'degraded' ? 'degraded' : 'unknown',
                'is_configured' => true, 'is_authenticated' => true, 'is_identity_known' => false,
                'has_target' => false, 'has_permission' => false, 'evidence_level' => 'E2',
                'message' => __('Select a Blogger blog and verify the connection before publishing.', 'calliope-social-publisher'),
                'action' => __('Save and test', 'calliope-social-publisher'),
                'expires_at' => $expires_at, 'connection_method' => 'oauth2',
            ]);
        }

        if (!is_array($target_marker)) {
            return $this->create_state([
                'status' => 'connected_unverified', 'health' => 'unknown',
                'is_configured' => true, 'is_authenticated' => true, 'is_identity_known' => false,
                'has_target' => false, 'has_permission' => false, 'evidence_level' => 'E2',
                'message' => __('Blogger credentials are saved but this blog has not been verified.', 'calliope-social-publisher'),
                'action' => __('Save and test', 'calliope-social-publisher'),
                'expires_at' => $expires_at, 'connection_method' => 'oauth2',
            ]);
        }

        $target_known = $target_marker['identity_fingerprint'] !== ''
            && $target_marker['target_identity_fingerprint'] !== ''
            && $target_marker['resolved_blog_id'] === $blog_id
            && $target_marker['validated_at'] > 0;
        if (!$target_known) {
            return $this->create_state([
                'status' => 'connected_unverified', 'health' => $target_marker['health'] === 'degraded' ? 'degraded' : 'unknown',
                'is_configured' => true, 'is_authenticated' => true, 'is_identity_known' => $identity_known,
                'has_target' => false, 'has_permission' => false, 'evidence_level' => $identity_known ? 'E3' : 'E2',
                'message' => __('The selected Blogger blog has not been verified.', 'calliope-social-publisher'),
                'action' => __('Save and test', 'calliope-social-publisher'),
                'validated_at' => $identity_known ? $target_marker['validated_at'] : 0,
                'expires_at' => $expires_at, 'connection_method' => 'oauth2',
            ]);
        }

        if ($target_marker['blogger_scope_state'] !== 'granted' || !in_array(self::REQUIRED_SCOPE, $scopes, true)) {
            return $this->create_state([
                'status' => 'permission_missing', 'health' => 'invalid',
                'is_configured' => true, 'is_authenticated' => true, 'is_identity_known' => true,
                'has_target' => true, 'has_permission' => false, 'evidence_level' => 'E4',
                'message' => __('The Blogger OAuth token does not include publishing permission.', 'calliope-social-publisher'),
                'action' => __('Reconnect with Blogger', 'calliope-social-publisher'),
                'validated_at' => $target_marker['validated_at'], 'expires_at' => $expires_at,
                'connection_method' => 'oauth2',
            ]);
        }

        $ready_health = $access_token_needs_refresh ? 'degraded' : $target_marker['health'];
        return $this->create_state([
            'status' => 'ready_to_publish', 'health' => $ready_health,
            'is_configured' => true, 'is_authenticated' => true, 'is_identity_known' => true,
            'has_target' => true, 'has_permission' => true, 'evidence_level' => 'E5',
            'message' => $access_token_needs_refresh
                ? __('Blogger is ready and will renew its access token automatically when publishing.', 'calliope-social-publisher')
                : ($target_marker['health'] === 'degraded'
                    ? __('Blogger is ready, but the last remote check had a temporary problem.', 'calliope-social-publisher')
                    : __('Blogger is ready to publish.', 'calliope-social-publisher')),
            'action' => (!$access_token_needs_refresh && $target_marker['health'] === 'degraded')
                ? __('Save and test', 'calliope-social-publisher')
                : '',
            'validated_at' => $target_marker['validated_at'], 'expires_at' => $expires_at,
            'connection_method' => 'oauth2',
        ], 'E5');
    }

    public function normalize_blog_id($value) {
        if (!is_scalar($value) || is_bool($value) || is_float($value)) return '';
        $value = (string) $value;
        if (preg_match('/[\x00-\x1F\x7F]/', $value)) return '';
        $value = trim($value);
        if ($value === '' || !preg_match('/^\d{1,' . self::MAX_BLOG_ID_LENGTH . '}$/D', $value)) return '';
        return $value;
    }

    /**
     * Accept either a numeric Blogger Blog ID or a Blogger dashboard URL such as
     * https://www.blogger.com/blog/posts/1234567890 and return the numeric ID.
     */
    public function normalize_blog_reference($value) {
        if (!is_scalar($value) || is_bool($value) || is_float($value)) return '';
        $value = trim((string) $value);
        if ($value === '' || strlen($value) > 2048 || preg_match('/[\x00-\x1F\x7F]/', $value)) return '';

        $direct = $this->normalize_blog_id($value);
        if ($direct !== '') return $direct;

        $patterns = [
            '~(?:https?://)?(?:www\.)?blogger\.com/blog/posts/(\d{1,' . self::MAX_BLOG_ID_LENGTH . '})(?:[/?#]|$)~i',
            '~(?:[?&](?:blogID|blogId|blog_id)=)(\d{1,' . self::MAX_BLOG_ID_LENGTH . '})(?:[&#]|$)~i',
        ];
        foreach ($patterns as $pattern) {
            if (preg_match($pattern, $value, $match)) return $this->normalize_blog_id($match[1]);
        }
        return '';
    }

    public function normalize_scopes($value) {
        if (is_array($value)) $parts = $value;
        elseif (is_scalar($value)) $parts = preg_split('/[\s,]+/', trim((string) $value), -1, PREG_SPLIT_NO_EMPTY);
        else $parts = [];
        $safe = [];
        foreach ((array) $parts as $scope) {
            if (!is_scalar($scope)) continue;
            $scope = trim((string) $scope);
            if ($scope === self::REQUIRED_SCOPE) $safe[$scope] = true;
        }
        $safe = array_keys($safe);
        sort($safe, SORT_STRING);
        return $safe;
    }

    public function build_auth_url($state) {
        $state = is_scalar($state) ? trim((string) $state) : '';
        return add_query_arg([
            'client_id' => $this->get_client_id(),
            'redirect_uri' => $this->get_redirect_uri(),
            'response_type' => 'code',
            'access_type' => 'offline',
            'prompt' => 'consent',
            'include_granted_scopes' => 'true',
            'scope' => self::REQUIRED_SCOPE,
            'state' => $state,
        ], $this->auth_base);
    }

    public function get_application_fingerprint($client_id, $client_secret) {
        return $this->application_fingerprint((string) $client_id, (string) $client_secret);
    }

    public function get_stored_validation_marker() {
        return $this->normalize_validation_marker(get_option(self::VALIDATION_OPTION, null));
    }

    /**
     * Proves that the currently stored offline session may safely lend its refresh
     * token to a newly validated candidate for the same identity.
     *
     * This method is local and side-effect free. It returns no credential material.
     */
    public function can_reuse_stored_refresh_token_for_identity($candidate_identity_fingerprint) {
        if (!is_string($candidate_identity_fingerprint) || !preg_match('/^[a-f0-9]{64}$/D', $candidate_identity_fingerprint)) return false;

        $client_id = $this->get_client_id();
        $client_secret = $this->get_client_secret();
        $access_token = $this->get_access_token();
        $refresh_token = $this->get_refresh_token();
        $expires_at = $this->get_expires_at();
        $scopes = $this->normalize_scopes(get_option('calliope_sp_blogger_granted_scopes', []));
        if ($client_id === '' || $client_secret === '' || $access_token === '' || $refresh_token === '' || $expires_at <= 0) return false;
        if (!in_array(self::REQUIRED_SCOPE, $scopes, true)) return false;

        $application_fingerprint = $this->application_fingerprint($client_id, $client_secret);
        $credentials_fingerprint = $this->credentials_fingerprint($client_id, $client_secret, $access_token, $refresh_token);
        $marker = $this->get_validation_marker_for_credentials($application_fingerprint, $credentials_fingerprint);
        if (!is_array($marker) || $marker['auth_method'] !== 'oauth2') return false;
        if ($marker['identity_fingerprint'] === '' || !hash_equals($marker['identity_fingerprint'], $candidate_identity_fingerprint)) return false;
        if ($marker['validated_at'] <= 0 || $marker['blogger_scope_state'] !== 'granted') return false;
        if (!$this->is_marker_lifecycle_consistent($marker, $expires_at, false)) return false;
        if ($marker['health'] === 'invalid' && !in_array($marker['failure_kind'], ['target', 'permission'], true)) return false;
        return true;
    }

    /** Validate the persisted access-token lifecycle without HTTP or writes. */
    public function is_marker_lifecycle_consistent(array $marker, $expires_at, $require_future = true) {
        $marker = $this->normalize_validation_marker($marker);
        $option_expiry = $this->normalize_timestamp_option($expires_at);
        if (!is_array($marker) || $option_expiry <= 0) return false;

        $marker_expiry = $this->normalize_timestamp_option($marker['access_token_expires_at'] ?? 0);
        $validated_at = $this->normalize_timestamp_option($marker['validated_at'] ?? 0);
        if ($marker_expiry <= 0 || $validated_at <= 0 || $marker_expiry !== $option_expiry) return false;

        $now = time();
        $latest_validated_at = $this->safe_timestamp_add($now, self::MAX_CLOCK_SKEW);
        if ($latest_validated_at <= 0 || $validated_at > $latest_validated_at) return false;
        if ($marker_expiry <= $validated_at) return false;
        if (($marker_expiry - $validated_at) > self::MAX_ACCESS_TOKEN_LIFETIME) return false;
        if ($require_future && $marker_expiry <= $now) return false;
        return true;
    }

    public function invalidate_validation() { delete_option(self::VALIDATION_OPTION); }

    /** Explicit remote verification with just-in-time renewal when the OAuth session is eligible. */
    public function test_connection() {
        $lifecycle = $this->maybe_refresh_access_token();
        if (is_wp_error($lifecycle)) return $lifecycle;
        return $this->validate_connection_remote();
    }

    public function validate_connection_remote() {
        $session = $this->validate_local_oauth_session(false, true, false);
        if (is_wp_error($session)) return $session;

        $client_id = $session['client_id'];
        $client_secret = $session['client_secret'];
        $access_token = $session['access_token'];
        $refresh_token = $session['refresh_token'];
        $expires_at = $session['expires_at'];
        $scopes = $session['scopes'];
        $blog_id = $this->get_blog_id();

        $app_fp = $session['application_fingerprint'];
        $cred_fp = $session['credentials_fingerprint'];
        $target_fp = $blog_id !== '' ? $this->target_fingerprint($blog_id) : '';
        $credential_marker = $session['credential_marker'];

        if (!in_array(self::REQUIRED_SCOPE, $scopes, true)) {
            $this->save_invalid_marker('permission', $app_fp, $cred_fp, $target_fp, ['blogger_scope_state' => 'denied']);
            $this->sync_legacy_status('permission_missing');
            return $this->safe_error('blogger_scope_missing', __('The Blogger publishing scope was not granted.', 'calliope-social-publisher'), 'configuration', 0, false, 'permission');
        }

        $application_fingerprint = $app_fp;
        $credentials_fingerprint = $cred_fp;
        $target_fingerprint = $target_fp;
        $previous_marker = $blog_id !== ''
            ? $this->get_matching_validation_marker($application_fingerprint, $credentials_fingerprint, $target_fingerprint)
            : $credential_marker;

        $candidate = $this->validate_oauth_candidate($access_token, $expires_at, $scopes, $blog_id);
        if (is_wp_error($candidate)) {
            $this->apply_remote_failure_to_validation($candidate, $application_fingerprint, $credentials_fingerprint, $target_fingerprint, $previous_marker);
            return $candidate;
        }

        $marker = $this->build_marker_from_evidence(
            $application_fingerprint,
            $credentials_fingerprint,
            $target_fingerprint,
            $expires_at,
            $candidate
        );
        if (!is_array($marker) || !$this->save_validation_marker($marker)) {
            return $this->safe_error('blogger_marker_persistence', __('Blogger could not save the verified connection.', 'calliope-social-publisher'), 'configuration', 0, true, 'temporary');
        }
        if (!empty($candidate['blogs_cache_verified'])) {
            update_option('calliope_sp_blogger_blogs_last', $candidate['blogs_cache'], false);
        } elseif (!empty($candidate['discovery_failed'])) {
            delete_option('calliope_sp_blogger_blogs_last');
        }
        $state = $this->get_connection_state();
        $this->sync_legacy_status(!is_wp_error($state) ? $state->get_status() : 'connected_unverified');
        if (!empty($candidate['target_failure'])) {
            return $this->safe_error(
                $candidate['target_failure'] === 'permission' ? 'blogger_permission_missing' : 'blogger_target_missing',
                $candidate['target_failure'] === 'permission'
                    ? __('The connected account cannot publish to the selected Blogger blog.', 'calliope-social-publisher')
                    : __('The selected Blogger blog could not be found.', 'calliope-social-publisher'),
                'target', (int) ($candidate['target_http_code'] ?? 0), false, $candidate['target_failure']
            );
        }
        if ($blog_id === '' && !empty($candidate['discovery_failed'])) {
            $discovery_message = isset($candidate['discovery_error_message']) && is_string($candidate['discovery_error_message'])
                ? trim($candidate['discovery_error_message']) : '';
            if ($discovery_message === '') {
                $discovery_message = __('The Google account connected, but Blogger could not load the blog list automatically. Paste the numeric Blog ID or Blogger dashboard URL in the manual destination field, then click Save and test.', 'calliope-social-publisher');
            }
            return $this->safe_error(
                isset($candidate['discovery_error_code']) && is_string($candidate['discovery_error_code']) && $candidate['discovery_error_code'] !== '' ? $candidate['discovery_error_code'] : 'blogger_discovery_failed',
                $discovery_message,
                'blogs', (int) ($candidate['discovery_http_code'] ?? 0), true, 'temporary'
            );
        }
        return true;
    }

    /**
     * Validate a candidate access token in memory. No options or markers are written.
     * Returns only bounded evidence plus a sanitized blogs cache.
     */
    public function validate_oauth_candidate($access_token, $expires_at, $scopes, $blog_id = '') {
        $access_token = $this->normalize_oauth_token($access_token);
        $expires_at = $this->normalize_timestamp_option($expires_at);
        $scopes = $this->normalize_scopes($scopes);
        $blog_id = $this->normalize_blog_id($blog_id);
        if ($access_token === '') {
            return $this->safe_error('blogger_candidate_token', __('Blogger returned an invalid access token.', 'calliope-social-publisher'), 'token_exchange', 0, false, 'auth');
        }
        if (!$this->is_future_expiry_plausible($expires_at)) return $this->safe_error('blogger_candidate_expired', __('Blogger returned an expired access token.', 'calliope-social-publisher'), 'token_exchange', 0, false, 'auth');
        if (!in_array(self::REQUIRED_SCOPE, $scopes, true)) return $this->safe_error('blogger_candidate_scope', __('Blogger publishing permission was not granted.', 'calliope-social-publisher'), 'token_exchange', 0, false, 'permission');

        $identity_response = $this->request('GET', '/users/self', $access_token, null, 'identity');
        if (is_wp_error($identity_response)) return $identity_response;
        $identity = $this->validate_identity_response($identity_response);
        if (is_wp_error($identity)) return $identity;

        $blogs = $this->discover_blogs($access_token);
        $discovery_error = is_wp_error($blogs) ? $blogs : null;
        if ($discovery_error) {
            $blogs = ['ids' => [], 'cache' => [], 'verified' => false];
        }

        $identity_fingerprint = $this->identity_fingerprint($identity);
        $cache = $blogs['cache'];
        $result = [
            'identity_fingerprint' => $identity_fingerprint,
            'resolved_blog_id' => '',
            'target_identity_fingerprint' => '',
            'blogger_scope_state' => 'granted',
            'blogs_cache' => $cache,
            'blogs_cache_verified' => !empty($blogs['verified']),
            'discovery_failed' => $discovery_error instanceof WP_Error,
            'discovery_http_code' => $discovery_error instanceof WP_Error ? (int) (($discovery_error->get_error_data()['http_code'] ?? 0)) : 0,
            'discovery_error_code' => $discovery_error instanceof WP_Error ? (string) $discovery_error->get_error_code() : '',
            'discovery_error_message' => $discovery_error instanceof WP_Error ? (string) $discovery_error->get_error_message() : '',
            'target_failure' => '',
            'target_http_code' => 0,
        ];
        if ($blog_id === '') return $result;

        if (isset($blogs['ids'][$blog_id])) {
            $result['resolved_blog_id'] = $blog_id;
            $result['target_identity_fingerprint'] = $this->target_identity_fingerprint($identity_fingerprint, $blog_id);
            return $result;
        }

        // The per-user BlogUserInfos endpoint is the strongest fallback when
        // automatic discovery fails or when an administrator enters a Blog ID manually.
        // A successful response proves that the authenticated Google user is related
        // to this exact Blogger blog instead of merely proving that the blog is public.
        $membership = $this->request('GET', '/users/self/blogs/' . rawurlencode($blog_id), $access_token, null, 'target');
        if (!is_wp_error($membership)) {
            $verified_target = $this->validate_blog_user_info_response($membership, $identity, $blog_id);
            if (is_wp_error($verified_target)) return $verified_target;
            $result['resolved_blog_id'] = $blog_id;
            $result['target_identity_fingerprint'] = $this->target_identity_fingerprint($identity_fingerprint, $blog_id);
            if (!empty($verified_target['cache'])) {
                $result['blogs_cache'] = $this->merge_blogs_cache($cache, $verified_target['cache']);
                $result['blogs_cache_verified'] = true;
            }
            $result['discovery_failed'] = false;
            return $result;
        }

        $membership_kind = $this->error_failure_kind($membership);
        if ($membership_kind === 'target') {
            $result['target_failure'] = 'target';
            $result['target_http_code'] = (int) (($membership->get_error_data()['http_code'] ?? 0));
            return $result;
        }
        if ($membership_kind === 'permission') {
            $result['target_failure'] = 'permission';
            $result['target_http_code'] = (int) (($membership->get_error_data()['http_code'] ?? 0));
            return $result;
        }

        // If list discovery succeeded, a missing per-user relation is authoritative.
        // If discovery itself failed, surface the stronger membership error instead of
        // the generic discovery error so the administrator gets the most useful clue.
        if ($discovery_error instanceof WP_Error) return $membership;
        return $membership;
    }

    /** Exchange authorization code without writing options. */
    public function exchange_code_candidate($code) {
        $code = is_string($code) && trim($code) === $code ? $code : '';
        $client_id = $this->get_client_id();
        $client_secret = $this->get_client_secret();
        if ($code === '' || strlen($code) > 4096 || preg_match('/[\x00-\x1F\x7F]/', $code)) return $this->safe_error('blogger_oauth_code', __('Blogger OAuth returned an invalid authorization code.', 'calliope-social-publisher'), 'token_exchange', 0, false, 'local');
        if ($client_id === '' || $client_secret === '') return $this->safe_error('blogger_oauth_application', __('Blogger OAuth application credentials are incomplete.', 'calliope-social-publisher'), 'token_exchange', 0, false, 'application');

        $response = wp_safe_remote_request($this->token_url, [
            'method' => 'POST', 'timeout' => 30, 'redirection' => 0,
            'reject_unsafe_urls' => true, 'sslverify' => true,
            'headers' => ['Accept' => 'application/json', 'Content-Type' => 'application/x-www-form-urlencoded'],
            'body' => [
                'code' => $code, 'client_id' => $client_id, 'client_secret' => $client_secret,
                'redirect_uri' => $this->get_redirect_uri(), 'grant_type' => 'authorization_code',
            ],
        ]);
        if (is_wp_error($response)) return $this->safe_error('blogger_oauth_transport', __('Blogger OAuth could not contact the token service.', 'calliope-social-publisher'), 'token_exchange', 0, true, 'temporary');
        $http = (int) wp_remote_retrieve_response_code($response);
        $json = json_decode((string) wp_remote_retrieve_body($response), true);
        if ($http < 200 || $http >= 300 || !is_array($json)) {
            $kind = ($http === 401 || (is_array($json) && ($json['error'] ?? '') === 'invalid_client')) ? 'application' : (($http === 408 || $http === 429 || $http >= 500) ? 'temporary' : 'temporary');
            return $this->safe_error('blogger_oauth_exchange', __('Blogger OAuth could not obtain a valid token response.', 'calliope-social-publisher'), 'token_exchange', $http, $kind === 'temporary', $kind);
        }

        $access = $this->normalize_oauth_token($json['access_token'] ?? null);
        $refresh = array_key_exists('refresh_token', $json)
            ? $this->normalize_oauth_token($json['refresh_token'])
            : '';
        $refresh_supplied = array_key_exists('refresh_token', $json);
        $token_type = isset($json['token_type']) && is_string($json['token_type']) ? strtolower(trim($json['token_type'])) : '';
        $scopes = $this->normalize_scopes($json['scope'] ?? null);
        $expires_in_raw = $json['expires_in'] ?? null;
        $expires_in = 0;
        if (is_int($expires_in_raw)) $expires_in = $expires_in_raw;
        elseif (is_string($expires_in_raw) && preg_match('/^[1-9]\d{0,9}$/D', $expires_in_raw)) $expires_in = (int) $expires_in_raw;

        if ($access === '' || ($refresh_supplied && $refresh === '')
            || $token_type !== 'bearer' || $expires_in <= 0 || $expires_in > self::MAX_ACCESS_TOKEN_LIFETIME
            || !in_array(self::REQUIRED_SCOPE, $scopes, true)) {
            return $this->safe_error('blogger_oauth_token_response', __('Blogger returned an incomplete or invalid token response.', 'calliope-social-publisher'), 'token_exchange', $http, true, 'temporary');
        }
        $now = time();
        if ($expires_in > PHP_INT_MAX - $now) return $this->safe_error('blogger_oauth_expiry', __('Blogger returned an invalid token expiry.', 'calliope-social-publisher'), 'token_exchange', $http, true, 'temporary');
        return [
            'access_token' => $access,
            'refresh_token' => $refresh,
            'expires_at' => $now + $expires_in,
            'scopes' => $scopes,
            'token_type' => 'bearer',
        ];
    }

    /** Commit a fully validated OAuth candidate and its marker. */
    public function commit_oauth_candidate(array $token_data, $refresh_token, array $evidence) {
        $access_token = $this->normalize_oauth_token($token_data['access_token'] ?? null);
        $refresh_token = $this->normalize_oauth_token($refresh_token);
        $token_type = isset($token_data['token_type']) && is_string($token_data['token_type'])
            ? strtolower(trim($token_data['token_type'])) : '';
        $expires_at = $this->normalize_timestamp_option($token_data['expires_at'] ?? 0);
        $scopes = $this->normalize_scopes($token_data['scopes'] ?? []);
        $client_id = $this->get_client_id();
        $client_secret = $this->get_client_secret();
        $blog_id = $this->get_blog_id();

        if ($client_id === '' || $client_secret === '' || $access_token === '' || $refresh_token === ''
            || $token_type !== 'bearer' || !$this->is_future_expiry_plausible($expires_at)
            || !in_array(self::REQUIRED_SCOPE, $scopes, true)) {
            return false;
        }

        $evidence = $this->normalize_commit_evidence($evidence, $blog_id);
        if (!is_array($evidence)) return false;

        $application_fingerprint = $this->application_fingerprint($client_id, $client_secret);
        $credentials_fingerprint = $this->credentials_fingerprint($client_id, $client_secret, $access_token, $refresh_token);
        $target_fingerprint = $blog_id !== '' ? $this->target_fingerprint($blog_id) : '';
        $marker = $this->build_marker_from_evidence(
            $application_fingerprint,
            $credentials_fingerprint,
            $target_fingerprint,
            $expires_at,
            $evidence
        );
        if (!is_array($marker) || !$this->is_marker_lifecycle_consistent($marker, $expires_at)) return false;

        $option_names = [
            'calliope_sp_blogger_access_token',
            'calliope_sp_blogger_refresh_token',
            'calliope_sp_blogger_expires_at',
            'calliope_sp_blogger_granted_scopes',
            'calliope_sp_blogger_auth_method',
            'calliope_sp_blogger_blogs_last',
            self::VALIDATION_OPTION,
            'calliope_sp_blogger_connection_status',
        ];
        $snapshot = $this->capture_option_snapshot($option_names);
        $candidate_values = [
            'calliope_sp_blogger_access_token' => $access_token,
            'calliope_sp_blogger_refresh_token' => $refresh_token,
            'calliope_sp_blogger_expires_at' => $expires_at,
            'calliope_sp_blogger_granted_scopes' => $scopes,
            'calliope_sp_blogger_auth_method' => 'oauth2',
        ];

        foreach ($candidate_values as $name => $value) {
            if (!$this->persist_option_value($name, $value)) {
                $this->restore_option_snapshot($snapshot);
                return false;
            }
        }
        if (!empty($evidence['blogs_cache_verified'])) {
            if (!$this->persist_option_value('calliope_sp_blogger_blogs_last', $evidence['blogs_cache'])) {
                $this->restore_option_snapshot($snapshot);
                return false;
            }
        } else {
            delete_option('calliope_sp_blogger_blogs_last');
        }
        if (!$this->save_validation_marker($marker)) {
            $this->restore_option_snapshot($snapshot);
            return false;
        }

        $persisted_marker = $this->normalize_validation_marker(get_option(self::VALIDATION_OPTION, null));
        if (!is_array($persisted_marker) || $persisted_marker !== $marker) {
            $this->restore_option_snapshot($snapshot);
            return false;
        }

        $state = $this->get_connection_state();
        if (is_wp_error($state)) {
            $this->restore_option_snapshot($snapshot);
            return false;
        }
        $expected_status = $evidence['target_failure'] !== ''
            ? ($evidence['target_failure'] === 'permission' ? 'permission_missing' : 'target_missing')
            : ($blog_id === '' ? 'target_missing' : 'ready_to_publish');
        if ($state->get_status() !== $expected_status
            || ($expected_status === 'ready_to_publish' && !$state->is_ready())) {
            $this->restore_option_snapshot($snapshot);
            return false;
        }
        if (!$this->persist_option_value('calliope_sp_blogger_connection_status', $expected_status)) {
            $this->restore_option_snapshot($snapshot);
            return false;
        }
        return true;
    }

    /** Explicit blog list helper. It never selects a blog and never refreshes. */
    public function fetch_blogs() {
        $session = $this->validate_local_oauth_session(false, true);
        if (is_wp_error($session)) return $session;

        $blogs = $this->discover_blogs($session['access_token']);
        if (is_wp_error($blogs)) return $blogs;
        update_option('calliope_sp_blogger_blogs_last', $blogs['cache'], false);
        return $blogs['cache'];
    }

    /**
     * Refresh the Google access token only during an explicit action and only
     * when it is expired or within the shared lifecycle threshold.
     *
     * @return true|WP_Error
     */
    public function maybe_refresh_access_token($threshold_seconds = null) {
        $threshold_seconds = $threshold_seconds === null && class_exists('Calliope_SP_Master')
            ? Calliope_SP_Master::get_lifecycle_refresh_threshold_seconds()
            : max(0, (int) $threshold_seconds);
        $session = $this->validate_local_oauth_session(false, false, true);
        if (is_wp_error($session)) return $session;
        $now = time();
        if ($session['expires_at'] > ($now + $threshold_seconds)) return true;

        $lock = class_exists('Calliope_SP_Master') ? Calliope_SP_Master::acquire_lifecycle_refresh_lock('blogger') : true;
        if (is_wp_error($lock)) {
            return $this->get_expires_at() > (time() + $threshold_seconds) ? true : $lock;
        }
        try {
            $session = $this->validate_local_oauth_session(false, false, true);
            if (is_wp_error($session)) return $session;
            if ($session['expires_at'] > (time() + $threshold_seconds)) return true;

            $response = wp_remote_post('https://oauth2.googleapis.com/token', [
                'timeout'=>30, 'redirection'=>0, 'reject_unsafe_urls'=>true, 'sslverify'=>true,
                'headers'=>['Accept'=>'application/json','Content-Type'=>'application/x-www-form-urlencoded'],
                'body'=>[
                    'client_id'=>$session['client_id'], 'client_secret'=>$session['client_secret'],
                    'refresh_token'=>$session['refresh_token'], 'grant_type'=>'refresh_token',
                ],
            ]);
            if (is_wp_error($response)) {
                if ($session['expires_at'] > time()) {
                    $this->mark_refresh_degraded($session['credential_marker']);
                    return true;
                }
                return $this->safe_error('blogger_refresh_transport', __('Blogger could not renew the access token right now.', 'calliope-social-publisher'), 'refresh', 0, true, 'temporary');
            }
            $http = (int) wp_remote_retrieve_response_code($response);
            $json = json_decode((string) wp_remote_retrieve_body($response), true);
            if ($http === 429 || $http >= 500) {
                if ($session['expires_at'] > time()) {
                    $this->mark_refresh_degraded($session['credential_marker']);
                    return true;
                }
                return $this->safe_error('blogger_refresh_temporary', __('Blogger could not renew the access token right now.', 'calliope-social-publisher'), 'refresh', $http, true, 'temporary');
            }
            if ($http < 200 || $http >= 300 || !is_array($json)) {
                $this->mark_refresh_invalid($session['credential_marker']);
                return $this->safe_error('blogger_refresh_rejected', __('Blogger requires the Google account to be reconnected.', 'calliope-social-publisher'), 'refresh', $http, false, 'auth');
            }
            $access_token = $this->normalize_oauth_token($json['access_token'] ?? null);
            $token_type = isset($json['token_type']) && is_scalar($json['token_type']) ? strtolower(trim((string)$json['token_type'])) : '';
            $expires_in = isset($json['expires_in']) && is_numeric($json['expires_in']) ? (int)$json['expires_in'] : 0;
            $scopes = array_key_exists('scope', $json) ? $this->normalize_scopes($json['scope']) : $session['scopes'];
            if ($access_token === '' || $token_type !== 'bearer' || $expires_in <= 0 || !in_array(self::REQUIRED_SCOPE, $scopes, true)) {
                return $this->safe_error('blogger_refresh_invalid_response', __('Blogger returned an incomplete token renewal response.', 'calliope-social-publisher'), 'refresh', $http, true, 'temporary');
            }
            $expires_at = time() + $expires_in;
            $candidate = $this->validate_oauth_candidate($access_token, $expires_at, $scopes, $this->get_blog_id());
            if (is_wp_error($candidate)) return $candidate;
            if (!$this->can_reuse_stored_refresh_token_for_identity((string)($candidate['identity_fingerprint'] ?? ''))) {
                return $this->safe_error('blogger_refresh_identity', __('Blogger returned a different Google account during renewal.', 'calliope-social-publisher'), 'identity', 200, false, 'identity');
            }
            $token_data = ['access_token'=>$access_token,'token_type'=>'bearer','expires_at'=>$expires_at,'scopes'=>$scopes];
            if (!$this->commit_oauth_candidate($token_data, $session['refresh_token'], $candidate)) {
                return $this->safe_error('blogger_refresh_commit', __('Blogger token renewal could not be committed safely.', 'calliope-social-publisher'), 'refresh', 0, true, 'temporary');
            }
            return true;
        } finally {
            if (class_exists('Calliope_SP_Master') && is_string($lock)) Calliope_SP_Master::release_lifecycle_refresh_lock('blogger', $lock);
        }
    }

    public function ensure_access_token() {
        $lifecycle = $this->maybe_refresh_access_token();
        if (is_wp_error($lifecycle)) return $lifecycle;
        $session = $this->validate_local_oauth_session(false, true);
        if (is_wp_error($session)) return $session;
        return $session['access_token'];
    }

    /** Compatibility wrapper; returns candidate data and does not persist it. */
    public function exchange_code($code) { return $this->exchange_code_candidate($code); }

    public function publish($data) {
        $lifecycle = $this->maybe_refresh_access_token();
        if (is_wp_error($lifecycle)) return $lifecycle;
        $session = $this->validate_local_oauth_session(false, true);
        if (is_wp_error($session)) return $session;

        $state = $this->get_connection_state();
        if (is_wp_error($state) || !($state instanceof Calliope_SP_Connection_State) || !$state->is_ready()) {
            return $this->safe_error('blogger_not_ready', __('Verify Blogger and select a blog before publishing.', 'calliope-social-publisher'), 'publish', 0, false, 'local');
        }

        $client_id = $session['client_id'];
        $client_secret = $session['client_secret'];
        $access = $session['access_token'];
        $refresh = $session['refresh_token'];
        $blog_id = $this->get_blog_id();
        $target_fp = $this->target_fingerprint($blog_id);
        $marker = $this->get_matching_validation_marker(
            $this->application_fingerprint($client_id, $client_secret),
            $this->credentials_fingerprint($client_id, $client_secret, $access, $refresh),
            $target_fp
        );
        if (!is_array($marker) || $marker['resolved_blog_id'] === '' || !hash_equals($blog_id, $marker['resolved_blog_id'])) {
            return $this->safe_error('blogger_target_unverified', __('The Blogger destination is not verified.', 'calliope-social-publisher'), 'publish', 0, false, 'target');
        }
        if (!$this->is_marker_lifecycle_consistent($marker, $this->get_expires_at())) {
            return $this->safe_error('blogger_refresh_required', __('Blogger needs a complete offline OAuth session. Reconnect the Google account.', 'calliope-social-publisher'), 'publish', 0, false, 'local');
        }
        $resolved_blog_id = $marker['resolved_blog_id'];
        $temp_images = [];
        $image_assets = $this->image_assets_from_payload((array) $data);
        if (is_wp_error($image_assets)) {
            return $image_assets;
        }
        if ($image_assets !== []) {
            $data['image_urls'] = array_values(array_filter(array_map(static function ($asset) {
                return is_array($asset) ? esc_url_raw((string) ($asset['url'] ?? '')) : '';
            }, $image_assets)));
            $data['image_alts'] = array_values(array_map(static function ($asset) {
                return is_array($asset) ? sanitize_text_field((string) ($asset['alt'] ?? '')) : '';
            }, $image_assets));
            foreach ($image_assets as $asset) {
                if (is_array($asset) && !empty($asset['temporary_path'])) {
                    $temp_images[] = (string) $asset['temporary_path'];
                }
            }
            if (empty($data['image_url']) && !empty($data['image_urls'][0])) {
                $data['image_url'] = $data['image_urls'][0];
            }
        } elseif (!empty($data['image_b64']) && empty($data['image_url'])) {
            $temp_image = $this->image_url_from_b64((string) $data['image_b64']);
            if (is_wp_error($temp_image)) return $temp_image;
            $data['image_url'] = $temp_image['url'];
            if (!empty($temp_image['path'])) {
                $temp_images[] = (string) $temp_image['path'];
            }
        }
        $title = !empty($data['title']) ? sanitize_text_field((string) $data['title']) : '';
        $html = $this->format_html($data);
        $payload = ['kind' => 'blogger#post', 'title' => $title, 'content' => $html];
        $response = $this->request('POST', '/blogs/' . rawurlencode($resolved_blog_id) . '/posts', $access, $payload, 'publish');
        if (is_wp_error($response)) {
            foreach ($temp_images as $path) $this->delete_temp_image($path);
            $this->apply_operation_failure($response, $marker);
            return $response;
        }
        $json = $response['json'];
        $post_id = isset($json['id']) && is_scalar($json['id']) ? trim((string) $json['id']) : '';
        if ($post_id === '' || strlen($post_id) > 256 || preg_match('/[\x00-\x1F\x7F]/', $post_id)) {
            foreach ($temp_images as $path) $this->delete_temp_image($path);
            $error = $this->safe_error('blogger_publish_response', __('Blogger returned an incomplete publishing response.', 'calliope-social-publisher'), 'publish', (int) $response['http_code'], true, 'temporary');
            $this->apply_operation_failure($error, $marker);
            return $error;
        }
        if (isset($json['blog']['id'])) {
            $response_blog = $this->normalize_blog_id($json['blog']['id']);
            if ($response_blog === '' || !hash_equals($resolved_blog_id, $response_blog)) {
                foreach ($temp_images as $path) $this->delete_temp_image($path);
                $error = $this->safe_error('blogger_publish_target', __('Blogger returned a different destination blog.', 'calliope-social-publisher'), 'publish', (int) $response['http_code'], false, 'target');
                $this->apply_operation_failure($error, $marker);
                return $error;
            }
        }
        $marker['health'] = 'healthy';
        $marker['failure_kind'] = '';
        $marker['validated_at'] = time();
        $marker['blogger_scope_state'] = 'granted';
        $this->save_validation_marker($marker);
        $this->sync_legacy_status('ready_to_publish');
        return [
            'ok' => true,
            'post_id' => sanitize_text_field($post_id),
            'url' => isset($json['url']) && is_scalar($json['url']) ? esc_url_raw((string) $json['url']) : '',
            'media_type' => !empty($data['image_url']) || !empty($data['image_b64']) ? 'image' : 'text',
        ];
    }

    /**
     * Discover Blogger blogs using the simplest documented endpoint first and
     * compatibility fallbacks only when the previous request fails.
     *
     * @return array{ids:array<string,bool>,cache:array<int,array{id:string,name:string,url:string}>,verified:bool}|WP_Error
     */
    private function discover_blogs($access_token) {
        $endpoints = [
            '/users/self/blogs',
            '/users/self/blogs?view=AUTHOR',
            '/users/self/blogs?view=ADMIN',
        ];
        $last_error = null;
        foreach ($endpoints as $index => $endpoint) {
            $response = $this->request('GET', $endpoint, $access_token, null, 'blogs');
            if (is_wp_error($response)) {
                $last_error = $response;
                $this->log_blogger_event('blog_discovery_attempt_failed', [
                    'attempt' => $index + 1,
                    'endpoint' => $endpoint,
                    'error_code' => (string) $response->get_error_code(),
                    'http_code' => (int) (($response->get_error_data()['http_code'] ?? 0)),
                ], 'warning');
                if (in_array((string) $response->get_error_code(), ['blogger_auth', 'blogger_api_disabled', 'blogger_scope_missing', 'blogger_permission'], true)) {
                    return $response;
                }
                continue;
            }
            $blogs = $this->validate_blogs_response($response);
            if (is_wp_error($blogs)) {
                $last_error = $blogs;
                continue;
            }
            $blogs['verified'] = true;
            $this->log_blogger_event('blog_discovery_success', [
                'attempt' => $index + 1,
                'endpoint' => $endpoint,
                'blogs_found' => count($blogs['cache']),
            ]);
            return $blogs;
        }
        return $last_error instanceof WP_Error
            ? $last_error
            : $this->safe_error('blogger_discovery_failed', __('Blogger could not load the available blogs.', 'calliope-social-publisher'), 'blogs', 0, true, 'temporary');
    }

    /** Validate the authenticated user's relation to one exact blog. */
    private function validate_blog_user_info_response(array $response, $identity, $expected_blog_id) {
        $json = $response['json'];
        if (isset($json['kind']) && $json['kind'] !== 'blogger#blogUserInfo') {
            return $this->safe_error('blogger_blog_user_info_kind', __('Blogger returned an unexpected blog permission response.', 'calliope-social-publisher'), 'target', (int) $response['http_code'], true, 'temporary');
        }
        $blog = isset($json['blog']) && is_array($json['blog']) ? $json['blog'] : [];
        $per_user = isset($json['blog_user_info']) && is_array($json['blog_user_info']) ? $json['blog_user_info'] : [];
        $blog_id = $this->normalize_blog_id($blog['id'] ?? '');
        $per_user_blog_id = $this->normalize_blog_id($per_user['blogId'] ?? '');
        $user_id = isset($per_user['userId']) && is_scalar($per_user['userId']) ? trim((string) $per_user['userId']) : '';
        if ($blog_id === '' || $per_user_blog_id === '' || !hash_equals($expected_blog_id, $blog_id) || !hash_equals($expected_blog_id, $per_user_blog_id)) {
            return $this->safe_error('blogger_blog_user_info_target', __('Blogger returned a different blog than requested.', 'calliope-social-publisher'), 'target', (int) $response['http_code'], false, 'target');
        }
        if ($user_id !== '' && is_string($identity) && $identity !== '' && !hash_equals((string) $identity, $user_id)) {
            return $this->safe_error('blogger_blog_user_info_identity', __('Blogger returned a different Google user than the connected account.', 'calliope-social-publisher'), 'target', (int) $response['http_code'], false, 'identity');
        }
        $name = isset($blog['name']) && is_scalar($blog['name']) ? $this->safe_text($blog['name'], 120) : $blog_id;
        $url = isset($blog['url']) && is_scalar($blog['url']) ? esc_url_raw((string) $blog['url']) : '';
        return ['cache' => [['id' => $blog_id, 'name' => $name !== '' ? $name : $blog_id, 'url' => $url]]];
    }

    private function merge_blogs_cache(array $left, array $right) {
        return $this->normalize_blogs_cache(array_merge($left, $right));
    }

    private function log_blogger_event($event, array $context = [], $level = 'info') {
        if (!class_exists('Calliope_SP_Logger')) return;
        $payload = ['event' => sanitize_key((string) $event)];
        foreach ($context as $key => $value) {
            $key = sanitize_key((string) $key);
            if ($key === '') continue;
            if (is_bool($value) || is_int($value) || is_float($value)) $payload[$key] = $value;
            elseif (is_scalar($value)) $payload[$key] = $this->safe_text((string) $value, 300);
        }
        Calliope_SP_Logger::log('blogger', in_array($level, ['info','warning','error'], true) ? $level : 'info', wp_json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
    }

    private function log_blogger_api_failure($phase, $http_code, array $json, $endpoint) {
        $provider_status = '';
        $provider_code = '';
        $provider_message = '';
        $provider_reason = '';
        if (isset($json['error']['status']) && is_scalar($json['error']['status'])) $provider_status = (string) $json['error']['status'];
        if (isset($json['error']['code']) && is_scalar($json['error']['code'])) $provider_code = (string) $json['error']['code'];
        if (isset($json['error']['message']) && is_scalar($json['error']['message'])) $provider_message = (string) $json['error']['message'];
        if (!empty($json['error']['errors']) && is_array($json['error']['errors'])) {
            foreach ($json['error']['errors'] as $item) {
                if (is_array($item) && isset($item['reason']) && is_scalar($item['reason'])) { $provider_reason = (string) $item['reason']; break; }
            }
        }
        if ($provider_reason === '' && !empty($json['error']['details']) && is_array($json['error']['details'])) {
            foreach ($json['error']['details'] as $item) {
                if (is_array($item) && isset($item['reason']) && is_scalar($item['reason'])) { $provider_reason = (string) $item['reason']; break; }
            }
        }
        $this->log_blogger_event('api_error', [
            'phase' => (string) $phase,
            'http_code' => (int) $http_code,
            'endpoint' => (string) $endpoint,
            'provider_code' => $provider_code,
            'provider_status' => $provider_status,
            'provider_reason' => $provider_reason,
            'provider_message' => $provider_message,
        ], 'error');
    }

    private function request($method, $endpoint, $token, $body = null, $phase = 'request') {
        $method = strtoupper((string) $method);
        if (!$this->is_allowlisted_endpoint($method, $endpoint)) return $this->safe_error('blogger_endpoint', __('Blogger request endpoint is not allowed.', 'calliope-social-publisher'), $phase, 0, false, 'local');
        $url = $this->api_base . $endpoint;
        $args = [
            'method' => $method, 'timeout' => 30, 'redirection' => 0,
            'reject_unsafe_urls' => true, 'sslverify' => true,
            'headers' => ['Authorization' => 'Bearer ' . (string) $token, 'Accept' => 'application/json'],
        ];
        if ($body !== null) {
            $args['headers']['Content-Type'] = 'application/json; charset=utf-8';
            $args['body'] = wp_json_encode($body);
        }
        $response = wp_safe_remote_request($url, $args);
        if (is_wp_error($response)) return $this->safe_error('blogger_transport', __('Blogger could not be reached. Please try again.', 'calliope-social-publisher'), $phase, 0, true, 'temporary');
        $http = (int) wp_remote_retrieve_response_code($response);
        $raw = (string) wp_remote_retrieve_body($response);
        $json = json_decode($raw, true);
        if ($http >= 200 && $http < 300) {
            if (!is_array($json)) return $this->safe_error('blogger_json', __('Blogger returned an invalid response.', 'calliope-social-publisher'), $phase, $http, true, 'temporary');
            return ['http_code' => $http, 'json' => $json];
        }
        return $this->build_http_error($phase, $http, is_array($json) ? $json : [], $endpoint);
    }

    private function is_allowlisted_endpoint($method, $endpoint) {
        if (!is_string($endpoint) || $endpoint === '' || strpos($endpoint, '://') !== false || strpos($endpoint, '..') !== false) return false;
        if ($method === 'GET' && $endpoint === '/users/self') return true;
        if ($method === 'GET' && in_array($endpoint, ['/users/self/blogs', '/users/self/blogs?view=AUTHOR', '/users/self/blogs?view=ADMIN'], true)) return true;
        if ($method === 'GET' && preg_match('#^/users/self/blogs/\d{1,' . self::MAX_BLOG_ID_LENGTH . '}$#D', $endpoint)) return true;
        if ($method === 'GET' && preg_match('#^/blogs/\d{1,' . self::MAX_BLOG_ID_LENGTH . '}$#D', $endpoint)) return true;
        if ($method === 'POST' && preg_match('#^/blogs/\d{1,' . self::MAX_BLOG_ID_LENGTH . '}/posts$#D', $endpoint)) return true;
        return false;
    }

    private function validate_identity_response(array $response) {
        $json = $response['json'];
        if (isset($json['kind']) && $json['kind'] !== 'blogger#user') return $this->safe_error('blogger_identity_kind', __('Blogger returned an unexpected user response.', 'calliope-social-publisher'), 'identity', (int) $response['http_code'], true, 'temporary');
        if (!isset($json['id']) || !is_scalar($json['id'])) return $this->safe_error('blogger_identity_missing', __('Blogger returned an incomplete user response.', 'calliope-social-publisher'), 'identity', (int) $response['http_code'], true, 'temporary');
        $identity_raw = (string) $json['id'];
        if (preg_match('/[\x00-\x1F\x7F]/', $identity_raw)) return $this->safe_error('blogger_identity_invalid', __('Blogger returned an invalid user identity.', 'calliope-social-publisher'), 'identity', (int) $response['http_code'], true, 'temporary');
        $identity = trim($identity_raw);
        if ($identity === '' || strlen($identity) > self::MAX_IDENTITY_LENGTH) return $this->safe_error('blogger_identity_invalid', __('Blogger returned an invalid user identity.', 'calliope-social-publisher'), 'identity', (int) $response['http_code'], true, 'temporary');
        return $identity;
    }

    private function validate_blogs_response(array $response) {
        $json = $response['json'];
        if (isset($json['kind']) && $json['kind'] !== 'blogger#blogList') return $this->safe_error('blogger_blogs_kind', __('Blogger returned an unexpected blogs response.', 'calliope-social-publisher'), 'blogs', (int) $response['http_code'], true, 'temporary');

        // Google may omit an empty repeated field from a successful JSON response.
        // When fetchUserInfo is used, some responses may also expose the blogs as
        // nested BlogUserInfo resources. Treat a missing list as empty, but still
        // reject fields that are present with an invalid type.
        if (array_key_exists('items', $json) && !is_array($json['items'])) {
            return $this->safe_error('blogger_blogs_invalid', __('Blogger returned an invalid blogs list.', 'calliope-social-publisher'), 'blogs', (int) $response['http_code'], true, 'temporary');
        }
        if (array_key_exists('blogUserInfos', $json) && !is_array($json['blogUserInfos'])) {
            return $this->safe_error('blogger_blog_user_infos_invalid', __('Blogger returned invalid per-user blog information.', 'calliope-social-publisher'), 'blogs', (int) $response['http_code'], true, 'temporary');
        }

        $items = isset($json['items']) ? $json['items'] : [];
        if (!empty($json['blogUserInfos'])) {
            foreach ($json['blogUserInfos'] as $blog_user_info) {
                if (is_array($blog_user_info) && isset($blog_user_info['blog']) && is_array($blog_user_info['blog'])) {
                    $items[] = $blog_user_info['blog'];
                }
            }
        }

        $ids = [];
        $cache = [];
        foreach ($items as $item) {
            if (!is_array($item)) continue;
            $id = isset($item['id']) ? $this->normalize_blog_id($item['id']) : '';
            if ($id === '' || isset($ids[$id])) continue;
            $name = isset($item['name']) && is_scalar($item['name']) ? $this->safe_text($item['name'], 120) : $id;
            $url = isset($item['url']) && is_scalar($item['url']) ? esc_url_raw((string) $item['url']) : '';
            $ids[$id] = true;
            if (count($cache) < self::MAX_CACHE_ITEMS) $cache[] = ['id' => $id, 'name' => $name !== '' ? $name : $id, 'url' => $url];
        }
        return ['ids' => $ids, 'cache' => $cache];
    }

    private function build_http_error($phase, $http_code, array $json, $endpoint = '') {
        $this->log_blogger_api_failure($phase, $http_code, $json, $endpoint);
        $code = '';
        $message = '';
        $reason = '';
        if (isset($json['error']['code']) && is_scalar($json['error']['code'])) $code = strtolower(trim((string) $json['error']['code']));
        if (isset($json['error']['status']) && is_scalar($json['error']['status'])) $code .= ' ' . strtolower(trim((string) $json['error']['status']));
        if (isset($json['error']['message']) && is_scalar($json['error']['message'])) $message = strtolower($this->safe_text($json['error']['message'], 300));
        if (!empty($json['error']['errors']) && is_array($json['error']['errors'])) {
            foreach ($json['error']['errors'] as $item) {
                if (is_array($item) && isset($item['reason']) && is_scalar($item['reason'])) { $reason = strtolower($this->safe_text($item['reason'], 120)); break; }
            }
        }
        $haystack = $code . ' ' . $reason . ' ' . $message;
        if (strpos($haystack, 'service_disabled') !== false || strpos($haystack, 'accessnotconfigured') !== false || strpos($haystack, 'has not been used') !== false || strpos($haystack, 'it is disabled') !== false) {
            return $this->safe_error('blogger_api_disabled', __('Blogger API is not enabled for the Google Cloud project that owns this OAuth Client ID. Enable Blogger API in that same project, wait a few minutes, then reconnect.', 'calliope-social-publisher'), $phase, $http_code, true, 'application');
        }
        if (strpos($haystack, 'insufficient authentication scopes') !== false || strpos($haystack, 'insufficient_scope') !== false) {
            return $this->safe_error('blogger_scope_missing', __('Google did not grant the Blogger publishing scope. Clear the Blogger tokens and connect again, accepting the Blogger permission.', 'calliope-social-publisher'), $phase, $http_code, false, 'permission');
        }
        if ($http_code === 401 || strpos($haystack, 'invalid credential') !== false || strpos($haystack, 'invalid token') !== false || strpos($haystack, 'unauthenticated') !== false) {
            return $this->safe_error('blogger_auth', __('Blogger rejected the access token.', 'calliope-social-publisher'), $phase, $http_code, false, 'auth');
        }
        if ($http_code === 408 || $http_code === 429 || $http_code >= 500) return $this->safe_error('blogger_temporary', __('Blogger is temporarily unavailable. Please try again.', 'calliope-social-publisher'), $phase, $http_code, true, 'temporary');
        if ($phase === 'target' && $http_code === 404) return $this->safe_error('blogger_target_missing', __('The selected Blogger blog was not found.', 'calliope-social-publisher'), $phase, $http_code, false, 'target');
        if ($phase === 'publish' && $http_code === 404) return $this->safe_error('blogger_target_missing', __('The selected Blogger blog was not found.', 'calliope-social-publisher'), $phase, $http_code, false, 'target');
        if (($phase === 'target' || $phase === 'publish' || $phase === 'blogs') && $http_code === 403) {
            return $this->safe_error('blogger_permission', __('Blogger denied access for the connected Google account. Confirm that this account is an author or administrator of the destination blog, then reconnect.', 'calliope-social-publisher'), $phase, $http_code, false, 'permission');
        }
        if ($phase === 'publish' && ($http_code === 400 || $http_code === 422)) return $this->safe_error('blogger_editorial', __('Blogger rejected the post content.', 'calliope-social-publisher'), $phase, $http_code, false, 'local');
        if ($phase === 'blogs' && in_array($http_code, [400, 404], true)) return $this->safe_error('blogger_discovery_request', __('Blogger rejected the automatic blog-list request. Social Publisher will try a compatibility fallback; if the list remains empty, use the manual Blog ID field.', 'calliope-social-publisher'), $phase, $http_code, true, 'temporary');
        return $this->safe_error('blogger_remote', __('Blogger returned an unexpected response. Check Social Publisher Logs for the sanitized Google API status and message.', 'calliope-social-publisher'), $phase, $http_code, true, 'temporary');
    }

    private function build_marker_from_evidence($application_fingerprint, $credentials_fingerprint, $target_fingerprint, $expires_at, array $evidence) {
        if (!$this->is_fingerprint($application_fingerprint) || !$this->is_fingerprint($credentials_fingerprint)) return false;
        if ($target_fingerprint !== '' && !$this->is_fingerprint($target_fingerprint)) return false;

        $blog_id = $this->get_blog_id();
        $evidence = $this->normalize_commit_evidence($evidence, $blog_id);
        $expires_at = $this->normalize_timestamp_option($expires_at);
        if (!is_array($evidence) || !$this->is_future_expiry_plausible($expires_at)) return false;

        $validated_at = time();
        $failure = $evidence['target_failure'];
        $marker = [
            'schema' => self::VALIDATION_SCHEMA,
            'health' => $failure !== '' ? 'invalid' : 'healthy',
            'failure_kind' => $failure,
            'validated_at' => $validated_at,
            'application_fingerprint' => $application_fingerprint,
            'credentials_fingerprint' => $credentials_fingerprint,
            'identity_fingerprint' => $evidence['identity_fingerprint'],
            'target_fingerprint' => $target_fingerprint,
            'target_identity_fingerprint' => $failure === '' ? $evidence['target_identity_fingerprint'] : '',
            'resolved_blog_id' => $failure === '' ? $evidence['resolved_blog_id'] : '',
            'auth_method' => 'oauth2',
            'blogger_scope_state' => 'granted',
            'access_token_expires_at' => $expires_at,
        ];
        $marker = $this->normalize_validation_marker($marker);
        if (!is_array($marker) || !$this->is_marker_lifecycle_consistent($marker, $expires_at)) return false;
        return $marker;
    }

    private function apply_remote_failure_to_validation($error, $application_fingerprint, $credentials_fingerprint, $target_fingerprint, $previous_marker) {
        $kind = $this->error_failure_kind($error);
        $data = is_wp_error($error) ? $error->get_error_data() : [];
        $retryable = is_array($data) && !empty($data['retryable']);
        if ($kind === 'temporary' || $retryable) {
            if (is_array($previous_marker)) {
                $previous_marker['health'] = 'degraded';
                $previous_marker['failure_kind'] = '';
                $this->save_validation_marker($previous_marker);
                $this->sync_legacy_status($previous_marker['resolved_blog_id'] !== '' ? 'ready_to_publish' : 'connected_unverified');
                return;
            }
            $this->save_validation_marker([
                'schema' => self::VALIDATION_SCHEMA, 'health' => 'degraded', 'failure_kind' => '', 'validated_at' => 0,
                'application_fingerprint' => $application_fingerprint, 'credentials_fingerprint' => $credentials_fingerprint,
                'identity_fingerprint' => '', 'target_fingerprint' => $target_fingerprint,
                'target_identity_fingerprint' => '', 'resolved_blog_id' => '', 'auth_method' => 'oauth2',
                'blogger_scope_state' => 'unknown', 'access_token_expires_at' => $this->get_expires_at(),
            ]);
            $this->sync_legacy_status('connected_unverified');
            return;
        }
        if (in_array($kind, ['auth', 'application', 'identity', 'target', 'permission'], true)) {
            $this->save_invalid_marker($kind, $application_fingerprint, $credentials_fingerprint, $target_fingerprint);
            $map = ['auth' => 'token_invalid', 'application' => 'configuration_error', 'identity' => 'identity_missing', 'target' => 'target_missing', 'permission' => 'permission_missing'];
            $this->sync_legacy_status($map[$kind]);
        }
    }

    private function mark_refresh_degraded($marker) {
        if (!is_array($marker)) return;
        $marker['health'] = 'degraded';
        $marker['failure_kind'] = '';
        $this->save_validation_marker($marker);
        $this->sync_legacy_status(!empty($marker['resolved_blog_id']) ? 'ready_to_publish' : 'connected_unverified');
    }

    private function mark_refresh_invalid($marker) {
        if (!is_array($marker)) return;
        $marker['health'] = 'invalid';
        $marker['failure_kind'] = 'auth';
        $this->save_validation_marker($marker);
        $this->sync_legacy_status('token_invalid');
    }

    private function apply_operation_failure($error, array $marker) {
        $kind = $this->error_failure_kind($error);
        $data = is_wp_error($error) ? $error->get_error_data() : [];
        $retryable = is_array($data) && !empty($data['retryable']);
        if ($kind === 'temporary' || $retryable) {
            $marker['health'] = 'degraded';
            $marker['failure_kind'] = '';
            $this->save_validation_marker($marker);
            $this->sync_legacy_status('ready_to_publish');
            return;
        }
        if (in_array($kind, ['auth', 'target', 'permission'], true)) {
            $marker['health'] = 'invalid';
            $marker['failure_kind'] = $kind;
            if ($kind === 'target') { $marker['resolved_blog_id'] = ''; $marker['target_identity_fingerprint'] = ''; }
            $this->save_validation_marker($marker);
            $map = ['auth' => 'token_invalid', 'target' => 'target_missing', 'permission' => 'permission_missing'];
            $this->sync_legacy_status($map[$kind]);
        }
    }

    private function application_fingerprint($client_id, $client_secret) {
        return $this->hmac_fingerprint(['schema' => self::VALIDATION_SCHEMA, 'client_id' => (string) $client_id, 'client_secret' => (string) $client_secret, 'redirect_uri' => $this->get_redirect_uri()]);
    }
    private function credentials_fingerprint($client_id, $client_secret, $access_token, $refresh_token) {
        return $this->hmac_fingerprint(['schema' => self::VALIDATION_SCHEMA, 'client_id' => (string) $client_id, 'client_secret' => (string) $client_secret, 'access_token' => (string) $access_token, 'refresh_token' => (string) $refresh_token]);
    }
    private function identity_fingerprint($identity) { return $this->hmac_fingerprint(['schema' => self::VALIDATION_SCHEMA, 'identity' => (string) $identity]); }
    private function target_fingerprint($blog_id) { return $this->hmac_fingerprint(['schema' => self::VALIDATION_SCHEMA, 'blog_id' => (string) $blog_id]); }
    private function target_identity_fingerprint($identity_fingerprint, $blog_id) { return $this->hmac_fingerprint(['schema' => self::VALIDATION_SCHEMA, 'identity_fingerprint' => (string) $identity_fingerprint, 'blog_id' => (string) $blog_id]); }
    private function hmac_fingerprint(array $value) { return hash_hmac('sha256', wp_json_encode($value), wp_salt('auth')); }

    private function get_matching_validation_marker($application_fingerprint, $credentials_fingerprint, $target_fingerprint) {
        $marker = $this->get_validation_marker_for_credentials($application_fingerprint, $credentials_fingerprint);
        if (!is_array($marker) || $target_fingerprint === '' || $marker['target_fingerprint'] === '' || !hash_equals($target_fingerprint, $marker['target_fingerprint'])) return null;
        return $marker;
    }
    private function get_validation_marker_for_credentials($application_fingerprint, $credentials_fingerprint) {
        $marker = $this->normalize_validation_marker(get_option(self::VALIDATION_OPTION, null));
        if (!is_array($marker)) return null;
        if ($application_fingerprint === '' || $credentials_fingerprint === '') return null;
        if (!hash_equals($application_fingerprint, $marker['application_fingerprint']) || !hash_equals($credentials_fingerprint, $marker['credentials_fingerprint'])) return null;
        return $marker;
    }

    private function normalize_validation_marker($marker) {
        if (!is_array($marker) || ($marker['schema'] ?? '') !== self::VALIDATION_SCHEMA) return null;
        $health = isset($marker['health']) && is_string($marker['health']) ? $marker['health'] : '';
        $failure = isset($marker['failure_kind']) && is_string($marker['failure_kind']) ? $marker['failure_kind'] : '';
        if (!in_array($health, ['healthy', 'degraded', 'invalid'], true) || !in_array($failure, ['', 'auth', 'application', 'identity', 'target', 'permission'], true)) return null;
        $fingerprint_keys = ['application_fingerprint','credentials_fingerprint','identity_fingerprint','target_fingerprint','target_identity_fingerprint'];
        $out = ['schema' => self::VALIDATION_SCHEMA, 'health' => $health, 'failure_kind' => $failure];
        foreach ($fingerprint_keys as $key) {
            $value = isset($marker[$key]) && is_string($marker[$key]) ? $marker[$key] : '';
            if ($value !== '' && !preg_match('/^[a-f0-9]{64}$/D', $value)) return null;
            $out[$key] = $value;
        }
        $out['validated_at'] = $this->normalize_timestamp_option($marker['validated_at'] ?? 0);
        $out['resolved_blog_id'] = $this->normalize_blog_id($marker['resolved_blog_id'] ?? '');
        $out['auth_method'] = ($marker['auth_method'] ?? '') === 'oauth2' ? 'oauth2' : '';
        $scope_state = isset($marker['blogger_scope_state']) && is_string($marker['blogger_scope_state']) ? $marker['blogger_scope_state'] : '';
        if (!in_array($scope_state, ['granted', 'unknown', 'denied'], true)) return null;
        $out['blogger_scope_state'] = $scope_state;
        $out['access_token_expires_at'] = $this->normalize_timestamp_option($marker['access_token_expires_at'] ?? 0);
        if ($out['application_fingerprint'] === '' || $out['credentials_fingerprint'] === '' || $out['auth_method'] !== 'oauth2') return null;
        return $out;
    }

    private function save_validation_marker(array $marker) {
        $marker = $this->normalize_validation_marker($marker);
        if (!is_array($marker)) return false;
        if (!$this->persist_option_value(self::VALIDATION_OPTION, $marker)) return false;
        $stored = $this->normalize_validation_marker(get_option(self::VALIDATION_OPTION, null));
        return is_array($stored) && $stored === $marker;
    }

    private function save_invalid_marker($failure_kind, $application_fingerprint, $credentials_fingerprint, $target_fingerprint, array $context = []) {
        $previous = $this->get_validation_marker_for_credentials($application_fingerprint, $credentials_fingerprint);
        $identity = is_array($previous) ? $previous['identity_fingerprint'] : '';
        $validated_at = is_array($previous) ? $previous['validated_at'] : 0;
        $marker = [
            'schema' => self::VALIDATION_SCHEMA, 'health' => 'invalid', 'failure_kind' => $failure_kind,
            'validated_at' => $validated_at, 'application_fingerprint' => $application_fingerprint,
            'credentials_fingerprint' => $credentials_fingerprint, 'identity_fingerprint' => $identity,
            'target_fingerprint' => $target_fingerprint, 'target_identity_fingerprint' => '', 'resolved_blog_id' => '',
            'auth_method' => 'oauth2', 'blogger_scope_state' => $context['blogger_scope_state'] ?? (is_array($previous) ? $previous['blogger_scope_state'] : 'unknown'),
            'access_token_expires_at' => $this->get_expires_at(),
        ];
        return $this->save_validation_marker($marker);
    }

    private function safe_error($code, $message, $phase, $http_code, $retryable, $failure_kind) {
        return new WP_Error($code, $message, ['phase' => (string) $phase, 'http_code' => (int) $http_code, 'retryable' => (bool) $retryable, 'failure_kind' => (string) $failure_kind]);
    }
    private function error_failure_kind($error) {
        if (!is_wp_error($error)) return '';
        $data = $error->get_error_data();
        return is_array($data) && isset($data['failure_kind']) && is_string($data['failure_kind']) ? $data['failure_kind'] : '';
    }
    private function create_state(array $data, $minimum = 'E5') {
        $data['network'] = 'blogger';
        return Calliope_SP_Connection_State::create($data, $minimum);
    }
    private function normalize_application_credential($value) {
        if (!is_string($value)) return '';
        $value = trim($value);
        if ($value === '' || strlen($value) > self::MAX_TOKEN_LENGTH || preg_match('/[\x00-\x1F\x7F]/', $value)) return '';
        return $value;
    }

    private function has_option_material($value) {
        if (is_string($value)) return $value !== '';
        return $value !== null && $value !== false;
    }

    private function is_fingerprint($value) {
        return is_string($value) && preg_match('/^[a-f0-9]{64}$/D', $value) === 1;
    }

    private function normalize_blogs_cache($cache) {
        if (!is_array($cache)) return [];
        $out = [];
        $seen = [];
        foreach ($cache as $item) {
            if (!is_array($item)) continue;
            $id = $this->normalize_blog_id($item['id'] ?? '');
            if ($id === '' || isset($seen[$id])) continue;
            $name = isset($item['name']) && is_scalar($item['name']) ? $this->safe_text($item['name'], 120) : $id;
            $url = isset($item['url']) && is_scalar($item['url']) ? esc_url_raw((string) $item['url']) : '';
            $seen[$id] = true;
            $out[] = ['id' => $id, 'name' => $name !== '' ? $name : $id, 'url' => $url];
            if (count($out) >= self::MAX_CACHE_ITEMS) break;
        }
        return $out;
    }

    private function normalize_commit_evidence(array $evidence, $blog_id) {
        $blog_id = $this->normalize_blog_id($blog_id);
        $identity_fingerprint = $evidence['identity_fingerprint'] ?? null;
        $scope_state = $evidence['blogger_scope_state'] ?? null;
        $target_failure = $evidence['target_failure'] ?? '';
        $resolved_blog_id = $evidence['resolved_blog_id'] ?? '';
        $target_identity_fingerprint = $evidence['target_identity_fingerprint'] ?? '';

        if (!$this->is_fingerprint($identity_fingerprint)) return false;
        if (!is_string($scope_state) || $scope_state !== 'granted') return false;
        if (!is_string($target_failure) || !in_array($target_failure, ['', 'target', 'permission'], true)) return false;
        if (!is_string($resolved_blog_id) || !is_string($target_identity_fingerprint)) return false;

        $resolved_blog_id = $this->normalize_blog_id($resolved_blog_id);
        if ($blog_id === '') {
            if ($resolved_blog_id !== '' || $target_identity_fingerprint !== '' || $target_failure !== '') return false;
        } elseif ($target_failure === '') {
            if ($resolved_blog_id === '' || !hash_equals($blog_id, $resolved_blog_id) || !$this->is_fingerprint($target_identity_fingerprint)) return false;
        } else {
            if ($resolved_blog_id !== '' || $target_identity_fingerprint !== '') return false;
        }

        return [
            'identity_fingerprint' => $identity_fingerprint,
            'resolved_blog_id' => $resolved_blog_id,
            'target_identity_fingerprint' => $target_identity_fingerprint,
            'blogger_scope_state' => 'granted',
            'blogs_cache' => $this->normalize_blogs_cache($evidence['blogs_cache'] ?? []),
            'blogs_cache_verified' => !empty($evidence['blogs_cache_verified']),
            'discovery_failed' => !empty($evidence['discovery_failed']),
            'discovery_http_code' => isset($evidence['discovery_http_code']) && is_int($evidence['discovery_http_code']) ? max(0, $evidence['discovery_http_code']) : 0,
            'target_failure' => $target_failure,
            'target_http_code' => isset($evidence['target_http_code']) && is_int($evidence['target_http_code'])
                ? max(0, $evidence['target_http_code']) : 0,
        ];
    }

    private function validate_local_oauth_session($require_marker = false, $require_future = true, $require_scope = true) {
        $client_id = $this->get_client_id();
        $client_secret = $this->get_client_secret();
        $raw_access = get_option('calliope_sp_blogger_access_token', '');
        $raw_refresh = get_option('calliope_sp_blogger_refresh_token', '');
        $access_token = $this->normalize_oauth_token($raw_access);
        $refresh_token = $this->normalize_oauth_token($raw_refresh);
        $expires_at = $this->get_expires_at();
        $scopes = $this->normalize_scopes(get_option('calliope_sp_blogger_granted_scopes', []));

        if ($client_id === '' || $client_secret === '') {
            return $this->safe_error('blogger_configuration', __('Blogger OAuth application credentials are incomplete.', 'calliope-social-publisher'), 'configuration', 0, false, 'application');
        }
        if ($access_token === '' || $refresh_token === '') {
            return $this->safe_error('blogger_refresh_required', __('Reconnect Blogger to create a complete offline OAuth session.', 'calliope-social-publisher'), 'configuration', 0, false, 'local');
        }
        if ($expires_at <= 0) {
            return $this->safe_error('blogger_refresh_required', __('Blogger needs a complete offline OAuth session. Reconnect the Google account.', 'calliope-social-publisher'), 'configuration', 0, false, 'local');
        }
        if ($require_future && $expires_at <= time()) {
            return $this->safe_error('blogger_token_expired', __('The Blogger access token is expired or has no valid expiry.', 'calliope-social-publisher'), 'configuration', 0, false, 'auth');
        }
        if ($require_future && !$this->is_future_expiry_plausible($expires_at)) {
            return $this->safe_error('blogger_refresh_required', __('Blogger needs a complete offline OAuth session. Reconnect the Google account.', 'calliope-social-publisher'), 'configuration', 0, false, 'local');
        }
        if ($require_scope && !in_array(self::REQUIRED_SCOPE, $scopes, true)) {
            return $this->safe_error('blogger_scope_missing', __('The Blogger publishing scope was not granted.', 'calliope-social-publisher'), 'configuration', 0, false, 'permission');
        }

        $application_fingerprint = $this->application_fingerprint($client_id, $client_secret);
        $credentials_fingerprint = $this->credentials_fingerprint($client_id, $client_secret, $access_token, $refresh_token);
        $credential_marker = $this->get_validation_marker_for_credentials($application_fingerprint, $credentials_fingerprint);
        if (is_array($credential_marker)
            && !$this->is_marker_lifecycle_consistent($credential_marker, $expires_at, $require_future)
            && !($require_future && $this->is_unverified_degraded_marker_lifecycle_safe($credential_marker, $expires_at))) {
            return $this->safe_error('blogger_refresh_required', __('Blogger needs a complete offline OAuth session. Reconnect the Google account.', 'calliope-social-publisher'), 'configuration', 0, false, 'local');
        }
        if ($require_marker && !is_array($credential_marker)) {
            return $this->safe_error('blogger_verification_required', __('Verify Blogger again before publishing.', 'calliope-social-publisher'), 'configuration', 0, false, 'local');
        }

        return [
            'client_id' => $client_id,
            'client_secret' => $client_secret,
            'access_token' => $access_token,
            'refresh_token' => $refresh_token,
            'expires_at' => $expires_at,
            'scopes' => $scopes,
            'application_fingerprint' => $application_fingerprint,
            'credentials_fingerprint' => $credentials_fingerprint,
            'credential_marker' => $credential_marker,
        ];
    }

    private function capture_option_snapshot(array $names) {
        $snapshot = [];
        foreach ($names as $name) {
            $sentinel = new stdClass();
            $value = get_option($name, $sentinel);
            $snapshot[$name] = ['exists' => $value !== $sentinel, 'value' => $value !== $sentinel ? $value : null];
        }
        return $snapshot;
    }

    private function persist_option_value($name, $value) {
        $sentinel = new stdClass();
        $current = get_option($name, $sentinel);
        if ($current === $sentinel) {
            add_option($name, $value, '', 'no');
        } else {
            update_option($name, $value, false);
        }
        $stored = get_option($name, $sentinel);
        return $stored !== $sentinel && $stored === $value;
    }

    private function restore_option_snapshot(array $snapshot) {
        $ok = true;
        foreach ($snapshot as $name => $entry) {
            if (!is_array($entry) || !array_key_exists('exists', $entry)) {
                $ok = false;
                continue;
            }
            if ($entry['exists']) {
                $this->persist_option_value($name, $entry['value']);
                $sentinel = new stdClass();
                if (get_option($name, $sentinel) === $sentinel || get_option($name, $sentinel) !== $entry['value']) $ok = false;
            } else {
                delete_option($name);
                $sentinel = new stdClass();
                if (get_option($name, $sentinel) !== $sentinel) $ok = false;
            }
        }
        return $ok;
    }

    private function normalize_timestamp_option($value) {
        if (is_int($value)) return $value > 0 ? $value : 0;
        if (!is_string($value) || !preg_match('/^[1-9][0-9]*$/D', $value)) return 0;

        $maximum = (string) PHP_INT_MAX;
        $value_length = strlen($value);
        $maximum_length = strlen($maximum);
        if ($value_length > $maximum_length) return 0;
        if ($value_length === $maximum_length && strcmp($value, $maximum) > 0) return 0;

        $number = (int) $value;
        return $number > 0 ? $number : 0;
    }

    private function safe_timestamp_add($timestamp, $seconds) {
        $timestamp = $this->normalize_timestamp_option($timestamp);
        if (!is_int($seconds) || $timestamp <= 0 || $seconds < 0 || $seconds > PHP_INT_MAX - $timestamp) return 0;
        return $timestamp + $seconds;
    }

    private function is_future_expiry_plausible($expires_at) {
        $expires_at = $this->normalize_timestamp_option($expires_at);
        $now = time();
        $maximum = $this->safe_timestamp_add($now, self::MAX_ACCESS_TOKEN_LIFETIME);
        return $expires_at > $now && $maximum > 0 && $expires_at <= $maximum;
    }


    private function is_unverified_degraded_marker_lifecycle_safe(array $marker, $expires_at) {
        $marker = $this->normalize_validation_marker($marker);
        $option_expiry = $this->normalize_timestamp_option($expires_at);
        if (!is_array($marker) || !$this->is_future_expiry_plausible($option_expiry)) return false;
        if ($marker['health'] !== 'degraded' || $marker['failure_kind'] !== '' || $marker['validated_at'] !== 0) return false;
        if ($marker['identity_fingerprint'] !== '' || $marker['target_identity_fingerprint'] !== '' || $marker['resolved_blog_id'] !== '') return false;
        $marker_expiry = $this->normalize_timestamp_option($marker['access_token_expires_at'] ?? 0);
        return $marker_expiry > 0 && $marker_expiry === $option_expiry;
    }
    private function safe_text($value, $limit) {
        if (!is_scalar($value)) return '';
        $value = trim(wp_strip_all_tags((string) $value));
        if (preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', $value)) return '';
        return function_exists('mb_substr') ? mb_substr($value, 0, $limit, 'UTF-8') : substr($value, 0, $limit);
    }
    private function sync_legacy_status($status) {
        $status = sanitize_key((string) $status);
        if ((string) get_option('calliope_sp_blogger_connection_status', '') !== $status) update_option('calliope_sp_blogger_connection_status', $status, false);
    }

    private function format_html($data) {
        $parts = [];
        $image_urls = [];
        if (!empty($data['image_urls']) && is_array($data['image_urls'])) {
            foreach ($data['image_urls'] as $url) {
                $url = esc_url((string) $url);
                if ($url !== '') {
                    $image_urls[] = $url;
                }
            }
        } elseif (!empty($data['image_url'])) {
            $img = esc_url((string) $data['image_url']);
            if ($img !== '') {
                $image_urls[] = $img;
            }
        }
        $image_alts = !empty($data['image_alts']) && is_array($data['image_alts']) ? array_values($data['image_alts']) : [];
        foreach ($image_urls as $index => $img) {
            if ($img) {
                $alt_source = isset($image_alts[$index]) && is_scalar($image_alts[$index]) && trim((string) $image_alts[$index]) !== ''
                    ? (string) $image_alts[$index]
                    : (string) ($data['alt_text'] ?? '');
                $alt = $alt_source !== '' ? esc_attr($alt_source) : '';
                $parts[] = '<p><img src="' . $img . '" alt="' . $alt . '" style="max-width:100%;height:auto;display:block;margin:0 auto;" /></p>';
            }
        }
        $youtube_embed = $this->youtube_embed_html($data);
        if ($youtube_embed !== '') {
            $parts[] = $youtube_embed;
        }
        if (!empty($data['content'])) {
            $txt = nl2br(esc_html(trim((string) $data['content'])));
            $parts[] = '<p>' . $txt . '</p>';
        }
        if (!empty($data['link'])) {
            $url = esc_url((string) $data['link']);
            if ($url && $youtube_embed === '') $parts[] = '<p><a href="' . $url . '">' . $url . '</a></p>';
        }
        if (!empty($data['hashtags'])) $parts[] = '<p>' . esc_html(trim((string) $data['hashtags'])) . '</p>';
        return implode("\n", array_filter($parts));
    }

    private function youtube_embed_html($data) {
        $metadata = is_array($data['metadata'] ?? null) ? $data['metadata'] : [];
        if (sanitize_key((string) ($metadata['video_source'] ?? '')) !== 'youtube_link') {
            return '';
        }
        $url = trim((string) ($metadata['youtube_link_url'] ?? $data['link'] ?? ''));
        $parts = wp_parse_url($url);
        if (!is_array($parts)) {
            return '';
        }
        $query = [];
        if (!empty($parts['query'])) {
            parse_str((string) $parts['query'], $query);
        }
        $video_id = isset($query['v']) && is_scalar($query['v']) ? (string) $query['v'] : '';
        if (!preg_match('/^[A-Za-z0-9_-]{6,32}$/D', $video_id)) {
            return '';
        }
        $src = 'https://www.youtube.com/embed/' . rawurlencode($video_id);
        return '<p><iframe width="560" height="315" src="' . esc_url($src) . '" title="YouTube video" frameborder="0" allowfullscreen></iframe></p>';
    }

    /**
     * Resolve Pro multi-image media items into URLs Blogger can embed in the post HTML.
     *
     * @param array<string,mixed> $data
     * @return array<int,array{url:string,alt:string,position:int,temporary_path?:string}>|WP_Error
     */
    private function image_assets_from_payload(array $data) {
        $mode = sanitize_key((string) ($data['media_mode'] ?? 'none'));
        if ($mode !== 'multiple_images') {
            return [];
        }
        if (!class_exists('Calliope_SPP_Media_Set') || !class_exists('Calliope_SPP_Media_Resolver')) {
            return new WP_Error('blogger_multi_image_requires_pro', __('Blogger multiple-image publishing requires Social Publisher Pro media support.', 'calliope-social-publisher'), [
                'phase' => 'prepare', 'retryable' => false, 'failure_kind' => 'local',
            ]);
        }

        $media = isset($data['media']) && is_array($data['media']) ? $data['media'] : [];
        $normalized = array_values(array_filter(
            Calliope_SPP_Media_Set::normalize($media),
            static fn(array $item): bool => (string) ($item['type'] ?? '') === 'image'
        ));
        if (count($normalized) < 2) {
            return new WP_Error('blogger_multi_image_invalid', __('Blogger multiple-image publishing requires 2 to 10 local image attachments.', 'calliope-social-publisher'), [
                'phase' => 'prepare', 'retryable' => false, 'failure_kind' => 'local',
            ]);
        }
        $requested_count = count($normalized);
        if ($requested_count > self::MAX_IMAGE_ITEMS) {
            $normalized = array_slice($normalized, 0, self::MAX_IMAGE_ITEMS);
            if (class_exists('Calliope_SP_Logger')) {
                Calliope_SP_Logger::log('blogger', 'warning', wp_json_encode([
                    'event' => 'blogger_images_limited',
                    'requested_count' => $requested_count,
                    'published_count' => count($normalized),
                    'limit' => self::MAX_IMAGE_ITEMS,
                ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
            }
        }

        $contract = Calliope_SPP_Media_Set::validate_contract('multiple_images', $normalized);
        if (is_wp_error($contract)) {
            return new WP_Error('blogger_multi_image_invalid', __('Blogger multiple-image publishing requires 2 to 10 local image attachments.', 'calliope-social-publisher'), [
                'phase' => 'prepare', 'retryable' => false, 'failure_kind' => 'local',
            ]);
        }

        $resolved = Calliope_SPP_Media_Resolver::resolve($normalized, [
            'allowed_video_mimes' => [],
            'log_context' => ['network' => 'blogger', 'mode' => 'multiple_images'],
        ]);
        if (is_wp_error($resolved)) {
            return new WP_Error('blogger_multi_image_media_error', sprintf(
                /* translators: %s: media preparation error. */
                __('Blogger could not prepare the selected images. %s', 'calliope-social-publisher'),
                $resolved->get_error_message()
            ), [
                'phase' => 'prepare', 'retryable' => false, 'failure_kind' => 'local',
            ]);
        }

        $by_id = [];
        foreach ($normalized as $item) {
            $by_id[(int) ($item['id'] ?? 0)] = $item;
        }

        $assets = [];
        foreach ($resolved as $index => $file) {
            if (!is_array($file) || empty($file['path']) || empty($file['id'])) {
                return new WP_Error('blogger_multi_image_media_error', __('Blogger could not prepare one of the selected images.', 'calliope-social-publisher'), [
                    'phase' => 'prepare', 'retryable' => false, 'failure_kind' => 'local',
                ]);
            }

            $id = absint($file['id'] ?? 0);
            $source = $by_id[$id] ?? [];
            $description = trim((string) ($source['alt'] ?? ''));
            if ($description === '') {
                $description = trim((string) ($source['caption'] ?? ''));
            }
            if ($description === '') {
                $description = trim((string) ($file['description'] ?? ''));
            }

            $url = wp_get_attachment_url($id);
            $asset = [
                'url' => is_string($url) && $this->is_safe_public_image_url($url) ? esc_url_raw($url) : '',
                'alt' => sanitize_text_field($description),
                'position' => absint($source['position'] ?? $index),
            ];
            if ($asset['url'] === '') {
                $temp = $this->image_asset_from_file($file);
                if (is_wp_error($temp)) {
                    return $temp;
                }
                $asset['url'] = $temp['url'];
                if (!empty($temp['path'])) {
                    $asset['temporary_path'] = $temp['path'];
                }
            }
            $assets[] = $asset;
        }

        usort($assets, static function ($left, $right) {
            return ((int) ($left['position'] ?? 0)) <=> ((int) ($right['position'] ?? 0));
        });

        return array_values($assets);
    }

    /** @param array<string,mixed> $file */
    private function image_asset_from_file(array $file) {
        $path = (string) ($file['path'] ?? '');
        if ($path === '' || !is_file($path) || !is_readable($path)) {
            return new WP_Error('blogger_image_read_failed', __('Blogger could not read a selected image file.', 'calliope-social-publisher'), [
                'phase' => 'prepare', 'retryable' => false, 'failure_kind' => 'local',
            ]);
        }
        $bytes = file_get_contents($path);
        if (!is_string($bytes) || $bytes === '') {
            return new WP_Error('blogger_image_read_failed', __('Blogger could not read a selected image file.', 'calliope-social-publisher'), [
                'phase' => 'prepare', 'retryable' => false, 'failure_kind' => 'local',
            ]);
        }
        $mime = sanitize_mime_type((string) ($file['mime_type'] ?? 'image/jpeg'));
        if ($mime === '') {
            $mime = 'image/jpeg';
        }
        return $this->image_url_from_b64('data:' . $mime . ';base64,' . base64_encode($bytes));
    }

    /** @return bool */
    private function is_safe_public_image_url($url) {
        $url = (string) $url;
        if ($url === '') return false;
        $parts = wp_parse_url($url);
        if (!is_array($parts) || !in_array(strtolower((string) ($parts['scheme'] ?? '')), ['http', 'https'], true) || empty($parts['host']) || isset($parts['user']) || isset($parts['pass'])) return false;
        return function_exists('wp_http_validate_url') ? (bool) wp_http_validate_url($url) : true;
    }

    private function image_url_from_b64($b64) {
        $asset = class_exists('Calliope_SP_Media_Helper')
            ? Calliope_SP_Media_Helper::create_public_image_from_b64($b64, [
                'context' => 'blogger', 'filename_prefix' => 'calliope-social-publisher-blogger',
                'network' => 'blogger', 'max_attempts' => 6,
            ])
            : new WP_Error('upload_failed', 'Blogger could not prepare a public image URL.');
        if (is_wp_error($asset)) return $asset;
        return ['url' => esc_url_raw((string) ($asset['url'] ?? '')), 'path' => (string) ($asset['local_path'] ?? '')];
    }
    private function delete_temp_image($path) {
        $path = (string) $path;
        if ($path !== '' && file_exists($path) && is_file($path)) wp_delete_file($path);
    }
}
