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

/**
 * LinkedIn integration for the authenticated member's personal profile.
 * Company and organization publishing are intentionally outside Free.
 */
class Calliope_SP_LinkedIn {

    const VALIDATION_OPTION = 'calliope_sp_linkedin_validation';
    const VALIDATION_SCHEMA = 'linkedin-validation-v1';
    const API_VERSION = '202607';

    private $auth_base = 'https://www.linkedin.com/oauth/v2';
    private $api_base = 'https://api.linkedin.com';
    private $api_version = self::API_VERSION;

    private const MAX_IMAGE_PIXELS = 36152320;
    private const MAX_GIF_FRAMES = 250;
    private const MAX_ALT_TEXT_LENGTH = 4086;
    private const ARTICLE_THUMB_MAX_BYTES = 5 * 1024 * 1024;

    public function get_client_id() {
        return $this->option_string('calliope_sp_li_client_id');
    }

    public function get_client_secret() {
        return $this->option_string('calliope_sp_li_client_secret');
    }

    public function get_access_token() {
        return $this->option_string('calliope_sp_li_access_token');
    }

    public function get_expires_at() {
        return (int) get_option('calliope_sp_li_expires_at', 0);
    }

    public function get_refresh_token() {
        return $this->option_string('calliope_sp_li_refresh_token');
    }

    public function get_refresh_expires_at() {
        return (int) get_option('calliope_sp_li_refresh_expires_at', 0);
    }

    public function get_person_id() {
        return $this->option_string('calliope_sp_li_person_id');
    }

    public function get_redirect_uri() {
        return rest_url('calliope-social-publisher/v1/linkedin/callback');
    }

    public function get_api_version() {
        return self::API_VERSION;
    }

    public function has_saved_credentials() {
        return $this->get_client_id() !== '' && $this->get_client_secret() !== '';
    }

    public function sanitize_oauth_scope_mode($scope_mode, $target = null) {
        return 'member';
    }

    public function get_oauth_scope_list_for_target($target = null, $scope_mode = 'member') {
        return ['openid', 'profile', 'w_member_social'];
    }

    public function get_oauth_scopes_for_target($target = null, $scope_mode = 'member') {
        return implode(' ', $this->get_oauth_scope_list_for_target($target, $scope_mode));
    }

    public function get_oauth_scopes_for_saved_target() {
        return $this->get_oauth_scopes_for_target();
    }

    public function get_saved_scope_list() {
        return $this->normalize_scopes(get_option('calliope_sp_li_last_scope_list', []));
    }

    public function token_has_scopes($required_scopes) {
        $saved = $this->get_saved_scope_list();
        foreach ((array) $required_scopes as $scope) {
            if (!in_array((string) $scope, $saved, true)) {
                return false;
            }
        }
        return true;
    }

    public function get_application_fingerprint($client_id = '', $client_secret = '') {
        $client_id = $client_id !== '' ? trim((string) $client_id) : $this->get_client_id();
        $client_secret = $client_secret !== '' ? trim((string) $client_secret) : $this->get_client_secret();
        if ($client_id === '' || $client_secret === '') {
            return '';
        }
        return $this->fingerprint([
            'schema' => self::VALIDATION_SCHEMA,
            'client_id' => $client_id,
            'client_secret' => $client_secret,
            'redirect_uri' => $this->get_redirect_uri(),
            'api_version' => self::API_VERSION,
        ]);
    }

    public function get_connection_state() {
        if (!class_exists('Calliope_SP_Connection_State')) {
            return new WP_Error('linkedin_connection_contract_missing', 'LinkedIn connection state contract is unavailable.');
        }

        $client_id = $this->get_client_id();
        $client_secret = $this->get_client_secret();
        $token = $this->get_access_token();
        $expires_at = $this->get_expires_at();
        $target = $this->resolve_publish_target();
        $marker = $this->get_validation_marker();
        $configured = $client_id !== '' && $client_secret !== '';
        $partial = (($client_id !== '') xor ($client_secret !== ''));
        $authenticated = $token !== '';
        $has_any = $client_id !== '' || $client_secret !== '' || $authenticated || $this->get_person_id() !== '' || is_array($marker);

        if (!$has_any) {
            return $this->create_state('not_configured', 'unknown', false, false, false, false, false, 'E0',
                __('Add your LinkedIn Client ID and Client Secret, save, and then click Connect with LinkedIn.', 'calliope-social-publisher'),
                __('Save credentials', 'calliope-social-publisher'), 0, 0);
        }

        if ($partial || ($authenticated && !$configured)) {
            return $this->create_state('configuration_error', 'invalid', true, false, false, false, false, 'E1',
                __('LinkedIn Client ID and Client Secret must both be configured.', 'calliope-social-publisher'),
                __('Complete the configuration', 'calliope-social-publisher'), $expires_at, 0);
        }

        if (!$authenticated) {
            return $this->create_state('credentials_saved', 'unknown', true, false, false, false, false, 'E1',
                __('Credentials are saved. Click Connect with LinkedIn to finish the OAuth connection.', 'calliope-social-publisher'),
                __('Connect with LinkedIn', 'calliope-social-publisher'), 0, 0);
        }

        if ($expires_at > 0 && $expires_at <= time()) {
            return $this->create_state('token_expired', 'invalid', true, true, false, false, false, 'E2',
                __('The LinkedIn token expired. Please reconnect.', 'calliope-social-publisher'),
                __('Reconnect with LinkedIn', 'calliope-social-publisher'), $expires_at, 0);
        }

        if ($target['kind'] !== 'person') {
            $identity_known = false;
            $message = __('This LinkedIn connection is only for personal profiles. Company pages and business page URLs are not supported here.', 'calliope-social-publisher');
            if ($target['kind'] === 'none') {
                $message = __('The LinkedIn token is saved, but this connection has not been verified for publishing. Click Save and test.', 'calliope-social-publisher');
            }
            return $this->create_state('connected_unverified', 'unknown', true, true, $identity_known, false, false, 'E2',
                $message, __('Save and test', 'calliope-social-publisher'), $expires_at, 0);
        }

        if (!$this->marker_matches_current($marker)) {
            return $this->create_state('connected_unverified', 'unknown', true, true, false, false, false, 'E2',
                __('The LinkedIn token is saved, but this connection has not been verified for publishing. Click Save and test.', 'calliope-social-publisher'),
                __('Save and test', 'calliope-social-publisher'), $expires_at, 0);
        }

        $failure = (string) ($marker['failure_kind'] ?? '');
        $validated_at = (int) ($marker['validated_at'] ?? 0);
        if ($failure === 'auth') {
            return $this->create_state('token_invalid', 'invalid', true, true, false, false, false, 'E2',
                __('LinkedIn rejected the saved access token. Reconnect LinkedIn and test the connection again.', 'calliope-social-publisher'),
                __('Reconnect with LinkedIn', 'calliope-social-publisher'), $expires_at, $validated_at);
        }
        if ($failure === 'permission') {
            return $this->create_state('permission_missing', 'invalid', true, true, true, true, false, 'E4',
                __('The token is saved, but publishing permission could not be verified. Connect with LinkedIn through OAuth to confirm w_member_social.', 'calliope-social-publisher'),
                __('Reconnect with LinkedIn', 'calliope-social-publisher'), $expires_at, $validated_at);
        }
        if ($failure === 'identity') {
            return $this->create_state('identity_missing', 'invalid', true, true, false, false, false, 'E2',
                __('LinkedIn could not verify the connected personal profile.', 'calliope-social-publisher'),
                __('Reconnect with LinkedIn', 'calliope-social-publisher'), $expires_at, $validated_at);
        }
        if ($failure === 'target') {
            return $this->create_state('target_missing', 'invalid', true, true, true, false, false, 'E3',
                __('LinkedIn could not verify a valid personal profile target.', 'calliope-social-publisher'),
                __('Save and test', 'calliope-social-publisher'), $expires_at, $validated_at);
        }

        if (($marker['permission_state'] ?? '') !== 'granted' || !$this->scopes_are_exact_member($this->get_saved_scope_list())) {
            return $this->create_state('permission_missing', 'invalid', true, true, true, true, false, 'E4',
                __('The token is saved, but publishing permission could not be verified. Connect with LinkedIn through OAuth to confirm w_member_social.', 'calliope-social-publisher'),
                __('Reconnect with LinkedIn', 'calliope-social-publisher'), $expires_at, $validated_at);
        }

        $health = (($marker['health'] ?? '') === 'degraded') ? 'degraded' : 'healthy';
        return $this->create_state('ready_to_publish', $health, true, true, true, true, true, 'E6',
            __('LinkedIn personal profile is ready to publish.', 'calliope-social-publisher'), '', $expires_at, $validated_at);
    }

