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

/**
 * Mastodon integration with local, persistent connection evidence.
 */
class Calliope_SP_Mastodon {

    private const VALIDATION_OPTION = 'calliope_sp_mstdn_validation';
    private const VALIDATION_SCHEMA = 'mastodon-validation-v1';
    private const VIDEO_MAX_BYTES = 50 * 1024 * 1024;

    /**
     * Build the local Mastodon connection state without HTTP or option writes.
     *
     * @return Calliope_SP_Connection_State|WP_Error
     */
    public function get_connection_state() {
        if (!class_exists('Calliope_SP_Connection_State')) {
            return new WP_Error(
                'mastodon_connection_contract_missing',
                __('Mastodon connection status is temporarily unavailable.', 'calliope-social-publisher')
            );
        }

        $raw_instance = (string) get_option('calliope_sp_mstdn_instance', '');
        $instance = $this->normalize_instance($raw_instance);
        $token = trim((string) get_option('calliope_sp_mstdn_token', ''));
        $has_instance_value = trim($raw_instance) !== '';
        $has_token = $token !== '';

        if (!$has_instance_value && !$has_token) {
            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 a public Mastodon server and an access token, or connect with OAuth.', 'calliope-social-publisher'),
                'action'                => __('Save and test', 'calliope-social-publisher'),
                'connection_method'     => 'unknown',
            ], 'E4');
        }

        if ($instance !== '' && !$has_token) {
            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'               => __('The Mastodon server is saved. Connect with OAuth or enter an access token.', 'calliope-social-publisher'),
                'action'                => __('Connect with Mastodon', 'calliope-social-publisher'),
                'connection_method'     => 'unknown',
            ], 'E4');
        }

        if ($instance === '' || !$has_token) {
            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'               => __('Complete the Mastodon server and access token configuration.', 'calliope-social-publisher'),
                'action'                => __('Save and test', 'calliope-social-publisher'),
                'connection_method'     => 'unknown',
            ], 'E4');
        }

        $credentials_fingerprint = $this->credentials_fingerprint($instance, $token);
        $instance_fingerprint = $this->instance_fingerprint($instance);
        $marker = $this->get_matching_validation_marker($credentials_fingerprint, $instance_fingerprint);

        if (!is_array($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'               => __('Mastodon credentials are saved but have not been verified for this server.', 'calliope-social-publisher'),
                'action'                => __('Save and test', 'calliope-social-publisher'),
                'connection_method'     => 'token',
            ], 'E4');
        }

        $method = $marker['auth_method'] === 'oauth' ? 'instance_oauth2' : 'token';
        $identity_known = $marker['identity_fingerprint'] !== '';
        $failure_kind = $marker['failure_kind'];

        if ($marker['health'] === 'invalid') {
            $status_map = [
                'auth'       => 'token_invalid',
                'instance'   => 'configuration_error',
                'identity'   => 'identity_missing',
                'permission' => 'permission_missing',
            ];
            $status = isset($status_map[$failure_kind]) ? $status_map[$failure_kind] : 'connected_unverified';
            $messages = [
                'token_invalid'      => __('The Mastodon access token is invalid or was revoked.', 'calliope-social-publisher'),
                'configuration_error'=> __('The saved Mastodon server is invalid or incompatible.', 'calliope-social-publisher'),
                'identity_missing'   => __('Mastodon could not confirm the connected account identity.', 'calliope-social-publisher'),
                'permission_missing' => __('The Mastodon token does not have permission to publish statuses.', 'calliope-social-publisher'),
                'connected_unverified'=> __('Mastodon must be verified again before publishing.', 'calliope-social-publisher'),
            ];

            return $this->create_state([
                'status'                => $status,
                'health'                => 'invalid',
                'is_configured'         => true,
                'is_authenticated'      => true,
                'is_identity_known'     => $status === 'identity_missing' ? false : $identity_known,
                'has_target'            => $status === 'identity_missing' ? false : $identity_known,
                'has_permission'        => false,
                'evidence_level'        => $status === 'identity_missing' ? 'E2' : ($identity_known ? 'E4' : 'E2'),
                'message'               => $messages[$status],
                'action'                => $status === 'configuration_error' ? __('Review server settings', 'calliope-social-publisher') : __('Save and test', 'calliope-social-publisher'),
                'validated_at'          => $marker['validated_at'],
                'connection_method'     => $method,
            ], 'E4');
        }

        if (!$identity_known || $marker['validated_at'] <= 0) {
            return $this->create_state([
                'status'                => 'connected_unverified',
                'health'                => $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'               => __('Mastodon credentials are saved but have not been verified for this server.', 'calliope-social-publisher'),
                'action'                => __('Save and test', 'calliope-social-publisher'),
                'connection_method'     => $method,
            ], 'E4');
        }

        if ($marker['status_scope_state'] === 'denied') {
            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 Mastodon token does not have permission to publish statuses.', 'calliope-social-publisher'),
                'action'                => __('Reconnect with Mastodon', 'calliope-social-publisher'),
                'validated_at'          => $marker['validated_at'],
                'connection_method'     => $method,
            ], 'E4');
        }

        $oauth_status_granted = $marker['auth_method'] === 'oauth'
            && $marker['status_scope_state'] === 'granted';
        $oauth_complete = $oauth_status_granted
            && $marker['media_scope_state'] === 'granted';
        $evidence = $oauth_status_granted ? 'E5' : 'E4';
        $minimum = $oauth_status_granted ? 'E5' : 'E4';
        $health = $marker['health'];
        if (!$oauth_complete || $marker['media_scope_state'] !== 'granted') {
            $health = 'degraded';
        }

        $message = __('Mastodon is ready to publish.', 'calliope-social-publisher');
        if ($marker['auth_method'] === 'manual' && $marker['status_scope_state'] === 'unknown') {
            $message = __('The Mastodon account was verified. Publishing permissions will be confirmed on the first post.', 'calliope-social-publisher');
        } elseif ($marker['media_scope_state'] === 'denied') {
            $message = __('Mastodon is ready for text, but the token cannot upload media.', 'calliope-social-publisher');
        } elseif ($health === 'degraded') {
            $message = __('Mastodon is ready, but the latest check or one publishing permission needs attention.', 'calliope-social-publisher');
        }

        return $this->create_state([
            'status'                => 'ready_to_publish',
            'health'                => $health,
            'is_configured'         => true,
            'is_authenticated'      => true,
            'is_identity_known'     => true,
            'has_target'            => true,
            'has_permission'        => true,
            'evidence_level'        => $evidence,
            'message'               => $message,
            'action'                => $health === 'degraded' ? __('Review connection', 'calliope-social-publisher') : '',
            'validated_at'          => $marker['validated_at'],
            'connection_method'     => $method,
        ], $minimum);
    }

    /**
     * Explicit remote validation. No status or media is published.
     *
     * @param string       $auth_method manual or oauth.
     * @param string|array $scope_value Scope returned by OAuth, when known.
     * @return true|WP_Error
     */
    public function test_connection($auth_method = 'manual', $scope_value = '') {
        $instance = $this->normalize_instance(get_option('calliope_sp_mstdn_instance', ''));
        $token = trim((string) get_option('calliope_sp_mstdn_token', ''));
        $auth_method = $auth_method === 'oauth' ? 'oauth' : 'manual';

        if ($instance === '' || $token === '') {
            $this->sync_legacy_status($instance !== '' ? 'credentials_saved' : 'configuration_error');
            return new WP_Error(
                'mastodon_configuration',
                __('Missing Mastodon server or access token.', 'calliope-social-publisher'),
                ['phase' => 'configuration', 'retryable' => false, 'failure_kind' => 'instance']
            );
        }

        if (!Calliope_SP_Host_Validator::is_safe_public_host($instance, true)) {
            $this->sync_legacy_status('configuration_error');
            return new WP_Error(
                'mastodon_instance_invalid',
                __('The Mastodon server must be a public HTTPS host.', 'calliope-social-publisher'),
                ['phase' => 'configuration', 'retryable' => false, 'failure_kind' => 'instance']
            );
        }

        $credentials_fingerprint = $this->credentials_fingerprint($instance, $token);
        $instance_fingerprint = $this->instance_fingerprint($instance);
        $previous_marker = $this->get_matching_validation_marker($credentials_fingerprint, $instance_fingerprint);
        $scope_states = $this->scope_states($scope_value);
        if ($auth_method === 'manual') {
            $scope_states = ['status' => 'unknown', 'media' => 'unknown'];
        }

        $response = $this->request_instance('GET', $instance, '/api/v1/accounts/verify_credentials', [
            'headers' => [
                'Authorization' => 'Bearer ' . $token,
                'Accept'        => 'application/json',
            ],
            'timeout' => 30,
        ]);

        if (is_wp_error($response)) {
            $this->apply_remote_failure_to_validation(
                $response,
                $credentials_fingerprint,
                $instance_fingerprint,
                $previous_marker,
                ['auth_method' => $auth_method, 'status_scope_state' => $scope_states['status'], 'media_scope_state' => $scope_states['media']]
            );
            return $response;
        }

        $http_code = (int) wp_remote_retrieve_response_code($response);
        $raw = (string) wp_remote_retrieve_body($response);
        if ($http_code < 200 || $http_code >= 300) {
            $error = $this->build_http_error('identity', $http_code, $raw);
            $this->apply_remote_failure_to_validation(
                $error,
                $credentials_fingerprint,
                $instance_fingerprint,
                $previous_marker,
                ['auth_method' => $auth_method, 'status_scope_state' => $scope_states['status'], 'media_scope_state' => $scope_states['media']]
            );
            return $error;
        }

        $json = json_decode($raw, true);
        if (!is_array($json)) {
            $error = $this->safe_error(
                'mastodon_identity_response',
                __('Mastodon returned an unexpected account response. Please try again.', 'calliope-social-publisher'),
                'identity',
                $http_code,
                true,
                'temporary'
            );
            $this->apply_remote_failure_to_validation($error, $credentials_fingerprint, $instance_fingerprint, $previous_marker, [
                'auth_method' => $auth_method,
                'status_scope_state' => $scope_states['status'],
                'media_scope_state' => $scope_states['media'],
            ]);
            return $error;
        }

        if (!array_key_exists('id', $json)) {
            $error = $this->safe_error(
                'mastodon_identity_response',
                __('Mastodon returned an unexpected account response. Please try again.', 'calliope-social-publisher'),
                'identity',
                $http_code,
                true,
                'temporary'
            );
            $this->apply_remote_failure_to_validation($error, $credentials_fingerprint, $instance_fingerprint, $previous_marker, [
                'auth_method' => $auth_method,
                'status_scope_state' => $scope_states['status'],
                'media_scope_state' => $scope_states['media'],
            ]);
            return $error;
        }

        $account_id = $this->normalize_account_id($json['id']);
        if ($account_id === '') {
            $error = $this->safe_error(
                'mastodon_identity_missing',
                __('Mastodon could not confirm the connected account identity.', 'calliope-social-publisher'),
                'identity',
                $http_code,
                false,
                'identity'
            );
            $this->apply_remote_failure_to_validation($error, $credentials_fingerprint, $instance_fingerprint, $previous_marker, [
                'auth_method' => $auth_method,
                'status_scope_state' => $scope_states['status'],
                'media_scope_state' => $scope_states['media'],
            ]);
            return $error;
        }

        $identity_fingerprint = $this->identity_fingerprint($instance, $account_id);
        if (is_array($previous_marker)
            && $previous_marker['identity_fingerprint'] !== ''
            && !hash_equals($previous_marker['identity_fingerprint'], $identity_fingerprint)
        ) {
            $error = $this->safe_error(
                'mastodon_identity_changed',
                __('Mastodon returned a different account identity. Verify the connection again.', 'calliope-social-publisher'),
                'identity',
                $http_code,
                false,
                'identity'
            );
            $this->save_invalid_marker('identity', $credentials_fingerprint, $instance_fingerprint, [
                'identity_fingerprint' => $previous_marker['identity_fingerprint'],
                'auth_method' => $auth_method,
                'status_scope_state' => $scope_states['status'],
                'media_scope_state' => $scope_states['media'],
                'validated_at' => $previous_marker['validated_at'],
            ]);
            $this->sync_legacy_status('identity_missing');
            return $error;
        }

        if ($scope_states['status'] === 'denied') {
            $this->save_invalid_marker('permission', $credentials_fingerprint, $instance_fingerprint, [
                'identity_fingerprint' => $identity_fingerprint,
                'auth_method' => $auth_method,
                'status_scope_state' => 'denied',
                'media_scope_state' => $scope_states['media'],
                'validated_at' => time(),
            ]);
            $this->sync_legacy_status('permission_missing');
            return $this->safe_error(
                'mastodon_status_scope_missing',
                __('The Mastodon token does not have permission to publish statuses.', 'calliope-social-publisher'),
                'permission',
                403,
                false,
                'permission'
            );
        }

        $this->save_validation_marker([
            'health'                  => 'healthy',
            'failure_kind'            => '',
            'validated_at'            => time(),
            'credentials_fingerprint' => $credentials_fingerprint,
            'instance_fingerprint'    => $instance_fingerprint,
            'identity_fingerprint'    => $identity_fingerprint,
            'auth_method'             => $auth_method,
            'status_scope_state'      => $scope_states['status'],
            'media_scope_state'       => $scope_states['media'],
        ]);

        $state = $this->get_connection_state();
        if (!is_wp_error($state) && $state->is_ready()) {
            $this->sync_legacy_status('ready_to_publish');
            return true;
        }

        return new WP_Error(
            'mastodon_validation_incomplete',
            __('Mastodon could not confirm publishing readiness.', 'calliope-social-publisher')
        );
    }

    /**
     * Publish a status only after local readiness has been demonstrated.
     *
     * @param array $data Publication data.
     * @return true|WP_Error
     */
    public function publish($data) {
        $state = $this->get_connection_state();
        if (is_wp_error($state)) {
            return $state;
        }
        if (!$state->is_ready()) {
            return new WP_Error(
                'mastodon_not_ready',
                __('Verify Mastodon from Connections before publishing.', 'calliope-social-publisher'),
                ['phase' => 'precheck', 'retryable' => false, 'failure_kind' => 'local']
            );
        }

        $instance = $this->normalize_instance(get_option('calliope_sp_mstdn_instance', ''));
        $token = trim((string) get_option('calliope_sp_mstdn_token', ''));
        $credentials_fingerprint = $this->credentials_fingerprint($instance, $token);
        $instance_fingerprint = $this->instance_fingerprint($instance);
        $marker = $this->get_matching_validation_marker($credentials_fingerprint, $instance_fingerprint);
        if (!is_array($marker)) {
            return new WP_Error(
                'mastodon_not_ready',
                __('Verify Mastodon from Connections before publishing.', 'calliope-social-publisher'),
                ['phase' => 'precheck', 'retryable' => false, 'failure_kind' => 'local']
            );
        }

        $media_mode = sanitize_key((string) ($data['media_mode'] ?? 'none'));
        $max_media_attachments = $this->get_max_media_attachments($instance);
        if ($media_mode === 'multiple_images' && isset($data['media']) && is_array($data['media']) && count($data['media']) > $max_media_attachments) {
            return $this->publish_limited_images((array) $data, $max_media_attachments);
        }
        $has_video = $media_mode === 'video';
        $has_multiple_images = $media_mode === 'multiple_images';
        $has_image = !$has_video && (!empty($data['image_b64']) || $has_multiple_images);
        $this->log_video_diagnostics('publish_start', [
            'media_mode' => $media_mode,
            'has_video' => $has_video,
            'has_image_b64' => !empty($data['image_b64']),
            'media_count' => isset($data['media']) && is_array($data['media']) ? count($data['media']) : 0,
            'media' => $this->media_log_items(isset($data['media']) && is_array($data['media']) ? $data['media'] : []),
        ]);
        if (($has_image || $has_video) && $marker['media_scope_state'] === 'denied') {
            return new WP_Error(
                'mastodon_media_permission_missing',
                __('This Mastodon token can publish text but cannot upload media.', 'calliope-social-publisher'),
                ['phase' => 'precheck', 'retryable' => false, 'failure_kind' => 'permission']
            );
        }

        $text = $this->format_text_clean_tags($data, 490);
        $media_id = null;
        $media_ids = [];
        if ($has_video) {
            $media_id = $this->upload_video_media($instance, $token, (array) $data);
            if (is_wp_error($media_id)) {
                if ($this->get_error_failure_kind($media_id) !== 'local') {
                    $this->apply_operation_failure($media_id, $marker, 'media');
                }
                return $media_id;
            }
            $marker['media_scope_state'] = 'granted';
            $marker['failure_kind'] = '';
            $marker['health'] = $marker['status_scope_state'] === 'granted' ? 'healthy' : 'degraded';
            $this->save_validation_marker($marker);
        } elseif ($has_multiple_images) {
            $image_assets = $this->multi_image_assets_from_payload((array) $data, $max_media_attachments);
            if (is_wp_error($image_assets)) {
                return $image_assets;
            }
            foreach ($image_assets as $image_asset) {
                $uploaded = $this->upload_media_file($instance, $token, $image_asset);
                if (is_wp_error($uploaded)) {
                    if ($this->get_error_failure_kind($uploaded) !== 'local') {
                        $this->apply_operation_failure($uploaded, $marker, 'media');
                    }
                    return $uploaded;
                }
                $media_ids[] = (string) $uploaded;
            }
            $marker['media_scope_state'] = 'granted';
            $marker['failure_kind'] = '';
            $marker['health'] = $marker['status_scope_state'] === 'granted' ? 'healthy' : 'degraded';
            $this->save_validation_marker($marker);
        } elseif ($has_image) {
            $media_id = $this->upload_media($instance, $token, $data['image_b64'], $data['alt_text'] ?? '');
            if (is_wp_error($media_id)) {
                if ($this->get_error_failure_kind($media_id) !== 'local') {
                    $this->apply_operation_failure($media_id, $marker, 'media');
                }
                return $media_id;
            }
            $marker['media_scope_state'] = 'granted';
            $marker['failure_kind'] = '';
            $marker['health'] = $marker['status_scope_state'] === 'granted' ? 'healthy' : 'degraded';
            $this->save_validation_marker($marker);
        }

        $body = [
            'status'     => $text,
            'visibility' => 'public',
        ];
        if ($media_ids !== []) {
            $body['media_ids'] = array_values($media_ids);
        } elseif ($media_id) {
            $body['media_ids'] = [(string) $media_id];
        }

        if ($has_video) {
            $this->log_video_diagnostics('status_prepare', [
                'media_id_set' => is_string($media_id) && $media_id !== '',
                'text_length' => function_exists('mb_strlen') ? mb_strlen($text, 'UTF-8') : strlen($text),
            ]);
        }

        $response = $this->request_instance('POST', $instance, '/api/v1/statuses', [
            'headers' => [
                'Authorization' => 'Bearer ' . $token,
                'Content-Type'  => 'application/json',
                'Accept'        => 'application/json',
            ],
            'body'    => wp_json_encode($body),
            'timeout' => 45,
        ]);

        if ($has_video) {
            $this->log_video_diagnostics('status_response', [
                'http_code' => is_wp_error($response) ? 0 : (int) wp_remote_retrieve_response_code($response),
                'transport_error' => is_wp_error($response),
            ], is_wp_error($response) ? 'error' : 'info');
        }

        if (is_wp_error($response)) {
            $this->apply_operation_failure($response, $marker, 'status');
            return $response;
        }

        $http_code = (int) wp_remote_retrieve_response_code($response);
        if ($http_code < 200 || $http_code >= 300) {
            if ($http_code === 422) {
                return $this->safe_error(
                    'mastodon_status_editorial',
                    __('Mastodon rejected the status content. Review it and try again.', 'calliope-social-publisher'),
                    'status',
                    $http_code,
                    false,
                    'editorial'
                );
            }
            $error = $this->build_http_error('status', $http_code, (string) wp_remote_retrieve_body($response));
            $this->apply_operation_failure($error, $marker, 'status');
            return $error;
        }

        $status_json = [];
        if ($has_video || $has_multiple_images) {
            $status_json = json_decode((string) wp_remote_retrieve_body($response), true);
            $attachments = is_array($status_json) && isset($status_json['media_attachments']) && is_array($status_json['media_attachments'])
                ? $status_json['media_attachments']
                : [];
        }
        if ($has_video) {
            $first_attachment = isset($attachments[0]) && is_array($attachments[0]) ? $attachments[0] : [];
            $first_type = sanitize_key((string) ($first_attachment['type'] ?? ''));
            $has_video_attachment = in_array($first_type, ['video', 'gifv'], true);
            $this->log_video_diagnostics('status_response_validated', [
                'media_attachment_count' => count($attachments),
                'first_media_type' => $first_type,
                'has_video_attachment' => $has_video_attachment,
            ], $has_video_attachment ? 'info' : 'warning');
            if (!$has_video_attachment) {
                return $this->safe_error(
                    'mastodon_video_attachment_missing',
                    __('Mastodon created the status but did not return a video attachment.', 'calliope-social-publisher'),
                    'status',
                    $http_code,
                    true,
                    'temporary'
                );
            }
        }

        $marker['status_scope_state'] = 'granted';
        if ($has_image) {
            $marker['media_scope_state'] = 'granted';
        }
        $marker['validated_at'] = time();
        $marker['failure_kind'] = '';
        $marker['health'] = $marker['media_scope_state'] === 'granted' ? 'healthy' : 'degraded';
        $this->save_validation_marker($marker);
        $this->sync_legacy_status('ready_to_publish');

        if ($has_multiple_images) {
            if (count($attachments) !== count($media_ids)) {
                return $this->safe_error(
                    'mastodon_images_attachment_missing',
                    __('Mastodon created the status but did not confirm every selected image attachment.', 'calliope-social-publisher'),
                    'status',
                    $http_code,
                    true,
                    'temporary'
                );
            }
            return [
                'ok' => true,
                'media_type' => 'gallery',
                'attachments' => count($attachments),
                'post_id' => isset($status_json['id']) && is_scalar($status_json['id']) ? sanitize_text_field((string) $status_json['id']) : '',
                'url' => isset($status_json['url']) && is_scalar($status_json['url']) ? esc_url_raw((string) $status_json['url']) : '',
            ];
        }
        if ($has_video) {
            return [
                'ok' => true,
                'media_type' => 'video',
                'media_id' => is_scalar($media_id) ? sanitize_text_field((string) $media_id) : '',
                'post_id' => is_array($status_json) && isset($status_json['id']) && is_scalar($status_json['id']) ? sanitize_text_field((string) $status_json['id']) : '',
                'url' => is_array($status_json) && isset($status_json['url']) && is_scalar($status_json['url']) ? esc_url_raw((string) $status_json['url']) : '',
            ];
        }

        return [
            'ok' => true,
            'post_id' => is_array($status_json) && isset($status_json['id']) && is_scalar($status_json['id']) ? sanitize_text_field((string) $status_json['id']) : '',
            'url' => is_array($status_json) && isset($status_json['url']) && is_scalar($status_json['url']) ? esc_url_raw((string) $status_json['url']) : '',
            'media_type' => !empty($data['image_b64']) ? 'image' : 'text',
        ];
    }

    /** @return array<string,mixed>|WP_Error */
    private function publish_limited_images(array $data, int $limit) {
        $media = isset($data['media']) && is_array($data['media']) ? array_values($data['media']) : [];
        $count = count($media);
        $limit = max(1, min(10, $limit));
        if ($count <= $limit || $count > 10) {
            return $this->safe_error('mastodon_images_invalid', __('Mastodon multiple-image publishing requires 2 to 10 images.', 'calliope-social-publisher'), 'prepare', 0, false, 'local');
        }
        $limited = $data;
        $limited['media'] = array_slice($media, 0, $limit);
        $limited['media_mode'] = 'multiple_images';
        $limited['image_b64'] = '';
        $result = $this->publish($limited);
        if (is_wp_error($result)) { return $result; }
        $result = is_array($result) ? $result : ['ok' => true];
        $result['ok'] = true;
        $result['warning'] = true;
        $result['warning_code'] = 'mastodon_images_limited';
        $result['ui_status'] = 'warning';
        $result['media_type'] = 'gallery';
        $result['attachments'] = $limit;
        $result['requested_attachments'] = $count;
        $result['omitted_attachments'] = $count - $limit;
        $result['user_message'] = sprintf(
            /* translators: 1: published image count, 2: requested image count. */
            __('Mastodon published one post with the first %1$d of %2$d selected images, which is this server maximum.', 'calliope-social-publisher'),
            $limit,
            $count
        );
        return $result;
    }

    private function get_max_media_attachments($instance) {
        $instance = $this->normalize_instance($instance);
        $fallback = 4;
        if ($instance === '') { return $fallback; }
        $cache_key = 'calliope_sp_mstdn_media_limit_' . substr(hash('sha256', $instance), 0, 32);
        $cached = get_transient($cache_key);
        if (is_numeric($cached) && (int) $cached > 0) {
            return max(1, min(10, (int) $cached));
        }
        foreach (['/api/v2/instance', '/api/v1/instance'] as $endpoint) {
            $response = $this->request_instance('GET', $instance, $endpoint, [
                'headers' => ['Accept' => 'application/json'],
                'timeout' => 20,
            ]);
            if (is_wp_error($response) || (int) wp_remote_retrieve_response_code($response) < 200 || (int) wp_remote_retrieve_response_code($response) >= 300) {
                continue;
            }
            $json = json_decode((string) wp_remote_retrieve_body($response), true);
            $limit = is_array($json) ? absint($json['configuration']['statuses']['max_media_attachments'] ?? 0) : 0;
            if ($limit > 0) {
                $limit = max(1, min(10, $limit));
                set_transient($cache_key, $limit, DAY_IN_SECONDS);
                return $limit;
            }
        }
        set_transient($cache_key, $fallback, HOUR_IN_SECONDS);
        return $fallback;
    }

    /** @return string */
    public function normalize_instance($instance) {
        if (!is_scalar($instance) || is_float($instance)) {
            return '';
        }
        $instance = trim((string) $instance);
        if ($instance === '' || strpos($instance, "\0") !== false || preg_match('/[\x00-\x1F\x7F]/', $instance)) {
            return '';
        }

        if (preg_match('/^@[^@\s]+@([^@\s\/]+)$/u', $instance, $match)) {
            $instance = $match[1];
        }

        if (preg_match('#^[a-z][a-z0-9+.-]*://#i', $instance)) {
            $parts = wp_parse_url($instance);
            if (!is_array($parts)
                || strtolower((string) ($parts['scheme'] ?? '')) !== 'https'
                || !empty($parts['user'])
                || !empty($parts['pass'])
                || !empty($parts['port'])
                || !empty($parts['query'])
                || !empty($parts['fragment'])
                || (!empty($parts['path']) && $parts['path'] !== '/')
            ) {
                return '';
            }
            $instance = (string) ($parts['host'] ?? '');
        } elseif (preg_match('/[\/?#]/', $instance)) {
            return '';
        }

        $instance = strtolower(rtrim(trim($instance), '.'));
        $instance = trim($instance, '[]');
        if (!Calliope_SP_Host_Validator::is_safe_public_host($instance, false)) {
            return '';
        }
        return sanitize_text_field($instance);
    }

    /** @return array{status:string,media:string} */
    public function scope_states($scope_value) {
        $known = false;
        $scopes = [];
        if (is_string($scope_value)) {
            $scope_value = trim($scope_value);
            $known = $scope_value !== '';
            $scopes = $known ? preg_split('/[\s+]+/', strtolower($scope_value)) : [];
        } elseif (is_array($scope_value)) {
            $known = !empty($scope_value);
            $scopes = $scope_value;
        }
        $normalized = [];
        foreach ((array) $scopes as $scope) {
            if (!is_scalar($scope)) continue;
            $scope = sanitize_key(str_replace(':', '__colon__', strtolower(trim((string) $scope))));
            $scope = str_replace('__colon__', ':', $scope);
            if ($scope !== '') $normalized[$scope] = true;
        }
        if (!$known) return ['status' => 'unknown', 'media' => 'unknown'];
        $parent_write = isset($normalized['write']);
        return [
            'status' => ($parent_write || isset($normalized['write:statuses'])) ? 'granted' : 'denied',
            'media'  => ($parent_write || isset($normalized['write:media'])) ? 'granted' : 'denied',
        ];
    }

    /** @return string */
    private function normalize_account_id($value) {
        if (!is_scalar($value) || is_float($value)) return '';
        $value = trim((string) $value);
        if ($value === '' || strlen($value) > 200 || preg_match('/[\x00-\x20\x7F]/', $value)) return '';
        return $value;
    }

    /** @return string */
    private function credentials_fingerprint($instance, $token) {
        return hash_hmac('sha256', wp_json_encode([
            'schema' => self::VALIDATION_SCHEMA,
            'instance' => (string) $instance,
            'token' => (string) $token,
        ]), wp_salt('auth'));
    }

    /** @return string */
    private function instance_fingerprint($instance) {
        return hash_hmac('sha256', self::VALIDATION_SCHEMA . "\0instance\0" . (string) $instance, wp_salt('auth'));
    }

    /** @return string */
    private function identity_fingerprint($instance, $account_id) {
        return hash_hmac('sha256', wp_json_encode([
            'schema' => self::VALIDATION_SCHEMA,
            'instance' => (string) $instance,
            'account_id' => (string) $account_id,
        ]), wp_salt('auth'));
    }

    /** @return array|null */
    private function get_matching_validation_marker($credentials_fingerprint, $instance_fingerprint) {
        $marker = get_option(self::VALIDATION_OPTION, null);
        if (!is_array($marker)
            || ($marker['schema'] ?? '') !== self::VALIDATION_SCHEMA
            || !in_array(($marker['health'] ?? ''), ['healthy', 'degraded', 'invalid'], true)
            || !in_array(($marker['failure_kind'] ?? ''), ['', 'auth', 'instance', 'identity', 'permission'], true)
            || !in_array(($marker['auth_method'] ?? ''), ['manual', 'oauth'], true)
            || !in_array(($marker['status_scope_state'] ?? ''), ['granted', 'unknown', 'denied'], true)
            || !in_array(($marker['media_scope_state'] ?? ''), ['granted', 'unknown', 'denied'], true)
            || !is_string($marker['credentials_fingerprint'] ?? null)
            || !is_string($marker['instance_fingerprint'] ?? null)
            || !preg_match('/^[a-f0-9]{64}$/', (string) $marker['credentials_fingerprint'])
            || !preg_match('/^[a-f0-9]{64}$/', (string) $marker['instance_fingerprint'])
            || !hash_equals((string) $marker['credentials_fingerprint'], (string) $credentials_fingerprint)
            || !hash_equals((string) $marker['instance_fingerprint'], (string) $instance_fingerprint)
        ) {
            return null;
        }
        $identity = $marker['identity_fingerprint'] ?? '';
        if (!is_string($identity) || ($identity !== '' && !preg_match('/^[a-f0-9]{64}$/', $identity))) return null;
        $validated_at = $marker['validated_at'] ?? 0;
        if (!is_int($validated_at) && !(is_string($validated_at) && preg_match('/^\d+$/', $validated_at))) return null;
        $validated_at = (int) $validated_at;
        if ($validated_at < 0) return null;

        return [
            'schema'                  => self::VALIDATION_SCHEMA,
            'health'                  => (string) $marker['health'],
            'failure_kind'            => (string) $marker['failure_kind'],
            'validated_at'            => $validated_at,
            'credentials_fingerprint' => (string) $marker['credentials_fingerprint'],
            'instance_fingerprint'    => (string) $marker['instance_fingerprint'],
            'identity_fingerprint'    => $identity,
            'auth_method'             => (string) $marker['auth_method'],
            'status_scope_state'      => (string) $marker['status_scope_state'],
            'media_scope_state'       => (string) $marker['media_scope_state'],
        ];
    }

    /** @return void */
    private function save_validation_marker(array $marker) {
        $safe = [
            'schema'                  => self::VALIDATION_SCHEMA,
            'health'                  => in_array(($marker['health'] ?? ''), ['healthy', 'degraded', 'invalid'], true) ? (string) $marker['health'] : 'invalid',
            'failure_kind'            => in_array(($marker['failure_kind'] ?? ''), ['', 'auth', 'instance', 'identity', 'permission'], true) ? (string) $marker['failure_kind'] : '',
            'validated_at'            => max(0, (int) ($marker['validated_at'] ?? 0)),
            'credentials_fingerprint' => preg_match('/^[a-f0-9]{64}$/', (string) ($marker['credentials_fingerprint'] ?? '')) ? (string) $marker['credentials_fingerprint'] : '',
            'instance_fingerprint'    => preg_match('/^[a-f0-9]{64}$/', (string) ($marker['instance_fingerprint'] ?? '')) ? (string) $marker['instance_fingerprint'] : '',
            'identity_fingerprint'    => preg_match('/^[a-f0-9]{64}$/', (string) ($marker['identity_fingerprint'] ?? '')) ? (string) $marker['identity_fingerprint'] : '',
            'auth_method'             => in_array(($marker['auth_method'] ?? ''), ['manual', 'oauth'], true) ? (string) $marker['auth_method'] : 'manual',
            'status_scope_state'      => in_array(($marker['status_scope_state'] ?? ''), ['granted', 'unknown', 'denied'], true) ? (string) $marker['status_scope_state'] : 'unknown',
            'media_scope_state'       => in_array(($marker['media_scope_state'] ?? ''), ['granted', 'unknown', 'denied'], true) ? (string) $marker['media_scope_state'] : 'unknown',
        ];
        if (get_option(self::VALIDATION_OPTION, false) === false) {
            add_option(self::VALIDATION_OPTION, $safe, '', 'no');
            return;
        }
        update_option(self::VALIDATION_OPTION, $safe, false);
    }

    /** @return void */
    public function invalidate_validation() {
        delete_option(self::VALIDATION_OPTION);
    }

    /** @return void */
    private function save_invalid_marker($failure_kind, $credentials_fingerprint, $instance_fingerprint, array $context = []) {
        $this->save_validation_marker([
            'health'                  => 'invalid',
            'failure_kind'            => in_array($failure_kind, ['auth', 'instance', 'identity', 'permission'], true) ? $failure_kind : 'instance',
            'validated_at'            => max(0, (int) ($context['validated_at'] ?? time())),
            'credentials_fingerprint' => $credentials_fingerprint,
            'instance_fingerprint'    => $instance_fingerprint,
            'identity_fingerprint'    => (string) ($context['identity_fingerprint'] ?? ''),
            'auth_method'             => (string) ($context['auth_method'] ?? 'manual'),
            'status_scope_state'      => (string) ($context['status_scope_state'] ?? 'unknown'),
            'media_scope_state'       => (string) ($context['media_scope_state'] ?? 'unknown'),
        ]);
    }

    /** @return void */
    private function apply_remote_failure_to_validation($error, $credentials_fingerprint, $instance_fingerprint, $previous_marker, array $context = []) {
        if (!is_wp_error($error)) return;
        $data = $error->get_error_data();
        $data = is_array($data) ? $data : [];
        $failure_kind = (string) ($data['failure_kind'] ?? '');
        if (!empty($data['retryable']) || $failure_kind === 'temporary') {
            if (is_array($previous_marker)
                && $previous_marker['identity_fingerprint'] !== ''
                && $previous_marker['validated_at'] > 0
                && in_array($previous_marker['health'], ['healthy', 'degraded'], true)
            ) {
                $previous_marker['health'] = 'degraded';
                $previous_marker['failure_kind'] = '';
                $this->save_validation_marker($previous_marker);
                $this->sync_legacy_status('ready_to_publish');
                return;
            }
            $this->save_validation_marker([
                'health'                  => 'degraded',
                'failure_kind'            => '',
                'validated_at'            => 0,
                'credentials_fingerprint' => $credentials_fingerprint,
                'instance_fingerprint'    => $instance_fingerprint,
                'identity_fingerprint'    => '',
                'auth_method'             => (string) ($context['auth_method'] ?? 'manual'),
                'status_scope_state'      => (string) ($context['status_scope_state'] ?? 'unknown'),
                'media_scope_state'       => (string) ($context['media_scope_state'] ?? 'unknown'),
            ]);
            $this->sync_legacy_status('connected_unverified');
            return;
        }
        if (in_array($failure_kind, ['auth', 'instance', 'identity', 'permission'], true)) {
            $base = is_array($previous_marker) ? array_merge($previous_marker, $context) : $context;
            $this->save_invalid_marker($failure_kind, $credentials_fingerprint, $instance_fingerprint, $base);
            $status_map = [
                'auth' => 'token_invalid',
                'instance' => 'configuration_error',
                'identity' => 'identity_missing',
                'permission' => 'permission_missing',
            ];
            $this->sync_legacy_status($status_map[$failure_kind]);
        }
    }

    /** @return void */
    private function apply_operation_failure($error, array $marker, $capability) {
        if (!is_wp_error($error)) return;
        $data = $error->get_error_data();
        $data = is_array($data) ? $data : [];
        $failure_kind = (string) ($data['failure_kind'] ?? '');
        if (!empty($data['retryable']) || $failure_kind === 'temporary') {
            $marker['health'] = 'degraded';
            $marker['failure_kind'] = '';
            $this->save_validation_marker($marker);
            return;
        }
        if ($failure_kind === 'permission' && $capability === 'media') {
            $marker['media_scope_state'] = 'denied';
            $marker['health'] = 'degraded';
            $marker['failure_kind'] = '';
            $this->save_validation_marker($marker);
            $this->sync_legacy_status('ready_to_publish');
            return;
        }
        if (in_array($failure_kind, ['auth', 'instance', 'identity', 'permission'], true)) {
            $this->save_invalid_marker(
                $failure_kind,
                $marker['credentials_fingerprint'],
                $marker['instance_fingerprint'],
                $marker
            );
            $status_map = [
                'auth' => 'token_invalid',
                'instance' => 'configuration_error',
                'identity' => 'identity_missing',
                'permission' => 'permission_missing',
            ];
            $this->sync_legacy_status($status_map[$failure_kind]);
        }
    }

    /** @return WP_Error */
    private function build_http_error($phase, $http_code, $raw) {
        $safe_message = strtolower($this->extract_api_error($raw));
        $failure_kind = 'temporary';
        $retryable = in_array($http_code, [408, 429], true) || $http_code >= 500;
        if ($http_code === 401 || strpos($safe_message, 'invalid access token') !== false || strpos($safe_message, 'token is invalid') !== false || strpos($safe_message, 'token revoked') !== false) {
            $failure_kind = 'auth';
            $retryable = false;
        } elseif ($http_code === 403 && (
            strpos($safe_message, 'scope') !== false
            || strpos($safe_message, 'permission') !== false
            || strpos($safe_message, 'not authorized to post') !== false
            || strpos($safe_message, 'write:statuses') !== false
            || strpos($safe_message, 'write:media') !== false
        )) {
            $failure_kind = 'permission';
            $retryable = false;
        } elseif ($phase === 'identity' && $http_code === 404) {
            $failure_kind = 'instance';
            $retryable = false;
        }
        $messages = [
            'auth' => __('The Mastodon access token is invalid or was revoked.', 'calliope-social-publisher'),
            'permission' => __('The Mastodon token does not have the required publishing permission.', 'calliope-social-publisher'),
            'instance' => __('The Mastodon server does not provide the required API endpoint.', 'calliope-social-publisher'),
            'temporary' => __('Mastodon could not complete the request right now. Please try again.', 'calliope-social-publisher'),
        ];
        return $this->safe_error('mastodon_' . sanitize_key($phase) . '_http', $messages[$failure_kind], $phase, $http_code, $retryable, $failure_kind);
    }

    /** @return WP_Error */
    private function safe_error($code, $message, $phase, $http_code, $retryable, $failure_kind) {
        return new WP_Error(sanitize_key($code), $message, [
            'phase' => sanitize_key($phase),
            'http_code' => max(0, (int) $http_code),
            'retryable' => (bool) $retryable,
            'failure_kind' => sanitize_key($failure_kind),
        ]);
    }

    /** @return WP_Error|string */
    private function upload_media($instance, $token, $b64, $alt) {
        $file = $this->decode_image_data($b64);
        if (is_wp_error($file)) return $file;
        $last_error = null;
        foreach (['/api/v2/media', '/api/v1/media'] as $endpoint) {
            $result = $this->request_media_upload($instance, $token, $endpoint, $file['binary'], $file['filename'], $file['mime'], $alt);
            if (is_wp_error($result)) {
                if ($result->get_error_code() === 'mastodon_media_retry') {
                    $last_error = $result;
                    continue;
                }
                return $result;
            }
            return $result;
        }
        return $last_error instanceof WP_Error
            ? $last_error
            : new WP_Error('mastodon_media', __('Could not upload the image.', 'calliope-social-publisher'));
    }

    /** @return array<int,array<string,mixed>>|WP_Error */
    private function multi_image_assets_from_payload(array $data, int $max_items = 4) {
        if (!class_exists('Calliope_SPP_Media_Set') || !class_exists('Calliope_SPP_Media_Resolver')) {
            return $this->safe_error('mastodon_images_require_pro', __('Mastodon multiple-image publishing requires Social Publisher Pro media support.', 'calliope-social-publisher'), 'media', 0, false, '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'
        ));
        $max_items = max(1, min(10, $max_items));
        if (count($normalized) < 1 || count($normalized) > $max_items) {
            return $this->safe_error('mastodon_images_invalid', sprintf(
                /* translators: %d: maximum number of images allowed by the Mastodon server. */
                __('This Mastodon server accepts up to %d images in one post.', 'calliope-social-publisher'),
                $max_items
            ), 'media', 0, false, 'local');
        }
        $resolved = Calliope_SPP_Media_Resolver::resolve($normalized, [
            'allowed_video_mimes' => [],
            'log_context' => ['network' => 'mastodon', 'mode' => 'multiple_images'],
        ]);
        if (is_wp_error($resolved)) {
            return $this->safe_error('mastodon_images_media_error', sprintf(
                /* translators: %s: media preparation error. */
                __('Mastodon could not prepare the selected images. %s', 'calliope-social-publisher'),
                $resolved->get_error_message()
            ), 'media', 0, false, 'local');
        }
        return array_values($resolved);
    }

    /** @return string|WP_Error */
    private function upload_media_file($instance, $token, array $file) {
        $path = (string) ($file['path'] ?? '');
        $binary = $path !== '' && is_file($path) && is_readable($path) ? file_get_contents($path) : false;
        if (!is_string($binary) || $binary === '') {
            return $this->safe_error('mastodon_image_read_failed', __('Mastodon could not read one of the selected images.', 'calliope-social-publisher'), 'media', 0, false, 'local');
        }
        $last_error = null;
        foreach (['/api/v2/media', '/api/v1/media'] as $endpoint) {
            $result = $this->request_media_upload(
                $instance,
                $token,
                $endpoint,
                $binary,
                (string) ($file['filename'] ?? 'image'),
                (string) ($file['mime_type'] ?? 'image/jpeg'),
                (string) ($file['description'] ?? '')
            );
            if (is_wp_error($result)) {
                if ($result->get_error_code() === 'mastodon_media_retry') {
                    $last_error = $result;
                    continue;
                }
                return $result;
            }
            return $result;
        }
        return $last_error instanceof WP_Error
            ? $last_error
            : $this->safe_error('mastodon_image_upload_failed', __('Mastodon could not upload one of the selected images.', 'calliope-social-publisher'), 'media', 0, true, 'temporary');
    }

    /** @return WP_Error|string */
    private function upload_video_media($instance, $token, array $data) {
        $file = $this->video_asset_from_payload($data);
        if (is_wp_error($file)) {
            return $file;
        }

        $binary = $this->read_local_file((string) $file['path']);
        if ($binary === false) {
            return $this->safe_error(
                'mastodon_video_read_failed',
                __('Mastodon could not read the selected video file.', 'calliope-social-publisher'),
                'media',
                0,
                false,
                'local'
            );
        }

        $alt = (string) ($file['description'] ?? '');
        if ($alt === '' && isset($data['alt_text'])) {
            $alt = sanitize_text_field((string) $data['alt_text']);
        }

        $last_error = null;
        foreach (['/api/v2/media', '/api/v1/media'] as $endpoint) {
            $result = $this->request_media_upload($instance, $token, $endpoint, $binary, (string) $file['filename'], (string) $file['mime_type'], $alt);
            if (is_wp_error($result)) {
                if ($result->get_error_code() === 'mastodon_media_retry') {
                    $last_error = $result;
                    continue;
                }
                return $result;
            }
            return $result;
        }

        return $last_error instanceof WP_Error
            ? $last_error
            : $this->safe_error('mastodon_video_upload_failed', __('Could not upload the video.', 'calliope-social-publisher'), 'media', 0, true, 'temporary');
    }

    /** @return array<string,mixed>|WP_Error */
    private function video_asset_from_payload(array $data) {
        if (!class_exists('Calliope_SPP_Media_Set') || !class_exists('Calliope_SPP_Media_Resolver')) {
            return $this->safe_error(
                'mastodon_video_requires_pro',
                __('Mastodon video publishing requires Social Publisher Pro media support.', 'calliope-social-publisher'),
                'media',
                0,
                false,
                'local'
            );
        }

        $media = isset($data['media']) && is_array($data['media']) ? $data['media'] : [];
        $contract = Calliope_SPP_Media_Set::validate_contract('video', $media, [
            'image' => false,
            'max_images' => 0,
            'video' => true,
        ]);
        if (is_wp_error($contract)) {
            return $this->safe_error(
                'mastodon_video_invalid',
                __('Mastodon video publishing requires exactly one local video attachment.', 'calliope-social-publisher'),
                'media',
                0,
                false,
                'local'
            );
        }

        $max = $this->mastodon_video_max_file_size();
        $resolved = Calliope_SPP_Media_Resolver::resolve($media, [
            'max_file_bytes' => $max,
            'max_request_bytes' => $max,
            'allowed_video_mimes' => $this->mastodon_video_mimes(),
            'log_context' => ['network' => 'mastodon', 'mode' => 'video'],
        ]);
        if (is_wp_error($resolved)) {
            return $this->safe_error(
                'mastodon_video_media_error',
                sprintf(
                    /* translators: %s: media preparation error. */
                    __('Mastodon could not prepare the selected video. %s', 'calliope-social-publisher'),
                    $resolved->get_error_message()
                ),
                'media',
                0,
                false,
                'local'
            );
        }

        $file = $resolved[0] ?? null;
        if (!is_array($file) || empty($file['path']) || empty($file['mime_type'])) {
            return $this->safe_error('mastodon_video_media_error', __('Mastodon could not prepare the selected video.', 'calliope-social-publisher'), 'media', 0, false, 'local');
        }
        if (!$this->video_extension_matches((string) ($file['filename'] ?? ''), (string) $file['mime_type'])) {
            return $this->safe_error('mastodon_video_media_error', __('Mastodon video publishing supports MP4, WebM, and QuickTime files in this version.', 'calliope-social-publisher'), 'media', 0, false, 'local');
        }

        $this->log_video_diagnostics('video_resolved', [
            'attachment_id' => absint($file['id'] ?? 0),
            'mime_type' => sanitize_mime_type((string) ($file['mime_type'] ?? '')),
            'size' => absint($file['size'] ?? 0),
            'filename' => sanitize_file_name((string) ($file['filename'] ?? '')),
            'limit_bytes' => $max,
        ]);

        return $file;
    }

    /** @return string[] */
    private function mastodon_video_mimes() {
        $mimes = apply_filters('calliope_sp_mastodon_video_mimes', ['video/mp4', 'video/webm', 'video/quicktime']);
        $clean = [];
        foreach ((array) $mimes as $mime) {
            $mime = strtolower(sanitize_mime_type((string) $mime));
            if ($mime !== '' && strpos($mime, 'video/') === 0) {
                $clean[] = $mime;
            }
        }
        return $clean !== [] ? array_values(array_unique($clean)) : ['video/mp4', 'video/webm', 'video/quicktime'];
    }

    private function mastodon_video_max_file_size() {
        $limit = (int) apply_filters('calliope_sp_mastodon_video_max_file_size', self::VIDEO_MAX_BYTES);
        return max(1024, min(512 * 1024 * 1024, $limit));
    }

    /** @return string|false */
    private function read_local_file($path) {
        $path = (string) $path;
        if ($path === '' || !is_file($path) || !is_readable($path)) {
            return false;
        }
        $contents = file_get_contents($path);
        return is_string($contents) ? $contents : false;
    }

    private function video_extension_matches($filename, $mime) {
        $extension = strtolower((string) pathinfo((string) $filename, PATHINFO_EXTENSION));
        $mime = strtolower(sanitize_mime_type((string) $mime));
        $map = [
            'video/mp4'       => ['mp4', 'm4v'],
            'video/webm'      => ['webm'],
            'video/quicktime' => ['mov', 'qt'],
        ];
        return isset($map[$mime]) && in_array($extension, $map[$mime], true);
    }

    /** @return array|WP_Error */
    private function decode_image_data($b64) {
        $mime_type = 'image/jpeg';
        $filename = 'upload.jpg';
        if (preg_match('#^data:([^;]+);base64,#i', $b64, $matches)) {
            $detected_mime = strtolower((string) $matches[1]);
            $allowed_mimes = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'image/gif' => 'gif'];
            if (isset($allowed_mimes[$detected_mime])) {
                $mime_type = $detected_mime;
                $filename = 'upload.' . $allowed_mimes[$detected_mime];
            }
        }
        if (strpos($b64, 'base64,') !== false) {
            $parts = explode('base64,', $b64, 2);
            $b64 = $parts[1];
        }
        $bin = base64_decode($b64, true);
        if ($bin === false || $bin === '') {
            return new WP_Error('img', __('Could not decode the image.', 'calliope-social-publisher'), [
                'phase' => 'image', 'retryable' => false, 'failure_kind' => 'local',
            ]);
        }
        return ['binary' => $bin, 'mime' => $mime_type, 'filename' => $filename];
    }

    /** @return string|WP_Error */
    private function request_media_upload($instance, $token, $endpoint, $binary, $filename, $mime_type, $alt) {
        $boundary = '----CalliopeSPBoundary' . wp_generate_password(24, false, false);
        $eol = "\r\n";
        $body = '--' . $boundary . $eol;
        $body .= 'Content-Disposition: form-data; name="file"; filename="' . $filename . '"' . $eol;
        $body .= 'Content-Type: ' . $mime_type . $eol;
        $body .= 'Content-Transfer-Encoding: binary' . $eol . $eol;
        $body .= $binary . $eol;
        if (!empty($alt)) {
            $body .= '--' . $boundary . $eol;
            $body .= 'Content-Disposition: form-data; name="description"' . $eol . $eol;
            $body .= trim((string) $alt) . $eol;
        }
        $body .= '--' . $boundary . '--' . $eol;

        $response = $this->request_instance('POST', $instance, $endpoint, [
            'headers' => [
                'Authorization' => 'Bearer ' . $token,
                'Accept' => 'application/json',
                'Content-Type' => 'multipart/form-data; boundary=' . $boundary,
            ],
            'body' => $body,
            'timeout' => 60,
            'data_format' => 'body',
        ]);
        if (is_wp_error($response)) return $response;
        $http_code = (int) wp_remote_retrieve_response_code($response);
        $raw = (string) wp_remote_retrieve_body($response);
        $json = json_decode($raw, true);
        $is_video_upload = strpos(sanitize_mime_type((string) $mime_type), 'video/') === 0;
        if (($http_code === 404 || $http_code === 405) && $endpoint === '/api/v2/media') {
            return new WP_Error('mastodon_media_retry', 'fallback-to-v1', ['phase' => 'media', 'retryable' => false, 'failure_kind' => 'fallback']);
        }
        if ($http_code < 200 || $http_code >= 300) {
            if ($is_video_upload) {
                $this->log_video_diagnostics('media_upload_response', [
                    'endpoint' => $endpoint,
                    'http_code' => $http_code,
                    'mime_type' => sanitize_mime_type((string) $mime_type),
                    'filename' => sanitize_file_name((string) $filename),
                    'has_id' => !empty($json['id']),
                ], 'error');
            }
            return $this->build_http_error('media', $http_code, $raw);
        }
        if (!is_array($json) || empty($json['id'])) {
            if ($is_video_upload) {
                $this->log_video_diagnostics('media_upload_response', [
                    'endpoint' => $endpoint,
                    'http_code' => $http_code,
                    'mime_type' => sanitize_mime_type((string) $mime_type),
                    'filename' => sanitize_file_name((string) $filename),
                    'has_id' => false,
                ], 'error');
            }
            return $this->safe_error(
                'mastodon_media_response',
                __('Mastodon did not return a valid media attachment.', 'calliope-social-publisher'),
                'media',
                $http_code,
                true,
                'temporary'
            );
        }
        if ($is_video_upload && $endpoint === '/api/v2/media' && $http_code === 202 && !$this->wait_for_media_ready($instance, $token, (string) $json['id'])) {
            return $this->safe_error(
                'mastodon_video_processing_timeout',
                __('Mastodon accepted the video upload but it was not ready to attach yet. Try again in a moment.', 'calliope-social-publisher'),
                'media',
                $http_code,
                true,
                'temporary'
            );
        }
        if ($is_video_upload) {
            $this->log_video_diagnostics('media_upload_response', [
                'endpoint' => $endpoint,
                'http_code' => $http_code,
                'mime_type' => sanitize_mime_type((string) $mime_type),
                'filename' => sanitize_file_name((string) $filename),
                'has_id' => true,
                'media_id' => sanitize_text_field((string) $json['id']),
                'async_processing' => $endpoint === '/api/v2/media' && $http_code === 202,
            ]);
        }
        return (string) $json['id'];
    }

    /** @return bool */
    private function wait_for_media_ready($instance, $token, $media_id) {
        $last_http_code = 0;
        $max_polls = 60;
        for ($i = 0; $i < $max_polls; $i++) {
            usleep(1000000);
            $response = $this->request_instance('GET', $instance, '/api/v1/media/' . rawurlencode((string) $media_id), [
                'headers' => ['Authorization' => 'Bearer ' . $token, 'Accept' => 'application/json'],
                'timeout' => 20,
            ]);
            if (is_wp_error($response)) {
                $this->log_video_diagnostics('media_processing_poll', [
                    'media_id' => sanitize_text_field((string) $media_id),
                    'ready' => false,
                    'polls' => $i + 1,
                    'transport_error' => true,
                ], 'warning');
                return false;
            }
            $last_http_code = (int) wp_remote_retrieve_response_code($response);
            // Mastodon may answer 206 while the async media processor is still
            // producing the final attachment. Treat it as pending, not ready.
            if ($last_http_code === 206) {
                $this->log_video_diagnostics('media_processing_poll', [
                    'media_id' => sanitize_text_field((string) $media_id),
                    'ready' => false,
                    'polls' => $i + 1,
                    'http_code' => $last_http_code,
                    'response_reason' => 'partial_content_processing',
                ]);
                continue;
            }
            if ($last_http_code < 200 || $last_http_code >= 300) {
                $this->log_video_diagnostics('media_processing_poll', [
                    'media_id' => sanitize_text_field((string) $media_id),
                    'ready' => false,
                    'polls' => $i + 1,
                    'http_code' => $last_http_code,
                ], 'warning');
                return false;
            }
            $json = json_decode((string) wp_remote_retrieve_body($response), true);
            if (!is_array($json)) {
                $this->log_video_diagnostics('media_processing_poll', [
                    'media_id' => sanitize_text_field((string) $media_id),
                    'ready' => false,
                    'polls' => $i + 1,
                    'http_code' => $last_http_code,
                    'response_reason' => 'invalid_json',
                ], 'warning');
                return false;
            }
            if (!empty($json['url'])) {
                $this->log_video_diagnostics('media_processing_poll', [
                    'media_id' => sanitize_text_field((string) $media_id),
                    'ready' => true,
                    'polls' => $i + 1,
                    'http_code' => $last_http_code,
                ]);
                return true;
            }
        }
        $this->log_video_diagnostics('media_processing_poll', [
            'media_id' => sanitize_text_field((string) $media_id),
            'ready' => false,
            'polls' => $max_polls,
            'http_code' => $last_http_code,
            'response_reason' => 'processing_timeout',
        ], 'warning');
        return false;
    }

    /** @return array|WP_Error */
    private function request_instance($method, $instance, $path, array $args) {
        $instance = $this->normalize_instance($instance);
        if ($instance === '' || !Calliope_SP_Host_Validator::is_safe_public_host($instance, true)) {
            return $this->safe_error(
                'mastodon_instance_invalid',
                __('The Mastodon server must be a public HTTPS host.', 'calliope-social-publisher'),
                'configuration',
                0,
                false,
                'instance'
            );
        }
        $path = '/' . ltrim((string) $path, '/');
        if (strpos($path, "\0") !== false || strpos($path, '://') !== false) {
            return $this->safe_error('mastodon_path_invalid', __('The Mastodon API request is invalid.', 'calliope-social-publisher'), 'configuration', 0, false, 'instance');
        }
        $url = 'https://' . $instance . $path;
        $args['method'] = strtoupper((string) $method);
        $args['redirection'] = 0;
        $args['reject_unsafe_urls'] = true;
        $args['sslverify'] = true;
        $request = function_exists('wp_safe_remote_request') ? 'wp_safe_remote_request' : 'wp_remote_request';
        $response = call_user_func($request, $url, $args);
        if (is_wp_error($response)) {
            return $this->safe_error(
                'mastodon_transport',
                __('Mastodon could not complete the request right now. Please try again.', 'calliope-social-publisher'),
                'transport',
                0,
                true,
                'temporary'
            );
        }
        return $response;
    }

    /** @return string */
    private function extract_api_error($raw, $json = null) {
        if (!is_array($json)) $json = json_decode((string) $raw, true);
        $message = '';
        if (is_array($json)) {
            foreach (['error_description', 'error', 'message'] as $key) {
                if (!empty($json[$key]) && is_string($json[$key])) {
                    $message = (string) $json[$key];
                    break;
                }
            }
            if ($message === '' && !empty($json['errors']) && is_array($json['errors'])) {
                $first = reset($json['errors']);
                if (is_string($first)) $message = $first;
            }
        }
        $message = $message !== '' ? $message : (string) $raw;
        $message = wp_strip_all_tags($message);
        $message = preg_replace('/[\x00-\x1F\x7F]+/', ' ', $message);
        $message = preg_replace('/\s+/', ' ', $message);
        return mb_substr(trim((string) $message), 0, 240);
    }

    /** @return string */
    private function get_error_failure_kind($error) {
        if (!is_wp_error($error)) return '';
        $data = $error->get_error_data();
        return is_array($data) && isset($data['failure_kind']) ? (string) $data['failure_kind'] : '';
    }

    /** @return void */
    private function sync_legacy_status($status) {
        $status = sanitize_key((string) $status);
        if (get_option('calliope_sp_mstdn_connection_status', '') !== $status) {
            update_option('calliope_sp_mstdn_connection_status', $status, false);
        }
    }

    /** @return Calliope_SP_Connection_State|WP_Error */
    private function create_state(array $data, $minimum) {
        $data = array_merge([
            'network' => 'mastodon',
            'tone' => $data['health'] === 'degraded' && ($data['status'] ?? '') === 'ready_to_publish' ? 'warning' : null,
            'expires_at' => 0,
            'validated_at' => 0,
        ], $data);
        if ($data['tone'] === null) unset($data['tone']);
        return Calliope_SP_Connection_State::create($data, $minimum);
    }

    /** @param array<int,mixed> $media */
    private function media_log_items(array $media) {
        $items = [];
        foreach ($media as $item) {
            if (!is_array($item)) {
                continue;
            }
            $items[] = [
                'id' => absint($item['id'] ?? 0),
                'type' => sanitize_key((string) ($item['type'] ?? '')),
                'mime_type' => sanitize_mime_type((string) ($item['mime_type'] ?? '')),
            ];
        }
        return $items;
    }

    /** @param array<string,mixed> $entry */
    private function log_video_diagnostics($event, array $entry, $status = 'info') {
        if (!class_exists('Calliope_SP_Logger')) {
            return;
        }
        $entry = array_merge([
            'event' => 'mastodon_video_' . sanitize_key((string) $event),
        ], $entry);
        Calliope_SP_Logger::log('mastodon', sanitize_key((string) $status), wp_json_encode($entry, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
    }

    /**
     * Format title, content, hashtags, and the link while respecting the limit.
     */
    private function format_text_clean_tags($data, $limit) {
        $title = trim((string) ($data['title'] ?? ''));
        $content = trim((string) ($data['content'] ?? ''));
        $link = trim((string) ($data['link'] ?? ''));
        $tags = !empty($data['hashtags']) ? preg_split('/\s+/', trim((string) $data['hashtags'])) : [];
        $tags_clean = [];
        foreach ($tags as $tag) {
            $tag = trim((string) $tag);
            if ($tag !== '') $tags_clean[] = $tag;
        }
        $hashtags_line = implode(' ', $tags_clean);
        $fixed_blocks = [];
        if ($title !== '') $fixed_blocks[] = $title;
        if ($hashtags_line !== '') $fixed_blocks[] = $hashtags_line;
        if ($link !== '') $fixed_blocks[] = $link;
        $reserved_length = 0;
        if (!empty($fixed_blocks)) {
            $reserved_length = array_sum(array_map('mb_strlen', $fixed_blocks));
            $reserved_length += count($fixed_blocks) + ($link !== '' ? 1 : 0);
        }
        $content_limit = max(0, $limit - $reserved_length);
        if ($content !== '' && mb_strlen($content) > $content_limit) {
            $content = $content_limit > 3 ? mb_substr($content, 0, $content_limit - 3) . '...' : '';
        }
        $blocks = [];
        foreach ([$title, $content, $hashtags_line] as $block) if ($block !== '') $blocks[] = $block;
        $text = implode("\n", $blocks);
        if ($link !== '') $text = $text !== '' ? $text . "\n\n" . $link : $link;
        if (mb_strlen($text) <= $limit) return $text;
        if ($hashtags_line !== '') {
            $tags_final = [];
            foreach ($tags_clean as $tag) {
                $candidate = implode(' ', array_merge($tags_final, [$tag]));
                $candidate_blocks = [];
                foreach ([$title, $content, $candidate] as $block) if ($block !== '') $candidate_blocks[] = $block;
                $candidate_text = implode("\n", $candidate_blocks);
                if ($link !== '') $candidate_text = $candidate_text !== '' ? $candidate_text . "\n\n" . $link : $link;
                if (mb_strlen($candidate_text) <= $limit) $tags_final[] = $tag; else break;
            }
            $hashtags_line = implode(' ', $tags_final);
        }
        $blocks = [];
        foreach ([$title, $content, $hashtags_line] as $block) if ($block !== '') $blocks[] = $block;
        $text = implode("\n", $blocks);
        if ($link !== '') $text = $text !== '' ? $text . "\n\n" . $link : $link;
        return $text;
    }
}
