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

class Calliope_SP_Threads {

    private $base = 'https://graph.threads.net';
    private $version = 'v1.0';
    /** @var array<string,mixed> */
    private $last_image_asset = [];

    /**
     * Threads fetches the image URL immediately after we write it to uploads.
     * Some hosts/CDNs expose the file publicly a moment later than the local write.
     *
     * @param string $url
     * @param int    $max_attempts
     * @return true|WP_Error
     */
    private function wait_for_public_image_url($url, $max_attempts = 6) {
        $url = esc_url_raw((string) $url);
        if ($url === '') {
            return new WP_Error('upload_failed', 'Missing public image URL.');
        }

        $request_args = [
            'timeout'     => 20,
            'redirection' => 3,
            'user-agent'  => 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)',
        ];

        for ($attempt = 1; $attempt <= (int) $max_attempts; $attempt++) {
            $head = wp_remote_head($url, $request_args);
            if (!is_wp_error($head)) {
                $code = (int) wp_remote_retrieve_response_code($head);
                $type = strtolower((string) wp_remote_retrieve_header($head, 'content-type'));
                $len  = (string) wp_remote_retrieve_header($head, 'content-length');

                if ($code === 200 && strpos($type, 'image/') === 0 && ($len === '' || (int) $len > 0)) {
                    return true;
                }
            }

            $get = wp_remote_get($url, array_merge($request_args, [
                'headers' => [
                    'Range' => 'bytes=0-1023',
                ],
            ]));

            if (!is_wp_error($get)) {
                $code = (int) wp_remote_retrieve_response_code($get);
                $type = strtolower((string) wp_remote_retrieve_header($get, 'content-type'));
                $body = (string) wp_remote_retrieve_body($get);

                if (($code === 200 || $code === 206) && strpos($type, 'image/') === 0 && $body !== '') {
                    return true;
                }
            }

            sleep(min(4, $attempt));
        }

        if (class_exists('Calliope_SP_Logger')) {
            Calliope_SP_Logger::log('threads', 'error', 'Public image URL was not externally reachable in time: ' . $url);
        }