    public function resolve_publish_target($raw = '') {
        $raw = trim(wp_strip_all_tags((string) ($raw !== '' ? $raw : $this->get_person_id())));
        if ($raw === '') {
            return ['kind' => 'none', 'normalized' => '', 'id' => '', 'raw' => ''];
        }
        if (preg_match('#^urn:li:person:([A-Za-z0-9_-]+)$#D', $raw, $m)) {
            return ['kind' => 'person', 'normalized' => 'urn:li:person:' . $m[1], 'id' => $m[1], 'raw' => $raw];
        }
        if (preg_match('#^urn:li:(?:organization|company):#i', $raw) || ctype_digit($raw) || preg_match('~linkedin\.com/company/~i', $raw)) {
            return ['kind' => 'organization', 'normalized' => '', 'id' => '', 'raw' => $raw];
        }
        if (preg_match('~linkedin\.com/in/~i', $raw)) {
            return ['kind' => 'profile_url', 'normalized' => '', 'id' => '', 'raw' => $raw];
        }
        return ['kind' => 'invalid', 'normalized' => '', 'id' => '', 'raw' => $raw];
    }

    public function is_organization_target() {
        return $this->resolve_publish_target()['kind'] === 'organization';
    }

    public function get_publish_target_label() {
        return $this->resolve_publish_target()['kind'] === 'person' ? __('LinkedIn profile', 'calliope-social-publisher') : '';
    }

    public function build_posts_author_urn() {
        return $this->get_resolved_target_urn();
    }

    public function get_resolved_target_urn() {
        $state = $this->get_connection_state();
        if (is_wp_error($state) || !$state->is_ready()) {
            return '';
        }
        $marker = $this->get_validation_marker();
        $target = is_array($marker) ? (string) ($marker['resolved_target_person_urn'] ?? '') : '';
        return $this->normalize_person_urn($target);
    }

    public function validate_oauth_candidate($access_token, $expires_at, $scopes, $refresh_token = '', $refresh_expires_at = 0) {
        $access_token = $this->normalize_token($access_token);
        $expires_at = (int) $expires_at;
        $scopes = $this->normalize_scopes($scopes);
        if ($access_token === '' || $expires_at <= time()) {
            return new WP_Error('li_token_candidate_invalid', __('LinkedIn returned an invalid or expired access token.', 'calliope-social-publisher'), ['failure_kind' => 'auth', 'retryable' => false]);
        }
        if (!$this->scopes_are_exact_member($scopes)) {
            return new WP_Error('li_scope_missing', __('The token is saved, but publishing permission could not be verified. Connect with LinkedIn through OAuth to confirm w_member_social.', 'calliope-social-publisher'), ['failure_kind' => 'permission', 'retryable' => false]);
        }
        $identity = $this->request_userinfo($access_token);
        if (is_wp_error($identity)) {
            return $identity;
        }
        $refresh_token = $this->normalize_token($refresh_token);
        $refresh_expires_at = (int) $refresh_expires_at;
        if (($refresh_token === '') xor ($refresh_expires_at <= 0)) {
            return new WP_Error('li_refresh_candidate_invalid', __('LinkedIn returned incomplete refresh lifecycle data.', 'calliope-social-publisher'), ['failure_kind'=>'auth','retryable'=>false]);
        }
        if ($refresh_token !== '' && $refresh_expires_at <= time()) {
            return new WP_Error('li_refresh_candidate_expired', __('LinkedIn returned an expired refresh token.', 'calliope-social-publisher'), ['failure_kind'=>'auth','retryable'=>false]);
        }
        $marker = $this->build_validation_marker($access_token, $expires_at, $scopes, $identity['sub'], $identity['person_urn'], $refresh_token, $refresh_expires_at);
        if (is_wp_error($marker)) {
            return $marker;
        }
        return [
            'access_token' => $access_token,
            'expires_at' => $expires_at,
            'scopes' => $scopes,
            'person_urn' => $identity['person_urn'],
            'refresh_token' => $refresh_token,
            'refresh_expires_at' => $refresh_expires_at,
            'marker' => $marker,
        ];
    }

    public function validate_connection_remote() {
        $token = $this->get_access_token();
        $expires_at = $this->get_expires_at();
        $scopes = $this->get_saved_scope_list();
        if ($token === '') {
            return new WP_Error('cfg', __('Missing LinkedIn Access Token. Connect with LinkedIn first.', 'calliope-social-publisher'));
        }
        if ($expires_at <= time()) {
            return new WP_Error('expired', __('The LinkedIn token expired. Please reconnect.', 'calliope-social-publisher'), ['failure_kind' => 'auth', 'retryable' => false]);
        }
        $candidate = $this->validate_oauth_candidate($token, $expires_at, $scopes, $this->get_refresh_token(), $this->get_refresh_expires_at());
        if (is_wp_error($candidate)) {
            return $candidate;
        }
        $current = $this->resolve_publish_target();
        if ($current['kind'] !== 'none' && ($current['kind'] !== 'person' || !hash_equals($current['normalized'], $candidate['person_urn']))) {
            return new WP_Error('li_target_invalid', __('LinkedIn returned a different personal profile than the configured target.', 'calliope-social-publisher'), ['failure_kind' => 'target', 'retryable' => false]);
        }
        return $candidate;
    }

    public function test_connection() {
        $lifecycle = $this->maybe_refresh_access_token();
        if (is_wp_error($lifecycle)) return $lifecycle;
        return $this->validate_connection_remote();
    }

