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

class Calliope_SP_Facebook {

    private $api_base = 'https://graph.facebook.com/v20.0';

    /**
     * Convert base64 image into a temporary local image asset.
     * Facebook uploads this file directly via multipart/form-data so it does not depend
     * on Meta being able to fetch the wp-content/uploads URL.
     *
     * @param string $b64
     * @return array{url:string,static_url:string,path:string,mime:string}|WP_Error
     */
    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'         => 'facebook',
                'filename_prefix' => 'calliope-social-publisher-fb',
                'preferred_mime'  => 'image/jpeg',
                'network'         => 'facebook',
                'max_attempts'    => 8,
            ])
            : new WP_Error('img', 'Facebook could not prepare a public image URL.');

        if (is_wp_error($asset)) {
            return $asset;
        }

        return [
            'url'        => (string) $asset['url'],
            'static_url' => (string) ($asset['static_url'] ?? ''),
            'path'       => (string) $asset['local_path'],
            'mime'       => (string) ($asset['mime'] ?? 'image/jpeg'),
        ];
    }

    /**
     * Remove a temporary image created for Facebook publishing.
     *
     * @param string $path
     * @return void
     */
    private function delete_temp_image($path) {
        $path = (string) $path;
        if ($path !== '' && file_exists($path) && is_file($path)) {
            wp_delete_file($path);
        }
    }

    /**
     * Build a safe endpoint label for logs. Never include tokens or query params.
     *
     * @param string $path Graph API path.
     * @return string
     */
    private function safe_endpoint_label($path) {
        $path = '/' . ltrim((string) $path, '/');
        $path = preg_replace('/\?.*$/', '', $path);
        return (string) $path;
    }

    private function request($method, $path, $access_token, $params = []) {
        $url = rtrim($this->api_base, '/') . '/' . ltrim($path, '/');
        $endpoint = $this->safe_endpoint_label($path);

        $args = [
            'method'=>strtoupper($method),'timeout'=>60,'redirection'=>0,'reject_unsafe_urls'=>true,
            'headers'=>['Authorization'=>'Bearer '.$access_token],
        ];

        // Graph likes urlencoded form posts.

        if ($args['method'] === 'GET') {
            $url = add_query_arg($params, $url);
        } else {
            $args['headers']['Content-Type'] = 'application/x-www-form-urlencoded';
            $args['body'] = http_build_query($params, '', '&');
        }

        $res = wp_remote_request($url, $args);

        if (is_wp_error($res)) {
            $this->log_transport_failure($endpoint, $res);
            return $res;
        }

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

        return [
            'code'      => $code,
            'body_raw'  => $raw,
            'body_json' => $json,
            // Kept for backward compatibility inside this class, but never log it.
            'url'       => $url,
            'endpoint'  => $endpoint,
            'method'    => $args['method'],
        ];
    }

    /**
     * Send a Graph API multipart/form-data request with a local media file.
     *
     * This avoids the fragile "Facebook must fetch our public URL" path for Page
     * media. It is especially useful on hosts/CDNs that block Meta's media crawler
     * from wp-content/uploads URLs.
     *
     * @param string              $path         Graph API path.
     * @param string              $access_token Page access token.
     * @param array<string,mixed> $params       Form fields, without access_token.
     * @param string              $file_path    Absolute media file path.
     * @param string              $mime         Media MIME type.
     * @param string              $file_kind    image or video.
     * @param int                 $max_bytes    Optional per-file safety limit.
     * @param int                 $timeout      Request timeout in seconds.
     * @return array<string,mixed>|WP_Error
     */
    private function request_multipart_file($path, $access_token, array $params, $file_path, $mime = 'image/jpeg', $file_kind = 'image', $max_bytes = 0, $timeout = 120) {
        $file_path = (string) $file_path;
        $file_kind = sanitize_key((string) $file_kind) === 'video' ? 'video' : 'image';
        $media_label = $file_kind === 'video' ? 'video' : 'image';
        if ($file_path === '' || !file_exists($file_path) || !is_file($file_path) || !is_readable($file_path)) {
            return new WP_Error('facebook_media_error', 'Facebook could not read the prepared ' . $media_label . ' file.');
        }

        $max_bytes = max(0, (int) $max_bytes);
        $file_size = filesize($file_path);
        if (!is_int($file_size) || $file_size <= 0 || ($max_bytes > 0 && $file_size > $max_bytes)) {
            return new WP_Error('facebook_media_error', 'The selected Facebook ' . $media_label . ' file is too large or unreadable.');
        }

        $file_contents = file_get_contents($file_path);
        if ($file_contents === false || $file_contents === '') {
            return new WP_Error('facebook_media_error', 'Facebook could not read the prepared ' . $media_label . ' file.');
        }

        $url = rtrim($this->api_base, '/') . '/' . ltrim($path, '/');
        $endpoint = $this->safe_endpoint_label($path);
        $boundary = 'calliope_sp_' . wp_generate_password(24, false, false);
        $eol = "\r\n";
        $body = '';

        foreach ($params as $key => $value) {
            if ($value === null) {
                continue;
            }
            $name = preg_replace('/[^A-Za-z0-9_\-]/', '', (string) $key);
            if ($name === '') {
                continue;
            }
            $body .= '--' . $boundary . $eol;
            $body .= 'Content-Disposition: form-data; name="' . $name . '"' . $eol . $eol;
            $body .= (string) $value . $eol;
        }

        $filename = sanitize_file_name(basename($file_path));
        $mime = strtolower((string) $mime);
        if ($file_kind === 'video') {
            if (strpos($mime, 'video/') !== 0) {
                $mime = 'video/mp4';
            }
        } elseif (strpos($mime, 'image/') !== 0) {
            $mime = 'image/jpeg';
        }

        $body .= '--' . $boundary . $eol;
        $body .= 'Content-Disposition: form-data; name="source"; filename="' . $filename . '"' . $eol;
        $body .= 'Content-Type: ' . $mime . $eol . $eol;
        $body .= $file_contents . $eol;
        $body .= '--' . $boundary . '--' . $eol;

        $res = wp_remote_request($url, [
            'method'  => 'POST',
            'timeout' => max(30, (int) $timeout),
            'headers' => [
                'Content-Type'   => 'multipart/form-data; boundary=' . $boundary,
                'Authorization'  => 'Bearer ' . $access_token,
                'Content-Length' => strlen($body),
            ],
            'body'    => $body,
        ]);

        if (is_wp_error($res)) {
            $this->log_transport_failure($endpoint, $res);
            return $res;
        }

        $code = (int) wp_remote_retrieve_response_code($res);
        $raw  = (string) wp_remote_retrieve_body($res);
        $json = null;
        if ($raw !== '') {
            $decoded = json_decode($raw, true);
            if (is_array($decoded)) {
                $json = $decoded;
            }
        }

        return [
            'code'      => $code,
            'body_raw'  => $raw,
            'body_json' => $json,
            'url'       => $url,
            'endpoint'  => $endpoint,
            'method'    => 'POST',
        ];
    }

    /**
     * Extract Graph error data in a consistent shape.
     *
     * @param array<string,mixed> $resp
     * @return array<string,mixed>
     */
    private function graph_error_details($resp) {
        $error = [];
        if (is_array($resp) && isset($resp['body_json']['error']) && is_array($resp['body_json']['error'])) {
            $error = $resp['body_json']['error'];
        }

        return [
            'endpoint'            => (string) ($resp['endpoint'] ?? ''),
            'http_code'           => (int) ($resp['code'] ?? 0),
            'graph_error_message' => (string) ($error['message'] ?? ($resp['body_raw'] ?? 'Unknown error')),
            'graph_error_type'    => (string) ($error['type'] ?? ''),
            'graph_error_code'    => isset($error['code']) ? (int) $error['code'] : 0,
            'graph_error_subcode' => isset($error['error_subcode']) ? (int) $error['error_subcode'] : (isset($error['subcode']) ? (int) $error['subcode'] : 0),
            'fbtrace_id'          => (string) ($error['fbtrace_id'] ?? ''),
        ];
    }

    /**
     * Detect true identity/security checkpoint messages. Do not infer this from code 368 alone.
     *
     * @param string $message
     * @return bool
     */
    private function has_identity_confirmation_signal($message) {
        $message = (string) $message;
        if ($message === '') {
            return false;
        }

        $signals = [
            'confirm your identity',
            'con' . 'firma tu iden' . 'tidad',
            'identity confirmation',
            'checkpoint',
            'security check',
            'page publishing authorization',
            'account verification',
            'verify your identity',
            'veri' . 'fica tu iden' . 'tidad',
            'veri' . 'ficacion de iden' . 'tidad',
            'facebook mobile app',
            'app mo' . 'vil de face' . 'book',
            'confirm your account or phone',
            'con' . 'firmar tu cue' . 'nta o tu cel' . 'ular',
            'con' . 'firma tu cue' . 'nta o tu cel' . 'ular',
        ];

        foreach ($signals as $signal) {
            if (stripos($message, $signal) !== false) {
                return true;
            }
        }

        return false;
    }

    /**
     * Detect permission errors related to Page publishing.
     *
     * @param array<string,mixed> $details
     * @return bool
     */
    private function is_missing_publish_permission($details) {
        $message = (string) ($details['graph_error_message'] ?? '');
        $code = (int) ($details['graph_error_code'] ?? 0);

        if (stripos($message, 'pages_manage_posts') !== false || stripos($message, 'manage_posts') !== false) {
            return true;
        }

        if (
            stripos($message, 'permission') !== false &&
            (
                stripos($message, 'publish') !== false ||
                stripos($message, 'manage') !== false ||
                stripos($message, 'page') !== false
            )
        ) {
            return true;
        }

        // Common Meta OAuth/permission failure codes. Keep these narrower than 368.
        return in_array($code, [10, 200, 2500], true);
    }

    /**
     * Detect media failures so they do not get mixed with identity or generic restrictions.
     *
     * @param array<string,mixed> $details
     * @return bool
     */
    private function is_media_error($details) {
        $message = (string) ($details['graph_error_message'] ?? '');
        $endpoint = (string) ($details['endpoint'] ?? '');

        if (stripos($endpoint, '/photos') === false && stripos($endpoint, '/videos') === false) {
            return false;
        }

        $signals = [
            'image',
            'photo',
            'video',
            'media',
            'picture',
            'url supplied is invalid',
            'could not fetch',
            'could not download',
            'invalid url',
            'unsupported get request',
        ];

        foreach ($signals as $signal) {
            if (stripos($message, $signal) !== false) {
                return true;
            }
        }

        return false;
    }

    /**
     * Classify Graph failures without treating every code 368 as identity confirmation.
     *
     * @param array<string,mixed> $resp
     * @return WP_Error
     */
    private function build_graph_wp_error($resp) {
        $details = $this->graph_error_details($resp);
        $message = (string) ($details['graph_error_message'] ?? 'Unknown error');
        $type = (string) ($details['graph_error_type'] ?? '');
        $code = (int) ($details['graph_error_code'] ?? 0);

        $classification = 'facebook_graph_error';
        $user_message = 'Facebook publish failed: ' . trim($message . ' ' . $type . ' ' . $code);

        if ($this->has_identity_confirmation_signal($message)) {
            $classification = 'facebook_identity_confirmation';
            $user_message = 'Facebook requires identity confirmation before publishing to this Page. Open the Facebook mobile app with the connected account and follow the instructions to confirm your account or phone. After completing it, disconnect and reconnect Facebook in the plugin, then try again.';
        } elseif ($this->is_missing_publish_permission($details)) {
            $classification = 'facebook_missing_publish_permission';
            $user_message = 'Facebook is connected, but the Page token does not include permission to publish posts. Please disconnect and reconnect Facebook, making sure the selected Page is allowed and pages_manage_posts is granted.';
        } elseif ($this->is_media_error($details)) {
            $classification = 'facebook_media_error';
            $user_message = 'Facebook rejected the image/media for this post. The Page is connected, but Meta could not process the image URL or media payload. Please try a different image or publish a text-only test.';
        } elseif ($code === 368) {
            $classification = 'facebook_action_restricted';
            $user_message = 'Facebook rejected this publish request due to a publishing restriction or temporary action block. The Page is connected, but Meta did not allow this specific post to be published. Please try a simpler text-only post, wait a while, or review the app/Page permissions.';
        }

        $details['classification'] = $classification;
        $this->log_graph_failure((string) ($details['endpoint'] ?? ''), $details);

        return new WP_Error($classification, $user_message, $details);
    }

    /**
     * Log transport failures without tokens.
     *
     * @param string   $endpoint
     * @param WP_Error $error
     * @return void
     */
    private function log_transport_failure($endpoint, $error) {
        if (!class_exists('Calliope_SP_Logger')) {
            return;
        }

        Calliope_SP_Logger::log('facebook', 'error', wp_json_encode([
            'endpoint'       => $endpoint,
            'transport_error'=> $error->get_error_message(),
        ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
    }

    /**
     * Log Graph failures without tokens.
     *
     * @param string              $endpoint
     * @param array<string,mixed> $details
     * @return void
     */
    private function log_graph_failure($endpoint, $details) {
        if (!class_exists('Calliope_SP_Logger')) {
            return;
        }

        Calliope_SP_Logger::log('facebook', 'error', wp_json_encode([
            'graph_endpoint'      => $endpoint !== '' ? $endpoint : (string) ($details['endpoint'] ?? ''),
            'http_code'           => (int) ($details['http_code'] ?? 0),
            'graph_error_message' => (string) ($details['graph_error_message'] ?? ''),
            'graph_error_type'    => (string) ($details['graph_error_type'] ?? ''),
            'graph_error_code'    => (int) ($details['graph_error_code'] ?? 0),
            'graph_error_subcode' => (int) ($details['graph_error_subcode'] ?? 0),
            'fbtrace_id'          => (string) ($details['fbtrace_id'] ?? ''),
            'classification'      => (string) ($details['classification'] ?? ''),
        ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
    }

    /**
     * Return true when the composed text already contains the destination link.
     *
     * The editor may generate a natural call to action that includes the article
     * URL. Facebook also receives the URL separately in the `link` parameter for
     * its preview card, so adding it to the message again would duplicate it.
     */
    private function message_contains_link($text, $link) {
        $text = html_entity_decode((string) $text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
        $needle = $this->normalize_message_url($link);
        if ($text === '' || $needle === '') return false;

        if (!preg_match_all('~https?://[^\s<>"\']+~iu', $text, $matches)) return false;
        foreach ((array) ($matches[0] ?? []) as $candidate) {
            if ($this->normalize_message_url($candidate) === $needle) return true;
        }
        return false;
    }

    /**
     * Normalize a URL only for duplicate detection inside a Facebook message.
     */
    private function normalize_message_url($url) {
        $url = html_entity_decode(trim((string) $url), ENT_QUOTES | ENT_HTML5, 'UTF-8');
        $url = rtrim($url, " \t\n\r\0\x0B.,;:!?)]}'\"");
        if ($url === '') return '';

        $parts = function_exists('wp_parse_url') ? wp_parse_url($url) : parse_url($url);
        if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) return '';

        $scheme = strtolower((string) $parts['scheme']);
        $host = strtolower((string) $parts['host']);
        $port = isset($parts['port']) ? ':' . (int) $parts['port'] : '';
        $path = isset($parts['path']) ? (string) $parts['path'] : '';
        if ($path !== '/') $path = rtrim($path, '/');
        $query = isset($parts['query']) && $parts['query'] !== '' ? '?' . (string) $parts['query'] : '';

        return $scheme . '://' . $host . $port . $path . $query;
    }

    /**
     * Resolve a Pro video media item into a local file Facebook can upload.
     *
     * @param array<string,mixed> $data
     * @return array<string,mixed>|WP_Error
     */
    private function video_asset_from_payload($data) {
        if (!class_exists('Calliope_SPP_Media_Set') || !class_exists('Calliope_SPP_Media_Resolver')) {
            return new WP_Error('facebook_video_requires_pro', 'Facebook video publishing requires Social Publisher Pro media support.');
        }

        $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 new WP_Error('facebook_video_invalid', 'Facebook video publishing requires exactly one local video attachment.');
        }

        $max = $this->facebook_video_max_file_size();
        $resolved = Calliope_SPP_Media_Resolver::resolve($media, [
            'max_file_bytes' => $max,
            'max_request_bytes' => $max,
            'allowed_video_mimes' => $this->facebook_video_mimes(),
            'log_context' => ['network' => 'facebook', 'mode' => 'video'],
        ]);
        if (is_wp_error($resolved)) {
            return new WP_Error('facebook_video_media_error', 'Facebook could not prepare the selected video. ' . $resolved->get_error_message());
        }

        $file = $resolved[0] ?? null;
        if (!is_array($file) || empty($file['path']) || empty($file['mime_type'])) {
            return new WP_Error('facebook_video_media_error', 'Facebook could not prepare the selected video.');
        }
        if (!$this->video_extension_matches((string) ($file['filename'] ?? ''), (string) $file['mime_type'])) {
            return new WP_Error('facebook_video_media_error', 'Facebook video publishing supports MP4 and QuickTime files in this version.');
        }

        return $file;
    }

    /**
     * Resolve Pro multi-image media items into local files Facebook can upload.
     *
     * @param array<string,mixed> $data
     * @return array<int,array<string,mixed>>|WP_Error
     */
    private function multi_image_assets_from_payload($data) {
        if (!class_exists('Calliope_SPP_Media_Set') || !class_exists('Calliope_SPP_Media_Resolver')) {
            return new WP_Error('facebook_multi_image_requires_pro', 'Facebook multi-image publishing requires Social Publisher Pro media support.');
        }

        $media = isset($data['media']) && is_array($data['media']) ? $data['media'] : [];
        $contract = Calliope_SPP_Media_Set::validate_contract('multiple_images', $media);
        if (is_wp_error($contract)) {
            return new WP_Error('facebook_multi_image_invalid', 'Facebook multi-image publishing requires 2 to 10 local image attachments.');
        }

        $resolved = Calliope_SPP_Media_Resolver::resolve($media, [
            'allowed_image_mimes' => ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
            'allowed_video_mimes' => [],
            'log_context' => ['network' => 'facebook', 'mode' => 'multiple_images'],
        ]);
        if (is_wp_error($resolved)) {
            return new WP_Error('facebook_multi_image_media_error', 'Facebook could not prepare the selected images. ' . $resolved->get_error_message());
        }

        $by_id = [];
        foreach (Calliope_SPP_Media_Set::normalize($media) as $item) {
            $by_id[(int) ($item['id'] ?? 0)] = $item;
        }

        $assets = [];
        foreach ($resolved as $index => $file) {
            if (!is_array($file) || empty($file['path']) || empty($file['mime_type'])) {
                return new WP_Error('facebook_multi_image_media_error', 'Facebook could not prepare one of the selected images.');
            }
            $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'] ?? ''));
            }
            $assets[] = [
                'id' => $id,
                'path' => (string) $file['path'],
                'mime_type' => (string) $file['mime_type'],
                'description' => sanitize_text_field($description),
                'position' => absint($source['position'] ?? $index),
            ];
        }

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

        return array_values($assets);
    }

    /**
     * Remove uploaded unpublished photos when the final multi-image post cannot be created.
     *
     * @param array<int,string> $photo_ids
     * @param string            $page_token
     * @return void
     */
    private function cleanup_unpublished_photo_ids(array $photo_ids, $page_token) {
        foreach ($photo_ids as $photo_id) {
            $photo_id = sanitize_text_field((string) $photo_id);
            if ($photo_id === '') {
                continue;
            }
            $this->request('DELETE', '/' . rawurlencode($photo_id), $page_token, []);
        }
    }

    /** @return string[] */
    private function facebook_video_mimes() {
        $mimes = apply_filters('calliope_sp_facebook_video_mimes', ['video/mp4', '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/quicktime'];
    }

    private function facebook_video_max_file_size() {
        $limit = (int) apply_filters('calliope_sp_facebook_video_max_file_size', 100 * 1024 * 1024);
        return max(1024, min(512 * 1024 * 1024, $limit));
    }

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

    /**
     * Publish to a Facebook Page.
     *
     * - If Pro video media is provided: publish as Page video
     * - If Pro multi-image media is provided: publish as a Page multi-photo post
     * - If image is provided: publish as photo (with caption)
     * - Else if link is provided: publish as feed post with link
     * - Else: publish as text post
     *
     * @param array $data
     * @return true|WP_Error
     */
    public function publish($data) {
        $state=$this->get_connection_state();
        if(is_wp_error($state)||!$state->is_ready()) return new WP_Error('facebook_not_ready','Facebook Page is not ready to publish. Reconnect and verify the selected Page.');
        $page_id=$this->get_resolved_target_id(); $page_token=$this->meta_option_string('calliope_sp_fb_page_access_token');
        if($page_id===''||$page_token==='') return new WP_Error('facebook_not_ready','Facebook Page validation is incomplete.');

        $text = trim((string)($data['content'] ?? ''));
        $title = trim((string)($data['title'] ?? ''));
        $link = trim((string)($data['link'] ?? ''));
        $hashtags = trim((string)($data['hashtags'] ?? ''));
        $alt_text = trim((string)($data['alt_text'] ?? ''));
        $img_b64 = (string)($data['image_b64'] ?? '');
        $media_mode = sanitize_key((string)($data['media_mode'] ?? 'none'));

        $message_parts = [];
        if ($text) $message_parts[] = $text;
        if ($link && !$this->message_contains_link($text, $link)) {
            $message_parts[] = $link; // photos need the URL in the caption when the editor did not already include it
        }
        if ($hashtags) $message_parts[] = $hashtags;
        $message = trim(implode("\n\n", $message_parts));

        // Pro video -> /videos
        if ($media_mode === 'video') {
            $video_asset = $this->video_asset_from_payload(is_array($data) ? $data : []);
            if (is_wp_error($video_asset)) return $video_asset;

            $params = [
                'description' => $message,
                'published' => 'true',
            ];
            if ($title !== '') {
                $params['title'] = $title;
            }

            $resp = $this->request_multipart_file(
                '/' . rawurlencode($page_id) . '/videos',
                $page_token,
                $params,
                (string) $video_asset['path'],
                (string) $video_asset['mime_type'],
                'video',
                $this->facebook_video_max_file_size(),
                300
            );

            if (is_wp_error($resp)) return $resp;

            if ($resp['code'] < 200 || $resp['code'] >= 300) {
                return $this->build_graph_wp_error($resp);
            }

            $video_id = isset($resp['body_json']['id']) && is_scalar($resp['body_json']['id'])
                ? sanitize_text_field((string) $resp['body_json']['id'])
                : '';
            return [
                'ok' => true,
                'media_type' => 'video',
                'video_id' => $video_id,
            ];
        }

        // Pro multiple images -> upload unpublished photos, then attach them to one feed post.
        if ($media_mode === 'multiple_images') {
            $image_assets = $this->multi_image_assets_from_payload(is_array($data) ? $data : []);
            if (is_wp_error($image_assets)) return $image_assets;

            $photo_ids = [];
            foreach ($image_assets as $image_asset) {
                $resp = $this->request_multipart_file(
                    '/' . rawurlencode($page_id) . '/photos',
                    $page_token,
                    ['published' => 'false'],
                    (string) $image_asset['path'],
                    (string) ($image_asset['mime_type'] ?? 'image/jpeg')
                );

                if (is_wp_error($resp)) {
                    $this->cleanup_unpublished_photo_ids($photo_ids, $page_token);
                    return $resp;
                }
                if ($resp['code'] < 200 || $resp['code'] >= 300) {
                    $this->cleanup_unpublished_photo_ids($photo_ids, $page_token);
                    return $this->build_graph_wp_error($resp);
                }

                $photo_id = isset($resp['body_json']['id']) && is_scalar($resp['body_json']['id'])
                    ? sanitize_text_field((string) $resp['body_json']['id'])
                    : '';
                if ($photo_id === '') {
                    $this->cleanup_unpublished_photo_ids($photo_ids, $page_token);
                    return new WP_Error('facebook_multi_image_upload_failed', 'Facebook could not upload one of the selected images.');
                }

                $photo_ids[] = $photo_id;

                $photo_alt = trim((string) ($image_asset['description'] ?? ''));
                if ($photo_alt !== '') {
                    $this->request('POST', '/' . rawurlencode($photo_id), $page_token, [
                        'alt_text_custom' => $photo_alt,
                    ]);
                }
            }

            $params = ['message' => $message];
            foreach ($photo_ids as $index => $photo_id) {
                $params['attached_media[' . absint($index) . ']'] = wp_json_encode([
                    'media_fbid' => $photo_id,
                ]);
            }

            $resp = $this->request('POST', '/' . rawurlencode($page_id) . '/feed', $page_token, $params);
            if (is_wp_error($resp)) {
                $this->cleanup_unpublished_photo_ids($photo_ids, $page_token);
                return $resp;
            }
            if ($resp['code'] < 200 || $resp['code'] >= 300) {
                $this->cleanup_unpublished_photo_ids($photo_ids, $page_token);
                return $this->build_graph_wp_error($resp);
            }

            $post_id = isset($resp['body_json']['id']) && is_scalar($resp['body_json']['id'])
                ? sanitize_text_field((string) $resp['body_json']['id'])
                : '';

            return [
                'ok' => true,
                'media_type' => 'carousel',
                'attachments' => count($photo_ids),
                'post_id' => $post_id,
            ];
        }

        // Image -> /photos
        if ($img_b64) {
            $image_asset = $this->image_url_from_b64($img_b64);
            if (is_wp_error($image_asset)) return $image_asset;

            $resp = $this->request_multipart_file(
                '/' . rawurlencode($page_id) . '/photos',
                $page_token,
                [
                    'caption'   => $message,
                    'published' => 'true',
                ],
                $image_asset['path'],
                $image_asset['mime'] ?? 'image/jpeg'
            );

            if (is_wp_error($resp)) {
                $this->delete_temp_image($image_asset['path']);
                return $resp;
            }

            if ($resp['code'] < 200 || $resp['code'] >= 300) {
                $this->delete_temp_image($image_asset['path']);
                return $this->build_graph_wp_error($resp);
            }

            // Meta can return both the Photo object ID (`id`) and the Page story/post ID
            // (`post_id`). History and Analytics must keep the Page Post ID when available;
            // the Photo ID is still used for photo-specific operations such as alt text.
            $photo_id = $resp['body_json']['id'] ?? null;
            $story_post_id = $resp['body_json']['post_id'] ?? $photo_id;

            // Optional alt text (best-effort)
            if ($alt_text && $photo_id) {
                $this->request('POST', '/' . rawurlencode((string) $photo_id), $page_token, [
                    'alt_text_custom' => $alt_text,
                ]);
                // Ignore failures intentionally: publishing already succeeded.
            }

            $this->delete_temp_image($image_asset['path']);
            return [
                'ok' => true,
                'post_id' => is_scalar($story_post_id) ? sanitize_text_field((string) $story_post_id) : '',
                'media_id' => is_scalar($photo_id) ? sanitize_text_field((string) $photo_id) : '',
                'media_type' => 'image',
            ];
        }

        // Link or text -> /feed
        $params = [
            'message' => $message,
        ];
        if ($link) $params['link'] = $link;

        $resp = $this->request('POST', '/' . rawurlencode($page_id) . '/feed', $page_token, $params);

        if (is_wp_error($resp)) return $resp;

        if ($resp['code'] < 200 || $resp['code'] >= 300) {
            return $this->build_graph_wp_error($resp);
        }

        return [
            'ok' => true,
            'post_id' => isset($resp['body_json']['id']) && is_scalar($resp['body_json']['id']) ? sanitize_text_field((string) $resp['body_json']['id']) : '',
            'media_type' => 'text',
        ];
    }

    /**
     * Return true when the saved Page tasks prove that this user can create content.
     * Meta currently exposes CREATE_CONTENT for classic Pages and
     * PROFILE_PLUS_CREATE_CONTENT / PROFILE_PLUS_FULL_CONTROL for the new Pages experience.
     *
     * @param array<int,string> $tasks
     * @return bool
     */
    private function has_page_content_task(array $tasks) {
        $tasks = array_values(array_unique(array_filter(array_map(static function ($task) {
            return is_scalar($task) ? strtoupper(trim((string) $task)) : '';
        }, $tasks))));

        return (bool) array_intersect($tasks, [
            'CREATE_CONTENT',
            'PROFILE_PLUS_CREATE_CONTENT',
            'PROFILE_PLUS_FULL_CONTROL',
        ]);
    }

    /**
     * Check whether a token scope applies to the exact selected Page.
     * granular_scopes wins when Meta supplies target_ids for the permission.
     *
     * @param array<string,mixed> $debug_data
     * @param string              $scope
     * @param string              $page_id
     * @return bool
     */
    private function token_scope_applies_to_page(array $debug_data, $scope, $page_id) {
        $scope = (string) $scope;
        $page_id = (string) $page_id;
        $flat_scopes = [];

        if (!empty($debug_data['scopes']) && is_array($debug_data['scopes'])) {
            foreach ($debug_data['scopes'] as $flat_scope) {
                if (is_scalar($flat_scope)) {
                    $flat_scopes[] = trim((string) $flat_scope);
                }
            }
        }

        $granular_seen = false;
        if (!empty($debug_data['granular_scopes']) && is_array($debug_data['granular_scopes'])) {
            foreach ($debug_data['granular_scopes'] as $granular_scope) {
                if (!is_array($granular_scope) || !isset($granular_scope['scope']) || !is_scalar($granular_scope['scope'])) {
                    continue;
                }

                if (!hash_equals($scope, trim((string) $granular_scope['scope']))) {
                    continue;
                }

                $granular_seen = true;
                if (!isset($granular_scope['target_ids']) || !is_array($granular_scope['target_ids']) || empty($granular_scope['target_ids'])) {
                    return true;
                }

                foreach ($granular_scope['target_ids'] as $target_id) {
                    if (is_scalar($target_id) && hash_equals($page_id, trim((string) $target_id))) {
                        return true;
                    }
                }
            }
        }

        if ($granular_seen) {
            return false;
        }

        return in_array($scope, $flat_scopes, true);
    }

    /**
     * Preserve a previously valid marker during a temporary Meta failure, but mark
     * its health as degraded. This never creates readiness from an unverified token.
     *
     * @return void
     */
    private function degrade_existing_validation_marker() {
        $marker = $this->meta_validation_marker();
        if (!$marker) {
            return;
        }

        $marker['health'] = 'degraded';
        $marker['failure_kind'] = '';
        update_option('calliope_sp_facebook_validation', $marker, false);
    }

    /**
     * Build a compact temporary validation error without exposing tokens or raw bodies.
     *
     * @param string $phase
     * @param int    $http_code
     * @return WP_Error
     */
    private function temporary_validation_error($phase, $http_code = 0) {
        $this->degrade_existing_validation_marker();

        return new WP_Error(
            'facebook_validation_temporary',
            'Facebook could not verify the selected Page right now. Please try Save and test again.',
            [
                'phase'      => sanitize_key((string) $phase),
                'http_code'  => (int) $http_code,
                'retryable'  => true,
                'failure_kind' => 'temporary',
            ]
        );
    }

    /**
     * Test Connection is intentionally read-only. It validates the exact Page token,
     * the selected Page, optional Page task metadata, and pages_manage_posts before saving readiness.
     *
     * @return array<string,mixed>|WP_Error
     */
    public function test_connection() {
        return $this->diagnose_page_token();
    }

    /**
     * Diagnose the saved Facebook Page token and permissions without publishing.
     *
     * @return array<string,mixed>|WP_Error
     */
    public function diagnose_page_token() {
        $page_id = $this->meta_option_string('calliope_sp_fb_page_id');
        $page_token = $this->meta_option_string('calliope_sp_fb_page_access_token');
        $user_token = $this->meta_option_string('calliope_sp_fb_user_access_token');

        if ($page_id === '' || $page_token === '') {
            delete_option('calliope_sp_facebook_validation');
            $message = $user_token !== ''
                ? __('Facebook authorization is saved, but the Page token is missing. Open Connections, select the Page and run diagnostics; Social Publisher obtains the token automatically.', 'calliope-social-publisher')
                : __('Connect Facebook first so Social Publisher can obtain the Page token automatically.', 'calliope-social-publisher');
            return new WP_Error('facebook_missing_page_token', $message);
        }

        // A Page token must identify itself as the exact selected Page. Querying
        // /{page_id} alone is insufficient because a User token may also read it.
        $identity_resp = $this->request('GET', '/me', $page_token, [
            'fields' => 'id,name',
        ]);

        if (is_wp_error($identity_resp)) {
            return $this->temporary_validation_error('page_token_identity');
        }

        $identity_http = (int) ($identity_resp['code'] ?? 0);
        if ($identity_http < 200 || $identity_http >= 300) {
            if ($identity_http === 408 || $identity_http === 429 || $identity_http >= 500) {
                return $this->temporary_validation_error('page_token_identity', $identity_http);
            }

            $this->meta_save_validation_marker($page_id, $page_id, 'denied', 'auth', 'invalid');
            return $this->build_graph_wp_error($identity_resp);
        }

        $identity_data = is_array($identity_resp['body_json'] ?? null) ? $identity_resp['body_json'] : [];
        $remote_page_id = isset($identity_data['id']) && is_scalar($identity_data['id']) ? trim((string) $identity_data['id']) : '';
        $page_name = isset($identity_data['name']) && is_scalar($identity_data['name']) ? sanitize_text_field((string) $identity_data['name']) : '';

        if ($remote_page_id === '' || !hash_equals($page_id, $remote_page_id)) {
            $marker_identity = $remote_page_id !== '' ? $remote_page_id : $page_id;
            $this->meta_save_validation_marker($marker_identity, $page_id, 'denied', 'target', 'invalid');
            return new WP_Error(
                'facebook_target_missing',
                'The Page Access Token does not belong to the selected Facebook Page.',
                ['failure_kind' => 'target', 'retryable' => false]
            );
        }

        // Page task metadata is advisory. Meta does not return this field
        // consistently for every Page type, app mode, or OAuth flow. The exact
        // Page Access Token identity checked above remains the primary proof that
        // the selected Page was resolved correctly.
        $tasks = [];
        $tasks_status = $user_token === '' ? 'not_checked' : 'unavailable';

        if ($user_token !== '') {
            $tasks_resp = $this->request('GET', '/' . rawurlencode($page_id), $user_token, [
                'fields' => 'id,name,tasks',
            ]);

            if (!is_wp_error($tasks_resp)) {
                $tasks_http = (int) ($tasks_resp['code'] ?? 0);
                if ($tasks_http >= 200 && $tasks_http < 300) {
                    $tasks_data = is_array($tasks_resp['body_json'] ?? null) ? $tasks_resp['body_json'] : [];
                    $tasks_page_id = isset($tasks_data['id']) && is_scalar($tasks_data['id']) ? trim((string) $tasks_data['id']) : '';

                    if ($tasks_page_id !== '' && hash_equals($page_id, $tasks_page_id)) {
                        if (isset($tasks_data['tasks']) && is_array($tasks_data['tasks'])) {
                            foreach ($tasks_data['tasks'] as $task) {
                                if (is_scalar($task)) {
                                    $task = strtoupper(trim((string) $task));
                                    if ($task !== '') {
                                        $tasks[] = $task;
                                    }
                                }
                            }
                        }
                        $tasks = array_values(array_unique($tasks));
                        $tasks_status = !empty($tasks) ? 'returned' : 'omitted';
                    } else {
                        $tasks_status = 'target_mismatch';
                    }
                } else {
                    $tasks_status = 'http_' . $tasks_http;
                }
            } else {
                $tasks_status = 'transport_error';
            }

            if ($tasks_status !== 'returned' && $tasks_status !== 'omitted' && class_exists('Calliope_SP_Logger')) {
                Calliope_SP_Logger::log('facebook', 'warning', wp_json_encode([
                    'event' => 'facebook_page_tasks_advisory_unavailable',
                    'page_id' => $page_id,
                    'status' => $tasks_status,
                ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
            }
        }

        // Explicit task metadata can still prove that content creation is denied.
        // Missing or unavailable task metadata must never invalidate a Page token.
        if (!empty($tasks) && !$this->has_page_content_task($tasks)) {
            $this->meta_save_validation_marker($remote_page_id, $page_id, 'denied', 'permission', 'invalid');
            return new WP_Error(
                'facebook_missing_publish_permission',
                'Facebook is connected, but the selected Page does not grant a task that can create content.',
                [
                    'classification' => 'facebook_missing_publish_permission',
                    'page_id' => $page_id,
                    'page_tasks' => $tasks,
                    'failure_kind' => 'permission',
                    'retryable' => false,
                ]
            );
        }

        $app_id = $this->meta_option_string('calliope_sp_fb_app_id');
        $app_secret = $this->meta_option_string('calliope_sp_fb_app_secret');
        if ($app_id === '' || $app_secret === '') {
            return new WP_Error('facebook_permission_unverified', 'Facebook App credentials are required to verify the selected Page.');
        }

        // debug_token is useful additional evidence, but older working versions
        // treated it as best-effort because Meta may omit scopes or temporarily
        // reject inspection of an otherwise valid Page token.
        $debug_checked = false;
        $debug_valid = null;
        $debug_status = 'unavailable';
        $granted_scopes = [];
        $debug_data = [];

        $debug_resp = $this->request('GET', '/debug_token', $app_id . '|' . $app_secret, [
            'input_token' => $page_token,
        ]);

        if (!is_wp_error($debug_resp)) {
            $debug_http = (int) ($debug_resp['code'] ?? 0);
            if ($debug_http >= 200 && $debug_http < 300) {
                $debug_checked = true;
                $debug_data = isset($debug_resp['body_json']['data']) && is_array($debug_resp['body_json']['data']) ? $debug_resp['body_json']['data'] : [];
                $debug_valid = !empty($debug_data['is_valid']);
                $debug_status = $debug_valid ? 'valid' : 'invalid';

                if (!$debug_valid) {
                    $this->meta_save_validation_marker($remote_page_id, $page_id, 'denied', 'auth', 'invalid');
                    return new WP_Error('facebook_token_invalid', 'The saved Facebook Page token is no longer valid.');
                }

                if (!empty($debug_data['scopes']) && is_array($debug_data['scopes'])) {
                    foreach ($debug_data['scopes'] as $scope) {
                        if (is_scalar($scope) && trim((string) $scope) !== '') {
                            $granted_scopes[] = trim((string) $scope);
                        }
                    }
                }
                if (!empty($debug_data['granular_scopes']) && is_array($debug_data['granular_scopes'])) {
                    foreach ($debug_data['granular_scopes'] as $granular_scope) {
                        if (is_array($granular_scope) && isset($granular_scope['scope']) && is_scalar($granular_scope['scope'])) {
                            $scope = trim((string) $granular_scope['scope']);
                            if ($scope !== '') {
                                $granted_scopes[] = $scope;
                            }
                        }
                    }
                }
                $granted_scopes = array_values(array_unique($granted_scopes));

                // Reject only when Meta actually returned scope evidence and that
                // evidence explicitly excludes pages_manage_posts for this Page.
                if (!empty($granted_scopes) && !$this->token_scope_applies_to_page($debug_data, 'pages_manage_posts', $page_id)) {
                    $this->meta_save_validation_marker($remote_page_id, $page_id, 'denied', 'permission', 'invalid');
                    $details = [
                        'classification' => 'facebook_missing_publish_permission',
                        'page_id' => $page_id,
                        'page_name' => $page_name,
                        'page_tasks' => $tasks,
                        'granted_scopes' => $granted_scopes,
                        'required_scopes' => ['pages_manage_posts'],
                        'missing_required_scopes' => ['pages_manage_posts'],
                        'failure_kind' => 'permission',
                        'retryable' => false,
                    ];
                    if (class_exists('Calliope_SP_Logger')) {
                        Calliope_SP_Logger::log('facebook', 'warning', wp_json_encode($details, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
                    }
                    return new WP_Error(
                        'facebook_missing_publish_permission',
                        'Facebook is connected, but pages_manage_posts is not granted for the selected Page.',
                        $details
                    );
                }
            } else {
                $debug_status = 'http_' . $debug_http;
            }
        } else {
            $debug_status = 'transport_error';
        }

        if (!$debug_checked && class_exists('Calliope_SP_Logger')) {
            Calliope_SP_Logger::log('facebook', 'warning', wp_json_encode([
                'event' => 'facebook_debug_token_advisory_unavailable',
                'page_id' => $page_id,
                'status' => $debug_status,
            ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
        }

        if (!$this->meta_save_validation_marker($remote_page_id, $page_id, 'granted', '', 'healthy')) {
            return new WP_Error('facebook_marker', 'Facebook Page validation could not be saved.');
        }

        if (!empty($granted_scopes)) {
            update_option('calliope_sp_fb_granted_scopes', array_values($granted_scopes), false);
        }

        $diagnostic = [
            'ok' => true,
            'page_id' => $page_id,
            'page_name' => $page_name,
            'page_lookup_ok' => true,
            'page_token_identity_ok' => true,
            'debug_token_checked' => $debug_checked,
            'debug_token_valid' => $debug_valid,
            'debug_token_status' => $debug_status,
            'granted_scopes' => $granted_scopes,
            'required_scopes' => ['pages_manage_posts'],
            'missing_required_scopes' => [],
            'page_tasks' => $tasks,
            'page_tasks_returned' => !empty($tasks),
            'page_tasks_status' => $tasks_status,
            'permissions_status_message' => $debug_checked && !empty($granted_scopes)
                ? 'The Page token, selected Page, and pages_manage_posts permission were verified.'
                : 'The selected Page Access Token was verified. Optional Page task or scope diagnostics were not fully returned by Meta.',
        ];

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

        return $diagnostic;
    }

    /**
     * Legacy real publish tests are intentionally disabled.
     *
     * @param string $mode Ignored legacy mode.
     * @return WP_Error
     */
    public function run_publish_test($mode = 'text') {
        return new WP_Error(
            'facebook_publish_test_disabled',
            'Facebook publish tests are disabled. Use connection diagnostics or a mocked publication test.'
        );
    }

    private function meta_option_string($name) {
        $value = get_option($name, '');
        return is_string($value) ? trim($value) : '';
    }
    private function meta_fingerprint($parts) { return hash_hmac('sha256', implode("\n", array_map('strval', $parts)), wp_salt('auth')); }
    private function meta_validation_marker() {
        $marker=get_option('calliope_sp_facebook_validation',null);
        if(!is_array($marker)||($marker['schema']??'')!=='facebook-validation-v1') return null;
        foreach(['application_fingerprint','credentials_fingerprint','identity_fingerprint','target_fingerprint','target_identity_fingerprint'] as $k) if(!isset($marker[$k])||!is_string($marker[$k])||!preg_match('/^[a-f0-9]{64}$/D',$marker[$k])) return null;
        if(!in_array((string)($marker['health']??''),['healthy','degraded','invalid'],true)) return null;
        if(!in_array((string)($marker['failure_kind']??''),['','auth','permission','target'],true)) return null;
        if(!in_array((string)($marker['permission_state']??''),['granted','denied','unknown'],true)) return null;
        $app=$this->meta_fingerprint([$this->meta_option_string('calliope_sp_fb_app_id'),$this->meta_option_string('calliope_sp_fb_app_secret')]);
        $cred=$this->meta_fingerprint([$app,$this->meta_option_string('calliope_sp_fb_page_access_token')]); $target=$this->meta_option_string('calliope_sp_fb_page_id'); $tf=$this->meta_fingerprint([$cred,$target]);
        if(!hash_equals($app,$marker['application_fingerprint'])||!hash_equals($cred,$marker['credentials_fingerprint'])||!hash_equals($tf,$marker['target_fingerprint'])) return null;
        if(($marker['resolved_target_id']??'')!==$target||(int)($marker['validated_at']??0)<=0) return null;
        return $marker;
    }
    private function meta_save_validation_marker($identity,$target_id,$permission='granted',$failure_kind='',$health='healthy') {
        if(!is_string($identity)||!is_string($target_id)||$identity===''||$target_id==='') return false;
        if(!in_array($permission,['granted','denied','unknown'],true)||!in_array($failure_kind,['','auth','permission','target'],true)||!in_array($health,['healthy','degraded','invalid'],true)) return false;
        if($failure_kind===''&&$permission!=='granted') return false;
        $app=$this->meta_fingerprint([$this->meta_option_string('calliope_sp_fb_app_id'),$this->meta_option_string('calliope_sp_fb_app_secret')]); $cred=$this->meta_fingerprint([$app,$this->meta_option_string('calliope_sp_fb_page_access_token')]); $tf=$this->meta_fingerprint([$cred,$target_id]);
        $marker=['schema'=>'facebook-validation-v1','health'=>$health,'failure_kind'=>$failure_kind,'validated_at'=>time(),'application_fingerprint'=>$app,'credentials_fingerprint'=>$cred,'identity_fingerprint'=>$this->meta_fingerprint(['identity',$identity]),'target_fingerprint'=>$tf,'target_identity_fingerprint'=>$this->meta_fingerprint(['target',$identity,$target_id]),'resolved_target_id'=>$target_id,'permission_state'=>$permission,'auth_method'=>'oauth2','access_token_expires_at'=>(int)get_option('calliope_sp_fb_expires_at',0)];
        update_option('calliope_sp_facebook_validation',$marker,false); $saved=get_option('calliope_sp_facebook_validation',null); return is_array($saved)&&$saved==$marker;
    }
    public function get_connection_state() {
        $app_id     = $this->meta_option_string('calliope_sp_fb_app_id');
        $app_secret = $this->meta_option_string('calliope_sp_fb_app_secret');
        $page_token = $this->meta_option_string('calliope_sp_fb_page_access_token');
        $user_token = $this->meta_option_string('calliope_sp_fb_user_access_token');
        $target     = $this->meta_option_string('calliope_sp_fb_page_id');
        $expires    = (int) get_option('calliope_sp_fb_expires_at', 0);
        $marker     = $this->meta_validation_marker();

        $configured   = $app_id !== '' && $app_secret !== '';
        $partial      = (($app_id !== '') xor ($app_secret !== ''));
        $authenticated = $page_token !== '' || $user_token !== '';
        $status       = 'not_configured';
        $health       = 'unknown';
        $evidence     = 'E0';
        $identity     = false;
        $has_target   = false;
        $permission   = false;
        $ready        = false;
        $message      = __('Connect facebook to publish.', 'calliope-social-publisher');
        $action       = __('Connect facebook', 'calliope-social-publisher');

        if ($partial || ($authenticated && !$configured)) {
            $status   = 'configuration_error';
            $health   = 'invalid';
            $evidence = 'E1';
            $message  = __('The facebook application configuration is incomplete.', 'calliope-social-publisher');
        } elseif ($configured && !$authenticated) {
            $status   = 'credentials_saved';
            $evidence = 'E1';
            $message  = __('Application credentials are saved. Connect facebook.', 'calliope-social-publisher');
        } elseif ($configured && $authenticated) {
            $evidence = 'E2';

            if ($expires > 0 && $expires <= time()) {
                $status  = 'token_expired';
                $health  = 'invalid';
                $message = __('The facebook access token expired. Reconnect the account.', 'calliope-social-publisher');
            } elseif (!$marker) {
                $status  = 'connected_unverified';
                $message = __('Verification is required before publishing.', 'calliope-social-publisher');
                $action  = __('Save and test', 'calliope-social-publisher');
            } else {
                $failure = (string) ($marker['failure_kind'] ?? '');

                if ($failure === 'auth') {
                    $status  = 'token_invalid';
                    $health  = 'invalid';
                    $message = __('Verification is required before publishing.', 'calliope-social-publisher');
                    $action  = __('Save and test', 'calliope-social-publisher');
                } elseif ($failure === 'target') {
                    $status      = 'target_missing';
                    $health      = 'invalid';
                    $evidence    = 'E3';
                    $identity    = true;
                    $has_target  = false;
                    $message     = __('Select and verify the Facebook Page before publishing.', 'calliope-social-publisher');
                    $action      = __('Save and test', 'calliope-social-publisher');
                } elseif ($failure === 'permission') {
                    $status      = 'permission_missing';
                    $health      = 'invalid';
                    $evidence    = 'E4';
                    $identity    = true;
                    $has_target  = $target !== '';
                    $message     = __('Verification is required before publishing.', 'calliope-social-publisher');
                    $action      = __('Save and test', 'calliope-social-publisher');
                } else {
                    $status      = 'ready_to_publish';
                    $health      = (($marker['health'] ?? '') === 'degraded') ? 'degraded' : 'healthy';
                    $evidence    = 'E5';
                    $identity    = true;
                    $has_target  = true;
                    $permission  = true;
                    $ready       = true;
                    $message     = __('Ready to publish.', 'calliope-social-publisher');
                    $action      = '';
                }
            }
        }

        return Calliope_SP_Connection_State::create([
            'network'             => 'facebook',
            'status'              => $status,
            'health'              => $health,
            'is_configured'       => $configured,
            'is_authenticated'    => $authenticated,
            'is_identity_known'   => $identity,
            'has_target'          => $has_target,
            'has_permission'      => $permission,
            'is_ready'            => $ready,
            'needs_attention'     => !$ready || $health === 'degraded',
            'evidence_level'      => $evidence,
            'message'             => $message,
            'action'              => $action,
            'expires_at'          => $expires,
            'validated_at'        => $marker ? (int) $marker['validated_at'] : 0,
            'connection_method'   => 'oauth2',
        ], 'E5');
    }

    public function get_resolved_target_id() {
        $marker = $this->meta_validation_marker();
        if (!$marker || ($marker['failure_kind'] ?? '') !== '' || ($marker['permission_state'] ?? '') !== 'granted') {
            return '';
        }

        return (string) $marker['resolved_target_id'];
    }
}
