<?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);
        return $id;
    }

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

        $res = wp_remote_get($url, [
            'timeout' => 15,
            '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;
    }

    /**
     * Refresh token if it expires soon (best-effort).
     */
    public function maybe_refresh_token($threshold_seconds = 7 * DAY_IN_SECONDS) {
        $token = $this->get_token();
        if (!$token) return;

        $expires_at = (int) get_option('calliope_sp_threads_expires_at', 0);
        if ($expires_at > 0 && ($expires_at - time()) > $threshold_seconds) {
            return; // still valid for a while
        }

        $url = add_query_arg([
            'grant_type'   => 'th_refresh_token',
            'access_token' => $token,
        ], $this->base . '/refresh_access_token');

        $res = wp_remote_get($url, [
            'timeout' => 30,
        ]);

        if (is_wp_error($res)) {
            if (class_exists('Calliope_SP_Logger')) {
                Calliope_SP_Logger::log('threads', 'error', 'Refresh token error: ' . $res->get_error_message());
            }
            return;
        }

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

        if ($http >= 200 && $http < 300 && is_array($json) && !empty($json['access_token'])) {
            update_option('calliope_sp_threads_access_token', sanitize_text_field($json['access_token']));
            if (!empty($json['expires_in'])) {
                update_option('calliope_sp_threads_expires_at', time() + (int)$json['expires_in']);
            }
            if (class_exists('Calliope_SP_Logger')) {
                Calliope_SP_Logger::log('threads', 'info', 'Token refreshed ✅');
            }
        } else {
            if (class_exists('Calliope_SP_Logger')) {
                Calliope_SP_Logger::log('threads', 'error', 'Token refresh failed (HTTP ' . $http . '): ' . mb_substr($raw, 0, 800));
            }
        }
    }
public function test_connection() {
    $token = $this->get_token();

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

    // Ensure we have user_id (Threads user id). We can fetch it from /me.
    $uid = $this->get_user_id();
    if (!$uid) {
        $uid = $this->ensure_user_id($token);
        if (is_wp_error($uid)) {
            return $uid;
        }
    }

    $this->maybe_refresh_token();

    $token = $this->get_token();
    if (!$token) {
        return new WP_Error('cfg', 'Missing Threads configuration (token).');
    }

    // Re-check after refresh/ensure.
    $uid = $this->get_user_id();
    if (!$uid) {
        $uid = $this->ensure_user_id($token);
        if (is_wp_error($uid)) {
            return $uid;
        }
    }
    if (!$uid) {
        return new WP_Error('cfg', 'Threads: could not detect the user_id. Please reconnect.');
    }

    $url = add_query_arg(
        [
            'fields'       => 'id,username',
            'access_token' => $token,
        ],
        'https://graph.threads.net/v1.0/me'
    );

    $resp = wp_remote_get($url, ['timeout' => 20]);

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

    $code = wp_remote_retrieve_response_code($resp);
    $body = wp_remote_retrieve_body($resp);

    if ($code !== 200) {
        $json = json_decode($body, true);
        $msg  = (is_array($json) && !empty($json['error']['message'])) ? $json['error']['message'] : $body;
        return new WP_Error('api', 'Error (' . $code . '): ' . $msg);
    }

    $json = json_decode($body, true);
    if (empty($json['id'])) {
        return new WP_Error('api', 'Invalid response when testing Threads.');
    }

    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) {
        $token = $this->get_token();

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

        // Refreshing and validating user_id on each publish helps avoid the common 400 response.
        // "The requested resource does not exist" usually means the stored user_id is stale.
        $uid = '';
        $me = $this->fetch_me($token);
        if (!is_wp_error($me)) {
            $uid = sanitize_text_field($me['id'] ?? '');
            if ($uid) {
                update_option('calliope_sp_threads_user_id', $uid);
            }
        }
        if (!$uid) {
            $uid = $this->get_user_id();
        }
        if (!$uid) {
            $uid = $this->ensure_user_id($token);
            if (is_wp_error($uid)) {
                return $uid;
            }
        }

        $this->maybe_refresh_token();
        $token = $this->get_token();
        if (!$token) {
            return new WP_Error('cfg', 'Missing Threads configuration (token).');
        }

        $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 true;
            }

            // 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) {
        $title = !empty($data['title']) ? trim((string)$data['title']) . "\n\n" : '';
        $content = trim((string)($data['content'] ?? ''));
        $link = !empty($data['link']) ? "\n\n" . trim((string)$data['link']) : '';
        $tags = !empty($data['hashtags']) ? preg_split('/\s+/', trim((string)$data['hashtags'])) : [];

        $base_len = mb_strlen($title) + mb_strlen($link);
        $avail = max(0, $limit - $base_len);

        $content_final = '';
        if ($content !== '') {
            if (mb_strlen($content) <= $avail) {
                $content_final = $content;
            } else {
                $content_final = mb_substr($content, 0, max(0, $avail - 3)) . '...';
            }
        }

        $rem = max(0, $avail - mb_strlen($content_final));
        $tags_final = '';
        foreach ($tags as $tag) {
            $tag = trim((string)$tag);
            if ($tag === '') continue;
            $prefix = ($tags_final === '') ? "\n\n" : ' ';
            if (mb_strlen($prefix . $tag) <= $rem) {
                $tags_final .= $prefix . $tag;
                $rem -= mb_strlen($prefix . $tag);
            } else {
                break;
            }
        }

        return $title . $content_final . $tags_final . $link;
    }
}