    /**
     * Refresh only sessions for which LinkedIn officially issued a refresh token.
     * Apps outside LinkedIn's approved refresh program continue to reconnect at expiry.
     *
     * @return true|WP_Error
     */
    public function maybe_refresh_access_token($threshold_seconds = null) {
        $threshold_seconds = $threshold_seconds === null && class_exists('Calliope_SP_Master')
            ? Calliope_SP_Master::get_lifecycle_refresh_threshold_seconds()
            : max(0,(int)$threshold_seconds);
        $token=$this->get_access_token(); $expires_at=$this->get_expires_at(); $refresh=$this->get_refresh_token(); $refresh_expires=$this->get_refresh_expires_at(); $now=time();
        if($token==='') return new WP_Error('li_refresh_missing_token',__('Missing LinkedIn Access Token. Connect with LinkedIn first.','calliope-social-publisher'),['failure_kind'=>'auth','retryable'=>false]);
        if($expires_at>($now+$threshold_seconds)) return true;
        if($refresh===''||$refresh_expires<=0){
            if($expires_at>$now) return true;
            return new WP_Error('li_refresh_unavailable',__('The LinkedIn token expired. Please reconnect.','calliope-social-publisher'),['failure_kind'=>'auth','retryable'=>false]);
        }
        if($refresh_expires<=$now) return new WP_Error('li_refresh_expired',__('The LinkedIn renewal credential expired. Please reconnect.','calliope-social-publisher'),['failure_kind'=>'auth','retryable'=>false]);

        $marker=$this->get_validation_marker();
        if(!$this->marker_matches_current($marker)) return new WP_Error('li_refresh_unverified',__('Verify LinkedIn again before renewing the connection.','calliope-social-publisher'),['failure_kind'=>'auth','retryable'=>false]);
        $lock=class_exists('Calliope_SP_Master')?Calliope_SP_Master::acquire_lifecycle_refresh_lock('linkedin'):true;
        if(is_wp_error($lock)) return $this->get_expires_at()>(time()+$threshold_seconds)?true:$lock;
        try{
            $token=$this->get_access_token();$expires_at=$this->get_expires_at();$refresh=$this->get_refresh_token();$refresh_expires=$this->get_refresh_expires_at();$now=time();
            if($expires_at>($now+$threshold_seconds)) return true;
            if($refresh===''||$refresh_expires<=$now) return new WP_Error('li_refresh_unavailable',__('The LinkedIn connection must be reauthorized.','calliope-social-publisher'),['failure_kind'=>'auth','retryable'=>false]);
            $response=wp_remote_post($this->auth_base.'/accessToken',[
                'timeout'=>30,'redirection'=>0,'reject_unsafe_urls'=>true,'sslverify'=>true,
                'headers'=>['Accept'=>'application/json','Content-Type'=>'application/x-www-form-urlencoded'],
                'body'=>['grant_type'=>'refresh_token','refresh_token'=>$refresh,'client_id'=>$this->get_client_id(),'client_secret'=>$this->get_client_secret()],
            ]);
            if(is_wp_error($response)){if($expires_at>$now){$this->mark_degraded();return true;}return $this->build_transport_error('refresh',$response);}
            $http=(int)wp_remote_retrieve_response_code($response);$json=json_decode((string)wp_remote_retrieve_body($response),true);
            if($http===429||$http>=500){if($expires_at>$now){$this->mark_degraded();return true;}return new WP_Error('li_refresh_temporary',__('LinkedIn could not renew the token right now.','calliope-social-publisher'),['http_code'=>$http,'failure_kind'=>'temporary','retryable'=>true]);}
            if($http<200||$http>=300||!is_array($json)){$this->mark_failure('auth');return new WP_Error('li_refresh_rejected',__('LinkedIn rejected token renewal. Please reconnect.','calliope-social-publisher'),['http_code'=>$http,'failure_kind'=>'auth','retryable'=>false]);}
            $new_token=$this->normalize_token($json['access_token']??'');$expires_in=isset($json['expires_in'])&&is_numeric($json['expires_in'])?(int)$json['expires_in']:0;
            $new_refresh=$this->normalize_token($json['refresh_token']??'');$refresh_in=isset($json['refresh_token_expires_in'])&&is_numeric($json['refresh_token_expires_in'])?(int)$json['refresh_token_expires_in']:0;
            $scopes=array_key_exists('scope',$json)?$this->normalize_scopes($json['scope']):$this->get_saved_scope_list();
            if($new_token===''||$expires_in<=0||$new_refresh===''||$refresh_in<=0||!$this->scopes_are_exact_member($scopes)) return new WP_Error('li_refresh_invalid_response',__('LinkedIn returned an incomplete token renewal response.','calliope-social-publisher'),['http_code'=>$http,'failure_kind'=>'temporary','retryable'=>true]);
            $new_expires=$now+$expires_in;$new_refresh_expires=$now+$refresh_in;
            $candidate=$this->validate_oauth_candidate($new_token,$new_expires,$scopes,$new_refresh,$new_refresh_expires);
            if(is_wp_error($candidate)) return $candidate;
            $target=$this->resolve_publish_target();
            if($target['kind']!=='person'||!hash_equals($target['normalized'],$candidate['person_urn'])) return new WP_Error('li_refresh_target',__('LinkedIn returned a different personal profile during renewal.','calliope-social-publisher'),['failure_kind'=>'target','retryable'=>false]);
            $snapshot=$this->capture_lifecycle_snapshot();
            if(!$this->commit_lifecycle_candidate($candidate)){$this->restore_lifecycle_snapshot($snapshot);return new WP_Error('li_refresh_commit',__('LinkedIn token renewal could not be committed safely.','calliope-social-publisher'),['failure_kind'=>'temporary','retryable'=>true]);}
            return true;
        }finally{if(class_exists('Calliope_SP_Master')&&is_string($lock))Calliope_SP_Master::release_lifecycle_refresh_lock('linkedin',$lock);}
    }

    public function save_validation_marker(array $marker) {
        if (!$this->marker_is_structurally_valid($marker)) {
            return new WP_Error('li_validation_marker_invalid', __('LinkedIn could not save a valid connection proof.', 'calliope-social-publisher'));
        }
        update_option(self::VALIDATION_OPTION, $marker, false);
        $this->clear_legacy_validation_options();
        return true;
    }

    public function delete_validation_marker() {
        delete_option(self::VALIDATION_OPTION);
        $this->clear_legacy_validation_options();
    }

    public function invalidate_validation($status = '') {
        if ($status === 'token_invalid') {
            $this->mark_failure('auth');
            return;
        }
        $this->delete_validation_marker();
    }

    public function mark_token_invalid() {
        $this->mark_failure('auth');
    }

    public function mark_failure($failure_kind) {
        $failure_kind = sanitize_key((string) $failure_kind);
        if (!in_array($failure_kind, ['auth', 'permission', 'identity', 'target'], true)) {
            return;
        }
        $marker = $this->get_validation_marker();
        if (!$this->marker_matches_current($marker)) {
            return;
        }
        $marker['failure_kind'] = $failure_kind;
        $marker['health'] = 'invalid';
        update_option(self::VALIDATION_OPTION, $marker, false);
    }

    public function mark_degraded() {
        $marker = $this->get_validation_marker();
        if (!$this->marker_matches_current($marker) || !empty($marker['failure_kind'])) {
            return;
        }
        $marker['health'] = 'degraded';
        update_option(self::VALIDATION_OPTION, $marker, false);
    }

    public function publish($data) {
        $lifecycle = $this->maybe_refresh_access_token();
        if (is_wp_error($lifecycle)) return $lifecycle;
        $state = $this->get_connection_state();
        if (is_wp_error($state)) {
            return $state;
        }
        if (!$state->is_ready()) {
            return new WP_Error('network_not_ready', $state->get('message', __('The connection is not ready.', 'calliope-social-publisher')), [
                'readiness' => $state->get_status(),
                'retryable' => false,
            ]);
        }

        $token = $this->get_access_token();
        $author = $this->get_resolved_target_urn();
        if ($author === '') {
            return new WP_Error('li_target_invalid', __('LinkedIn could not verify a valid personal profile target.', 'calliope-social-publisher'));
        }

        $youtube_link_source = $this->is_youtube_link_source_payload((array) $data);
        $youtube_link_url = $youtube_link_source ? $this->youtube_link_url_from_payload((array) $data) : '';
        if ($youtube_link_source && $youtube_link_url === '') {
            return new WP_Error(
                'li_youtube_link_invalid',
                __('LinkedIn requires a valid YouTube URL to publish a YouTube link preview. No text-only fallback was sent.', 'calliope-social-publisher'),
                ['phase' => 'precheck', 'retryable' => false]
            );
        }

        $text = $this->format_text($data, 2900);
        $link = !empty($data['link']) ? esc_url_raw($data['link']) : '';
        $article = [];
        if ($youtube_link_url !== '') {
            $article = $this->build_youtube_article_content((array) $data, $youtube_link_url);
            if ($text === '' && !empty($article['title'])) {
                $text = (string) $article['title'];
            }
            $link = '';
        } elseif ($link !== '' && mb_strpos($text, $link) === false) {
            $text = trim($text . "\n\n" . $link);
        }

        $media_mode = sanitize_key((string) ($data['media_mode'] ?? 'none'));
        $multi_images = [];
        $image_urn = '';
        if ($article === [] && $media_mode === 'multiple_images') {
            $assets = $this->multi_image_assets_from_payload((array) $data);
            if (is_wp_error($assets)) {
                return $assets;
            }
            foreach ($assets as $asset) {
                $uploaded = $this->upload_image($author, (string) $asset['image_b64']);
                if (is_wp_error($uploaded)) {
                    return $uploaded;
                }
                $item = ['id' => $uploaded];
                $alt = $this->normalize_image_alt_text((string) ($asset['alt'] ?? ''));
                if ($alt !== '') {
                    $item['altText'] = $alt;
                }
                $multi_images[] = $item;
            }
        } elseif ($article === [] && !empty($data['image_b64'])) {
            $image_urn = $this->upload_image($author, (string) $data['image_b64']);
            if (is_wp_error($image_urn)) {
                return $image_urn;
            }
        }

        if ($text === '' && $image_urn === '' && $multi_images === [] && $article === []) {
            return new WP_Error('li_content_empty', __('Add text, a link, or an image before publishing to LinkedIn.', 'calliope-social-publisher'), ['retryable' => false]);
        }

        $payload = [
            'author' => $author,
            'commentary' => $text,
            'visibility' => 'PUBLIC',
            'distribution' => [
                'feedDistribution' => 'MAIN_FEED',
                'targetEntities' => [],
                'thirdPartyDistributionChannels' => [],
            ],
            'lifecycleState' => 'PUBLISHED',
            'isReshareDisabledByAuthor' => false,
        ];
        if ($multi_images !== []) {
            $payload['content'] = ['multiImage' => ['images' => $multi_images]];
        } elseif ($image_urn !== '') {
            $alt = !empty($data['alt_text']) ? (string) $data['alt_text'] : (!empty($data['title']) ? (string) $data['title'] : '');
            $payload['content'] = ['media' => ['id' => $image_urn, 'altText' => $this->normalize_image_alt_text($alt)]];
        } elseif ($article !== []) {
            $thumbnail_urn = $this->upload_youtube_article_thumbnail($author, $youtube_link_url);
            if (is_wp_error($thumbnail_urn)) {
                return $thumbnail_urn;
            }
            $article['thumbnail'] = $thumbnail_urn;
            $payload['content'] = ['article' => $article];
            $this->log_youtube_link_diagnostics('article_prepared', [
                'source' => $article['source'],
                'has_title' => $article['title'] !== '',
                'has_description' => $article['description'] !== '',
                'has_thumbnail' => $article['thumbnail'] !== '',
            ]);
        }

        $response = wp_remote_post($this->api_base . '/rest/posts', [
            'timeout' => 25,
            'redirection' => 0,
            'reject_unsafe_urls' => true,
            'headers' => $this->rest_headers($token),
            'body' => wp_json_encode($payload),
        ]);
        if (is_wp_error($response)) {
            $this->mark_degraded();
            return $this->build_transport_error('publish', $response);
        }

        $http = (int) wp_remote_retrieve_response_code($response);
        $post_urn = trim((string) wp_remote_retrieve_header($response, 'x-restli-id'));
        if ($http !== 201 || !$this->is_valid_post_urn($post_urn)) {
            $this->apply_http_failure_to_marker($http);
            return new WP_Error('li_publish_invalid_response', sprintf(
                /* translators: %d: HTTP status code returned by LinkedIn. */
                __('LinkedIn could not publish the post (HTTP %d).', 'calliope-social-publisher'),
                $http
            ), ['http_code' => $http, 'phase' => 'publish', 'retryable' => $this->is_retryable_http_status($http)]);
        }
        if ($multi_images !== []) {
            return [
                'ok' => true,
                'post_urn' => sanitize_text_field($post_urn),
                'media_type' => 'multi_image',
                'attachments' => count($multi_images),
            ];
        }
        return [
            'ok' => true,
            'post_urn' => sanitize_text_field($post_urn),
        ];
    }