        return new WP_Error(
            'upload_not_public',
            'The generated image is not publicly reachable yet. Please try publishing again in a few seconds.'
        );
    }

    private function api_url($path) {
        $path = ltrim((string)$path, '/');
        return $this->base . '/' . $this->version . '/' . $path;
    }

    private function get_token() {
        return (string) get_option('calliope_sp_threads_access_token', '');
    }

    private function get_user_id() {
        return (string) get_option('calliope_sp_threads_user_id', '');
    }

    /**
     * Try to fetch and save the Threads user_id using the /me endpoint.
     * Devuelve user_id (string) o WP_Error.
     */
    public function ensure_user_id($token = null) {
        $token = $token !== null ? (string)$token : $this->get_token();
        $uid = $this->get_user_id();

        if (!$token) {
            return new WP_Error('threads_config', 'Missing Threads configuration (token).');
        }
        if ($uid) {
            return $uid;
        }

        $me = $this->fetch_me($token);
        if (is_wp_error($me)) {
            return $me;
        }

        $id = sanitize_text_field($me['id'] ?? '');
        if (!$id) {
            return new WP_Error('threads_user_id', 'Threads: could not get the user_id from /me. Check permissions (threads_basic) and reconnect.');
        }

        update_option('calliope_sp_threads_user_id', $id, false);
        return $id;
    }

    private function fetch_me($token) {
        $url = add_query_arg([
            'fields' => 'id,username',
        ], $this->api_url('me'));

        $res = wp_remote_get($url, [
            'timeout' => 20, 'redirection' => 0, 'reject_unsafe_urls' => true,
            'headers' => [
                'Authorization' => 'Bearer ' . $token,
            ],
        ]);

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

        $http = wp_remote_retrieve_response_code($res);
        $body = json_decode(wp_remote_retrieve_body($res), true);

        if ($http < 200 || $http >= 300) {
            $msg = is_array($body) ? (json_encode($body)) : wp_remote_retrieve_body($res);
            return new WP_Error('threads_http', 'HTTP ' . $http . ' | ' . $msg);
        }

        if (!is_array($body)) {
            return new WP_Error('threads_bad_json', 'Invalid response when checking /me.');
        }

        return $body;
    }

    /**
     * Renew an unexpired long-lived Threads token just in time.
     * The long-lived access token is itself the refresh credential; no separate
     * refresh token is invented or persisted.
     *
     * @return true|WP_Error
     */
    public function maybe_refresh_token($threshold_seconds = null) {
        $threshold_seconds = $threshold_seconds === null && class_exists('Calliope_SP_Master')
            ? Calliope_SP_Master::get_lifecycle_refresh_threshold_seconds()
            : max(0, (int) $threshold_seconds);
        $token = trim($this->get_token());
        $expires_at = (int) get_option('calliope_sp_threads_expires_at', 0);
        $now = time();
        if ($token === '') return new WP_Error('threads_refresh_missing_token', __('Threads access token is missing.', 'calliope-social-publisher'), ['phase'=>'lifecycle','retryable'=>false,'failure_kind'=>'auth']);
        if ($expires_at <= $now) return new WP_Error('threads_token_expired', __('The Threads token expired and must be reconnected.', 'calliope-social-publisher'), ['phase'=>'lifecycle','retryable'=>false,'failure_kind'=>'auth']);
        if ($expires_at > ($now + $threshold_seconds)) return true;

        $lock = class_exists('Calliope_SP_Master') ? Calliope_SP_Master::acquire_lifecycle_refresh_lock('threads') : true;
        if (is_wp_error($lock)) {
            $current_expiry = (int) get_option('calliope_sp_threads_expires_at', 0);
            return $current_expiry > (time() + $threshold_seconds) ? true : $lock;
        }
        try {
            $token = trim($this->get_token());
            $expires_at = (int) get_option('calliope_sp_threads_expires_at', 0);
            $now = time();
            if ($expires_at > ($now + $threshold_seconds)) return true;
            if ($expires_at <= $now || $token === '') return new WP_Error('threads_token_expired', __('The Threads token expired and must be reconnected.', 'calliope-social-publisher'), ['phase'=>'lifecycle','retryable'=>false,'failure_kind'=>'auth']);

            $url = add_query_arg(['grant_type'=>'th_refresh_token'], $this->base . '/refresh_access_token');
            $response = wp_remote_get($url, [
                'timeout'=>30, 'redirection'=>0, 'reject_unsafe_urls'=>true, 'sslverify'=>true,
                'headers'=>['Authorization'=>'Bearer ' . $token, 'Accept'=>'application/json'],
            ]);
            if (is_wp_error($response)) {
                $this->mark_lifecycle_degraded();
                return true; // The previous token is still valid and can be used safely.
            }
            $http = (int) wp_remote_retrieve_response_code($response);
            $json = json_decode((string) wp_remote_retrieve_body($response), true);
            if ($http === 429 || $http >= 500) {
                $this->mark_lifecycle_degraded();
                return true;
            }
            if ($http < 200 || $http >= 300) {
                $this->mark_lifecycle_invalid('auth');
                return new WP_Error('threads_refresh_rejected', __('Threads rejected the token renewal. Reconnect the account.', 'calliope-social-publisher'), ['phase'=>'refresh','http_code'=>$http,'retryable'=>false,'failure_kind'=>'auth']);
            }
            $new_token = is_array($json) && isset($json['access_token']) && is_scalar($json['access_token']) ? trim((string) $json['access_token']) : '';
            $token_type = is_array($json) && isset($json['token_type']) && is_scalar($json['token_type']) ? strtolower(trim((string) $json['token_type'])) : 'bearer';
            $expires_in = is_array($json) && isset($json['expires_in']) && is_numeric($json['expires_in']) ? (int) $json['expires_in'] : 0;
            if ($new_token === '' || strlen($new_token) > 4096 || $token_type !== 'bearer' || $expires_in <= 0) {
                return new WP_Error('threads_refresh_invalid_response', __('Threads returned an incomplete token renewal response.', 'calliope-social-publisher'), ['phase'=>'refresh','http_code'=>$http,'retryable'=>true,'failure_kind'=>'temporary']);
            }
            $new_expires_at = $now + $expires_in;
            if ($new_expires_at <= $now) return new WP_Error('threads_refresh_invalid_expiry', __('Threads returned an invalid token expiry.', 'calliope-social-publisher'), ['phase'=>'refresh','retryable'=>true,'failure_kind'=>'temporary']);

            $me = $this->fetch_me($new_token);
            if (is_wp_error($me)) return $me;
            $identity = isset($me['id']) && is_scalar($me['id']) ? trim((string) $me['id']) : '';
            $target = $this->get_user_id();
            if ($identity === '' || $target === '' || !hash_equals($target, $identity)) {
                return new WP_Error('threads_refresh_identity_mismatch', __('Threads returned a different account during renewal.', 'calliope-social-publisher'), ['phase'=>'identity','retryable'=>false,'failure_kind'=>'identity']);
            }

            $snapshot = [
                'token'=>get_option('calliope_sp_threads_access_token', null),
                'expires'=>get_option('calliope_sp_threads_expires_at', null),
                'marker'=>get_option('calliope_sp_threads_validation', null),
                'status'=>get_option('calliope_sp_threads_connection_status', null),
            ];
            update_option('calliope_sp_threads_access_token', $new_token, false);
            update_option('calliope_sp_threads_expires_at', $new_expires_at, false);
            if (!$this->meta_save_validation_marker($identity, $target, 'granted')) {
                $this->restore_lifecycle_snapshot($snapshot);
                return new WP_Error('threads_refresh_commit', __('Threads renewal could not be committed safely.', 'calliope-social-publisher'), ['phase'=>'commit','retryable'=>true,'failure_kind'=>'temporary']);
            }
            update_option('calliope_sp_threads_connection_status', 'ready_to_publish', false);
            $state = $this->get_connection_state();
            if (is_wp_error($state) || !$state->is_ready() || !hash_equals($target, $this->get_resolved_target_id())) {
                $this->restore_lifecycle_snapshot($snapshot);
                return new WP_Error('threads_refresh_verify', __('Threads renewal could not be verified after saving.', 'calliope-social-publisher'), ['phase'=>'commit','retryable'=>true,'failure_kind'=>'temporary']);
            }
            return true;
        } finally {
            if (class_exists('Calliope_SP_Master') && is_string($lock)) Calliope_SP_Master::release_lifecycle_refresh_lock('threads', $lock);
        }
    }

    public function test_connection() {
        $lifecycle = $this->maybe_refresh_token();
        if (is_wp_error($lifecycle) && $lifecycle->get_error_code() !== 'threads_refresh_missing_token') return $lifecycle;
        $token = $this->get_token();
        if ($token === '') return new WP_Error('threads_config', 'Missing Threads access token.');
        $me = $this->fetch_me($token);
        if (is_wp_error($me)) return $me;
        $id = isset($me['id']) && is_scalar($me['id']) ? trim((string)$me['id']) : '';
        if ($id === '') return new WP_Error('threads_identity', 'Threads identity could not be validated.');
        $saved = $this->get_user_id();
        if ($saved !== '' && !hash_equals($saved, $id)) return new WP_Error('threads_identity', 'Threads returned a different account. Reconnect the intended account.');
        update_option('calliope_sp_threads_user_id', $id, false);
        if (!$this->meta_save_validation_marker($id, $id, 'granted')) return new WP_Error('threads_marker', 'Threads validation could not be saved.');
        return true;
    }

    /**
     * Build structured Graph/API diagnostics that are preserved in publisher logs.
     *
     * @param int                 $http_code
     * @param string              $raw
     * @param array<string,mixed> $json
     * @param array<string,mixed> $image_asset
     * @param array<string,mixed> $extra
     * @return array<string,mixed>
     */
    private function build_graph_error_data($http_code, $raw, $json = [], array $image_asset = [], array $extra = []) {
        $json = is_array($json) ? $json : [];
        $err = isset($json['error']) && is_array($json['error']) ? $json['error'] : [];

        return array_merge([
            'http_code'           => (int) $http_code,
            'graph_error_message' => (string) ($err['message'] ?? ($json['message'] ?? '')),
            'graph_error_type'    => (string) ($err['type'] ?? ''),
            'graph_error_code'    => (int) ($err['code'] ?? 0),
            'graph_error_subcode' => (int) ($err['error_subcode'] ?? 0),
            'fbtrace_id'          => (string) ($err['fbtrace_id'] ?? ''),
            'raw_response'        => mb_substr((string) $raw, 0, 1600),
            'image_url'           => (string) ($image_asset['url'] ?? ''),
            'passthrough_url'     => (string) ($image_asset['passthrough_url'] ?? ''),
            'meta_static_url'     => (string) ($image_asset['meta_static_url'] ?? ''),
            'static_url'          => (string) ($image_asset['static_url'] ?? ''),
            'url_strategy'        => (string) ($image_asset['url_strategy'] ?? ''),
            'mime'                => (string) ($image_asset['mime'] ?? ''),
            'width'               => (int) ($image_asset['width'] ?? 0),
            'height'              => (int) ($image_asset['height'] ?? 0),
            'filesize'            => (int) ($image_asset['filesize'] ?? 0),
            'local_path'          => (string) ($image_asset['local_path'] ?? $image_asset['path'] ?? ''),
            'http_head_status'    => (int) ($image_asset['http_head_status'] ?? 0),
            'http_get_status'     => (int) ($image_asset['http_get_status'] ?? 0),
            'http_content_type'   => (string) ($image_asset['http_content_type'] ?? ''),
            'http_content_length' => (string) ($image_asset['http_content_length'] ?? ''),
            'http_accept_ranges'  => (string) ($image_asset['http_accept_ranges'] ?? ''),
            'http_cache_control'  => (string) ($image_asset['http_cache_control'] ?? ''),
            'http_x_content_type_options' => (string) ($image_asset['http_x_content_type_options'] ?? ''),
            'http_server'         => (string) ($image_asset['http_server'] ?? ''),
            'http_via'            => (string) ($image_asset['http_via'] ?? ''),
            'http_cf_cache_status'=> (string) ($image_asset['http_cf_cache_status'] ?? ''),
            'http_x_litespeed_cache' => (string) ($image_asset['http_x_litespeed_cache'] ?? ''),
            'http_x_hcdn_cache_status' => (string) ($image_asset['http_x_hcdn_cache_status'] ?? ''),
            'http_x_hcdn_request_id' => (string) ($image_asset['http_x_hcdn_request_id'] ?? ''),
            'body_starts_with_jpeg' => (bool) ($image_asset['body_starts_with_jpeg'] ?? false),
            'body_detected_mime'  => (string) ($image_asset['body_detected_mime'] ?? ''),
            'body_magic'          => (string) ($image_asset['body_magic'] ?? ''),
            'body_matches_local'  => (bool) ($image_asset['body_matches_local'] ?? false),
            'body_size_matches_local' => (bool) ($image_asset['body_size_matches_local'] ?? false),
            'body_magic_matches_local' => (bool) ($image_asset['body_magic_matches_local'] ?? false),
            'http_content_length_matches_local' => (bool) ($image_asset['http_content_length_matches_local'] ?? false),
            'first_12_bytes_hex'  => (string) ($image_asset['first_12_bytes_hex'] ?? ''),
            'sha1'                => (string) ($image_asset['sha1'] ?? ''),
            'media_url_error_summary' => ((int) ($err['error_subcode'] ?? 0) === 2207052)
                ? 'Meta could not fetch or accept the public media URL.'
                : '',
            'http_diagnostic_summary' => sprintf(
                'strategy=%s head=%d get=%d type=%s length=%s jpeg_magic=%s body_match=%s',
                (string) ($image_asset['url_strategy'] ?? ''),
                (int) ($image_asset['http_head_status'] ?? 0),
                (int) ($image_asset['http_get_status'] ?? 0),
                (string) ($image_asset['http_content_type'] ?? ''),
                (string) ($image_asset['http_content_length'] ?? ''),
                !empty($image_asset['body_starts_with_jpeg']) ? 'yes' : 'no',
                !empty($image_asset['body_matches_local']) ? 'yes' : 'no'
            ),
        ], $extra);
    }

    public function publish($data) {
        $lifecycle = $this->maybe_refresh_token();
        if (is_wp_error($lifecycle)) return $lifecycle;
        $state = $this->get_connection_state();
        if (is_wp_error($state) || !$state->is_ready()) return new WP_Error('threads_not_ready', 'Threads is not ready to publish. Reconnect and verify the account.');
        $token = $this->get_token();
        $uid = $this->get_resolved_target_id();
        if ($token === '' || $uid === '') return new WP_Error('threads_not_ready', 'Threads validation is incomplete.');

        $text = $this->format_text($data, 480);

        // If image exists, we need a public image_url
        $has_image = !empty($data['image_b64']);
        $media_type = $has_image ? 'IMAGE' : 'TEXT';

        $args = [
            'media_type'   => $media_type,
            'text'         => $text,
            'access_token' => $token,
        ];

        if ($has_image) {
            $this->last_image_asset = [];
            $img_url = $this->image_url_from_b64($data['image_b64']);
            if (is_wp_error($img_url)) return $img_url;
            $args['image_url'] = $img_url;
            $image_asset = is_array($this->last_image_asset) ? $this->last_image_asset : [];
            if (class_exists('Calliope_SP_Logger')) {
                $payload_for_log = $args;
                unset($payload_for_log['access_token']);
                Calliope_SP_Logger::log('threads', 'info', wp_json_encode([
                    'message'      => 'Sending image URL to Threads media container.',
                    'image_url'    => $img_url,
                    'url_strategy' => (string) ($image_asset['url_strategy'] ?? ''),
                    'local_path'   => (string) ($image_asset['local_path'] ?? ''),
                    'mime'         => (string) ($image_asset['mime'] ?? ''),
                    'width'        => (int) ($image_asset['width'] ?? 0),
                    'height'       => (int) ($image_asset['height'] ?? 0),
                    'filesize'     => (int) ($image_asset['filesize'] ?? 0),
                    'first_12_bytes_hex' => (string) ($image_asset['first_12_bytes_hex'] ?? ''),
                    'sha1'         => (string) ($image_asset['sha1'] ?? ''),
                    'http_head_status' => (int) ($image_asset['http_head_status'] ?? 0),
                    'http_get_status' => (int) ($image_asset['http_get_status'] ?? 0),
                    'http_content_type' => (string) ($image_asset['http_content_type'] ?? ''),
                    'http_content_length' => (string) ($image_asset['http_content_length'] ?? ''),
                    'http_accept_ranges' => (string) ($image_asset['http_accept_ranges'] ?? ''),
                    'http_cache_control' => (string) ($image_asset['http_cache_control'] ?? ''),
                    'http_x_content_type_options' => (string) ($image_asset['http_x_content_type_options'] ?? ''),
                    'http_server' => (string) ($image_asset['http_server'] ?? ''),
                    'http_via' => (string) ($image_asset['http_via'] ?? ''),
                    'http_cf_cache_status' => (string) ($image_asset['http_cf_cache_status'] ?? ''),
                    'http_x_litespeed_cache' => (string) ($image_asset['http_x_litespeed_cache'] ?? ''),
                    'http_x_hcdn_cache_status' => (string) ($image_asset['http_x_hcdn_cache_status'] ?? ''),
                    'http_x_hcdn_request_id' => (string) ($image_asset['http_x_hcdn_request_id'] ?? ''),
                    'body_starts_with_jpeg' => (bool) ($image_asset['body_starts_with_jpeg'] ?? false),
                    'body_matches_local' => (bool) ($image_asset['body_matches_local'] ?? false),
                    'body_size_matches_local' => (bool) ($image_asset['body_size_matches_local'] ?? false),
                    'body_magic_matches_local' => (bool) ($image_asset['body_magic_matches_local'] ?? false),
                    'http_content_length_matches_local' => (bool) ($image_asset['http_content_length_matches_local'] ?? false),
                    'meta_request_body' => $payload_for_log,
                ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
            }
        }

        // 1) Create media container
        // IMPORTANT: send params in POST body (not querystring) to avoid URL/IRI parse issues with emojis/newlines.
        $create_url = $this->api_url($uid . '/threads');

        $res = wp_remote_post($create_url, [
            'timeout' => 45,
            'body'    => $args,
        ]);
if (is_wp_error($res)) return $res;

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

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

        if ($code < 200 || $code >= 300 || !is_array($json) || empty($json['id'])) {
            $msg = is_array($json) ? ($json['error']['message'] ?? $json['message'] ?? '') : '';
            $err_sub = is_array($json) ? (int) ($json['error']['error_subcode'] ?? 0) : 0;
            if (!$msg) $msg = 'Could not create the container.';
            $image_asset = is_array($this->last_image_asset) ? $this->last_image_asset : [];
            $error_data = $this->build_graph_error_data($code, $raw, is_array($json) ? $json : [], $image_asset, [
                'stage' => 'create_threads_container',
            ]);

            if (class_exists('Calliope_SP_Logger')) {
                Calliope_SP_Logger::log('threads', 'error', wp_json_encode(array_merge([
                    'message' => 'Meta media URL diagnostics: Threads create container failed with structured details.',
                ], $error_data), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
            }

            if ($err_sub === 2207052) {
                return new WP_Error(
                    'threads_invalid_media_url',
                    'Threads could not use this image because the public media URL is not accessible or does not return a valid image. Please try another image or use Original format.',
                    $error_data
                );
            }
            return new WP_Error('threads', 'Error (' . $code . '): ' . $msg, $error_data);
        }

        $container_id = sanitize_text_field($json['id']);

        // 2) Publish
        $publish_url = $this->api_url($uid . '/threads_publish');

        // Graph can return "Media Not Found" when publishing immediately after creating the container.
        // In that case, retry with a short backoff.
        $max_attempts = 5;
        $delays = [2, 4, 6, 8, 10]; // total max ~30s

        $last_code2 = 0;
        $last_raw2  = '';
        $last_json2 = null;

        for ($attempt = 1; $attempt <= $max_attempts; $attempt++) {
            $res2 = wp_remote_post($publish_url, [
                'timeout' => 45,
                'body'    => [
                    'creation_id'  => $container_id,
                    'access_token' => $token,
                ],
            ]);
            if (is_wp_error($res2)) return $res2;

            $last_code2 = (int) wp_remote_retrieve_response_code($res2);
            $last_raw2  = (string) wp_remote_retrieve_body($res2);
            $last_json2 = json_decode($last_raw2, true);

            if (class_exists('Calliope_SP_Logger')) {
                $lvl = ($last_code2 >= 200 && $last_code2 < 300) ? 'info' : 'error';
                Calliope_SP_Logger::log('threads', $lvl, 'Publish attempt ' . $attempt . ' HTTP ' . $last_code2 . ' | ' . mb_substr($last_raw2, 0, 1200));
            }

            // Success
            if ($last_code2 >= 200 && $last_code2 < 300 && is_array($last_json2) && !empty($last_json2['id'])) {
                return [
                    'ok' => true,
                    'post_id' => sanitize_text_field((string) $last_json2['id']),
                ];
            }

            // Retry only for the known propagation case
            $err_sub = 0;
            $err_code = 0;
            $err_msg = '';
            if (is_array($last_json2) && !empty($last_json2['error'])) {
                $err_sub  = (int) ($last_json2['error']['error_subcode'] ?? 0);
                $err_code = (int) ($last_json2['error']['code'] ?? 0);
                $err_msg  = (string) ($last_json2['error']['message'] ?? '');
            }

            $is_media_not_found = (
                $last_code2 === 400 &&
                $err_code === 24 &&
                $err_sub === 4279009
            );

            if ($is_media_not_found && $attempt < $max_attempts) {
                $sleep = $delays[$attempt - 1] ?? 4;
                if (class_exists('Calliope_SP_Logger')) {
                    Calliope_SP_Logger::log('threads', 'info', 'Media not found during propagation. Retrying in ' . $sleep . 's...');
                }
                sleep((int)$sleep);
                continue;
            }

            // Any other error: break and report
            break;
        }

        // Final failure
        $msg = is_array($last_json2) ? ($last_json2['error']['message'] ?? $last_json2['message'] ?? '') : '';
        if (!$msg) $msg = 'Could not publish.';
        return new WP_Error('threads', 'Error (' . $last_code2 . '): ' . $msg, $this->build_graph_error_data($last_code2, $last_raw2, is_array($last_json2) ? $last_json2 : [], is_array($this->last_image_asset) ? $this->last_image_asset : [], ['stage' => 'threads_publish']));
    }

    /**
     * Convert base64 image into a public URL (uploads).
     */
    private function image_url_from_b64($b64) {
        $asset = class_exists('Calliope_SP_Media_Helper')
            ? Calliope_SP_Media_Helper::create_public_image_from_b64($b64, Calliope_SP_Media_Helper::get_meta_static_image_args('threads'))
            : new WP_Error('threads_invalid_media_url', 'Threads could not prepare a valid public image URL.');

        if (is_wp_error($asset)) {
            return new WP_Error(
                'threads_invalid_media_url',
                'Threads could not use this image because the public media URL is not accessible or does not return a valid image. Please try another image or use Original format.',
                [
                    'stage'                  => 'prepare_public_media_url',
                    'original_error_code'    => $asset->get_error_code(),
                    'original_error_message' => $asset->get_error_message(),
                    'original_error_data'    => $asset->get_error_data(),
                ]
            );
        }

        $this->last_image_asset = is_array($asset) ? $asset : [];

        if (class_exists('Calliope_SP_Logger')) {
            Calliope_SP_Logger::log('threads', 'info', wp_json_encode([
                'message'      => 'Prepared public image URL for Threads.',
                'image_url'    => (string) ($asset['url'] ?? ''),
                'url_strategy' => (string) ($asset['url_strategy'] ?? ''),
                'mime'         => (string) ($asset['mime'] ?? ''),
                'width'        => (int) ($asset['width'] ?? 0),
                'height'       => (int) ($asset['height'] ?? 0),
                'filesize'     => (int) ($asset['filesize'] ?? 0),
                'local_path'   => (string) ($asset['local_path'] ?? ''),
                'first_12_bytes_hex' => (string) ($asset['first_12_bytes_hex'] ?? ''),
                'sha1'         => (string) ($asset['sha1'] ?? ''),
                'http_head_status' => (int) ($asset['http_head_status'] ?? 0),
                'http_get_status' => (int) ($asset['http_get_status'] ?? 0),
                'http_content_type' => (string) ($asset['http_content_type'] ?? ''),
                'http_content_length' => (string) ($asset['http_content_length'] ?? ''),
                'http_x_hcdn_cache_status' => (string) ($asset['http_x_hcdn_cache_status'] ?? ''),
                'http_x_hcdn_request_id' => (string) ($asset['http_x_hcdn_request_id'] ?? ''),
                'body_starts_with_jpeg' => (bool) ($asset['body_starts_with_jpeg'] ?? false),
                'body_matches_local' => (bool) ($asset['body_matches_local'] ?? false),
                'body_size_matches_local' => (bool) ($asset['body_size_matches_local'] ?? false),
                'body_magic_matches_local' => (bool) ($asset['body_magic_matches_local'] ?? false),
                'http_content_length_matches_local' => (bool) ($asset['http_content_length_matches_local'] ?? false),
            ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
        }

        return esc_url_raw((string) $asset['url']);
    }

    private function format_text($data, $limit) {
        $limit = max(1, (int) $limit);
        $title = trim((string) ($data['title'] ?? ''));
        $content = trim((string) ($data['content'] ?? ''));
        $link = trim((string) ($data['link'] ?? ''));

        $tags = [];
        if (isset($data['hashtags_list']) && is_array($data['hashtags_list'])) {
            $tags = $data['hashtags_list'];
        } elseif (class_exists('Calliope_SP_Publish_Helper')) {
            $tags = Calliope_SP_Publish_Helper::normalize_hashtags_list($data['hashtags'] ?? '', 'threads');
        } elseif (!empty($data['hashtags'])) {
            $tags = preg_split('/\s+/u', trim((string) $data['hashtags']));
        }

        $tags = array_values(array_filter(array_map(static function ($tag) {
            $tag = trim((string) $tag);
            if ($tag === '') {
                return '';
            }
            return strpos($tag, '#') === 0 ? $tag : '#' . ltrim($tag, '#');
        }, is_array($tags) ? $tags : [])));

        // The AI caption can already contain the destination URL. Appending the
        // link field again created two visible copies in Threads, so only add the
        // dedicated link block when the title/content does not already contain it.
        $link_to_append = ($link !== '' && !$this->text_contains_link($title . "\n" . $content, $link))
            ? $link
            : '';

        $text = $this->build_limited_threads_text($title, $content, $tags, $link_to_append, $limit);

        // If the URL was present only in a section removed by truncation, rebuild
        // while reserving a final link block. This guarantees one usable link,
        // never zero and never two.
        if ($link !== '' && !$this->text_contains_link($text, $link)) {
            $text = $this->build_limited_threads_text($title, $content, $tags, $link, $limit);
        }

        return $text;
    }

    private function build_limited_threads_text($title, $content, array $tags, $link, $limit) {
        $title = trim((string) $title);
        $content = trim((string) $content);
        $link = trim((string) $link);
        $limit = max(1, (int) $limit);
        $tags = array_values($tags);

        // Reserve hashtag space before shortening the caption. The previous
        // formatter filled the whole budget with content first, which routinely
        // left no room for any hashtag on longer Threads posts.
        while (!empty($tags)) {
            $hashtags_line = implode(' ', $tags);
            $fixed_blocks = array_values(array_filter([$title, $hashtags_line, $link], static function ($block) {
                return $block !== '';
            }));
            $fixed_length = array_sum(array_map('mb_strlen', $fixed_blocks));
            $separator_length = $content !== ''
                ? 2 * count($fixed_blocks)
                : 2 * max(0, count($fixed_blocks) - 1);

            if (($fixed_length + $separator_length) <= $limit) {
                break;
            }

            array_pop($tags);
        }

        $hashtags_line = implode(' ', $tags);
        $fixed_blocks = array_values(array_filter([$title, $hashtags_line, $link], static function ($block) {
            return $block !== '';
        }));
        $fixed_length = array_sum(array_map('mb_strlen', $fixed_blocks));
        $separator_length = $content !== ''
            ? 2 * count($fixed_blocks)
            : 2 * max(0, count($fixed_blocks) - 1);
        $content_limit = max(0, $limit - $fixed_length - $separator_length);

        if ($content !== '' && mb_strlen($content) > $content_limit) {
            if ($content_limit > 3) {
                $content = rtrim(mb_substr($content, 0, $content_limit - 3)) . '...';
            } elseif ($content_limit > 0) {
                $content = mb_substr($content, 0, $content_limit);
            } else {
                $content = '';
            }
        }

        $blocks = array_values(array_filter([$title, $content, $hashtags_line, $link], static function ($block) {
            return $block !== '';
        }));
        $text = implode("\n\n", $blocks);

        return mb_strlen($text) > $limit ? mb_substr($text, 0, $limit) : $text;
    }

    private function text_contains_link($text, $link) {
        $needle = $this->normalize_link_for_text_match($link);
        if ($needle === '') {
            return false;
        }

        $haystack = html_entity_decode((string) $text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
        $haystack = preg_replace('#https?://#iu', '', $haystack);
        $haystack = preg_replace('#\bwww\.#iu', '', (string) $haystack);
        $haystack = function_exists('mb_strtolower')
            ? mb_strtolower((string) $haystack, 'UTF-8')
            : strtolower((string) $haystack);

        return strpos((string) $haystack, $needle) !== false;
    }

    private function normalize_link_for_text_match($link) {
        $link = html_entity_decode(trim((string) $link), ENT_QUOTES | ENT_HTML5, 'UTF-8');
        $link = preg_replace('#^https?://#iu', '', $link);
        $link = preg_replace('#^www\.#iu', '', (string) $link);
        $link = rtrim((string) $link, "/ \t\n\r\0\x0B");

        return function_exists('mb_strtolower')
            ? mb_strtolower($link, 'UTF-8')
            : strtolower($link);
    }

    private function restore_lifecycle_snapshot(array $snapshot) {
        $map = [
            'token'=>'calliope_sp_threads_access_token', 'expires'=>'calliope_sp_threads_expires_at',
            'marker'=>'calliope_sp_threads_validation', 'status'=>'calliope_sp_threads_connection_status',
        ];
        foreach ($map as $key=>$option) {
            if (!array_key_exists($key, $snapshot) || $snapshot[$key] === null) delete_option($option);
            else update_option($option, $snapshot[$key], false);
        }
    }

    private function mark_lifecycle_degraded() {
        $marker = $this->meta_validation_marker();
        if (!is_array($marker)) return;
        $marker['health'] = 'degraded';
        $marker['failure_kind'] = '';
        update_option('calliope_sp_threads_validation', $marker, false);
    }

    private function mark_lifecycle_invalid($failure_kind) {
        $marker = $this->meta_validation_marker();
        if (!is_array($marker)) return;
        $marker['health'] = 'invalid';
        $marker['failure_kind'] = in_array($failure_kind, ['auth','permission','identity','target'], true) ? $failure_kind : 'auth';
        update_option('calliope_sp_threads_validation', $marker, false);
    }

    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_threads_validation', null);
        if (!is_array($marker) || ($marker['schema'] ?? '') !== 'threads-validation-v1') return null;
        foreach (['application_fingerprint','credentials_fingerprint','identity_fingerprint','target_fingerprint','target_identity_fingerprint'] as $key) {
            if (!isset($marker[$key]) || !is_string($marker[$key]) || !preg_match('/^[a-f0-9]{64}$/D', $marker[$key])) return null;
        }
        $app = $this->meta_fingerprint([$this->meta_option_string('calliope_sp_threads_client_id'), $this->meta_option_string('calliope_sp_threads_client_secret')]);
        $cred = $this->meta_fingerprint([$app, $this->meta_option_string('calliope_sp_threads_access_token')]);
        $target = $this->meta_option_string('calliope_sp_threads_user_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') {
        if (!is_string($identity) || !is_string($target_id) || $identity === '' || $target_id === '' || $permission !== 'granted') return false;
        $app = $this->meta_fingerprint([$this->meta_option_string('calliope_sp_threads_client_id'), $this->meta_option_string('calliope_sp_threads_client_secret')]);
        $cred = $this->meta_fingerprint([$app, $this->meta_option_string('calliope_sp_threads_access_token')]);
        $target = $this->meta_fingerprint([$cred, $target_id]);
        $marker = [
            'schema' => 'threads-validation-v1', 'health' => 'healthy', 'failure_kind' => '', 'validated_at' => time(),
            'application_fingerprint' => $app, 'credentials_fingerprint' => $cred,
            'identity_fingerprint' => $this->meta_fingerprint(['identity', $identity]),
            'target_fingerprint' => $target,
            'target_identity_fingerprint' => $this->meta_fingerprint(['target', $identity, $target_id]),
            'resolved_target_id' => $target_id, 'permission_state' => 'granted', 'auth_method' => 'oauth2',
            'access_token_expires_at' => (int) get_option('calliope_sp_threads_expires_at', 0),
        ];
        update_option('calliope_sp_threads_validation', $marker, false);
        $saved = get_option('calliope_sp_threads_validation', null);
        return is_array($saved) && $saved == $marker;
    }

    public function get_connection_state() {
        $app_id = $this->meta_option_string('calliope_sp_threads_client_id');
        $app_secret = $this->meta_option_string('calliope_sp_threads_client_secret');
        $token = $this->meta_option_string('calliope_sp_threads_access_token');
        $target = $this->meta_option_string('calliope_sp_threads_user_id');
        $expires = (int) get_option('calliope_sp_threads_expires_at', 0);
        $configured = $app_id !== '' && $app_secret !== '';
        $partial_app = (($app_id !== '') xor ($app_secret !== ''));
        $status='not_configured'; $health='unknown'; $evidence='E0'; $identity=false; $permission=false; $ready=false;
        $message=__('Connect threads to publish.', 'calliope-social-publisher'); $action=__('Connect threads', 'calliope-social-publisher');
        if ($partial_app || ($token !== '' && !$configured)) { $status='configuration_error'; $health='invalid'; $evidence='E1'; $message=__('The threads application configuration is incomplete.', 'calliope-social-publisher'); }
        elseif ($configured && $token === '') { $status='credentials_saved'; $evidence='E1'; $message=__('Application credentials are saved. Connect threads.', 'calliope-social-publisher'); }
        elseif ($configured && $token !== '') {
            $evidence='E2';
            if ($expires > 0 && $expires <= time()) { $status='token_expired'; $health='invalid'; $message=__('The threads access token expired. Reconnect the account.', 'calliope-social-publisher'); }
            elseif ($target === '') { $status='connected_unverified'; $message=__('Verification is required before publishing.', 'calliope-social-publisher'); $action=__('Save and test', 'calliope-social-publisher'); }
            else {
                $marker=$this->meta_validation_marker();
                if ($marker) {
                    if (($marker['failure_kind'] ?? '') === 'auth') { $status='token_invalid'; $health='invalid'; }
                    elseif (($marker['failure_kind'] ?? '') === 'permission') { $status='permission_missing'; $health='invalid'; }
                    elseif (($marker['failure_kind'] ?? '') === 'target') { $status='target_missing'; $health='invalid'; }
                    else { $status='ready_to_publish'; $health=(($marker['health'] ?? '')==='degraded'?'degraded':'healthy'); $evidence='E5'; $identity=true; $permission=true; $ready=true; $message=__('Ready to publish.', 'calliope-social-publisher'); $action=''; }
                } else { $status='connected_unverified'; $message=__('Verification is required before publishing.', 'calliope-social-publisher'); $action=__('Save and test', 'calliope-social-publisher'); }
            }
        }
        return Calliope_SP_Connection_State::create([
            'network'=>'threads','status'=>$status,'health'=>$health,'is_configured'=>$configured,'is_authenticated'=>$token!=='',
            'is_identity_known'=>$identity,'has_target'=>$target!=='','has_permission'=>$permission,'is_ready'=>$ready,
            'needs_attention'=>!$ready || $health==='degraded','evidence_level'=>$evidence,'message'=>$message,'action'=>$action,
            'expires_at'=>$expires,'validated_at'=>$this->meta_validation_marker() ? (int)$this->meta_validation_marker()['validated_at'] : 0,
            'connection_method'=>'oauth2',
        ], 'E5');
    }

    public function get_resolved_target_id() {
        $marker=$this->meta_validation_marker();
        return $marker ? (string)$marker['resolved_target_id'] : '';
    }

    /**
     * Internal publishing context for compatible extensions.
     * The returned token must never be rendered, logged, filtered publicly, or persisted elsewhere.
     *
     * @return array{ready:bool,target_id:string,access_token:string,api_base:string}|WP_Error
     */
    public function get_extension_publish_context() {
        $lifecycle = $this->maybe_refresh_token();
        if (is_wp_error($lifecycle)) {
            return new WP_Error('threads_not_ready', __('Threads is not ready to publish.', 'calliope-social-publisher'));
        }
        $state = $this->get_connection_state();
        if (is_wp_error($state) || !$state->is_ready()) {
            return new WP_Error('threads_not_ready', __('Threads is not ready to publish.', 'calliope-social-publisher'));
        }
        $target_id = $this->get_resolved_target_id();
        $token = trim($this->get_token());
        if ($target_id === '' || $token === '') {
            return new WP_Error('threads_not_ready', __('Threads validation is incomplete.', 'calliope-social-publisher'));
        }
        return [
            'ready'        => true,
            'target_id'    => $target_id,
            'access_token' => $token,
            'api_base'     => rtrim($this->base, '/') . '/' . trim($this->version, '/'),
        ];
    }

    /**
     * Reuse the exact Free Threads text formatter for extension-owned media modes.
     */
    public function format_extension_publish_text(array $data, $limit = 500): string {
        return $this->format_text($data, max(1, (int) $limit));
    }
}