    /** @return array<int,array{image_b64:string,alt:string}>|WP_Error */
    private function multi_image_assets_from_payload(array $data) {
        if (!class_exists('Calliope_SPP_Media_Set') || !class_exists('Calliope_SPP_Media_Resolver')) {
            return new WP_Error('li_multi_image_requires_pro', __('LinkedIn multiple-image publishing requires Social Publisher Pro media support.', 'calliope-social-publisher'), ['retryable' => false]);
        }
        $media = isset($data['media']) && is_array($data['media']) ? $data['media'] : [];
        $normalized = array_values(array_filter(
            Calliope_SPP_Media_Set::normalize($media),
            static fn(array $item): bool => (string) ($item['type'] ?? '') === 'image'
        ));
        if (count($normalized) < 2 || count($normalized) > 10) {
            return new WP_Error('li_multi_image_invalid', __('LinkedIn multiple-image publishing requires 2 to 10 images.', 'calliope-social-publisher'), ['retryable' => false]);
        }
        $contract = Calliope_SPP_Media_Set::validate_contract('multiple_images', $normalized);
        if (is_wp_error($contract)) {
            return new WP_Error('li_multi_image_invalid', __('LinkedIn multiple-image publishing requires 2 to 10 images.', 'calliope-social-publisher'), ['retryable' => false]);
        }
        $resolved = Calliope_SPP_Media_Resolver::resolve($normalized, [
            'allowed_image_mimes' => ['image/jpeg', 'image/png', 'image/gif'],
            'allowed_video_mimes' => [],
            'log_context' => ['network' => 'linkedin', 'mode' => 'multiple_images'],
        ]);
        if (is_wp_error($resolved)) {
            return new WP_Error('li_multi_image_media_error', sprintf(
                /* translators: %s: media preparation error. */
                __('LinkedIn could not prepare the selected images. %s', 'calliope-social-publisher'),
                $resolved->get_error_message()
            ), ['retryable' => false]);
        }
        $assets = [];
        foreach ($resolved as $file) {
            $path = (string) ($file['path'] ?? '');
            $bytes = $path !== '' && is_file($path) && is_readable($path) ? file_get_contents($path) : false;
            if (!is_string($bytes) || $bytes === '') {
                return new WP_Error('li_multi_image_read_failed', __('LinkedIn could not read one of the selected images.', 'calliope-social-publisher'), ['retryable' => false]);
            }
            $mime = sanitize_mime_type((string) ($file['mime_type'] ?? ''));
            $assets[] = [
                'image_b64' => 'data:' . $mime . ';base64,' . base64_encode($bytes),
                'alt' => sanitize_text_field((string) ($file['description'] ?? '')),
            ];
        }
        return $assets;
    }

    private function upload_image($owner_urn, $image_b64) {
        $image = $this->prepare_image_for_upload($image_b64);
        if (is_wp_error($image)) {
            return $image;
        }
        $token = $this->get_access_token();
        $init = wp_remote_post($this->api_base . '/rest/images?action=initializeUpload', [
            'timeout' => 25,
            'redirection' => 0,
            'reject_unsafe_urls' => true,
            'headers' => $this->rest_headers($token),
            'body' => wp_json_encode(['initializeUploadRequest' => ['owner' => $owner_urn]]),
        ]);
        if (is_wp_error($init)) {
            $this->mark_degraded();
            return $this->build_transport_error('image_initialize', $init);
        }
        $http = (int) wp_remote_retrieve_response_code($init);
        $raw = (string) wp_remote_retrieve_body($init);
        $json = json_decode($raw, true);
        if ($http !== 200) {
            $this->apply_http_failure_to_marker($http);
            return new WP_Error('li_image_initialize_http', sprintf(
                /* translators: %d: HTTP status code returned by LinkedIn. */
                __('LinkedIn could not prepare the image upload (HTTP %d).', 'calliope-social-publisher'), $http
            ), ['http_code' => $http, 'phase' => 'image_initialize', 'retryable' => $this->is_retryable_http_status($http), 'mime' => $image['mime']]);
        }
        $value = is_array($json) && isset($json['value']) && is_array($json['value']) ? $json['value'] : [];
        $upload_url = isset($value['uploadUrl']) && is_string($value['uploadUrl']) ? trim($value['uploadUrl']) : '';
        $image_urn = isset($value['image']) && is_string($value['image']) ? trim($value['image']) : '';
        if ($upload_url === '' || !$this->is_valid_image_urn($image_urn)) {
            return $this->invalid_initialize_response_error($image['mime']);
        }
        if (array_key_exists('uploadUrlExpiresAt', $value)) {
            if (!is_numeric($value['uploadUrlExpiresAt']) || (float) $value['uploadUrlExpiresAt'] <= (float) (time() * 1000)) {
                return $this->invalid_initialize_response_error($image['mime']);
            }
        }
        $validated = $this->validate_image_upload_url($upload_url);
        if (is_wp_error($validated)) {
            return $validated;
        }
        $put = wp_remote_request($validated['url'], [
            'method' => 'PUT',
            'timeout' => 60,
            'redirection' => 0,
            'reject_unsafe_urls' => true,
            'headers' => ['Authorization' => 'Bearer ' . $token, 'Content-Type' => $image['mime']],
            'body' => $image['bytes'],
        ]);
        if (is_wp_error($put)) {
            $this->mark_degraded();
            return $this->build_transport_error('image_upload', $put);
        }
        $put_http = (int) wp_remote_retrieve_response_code($put);
        if ($put_http < 200 || $put_http >= 300) {
            $this->apply_http_failure_to_marker($put_http);
            return new WP_Error('li_image_upload_http', sprintf(
                /* translators: %d: HTTP status code returned by LinkedIn. */
                __('LinkedIn could not upload the image (HTTP %d).', 'calliope-social-publisher'), $put_http
            ), ['http_code' => $put_http, 'phase' => 'image_upload', 'retryable' => $this->is_retryable_http_status($put_http), 'mime' => $image['mime'], 'host' => $validated['host']]);
        }
        return $image_urn;
    }

    private function prepare_image_for_upload($image_b64) {
        $input = trim((string) $image_b64);
        if ($input === '' || strpos($input, "\0") !== false) {
            return $this->image_error('li_image_invalid_base64', __('The selected image data is invalid. Please choose the image again.', 'calliope-social-publisher'));
        }

        $payload = $input;
        $source_mime = '';
        if (stripos($input, 'data:') === 0) {
            $comma = strpos($input, ',');
            if ($comma === false) {
                return $this->image_error('li_image_invalid_base64', __('The selected image data is invalid. Please choose the image again.', 'calliope-social-publisher'));
            }

            $metadata = trim(substr($input, 0, $comma));
            $payload = substr($input, $comma + 1);
            if (!preg_match('#^data:([^;,]+);base64$#i', $metadata, $matches)) {
                return $this->image_error('li_image_invalid_base64', __('The selected image data is invalid. Please choose the image again.', 'calliope-social-publisher'));
            }
            $source_mime = $this->normalize_image_mime((string) $matches[1]);
            if (strpos($source_mime, 'image/') !== 0) {
                return $this->image_error('li_image_unsupported_mime', __('LinkedIn supports JPG, PNG, and GIF images. Please choose one of those formats.', 'calliope-social-publisher'), [
                    'mime' => $source_mime,
                ]);
            }
        }

        $payload = preg_replace('/\s+/u', '', (string) $payload);
        if ($payload === '') {
            return $this->image_error('li_image_invalid_base64', __('The selected image data is invalid. Please choose the image again.', 'calliope-social-publisher'));
        }

        $bytes = base64_decode($payload, true);
        if ($bytes === false || $bytes === '') {
            return $this->image_error('li_image_invalid_base64', __('The selected image data is invalid. Please choose the image again.', 'calliope-social-publisher'));
        }

        $inspection = $this->inspect_image_bytes($bytes);
        if (is_wp_error($inspection)) {
            return $inspection;
        }

        if ($inspection['mime'] === 'image/webp') {
            $converted = $this->convert_webp_to_jpeg($bytes);
            if (is_wp_error($converted)) {
                return $converted;
            }
            $bytes = $converted;
            $inspection = $this->inspect_image_bytes($bytes);
            if (is_wp_error($inspection) || $inspection['mime'] !== 'image/jpeg') {
                return $this->image_error('li_image_conversion_failed', __('This server could not convert the WebP image for LinkedIn. Please use JPG or PNG.', 'calliope-social-publisher'));
            }
            $inspection['converted'] = true;
        }

        if (!in_array($inspection['mime'], ['image/jpeg', 'image/png', 'image/gif'], true)) {
            return $this->image_error('li_image_unsupported_mime', __('LinkedIn supports JPG, PNG, and GIF images. Please choose one of those formats.', 'calliope-social-publisher'), [
                'mime' => $inspection['mime'],
            ]);
        }

        if ($inspection['mime'] === 'image/gif') {
            $frames = $this->count_gif_frames($bytes);
            if ($frames < 1) {
                return $this->image_error('li_image_invalid', __('The selected file is not a valid image for LinkedIn.', 'calliope-social-publisher'));
            }
            if ($frames > self::MAX_GIF_FRAMES) {
                return $this->image_error('li_image_too_many_frames', __('The selected GIF has too many frames for LinkedIn.', 'calliope-social-publisher'), [
                    'mime' => 'image/gif',
                ]);
            }
        }

        $inspection['bytes'] = $bytes;
        $inspection['size_bytes'] = strlen($bytes);
        $inspection['source_mime'] = $source_mime;
        $inspection['extension'] = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif'][$inspection['mime']];
        if (!isset($inspection['converted'])) {
            $inspection['converted'] = false;
        }

        return $inspection;
    }

    private function inspect_image_bytes($bytes) {
        if (!is_string($bytes) || $bytes === '') {
            return $this->image_error('li_image_invalid', __('The selected file is not a valid image for LinkedIn.', 'calliope-social-publisher'));
        }

        $info = @getimagesizefromstring($bytes);
        if (!is_array($info)) {
            return $this->image_error('li_image_invalid', __('The selected file is not a valid image for LinkedIn.', 'calliope-social-publisher'));
        }

        $width = isset($info[0]) ? (int) $info[0] : 0;
        $height = isset($info[1]) ? (int) $info[1] : 0;
        $mime = !empty($info['mime']) ? $this->normalize_image_mime((string) $info['mime']) : '';

        if ($mime === '' && class_exists('finfo')) {
            $finfo = new finfo(FILEINFO_MIME_TYPE);
            $detected = $finfo->buffer($bytes);
            if (is_string($detected)) {
                $mime = $this->normalize_image_mime($detected);
            }
        }

        if ($width <= 0 || $height <= 0) {
            return $this->image_error('li_image_invalid', __('The selected file is not a valid image for LinkedIn.', 'calliope-social-publisher'));
        }

        if ($height > 0 && $width > intdiv(self::MAX_IMAGE_PIXELS - 1, $height)) {
            return $this->image_error('li_image_too_many_pixels', __('The selected image is too large for LinkedIn. Please use an image with fewer pixels.', 'calliope-social-publisher'), [
                'mime' => $mime,
            ]);
        }

        $pixel_count = $width * $height;
        if ($pixel_count >= self::MAX_IMAGE_PIXELS) {
            return $this->image_error('li_image_too_many_pixels', __('The selected image is too large for LinkedIn. Please use an image with fewer pixels.', 'calliope-social-publisher'), [
                'mime' => $mime,
            ]);
        }

        return [
            'mime' => $mime,
            'width' => $width,
            'height' => $height,
            'pixel_count' => $pixel_count,
        ];
    }

    private function normalize_image_mime($mime) {
        $mime = strtolower(trim((string) $mime));
        if (in_array($mime, ['image/jpg', 'image/pjpeg'], true)) {
            return 'image/jpeg';
        }
        return $mime;
    }

    private function convert_webp_to_jpeg($bytes) {
        if ((!function_exists('imagecreatefromstring') || !function_exists('imagecreatetruecolor') || !function_exists('imagejpeg')) && class_exists('Imagick')) {
            try {
                $image = new Imagick();
                $image->readImageBlob($bytes);
                $image->setImageBackgroundColor('white');
                if (method_exists($image, 'setImageAlphaChannel')) {
                    $image->setImageAlphaChannel(Imagick::ALPHACHANNEL_REMOVE);
                }
                $flattened = $image->mergeImageLayers(Imagick::LAYERMETHOD_FLATTEN);
                $flattened->setImageFormat('jpeg');
                $flattened->setImageCompressionQuality(90);
                $jpeg = $flattened->getImagesBlob();
                $flattened->clear();
                $flattened->destroy();
                $image->clear();
                $image->destroy();
                if (is_string($jpeg) && $jpeg !== '') {
                    return $jpeg;
                }
            } catch (Throwable $error) {
                // Fall through to the safe, translated conversion error below.
            }
        }

        if (!function_exists('imagecreatefromstring') || !function_exists('imagecreatetruecolor') || !function_exists('imagejpeg')) {
            return $this->image_error('li_image_conversion_failed', __('This server could not convert the WebP image for LinkedIn. Please use JPG or PNG.', 'calliope-social-publisher'));
        }

        $source = @imagecreatefromstring($bytes);
        if (!$source) {
            return $this->image_error('li_image_conversion_failed', __('This server could not convert the WebP image for LinkedIn. Please use JPG or PNG.', 'calliope-social-publisher'));
        }

        $width = imagesx($source);
        $height = imagesy($source);
        $target = ($width > 0 && $height > 0) ? imagecreatetruecolor($width, $height) : false;
        if (!$target) {
            imagedestroy($source);
            return $this->image_error('li_image_conversion_failed', __('This server could not convert the WebP image for LinkedIn. Please use JPG or PNG.', 'calliope-social-publisher'));
        }

        $white = imagecolorallocate($target, 255, 255, 255);
        imagefill($target, 0, 0, $white);
        imagealphablending($target, true);
        imagecopy($target, $source, 0, 0, 0, 0, $width, $height);

        ob_start();
        $ok = imagejpeg($target, null, 90);
        $jpeg = ob_get_clean();
        imagedestroy($target);
        imagedestroy($source);

        if (!$ok || !is_string($jpeg) || $jpeg === '') {
            return $this->image_error('li_image_conversion_failed', __('This server could not convert the WebP image for LinkedIn. Please use JPG or PNG.', 'calliope-social-publisher'));
        }

        return $jpeg;
    }

    private function count_gif_frames($bytes) {
        $length = strlen($bytes);
        if ($length < 14 || (substr($bytes, 0, 6) !== 'GIF87a' && substr($bytes, 0, 6) !== 'GIF89a')) {
            return -1;
        }

        $packed = ord($bytes[10]);
        $offset = 13;
        if (($packed & 0x80) !== 0) {
            $offset += 3 * (2 << ($packed & 0x07));
        }

        $frames = 0;
        while ($offset < $length) {
            $marker = ord($bytes[$offset]);
            $offset++;

            if ($marker === 0x3B) {
                return $frames;
            }

            if ($marker === 0x21) {
                if ($offset >= $length) {
                    return -1;
                }
                $offset++;
                if (!$this->skip_gif_sub_blocks($bytes, $offset, $length)) {
                    return -1;
                }
                continue;
            }

            if ($marker === 0x2C) {
                $frames++;
                if ($frames > self::MAX_GIF_FRAMES) {
                    return $frames;
                }
                if ($offset + 9 > $length) {
                    return -1;
                }
                $image_packed = ord($bytes[$offset + 8]);
                $offset += 9;
                if (($image_packed & 0x80) !== 0) {
                    $offset += 3 * (2 << ($image_packed & 0x07));
                }
                if ($offset >= $length) {
                    return -1;
                }
                $offset++;
                if (!$this->skip_gif_sub_blocks($bytes, $offset, $length)) {
                    return -1;
                }
                continue;
            }

            return -1;
        }

        return -1;
    }

    private function skip_gif_sub_blocks($bytes, &$offset, $length) {
        while ($offset < $length) {
            $size = ord($bytes[$offset]);
            $offset++;
            if ($size === 0) {
                return true;
            }
            if ($offset + $size > $length) {
                return false;
            }
            $offset += $size;
        }
        return false;
    }


    private function request_userinfo($token) {
        $response = wp_remote_get($this->api_base . '/v2/userinfo', [
            'timeout' => 20,
            'redirection' => 0,
            'reject_unsafe_urls' => true,
            'headers' => ['Authorization' => 'Bearer ' . $token, 'Accept' => 'application/json'],
        ]);
        if (is_wp_error($response)) {
            return $this->build_transport_error('identity', $response);
        }
        $http = (int) wp_remote_retrieve_response_code($response);
        $raw = (string) wp_remote_retrieve_body($response);
        $json = json_decode($raw, true);
        if ($http >= 200 && $http < 300 && is_array($json) && isset($json['sub']) && is_scalar($json['sub'])) {
            $sub = trim((string) $json['sub']);
            if ($this->is_valid_subject($sub)) {
                return ['sub' => $sub, 'person_urn' => 'urn:li:person:' . $sub];
            }
            return new WP_Error('li_identity_invalid', __('LinkedIn could not verify the connected personal profile.', 'calliope-social-publisher'), ['failure_kind' => 'identity', 'retryable' => false]);
        }
        if ($http === 401) {
            return new WP_Error('li_token_invalid', __('LinkedIn rejected the saved access token. Reconnect LinkedIn and test the connection again.', 'calliope-social-publisher'), ['http_code' => 401, 'phase' => 'identity', 'failure_kind' => 'auth', 'retryable' => false]);
        }
        if ($http === 403) {
            return new WP_Error('li_profile_scope', __('The token is saved, but publishing permission could not be verified. Connect with LinkedIn through OAuth to confirm w_member_social.', 'calliope-social-publisher'), ['http_code' => 403, 'phase' => 'identity', 'failure_kind' => 'permission', 'retryable' => false]);
        }
        if ($this->is_retryable_http_status($http)) {
            return new WP_Error('li_validation_temporary', __('LinkedIn could not verify the connection right now. Please try again later.', 'calliope-social-publisher'), ['http_code' => $http, 'phase' => 'identity', 'failure_kind' => 'temporary', 'retryable' => true]);
        }
        return new WP_Error('li_profile', __('LinkedIn could not verify the connected personal profile.', 'calliope-social-publisher'), ['http_code' => $http, 'phase' => 'identity', 'failure_kind' => 'identity', 'retryable' => false]);
    }

    private function build_validation_marker($token, $expires_at, array $scopes, $sub, $person_urn, $refresh_token = '', $refresh_expires_at = 0) {
        $application = $this->get_application_fingerprint();
        $person_urn = $this->normalize_person_urn($person_urn);
        if ($application === '' || $token === '' || $expires_at <= time() || !$this->scopes_are_exact_member($scopes) || !$this->is_valid_subject($sub) || $person_urn === '') {
            return new WP_Error('li_validation_marker_invalid', __('LinkedIn could not save a valid connection proof.', 'calliope-social-publisher'));
        }
        return [
            'schema' => self::VALIDATION_SCHEMA,
            'health' => 'healthy',
            'failure_kind' => '',
            'validated_at' => time(),
            'application_fingerprint' => $application,
            'credentials_fingerprint' => $this->credentials_fingerprint($token, $expires_at, $scopes, $refresh_token, $refresh_expires_at),
            'identity_fingerprint' => $this->fingerprint(['sub' => $sub]),
            'target_fingerprint' => $this->fingerprint(['target' => $person_urn]),
            'target_identity_fingerprint' => $this->fingerprint(['sub' => $sub, 'target' => $person_urn]),
            'resolved_target_person_urn' => $person_urn,
            'permission_state' => 'granted',
            'auth_method' => 'oauth2',
            'access_token_expires_at' => (int) $expires_at,
            'api_version' => self::API_VERSION,
            'refresh_token_fingerprint' => $refresh_token !== '' ? $this->fingerprint(['refresh_token'=>$refresh_token]) : '',
            'refresh_token_expires_at' => $refresh_token !== '' ? (int)$refresh_expires_at : 0,
        ];
    }

    private function get_validation_marker() {
        $marker = get_option(self::VALIDATION_OPTION, null);
        return is_array($marker) ? $marker : null;
    }

    private function marker_matches_current($marker) {
        if (!$this->marker_is_structurally_valid($marker)) {
            return false;
        }
        $target = $this->resolve_publish_target();
        $scopes = $this->get_saved_scope_list();
        if ($target['kind'] !== 'person' || !$this->scopes_are_exact_member($scopes)) {
            return false;
        }
        $checks = [
            [(string) $marker['application_fingerprint'], $this->get_application_fingerprint()],
            [(string) $marker['credentials_fingerprint'], $this->credentials_fingerprint($this->get_access_token(), $this->get_expires_at(), $scopes, $this->get_refresh_token(), $this->get_refresh_expires_at())],
            [(string) $marker['target_fingerprint'], $this->fingerprint(['target' => $target['normalized']])],
            [(string) $marker['resolved_target_person_urn'], $target['normalized']],
        ];
        foreach ($checks as $check) {
            if ($check[0] === '' || $check[1] === '' || !hash_equals($check[0], $check[1])) {
                return false;
            }
        }
        $refresh_token=$this->get_refresh_token(); $refresh_expires=$this->get_refresh_expires_at();
        $refresh_marker=(string)($marker['refresh_token_fingerprint']??'');
        $refresh_ok=$refresh_token==='' ? ($refresh_marker==='' && (int)($marker['refresh_token_expires_at']??0)===0)
            : ($refresh_marker!=='' && hash_equals($refresh_marker,$this->fingerprint(['refresh_token'=>$refresh_token])) && (int)($marker['refresh_token_expires_at']??0)===$refresh_expires);
        return (int) $marker['access_token_expires_at'] === $this->get_expires_at()
            && $refresh_ok
            && (string) $marker['api_version'] === self::API_VERSION
            && (string) $marker['auth_method'] === 'oauth2';
    }

    private function marker_is_structurally_valid($marker) {
        if (!is_array($marker) || ($marker['schema'] ?? '') !== self::VALIDATION_SCHEMA) {
            return false;
        }
        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 false;
            }
        }
        if (!isset($marker['validated_at'], $marker['access_token_expires_at']) || (int) $marker['validated_at'] <= 0 || (int) $marker['access_token_expires_at'] <= 0) {
            return false;
        }
        if (!in_array((string) ($marker['health'] ?? ''), ['healthy', 'degraded', 'invalid'], true)) {
            return false;
        }
        if (!in_array((string) ($marker['failure_kind'] ?? ''), ['', 'auth', 'permission', 'identity', 'target'], true)) {
            return false;
        }
        $refresh_fp=(string)($marker['refresh_token_fingerprint']??''); $refresh_exp=(int)($marker['refresh_token_expires_at']??0);
        if (($refresh_fp==='') xor ($refresh_exp===0)) return false;
        if ($refresh_fp!=='' && (!preg_match('/^[a-f0-9]{64}$/D',$refresh_fp) || $refresh_exp<=0)) return false;
        return $this->normalize_person_urn($marker['resolved_target_person_urn'] ?? '') !== ''
            && ($marker['permission_state'] ?? '') === 'granted'
            && ($marker['auth_method'] ?? '') === 'oauth2'
            && ($marker['api_version'] ?? '') === self::API_VERSION;
    }

    private function credentials_fingerprint($token, $expires_at, array $scopes, $refresh_token = '', $refresh_expires_at = 0) {
        if ($token === '') return '';
        $value=['client_id'=>$this->get_client_id(),'token'=>$token,'expires_at'=>(int)$expires_at,'scopes'=>array_values($this->normalize_scopes($scopes)),'api_version'=>self::API_VERSION];
        if($refresh_token!==''){$value['refresh_token']=$refresh_token;$value['refresh_expires_at']=(int)$refresh_expires_at;}
        return $this->fingerprint($value);
    }

    private function capture_lifecycle_snapshot(){ $names=['calliope_sp_li_access_token','calliope_sp_li_expires_at','calliope_sp_li_refresh_token','calliope_sp_li_refresh_expires_at','calliope_sp_li_person_id','calliope_sp_li_last_scope_list','calliope_sp_li_last_target_kind',self::VALIDATION_OPTION,'calliope_sp_li_connection_status'];$out=[];foreach($names as $n)$out[$n]=get_option($n,null);return $out; }
    private function restore_lifecycle_snapshot(array $snapshot){foreach($snapshot as $n=>$v){if($v===null)delete_option($n);else update_option($n,$v,false);}}
    private function commit_lifecycle_candidate(array $candidate){
        foreach(['access_token','expires_at','scopes','person_urn','marker'] as $k) if(!array_key_exists($k,$candidate)) return false;
        update_option('calliope_sp_li_access_token',(string)$candidate['access_token'],false);update_option('calliope_sp_li_expires_at',(int)$candidate['expires_at'],false);
        if(!empty($candidate['refresh_token'])){update_option('calliope_sp_li_refresh_token',(string)$candidate['refresh_token'],false);update_option('calliope_sp_li_refresh_expires_at',(int)$candidate['refresh_expires_at'],false);}else{delete_option('calliope_sp_li_refresh_token');delete_option('calliope_sp_li_refresh_expires_at');}
        update_option('calliope_sp_li_last_scope_list',array_values($candidate['scopes']),false);update_option('calliope_sp_li_last_target_kind','person',false);update_option('calliope_sp_li_person_id',(string)$candidate['person_urn'],false);
        $saved=$this->save_validation_marker($candidate['marker']);if(is_wp_error($saved))return false;update_option('calliope_sp_li_connection_status','ready_to_publish',false);
        $state=$this->get_connection_state();return !is_wp_error($state)&&$state->is_ready()&&hash_equals((string)$candidate['person_urn'],$this->get_resolved_target_urn());
    }

    private function fingerprint($value) {
        return hash_hmac('sha256', wp_json_encode($value), wp_salt('auth'));
    }

    private function clear_legacy_validation_options() {
        foreach ([
            'calliope_sp_li_validation_status',
            'calliope_sp_li_validated_at',
            'calliope_sp_li_validated_token_fingerprint',
            'calliope_sp_li_validated_target_fingerprint',
        ] as $option) {
            delete_option($option);
        }
    }

    private function normalize_scopes($scopes) {
        if (is_string($scopes)) {
            $scopes = preg_split('/\s+/', trim($scopes));
        }
        if (!is_array($scopes)) {
            return [];
        }
        $out = [];
        foreach ($scopes as $scope) {
            if (!is_scalar($scope)) {
                continue;
            }
            $scope = sanitize_key((string) $scope);
            if ($scope !== '') {
                $out[] = $scope;
            }
        }
        $out = array_values(array_unique($out));
        sort($out, SORT_STRING);
        return $out;
    }

    private function scopes_are_exact_member(array $scopes) {
        $required = $this->normalize_scopes($this->get_oauth_scope_list_for_target());
        $scopes = $this->normalize_scopes($scopes);
        return $scopes === $required;
    }

    private function normalize_token($token) {
        if (!is_scalar($token)) {
            return '';
        }
        $token = trim((string) $token);
        if ($token === '' || strpos($token, "\0") !== false || strlen($token) > 4096) {
            return '';
        }
        return $token;
    }

    private function normalize_person_urn($urn) {
        if (!is_scalar($urn)) {
            return '';
        }
        $urn = trim((string) $urn);
        return preg_match('/^urn:li:person:[A-Za-z0-9_-]+$/D', $urn) ? $urn : '';
    }

    private function is_valid_subject($sub) {
        return is_string($sub) && strlen($sub) <= 255 && (bool) preg_match('/^[A-Za-z0-9_-]+$/D', $sub);
    }

    private function option_string($name) {
        $value = get_option($name, '');
        return is_scalar($value) ? trim((string) $value) : '';
    }

    private function rest_headers($token) {
        return [
            'Authorization' => 'Bearer ' . $token,
            'Content-Type' => 'application/json; charset=utf-8',
            'Linkedin-Version' => self::API_VERSION,
            'X-Restli-Protocol-Version' => '2.0.0',
        ];
    }

    private function validate_image_upload_url($url) {
        $parts = wp_parse_url((string) $url);
        if (!is_array($parts)) {
            return $this->unsafe_upload_url_error();
        }
        $scheme = strtolower((string) ($parts['scheme'] ?? ''));
        $host = strtolower(rtrim((string) ($parts['host'] ?? ''), '.'));
        $port = isset($parts['port']) ? (int) $parts['port'] : 443;
        $path = (string) ($parts['path'] ?? '');
        $linkedin_host = $host === 'linkedin.com' || substr($host, -13) === '.linkedin.com';
        if ($scheme !== 'https' || !$linkedin_host || $port !== 443 || isset($parts['user']) || isset($parts['pass']) || !empty($parts['fragment']) || strpos($path, '/dms-uploads/') !== 0) {
            return $this->unsafe_upload_url_error();
        }
        return ['url' => (string) $url, 'host' => $host];
    }

    private function is_valid_image_urn($urn) {
        return is_string($urn) && (bool) preg_match('/^urn:li:image:[A-Za-z0-9_-]+$/D', $urn);
    }

    private function is_valid_post_urn($urn) {
        return is_string($urn) && (bool) preg_match('/^urn:li:(?:share|ugcPost):[0-9]+$/D', $urn);
    }

    private function normalize_image_alt_text($alt_text) {
        $alt_text = wp_strip_all_tags((string) $alt_text);
        $alt_text = preg_replace('/[\x00-\x1F\x7F]+/u', ' ', $alt_text);
        $alt_text = preg_replace('/\s+/u', ' ', (string) $alt_text);
        $alt_text = trim((string) $alt_text);
        if (mb_strlen($alt_text) > self::MAX_ALT_TEXT_LENGTH) {
            $alt_text = mb_substr($alt_text, 0, self::MAX_ALT_TEXT_LENGTH);
        }
        return $alt_text;
    }

    private function image_error($code, $message, array $data = []) {
        return new WP_Error($code, $message, array_merge(['phase' => 'image_prepare', 'retryable' => false], $data));
    }

    private function invalid_initialize_response_error($mime) {
        return new WP_Error('li_image_initialize_invalid_response', __('LinkedIn returned an invalid image upload response.', 'calliope-social-publisher'), [
            'phase' => 'image_initialize', 'retryable' => false, 'mime' => (string) $mime,
        ]);
    }

    private function unsafe_upload_url_error() {
        return new WP_Error('li_image_upload_url_invalid', __('LinkedIn returned an unsafe image upload address.', 'calliope-social-publisher'), [
            'phase' => 'image_initialize', 'retryable' => false,
        ]);
    }

    private function is_retryable_http_status($http) {
        $http = (int) $http;
        return in_array($http, [408, 409, 429], true) || $http >= 500;
    }

    private function apply_http_failure_to_marker($http) {
        $http = (int) $http;
        if ($http === 401) {
            $this->mark_failure('auth');
        } elseif ($http === 403) {
            $this->mark_failure('permission');
        } elseif ($this->is_retryable_http_status($http)) {
            $this->mark_degraded();
        }
    }

    private function build_transport_error($phase, $response) {
        $phase = sanitize_key((string) $phase);
        $code = $phase === 'image_initialize' ? 'li_image_initialize_transport' : ($phase === 'image_upload' ? 'li_image_upload_transport' : 'li_transport_error');
        return new WP_Error($code, __('LinkedIn could not be reached. Please try again later.', 'calliope-social-publisher'), [
            'phase' => $phase,
            'transport_code' => is_wp_error($response) ? sanitize_key((string) $response->get_error_code()) : '',
            'retryable' => true,
        ]);
    }

    private function is_youtube_link_source_payload(array $data) {
        $metadata = isset($data['metadata']) && is_array($data['metadata']) ? $data['metadata'] : [];
        return sanitize_key((string) ($metadata['video_source'] ?? '')) === 'youtube_link';
    }

    private function youtube_link_url_from_payload(array $data) {
        if (!$this->is_youtube_link_source_payload($data)) {
            return '';
        }
        $metadata = isset($data['metadata']) && is_array($data['metadata']) ? $data['metadata'] : [];
        $raw = isset($metadata['youtube_link_url']) ? (string) $metadata['youtube_link_url'] : (string) ($data['link'] ?? '');
        return $this->normalize_youtube_link_url($raw);
    }

    private function normalize_youtube_link_url($raw) {
        $raw = trim((string) $raw);
        if ($raw === '') {
            return '';
        }
        if (preg_match('/^(?:https?:\/\/)?(?:www\.|m\.)?(?:youtube\.com\/watch\?(?:[^#\s]*&)?v=|youtube\.com\/shorts\/|youtube\.com\/embed\/|youtu\.be\/)([A-Za-z0-9_-]{6,32})/i', $raw, $matches) !== 1) {
            return '';
        }
        return 'https://www.youtube.com/watch?v=' . $matches[1];
    }

    private function youtube_video_id_from_url($url) {
        $url = $this->normalize_youtube_link_url($url);
        if ($url === '') {
            return '';
        }
        $parts = wp_parse_url($url);
        if (!is_array($parts) || empty($parts['query'])) {
            return '';
        }
        parse_str((string) $parts['query'], $query);
        $id = isset($query['v']) && is_scalar($query['v']) ? (string) $query['v'] : '';
        return preg_match('/^[A-Za-z0-9_-]{6,32}$/D', $id) ? $id : '';
    }

    private function build_youtube_article_content(array $data, $url) {
        $url = $this->normalize_youtube_link_url($url);
        if ($url === '') {
            return [];
        }

        $title = $this->article_text((string) ($data['title'] ?? ''), 200);
        if ($title === '') {
            $title = __('YouTube video', 'calliope-social-publisher');
        }
        $description = $this->article_text((string) ($data['content'] ?? ''), 256);

        return [
            'source' => $url,
            'title' => $title,
            'description' => $description,
        ];
    }

    private function upload_youtube_article_thumbnail($owner_urn, $url) {
        $image_data = $this->fetch_youtube_thumbnail_data_uri($url);
        if (is_wp_error($image_data)) {
            return $image_data;
        }

        $thumbnail_urn = $this->upload_image($owner_urn, $image_data);
        if (is_wp_error($thumbnail_urn)) {
            $error_data = $thumbnail_urn->get_error_data();
            $http_code = is_array($error_data) ? (int) ($error_data['http_code'] ?? 0) : 0;
            return new WP_Error(
                'li_youtube_thumbnail_upload_failed',
                __('LinkedIn could not upload the YouTube thumbnail. No preview without image was published.', 'calliope-social-publisher'),
                ['phase' => 'article_thumbnail', 'retryable' => $this->is_retryable_http_status($http_code)]
            );
        }

        return $thumbnail_urn;
    }

    private function fetch_youtube_thumbnail_data_uri($url) {
        $video_id = $this->youtube_video_id_from_url($url);
        if ($video_id === '') {
            return new WP_Error('li_youtube_link_invalid', __('LinkedIn requires a valid YouTube URL to publish a YouTube link preview. No text-only fallback was sent.', 'calliope-social-publisher'), ['phase' => 'article_thumbnail', 'retryable' => false]);
        }

        foreach (['maxresdefault.jpg', 'sddefault.jpg', 'hqdefault.jpg', 'mqdefault.jpg'] as $filename) {
            $thumbnail_url = 'https://i.ytimg.com/vi/' . rawurlencode($video_id) . '/' . $filename;
            $response = wp_remote_get($thumbnail_url, [
                'timeout' => 20,
                'redirection' => 2,
                'limit_response_size' => self::ARTICLE_THUMB_MAX_BYTES + 1,
            ]);
            if (is_wp_error($response)) {
                $this->log_youtube_link_diagnostics('thumbnail_fetch_failed', ['reason' => sanitize_key((string) $response->get_error_code())], 'warning');
                continue;
            }

            $http = (int) wp_remote_retrieve_response_code($response);
            $bytes = (string) wp_remote_retrieve_body($response);
            if ($http < 200 || $http >= 300 || $bytes === '' || strlen($bytes) > self::ARTICLE_THUMB_MAX_BYTES) {
                $this->log_youtube_link_diagnostics('thumbnail_fetch_skipped', ['http_code' => $http, 'bytes' => strlen($bytes)], 'warning');
                continue;
            }

            $inspection = $this->inspect_image_bytes($bytes);
            if (is_wp_error($inspection) || !in_array((string) ($inspection['mime'] ?? ''), ['image/jpeg', 'image/png', 'image/gif'], true)) {
                $this->log_youtube_link_diagnostics('thumbnail_fetch_skipped', ['reason' => 'invalid_thumbnail_image'], 'warning');
                continue;
            }

            return 'data:' . (string) $inspection['mime'] . ';base64,' . base64_encode($bytes);
        }

        return new WP_Error(
            'li_youtube_thumbnail_missing',
            __('LinkedIn could not fetch a YouTube thumbnail for the preview. No preview without image was published.', 'calliope-social-publisher'),
            ['phase' => 'article_thumbnail', 'retryable' => true]
        );
    }

    private function article_text($value, $limit) {
        $value = wp_strip_all_tags((string) $value);
        $value = preg_replace('/[\x00-\x1F\x7F]/u', ' ', (string) $value);
        $value = preg_replace('/\s+/u', ' ', (string) $value);
        $value = trim((string) $value);
        $limit = max(0, (int) $limit);
        if ($limit > 0 && function_exists('mb_strlen') && mb_strlen($value, 'UTF-8') > $limit) {
            return mb_substr($value, 0, max(0, $limit - 3), 'UTF-8') . '...';
        }
        if ($limit > 0 && !function_exists('mb_strlen') && strlen($value) > $limit) {
            return substr($value, 0, max(0, $limit - 3)) . '...';
        }
        return $value;
    }

    private function log_youtube_link_diagnostics($event, array $entry, $status = 'info') {
        if (!class_exists('Calliope_SP_Logger')) {
            return;
        }
        $entry = array_merge([
            'event' => 'linkedin_youtube_link_' . sanitize_key((string) $event),
        ], $entry);
        Calliope_SP_Logger::log('linkedin', sanitize_key((string) $status), wp_json_encode($entry, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
    }

    private function format_text($data, $max_len) {
        $parts = [];
        foreach (['title', 'content', 'hashtags'] as $key) {
            if (!empty($data[$key])) {
                $parts[] = trim((string) $data[$key]);
            }
        }
        $text = trim(implode("\n\n", array_filter($parts)));
        if (mb_strlen($text) > $max_len) {
            $text = mb_substr($text, 0, $max_len - 3) . '...';
        }
        return $text;
    }

    private function create_state($status, $health, $configured, $authenticated, $identity, $target, $permission, $evidence, $message, $action, $expires_at, $validated_at) {
        return Calliope_SP_Connection_State::create([
            'network' => 'linkedin',
            'status' => $status,
            'health' => $health,
            'is_configured' => (bool) $configured,
            'is_authenticated' => (bool) $authenticated,
            'is_identity_known' => (bool) $identity,
            'has_target' => (bool) $target,
            'has_permission' => (bool) $permission,
            'evidence_level' => $evidence,
            'message' => $message,
            'action' => $action,
            'expires_at' => (int) $expires_at,
            'validated_at' => (int) $validated_at,
            'connection_method' => 'oauth2',
        ], 'E6');
    }
}
