<?php

/**
 * SocialPromoter LinkedIn Connector
 *
 * Handles OAuth 2.0 authentication and posting to LinkedIn personal profiles
 * and company pages via LinkedIn Community Management APIs.
 *
 * @package PublisherKits_SocialPromoter
 */
if ( !defined( 'ABSPATH' ) ) {
    exit;
}
/**
 * Class PKITS_SP_Connector_LinkedIn
 */
class PKITS_SP_Connector_LinkedIn implements PKITS_SP_Connector_Interface {
    // ----------------------------------------------------------------
    // Interface metadata methods
    // ----------------------------------------------------------------
    /** @return string */
    public function get_slug() {
        return 'linkedin';
    }

    /** @return string */
    public function get_label() {
        return 'LinkedIn';
    }

    /** @return string */
    public function get_badge() {
        return 'in';
    }

    /** @return string */
    public function get_tier() {
        return 'free';
    }

    /** @return int */
    public function get_char_limit() {
        return 3000;
    }

    /** @return string */
    public function get_auth_type() {
        return 'relay';
    }

    /**
     * LinkedIn uses a shared PublisherKits relay app - no credentials needed.
     *
     * @return array
     */
    public function get_credential_fields() {
        return array();
    }

    /** @return string */
    public function get_accounts_description() {
        return 'LinkedIn posting uses the shared PublisherKits app. Click Connect to authorise your LinkedIn profile and any company pages you administer.';
    }

    /**
     * LinkedIn OAuth 2.0 base URL.
     *
     * @var string
     */
    const OAUTH_BASE = 'https://www.linkedin.com/oauth/v2/';

    /**
     * LinkedIn legacy v2 API base URL.
     *
     * Used for OpenID userinfo only.
     *
     * @var string
     */
    const API_BASE = 'https://api.linkedin.com/v2/';

    /**
     * LinkedIn versioned REST API base URL.
     *
     * @var string
     */
    const REST_BASE = 'https://api.linkedin.com/rest/';

    /**
     * Default LinkedIn Marketing API version header.
     *
     * LinkedIn requires this header in YYYYMM format for versioned REST calls.
     * Filter with pkits_sp_linkedin_api_version if a deployment needs to pin
     * a different supported version.
     *
     * @var string
     */
    const REST_VERSION = '202606';

    /**
     * Build the LinkedIn OAuth authorization URL.
     *
     * This is the secondary per-install path. The primary production flow uses
     * the PublisherKits relay, so the relay must request the same scopes.
     *
     * @param string $client_id    LinkedIn App Client ID.
     * @param string $redirect_uri Callback URL registered in the LinkedIn app.
     * @param string $state        CSRF state token.
     * @return string Authorization URL.
     */
    public function get_auth_url( $client_id, $redirect_uri, $state ) {
        $params = array(
            'response_type' => 'code',
            'client_id'     => $client_id,
            'redirect_uri'  => $redirect_uri,
            'state'         => $state,
            'scope'         => implode( ' ', $this->get_required_scopes() ),
        );
        return self::OAUTH_BASE . 'authorization?' . http_build_query( $params );
    }

    /**
     * Return the scopes needed for profile and company-page posting.
     *
     * @return string[]
     */
    public function get_required_scopes() {
        return array(
            'openid',
            'profile',
            'w_member_social',
            'w_organization_social',
            'r_organization_social',
            'rw_organization_admin'
        );
    }

    /**
     * Exchange an authorization code for an access token.
     *
     * @param string $client_id     LinkedIn App Client ID.
     * @param string $client_secret LinkedIn App Client Secret.
     * @param string $code          Authorization code from the callback.
     * @param string $redirect_uri  Must match the one used in get_auth_url.
     * @return array|WP_Error Account data array, or WP_Error.
     */
    public function get_access_token(
        $client_id,
        $client_secret,
        $code,
        $redirect_uri
    ) {
        $response = wp_remote_post( self::OAUTH_BASE . 'accessToken', array(
            'body'    => array(
                'grant_type'    => 'authorization_code',
                'code'          => $code,
                'redirect_uri'  => $redirect_uri,
                'client_id'     => $client_id,
                'client_secret' => $client_secret,
            ),
            'timeout' => 20,
        ) );
        if ( is_wp_error( $response ) ) {
            return $response;
        }
        $data = json_decode( wp_remote_retrieve_body( $response ), true );
        if ( empty( $data['access_token'] ) ) {
            $msg = ( isset( $data['error_description'] ) ? $data['error_description'] : 'LinkedIn token exchange failed.' );
            return new WP_Error('li_token_failed', esc_html( $msg ));
        }
        // Get the user's profile info.
        $profile = $this->get_profile( $data['access_token'] );
        if ( is_wp_error( $profile ) ) {
            return $profile;
        }
        $accounts = array();
        $person_urn = 'urn:li:person:' . $profile['id'];
        // Personal profile account.
        $accounts[] = array(
            'platform'      => 'linkedin',
            'account_id'    => sanitize_text_field( $profile['id'] ),
            'account_type'  => 'personal',
            'urn'           => $person_urn,
            'label'         => sanitize_text_field( $profile['name'] ?? $profile['id'] ),
            'access_token'  => $data['access_token'],
            'refresh_token' => $data['refresh_token'] ?? '',
            'expires_at'    => ( !empty( $data['expires_in'] ) ? gmdate( 'Y-m-d H:i:s', time() + (int) $data['expires_in'] ) : '' ),
            'connected_at'  => gmdate( 'Y-m-d H:i:s' ),
        );
        // Fetch company pages the user can administer/post to (if scope allows).
        $pages = $this->get_admin_pages( $data['access_token'] );
        if ( !is_wp_error( $pages ) && !empty( $pages ) ) {
            foreach ( $pages as $page ) {
                $accounts[] = array(
                    'platform'      => 'linkedin',
                    'account_id'    => sanitize_text_field( $page['id'] ),
                    'account_type'  => 'company',
                    'urn'           => 'urn:li:organization:' . $page['id'],
                    'label'         => sanitize_text_field( $page['name'] ?? $page['id'] ) . ' (Company)',
                    'access_token'  => $data['access_token'],
                    'refresh_token' => $data['refresh_token'] ?? '',
                    'expires_at'    => ( !empty( $data['expires_in'] ) ? gmdate( 'Y-m-d H:i:s', time() + (int) $data['expires_in'] ) : '' ),
                    'connected_at'  => gmdate( 'Y-m-d H:i:s' ),
                );
            }
        }
        return $accounts;
    }

    /**
     * Get the authenticated user's basic profile.
     *
     * @param string $access_token Valid access token.
     * @return array|WP_Error Profile data or WP_Error.
     */
    public function get_profile( $access_token ) {
        $response = wp_remote_get( self::API_BASE . 'userinfo', array(
            'headers' => array(
                'Authorization' => 'Bearer ' . $access_token,
            ),
            'timeout' => 20,
        ) );
        if ( is_wp_error( $response ) ) {
            return $response;
        }
        $data = json_decode( wp_remote_retrieve_body( $response ), true );
        if ( empty( $data['sub'] ) ) {
            return new WP_Error('li_profile_failed', 'Could not retrieve LinkedIn profile.');
        }
        $name = trim( ($data['given_name'] ?? '') . ' ' . ($data['family_name'] ?? '') );
        if ( empty( $name ) ) {
            $name = $data['name'] ?? $data['sub'];
        }
        return array(
            'id'   => sanitize_text_field( $data['sub'] ),
            'name' => sanitize_text_field( $name ),
        );
    }

    /**
     * Get organization pages administered by the authenticated user.
     *
     * @param string $access_token Valid access token.
     * @return array|WP_Error Array of pages, or WP_Error.
     */
    private function get_admin_pages( $access_token ) {
        $response = wp_remote_get( self::REST_BASE . 'organizationAcls?q=roleAssignee&state=APPROVED', array(
            'headers' => $this->get_rest_headers( $access_token, true ),
            'timeout' => 20,
        ) );
        if ( is_wp_error( $response ) ) {
            return $response;
        }
        $code = wp_remote_retrieve_response_code( $response );
        $data = json_decode( wp_remote_retrieve_body( $response ), true );
        if ( $code < 200 || $code >= 300 ) {
            return new WP_Error('li_pages_failed', 'LinkedIn organization access lookup failed (' . $code . '): ' . esc_html( $this->get_error_message( $response ) ));
        }
        $pages = array();
        $seen = array();
        $valid_roles = array(
            'ADMINISTRATOR',
            'CONTENT_ADMIN',
            'CONTENT_ADMINISTRATOR',
            'DIRECT_SPONSORED_CONTENT_POSTER',
            'RECRUITING_POSTER'
        );
        if ( !empty( $data['elements'] ) ) {
            foreach ( $data['elements'] as $element ) {
                $role = ( isset( $element['role'] ) ? strtoupper( (string) $element['role'] ) : '' );
                if ( !in_array( $role, $valid_roles, true ) ) {
                    continue;
                }
                $org_urn = $element['organization'] ?? '';
                $org_id = $this->extract_organization_id( $org_urn );
                if ( empty( $org_id ) || isset( $seen[$org_id] ) ) {
                    continue;
                }
                $seen[$org_id] = true;
                $pages[] = array(
                    'id'   => $org_id,
                    'name' => $this->get_organization_label( $access_token, $org_id ),
                );
            }
        }
        return $pages;
    }

    /**
     * Format text for the LinkedIn Posts API commentary field (Little Text).
     *
     * The /rest/posts "commentary" field requires reserved characters to be
     * backslash-escaped or the post silently truncates (an unescaped "(" drops
     * everything after it). But hashtags must stay clickable, so rather than
     * escaping "#" into literal text we convert each "#tag" into LinkedIn's
     * clickable HashtagTemplate: {hashtag|\#|tag}. Explicit mention annotations
     * already written as @[Name](urn:li:...) are passed through intact. All
     * other text is fully escaped so punctuation can't corrupt the post.
     *
     * @param string $text Raw post text.
     * @return string Little Text formatted commentary.
     */
    private static function format_commentary( $text ) {
        $text = (string) $text;
        // Split out the parts we must preserve verbatim:
        //  - mention annotations: @[Display Name](urn:li:organization:123)
        //  - hashtags: #word
        // Everything captured is re-emitted as its proper Little Text form;
        // everything else is escaped as plain text.
        $pattern = '/(@\\[[^\\]]+\\]\\(urn:li:[a-z]+:[0-9A-Za-z._-]+\\)|#[\\p{L}0-9_]+)/u';
        $parts = preg_split(
            $pattern,
            $text,
            -1,
            PREG_SPLIT_DELIM_CAPTURE
        );
        $out = '';
        foreach ( $parts as $part ) {
            if ( '' === $part ) {
                continue;
            }
            if ( '#' === $part[0] && preg_match( '/^#([\\p{L}0-9_]+)$/u', $part, $m ) ) {
                // Clickable hashtag.
                $out .= '{hashtag|\\#|' . $m[1] . '}';
            } elseif ( '@' === $part[0] && preg_match( '/^@\\[[^\\]]+\\]\\(urn:li:[a-z]+:[0-9A-Za-z._-]+\\)$/u', $part ) ) {
                // Explicit mention annotation - emit verbatim (already valid).
                $out .= $part;
            } else {
                $out .= self::escape_little_text( $part );
            }
        }
        return $out;
    }

    /**
     * Escape Little Text reserved characters in a plain-text segment.
     *
     * Backslash is escaped first so the escapes added afterwards are not
     * double-processed.
     *
     * @param string $text Plain text segment (no hashtags/annotations).
     * @return string Escaped text.
     */
    private static function escape_little_text( $text ) {
        $text = str_replace( '\\', '\\\\', $text );
        $reserved = array(
            '|',
            '(',
            ')',
            '[',
            ']',
            '{',
            '}',
            '@',
            '#',
            '*',
            '~',
            '<',
            '>',
            '_'
        );
        foreach ( $reserved as $char ) {
            $text = str_replace( $char, '\\' . $char, $text );
        }
        return $text;
    }

    /**
     * Post a share to LinkedIn (personal profile or company page).
     *
     * @param array       $account    Account data (with decrypted access_token and urn).
     * @param string      $text       Post text.
     * @param string      $url        Optional URL. Kept in commentary by PKITS_SP_Poster.
     * @param array|null  $image_data Optional image data from PKITS_SP_Image_Handler.
     * @return array|WP_Error Array with 'platform_post_id', or WP_Error.
     */
    /**
     * Return a clear "reconnect" error when the stored access token has expired.
     *
     * LinkedIn access tokens last ~60 days. They are issued through the shared
     * PublisherKits relay app, whose client secret this plugin does not hold, so
     * the plugin cannot silently exchange the refresh token itself — the best it
     * can do is detect expiry and tell the user to reconnect, instead of failing
     * every call with an opaque 401. Returns null when no expiry is recorded (so
     * older connections without an expiry are never falsely flagged).
     *
     * @param array $account Account data.
     * @return WP_Error|null
     */
    private static function expiry_error( array $account ) {
        if ( empty( $account['expires_at'] ) ) {
            return null;
        }
        $expires = strtotime( $account['expires_at'] . ' UTC' );
        if ( $expires && $expires <= time() ) {
            return new WP_Error('li_token_expired', 'LinkedIn connection has expired. Please reconnect the account on the Accounts screen.');
        }
        return null;
    }

    public function post(
        array $account,
        $text,
        $url = '',
        $image_data = null
    ) {
        unset($url);
        if ( empty( $account['urn'] ) || empty( $account['access_token'] ) ) {
            return new WP_Error('li_missing_account', 'Missing LinkedIn account URN or access token.');
        }
        $expired = self::expiry_error( $account );
        if ( $expired ) {
            return $expired;
        }
        $image_error = '';
        $image_urn = '';
        // Image upload takes priority. If it fails, publish the text/link post
        // rather than failing the whole queue item.
        if ( !empty( $image_data ) ) {
            $asset_urn = $this->upload_image( $account, $image_data );
            PKITS_SP_Image_Handler::cleanup_temp( $image_data );
            if ( is_wp_error( $asset_urn ) ) {
                $image_error = $asset_urn->get_error_message();
            } else {
                $image_urn = $asset_urn;
            }
        }
        $payload = array(
            'author'                    => $account['urn'],
            'commentary'                => self::format_commentary( $text ),
            'visibility'                => 'PUBLIC',
            'distribution'              => array(
                'feedDistribution'               => 'MAIN_FEED',
                'targetEntities'                 => array(),
                'thirdPartyDistributionChannels' => array(),
            ),
            'lifecycleState'            => 'PUBLISHED',
            'isReshareDisabledByAuthor' => false,
        );
        if ( !empty( $image_urn ) ) {
            $payload['content'] = array(
                'media' => array(
                    'id' => $image_urn,
                ),
            );
        }
        $response = wp_remote_post( self::REST_BASE . 'posts', array(
            'headers' => $this->get_rest_headers( $account['access_token'], true ),
            'body'    => wp_json_encode( $payload ),
            'timeout' => 30,
        ) );
        if ( is_wp_error( $response ) ) {
            return $response;
        }
        $code = wp_remote_retrieve_response_code( $response );
        $data = json_decode( wp_remote_retrieve_body( $response ), true );
        if ( $code < 200 || $code >= 300 ) {
            $msg = $this->get_error_message( $response );
            return new WP_Error('li_post_failed', 'LinkedIn post failed (' . $code . '): ' . esc_html( $msg ));
        }
        $post_id = $this->get_response_header( $response, 'x-restli-id' );
        if ( empty( $post_id ) && !empty( $data['id'] ) ) {
            $post_id = sanitize_text_field( $data['id'] );
        }
        $result = array(
            'platform_post_id' => $post_id,
        );
        if ( !empty( $image_error ) ) {
            $result['_image_error'] = $image_error;
        }
        return $result;
    }

    /**
     * Upload an image to LinkedIn and return the image URN.
     *
     * LinkedIn's versioned REST API requires a two-step process: initialise
     * the upload, upload the binary to the returned URL, then reference the
     * returned urn:li:image:{id} in the post payload.
     *
     * @param array $account    Account data with access_token and urn.
     * @param array $image_data Image data from PKITS_SP_Image_Handler.
     * @return string|WP_Error Image URN string or WP_Error.
     */
    private function upload_image( array $account, array $image_data ) {
        $register_body = wp_json_encode( array(
            'initializeUploadRequest' => array(
                'owner' => $account['urn'],
            ),
        ) );
        $register_response = wp_remote_post( self::REST_BASE . 'images?action=initializeUpload', array(
            'headers' => $this->get_rest_headers( $account['access_token'], true ),
            'body'    => $register_body,
            'timeout' => 30,
        ) );
        if ( is_wp_error( $register_response ) ) {
            return $register_response;
        }
        $register_code = wp_remote_retrieve_response_code( $register_response );
        $register_data = json_decode( wp_remote_retrieve_body( $register_response ), true );
        if ( $register_code < 200 || $register_code >= 300 ) {
            return new WP_Error('li_register_failed', 'LinkedIn image registration failed (' . $register_code . '): ' . esc_html( $this->get_error_message( $register_response ) ) . ' [' . PKITS_SP_Image_Handler::describe_for_log( $image_data ) . ']');
        }
        $upload_url = ( isset( $register_data['value']['uploadUrl'] ) ? $register_data['value']['uploadUrl'] : '' );
        $image_urn = ( isset( $register_data['value']['image'] ) ? $register_data['value']['image'] : '' );
        if ( empty( $upload_url ) || empty( $image_urn ) ) {
            return new WP_Error('li_register_failed', 'LinkedIn image registration failed: missing upload URL or image URN.');
        }
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
        $image_binary = file_get_contents( $image_data['local_path'] );
        if ( false === $image_binary ) {
            return new WP_Error('li_image_read_failed', 'Could not read image file.');
        }
        $upload_response = wp_remote_request( $upload_url, array(
            'method'  => 'PUT',
            'headers' => array(
                'Authorization' => 'Bearer ' . $account['access_token'],
                'Content-Type'  => $image_data['mime_type'],
            ),
            'body'    => $image_binary,
            'timeout' => 60,
        ) );
        if ( is_wp_error( $upload_response ) ) {
            return $upload_response;
        }
        $upload_code = wp_remote_retrieve_response_code( $upload_response );
        if ( $upload_code < 200 || $upload_code >= 300 ) {
            $upload_body = trim( (string) wp_remote_retrieve_body( $upload_response ) );
            if ( strlen( $upload_body ) > 300 ) {
                $upload_body = substr( $upload_body, 0, 300 ) . '…';
            }
            return new WP_Error('li_upload_failed', 'LinkedIn image upload failed: HTTP ' . $upload_code . ' [' . PKITS_SP_Image_Handler::describe_for_log( $image_data ) . ']' . (( '' !== $upload_body ? ' Response: ' . esc_html( $upload_body ) : '' )));
        }
        return sanitize_text_field( $image_urn );
    }

    /**
     * Build common headers for LinkedIn REST calls.
     *
     * @param string $access_token         OAuth access token.
     * @param bool   $include_content_type Include JSON content type header.
     * @return array
     */
    private function get_rest_headers( $access_token, $include_content_type = false ) {
        $headers = array(
            'Authorization'             => 'Bearer ' . $access_token,
            'Linkedin-Version'          => apply_filters( 'pkits_sp_linkedin_api_version', self::REST_VERSION ),
            'X-Restli-Protocol-Version' => '2.0.0',
        );
        if ( $include_content_type ) {
            $headers['Content-Type'] = 'application/json';
        }
        return $headers;
    }

    /**
     * Return a response header value from WordPress HTTP responses.
     *
     * @param array|WP_HTTP_Requests_Response $response WordPress HTTP response.
     * @param string                          $name     Header name.
     * @return string
     */
    private function get_response_header( $response, $name ) {
        $headers = wp_remote_retrieve_headers( $response );
        if ( is_object( $headers ) && method_exists( $headers, 'offsetGet' ) ) {
            $value = $headers->offsetGet( $name );
            return ( !empty( $value ) ? sanitize_text_field( $value ) : '' );
        }
        if ( is_array( $headers ) ) {
            foreach ( $headers as $header_name => $value ) {
                if ( strtolower( (string) $header_name ) === strtolower( $name ) ) {
                    return sanitize_text_field( $value );
                }
            }
        }
        return '';
    }

    /**
     * Extract an organization id from a LinkedIn organization URN.
     *
     * @param string $organization_urn Organization URN.
     * @return string
     */
    private function extract_organization_id( $organization_urn ) {
        if ( preg_match( '/urn:li:organization:(\\d+)/', (string) $organization_urn, $matches ) ) {
            return sanitize_text_field( $matches[1] );
        }
        return '';
    }

    /**
     * Get a display label for an organization.
     *
     * @param string $access_token Valid access token.
     * @param string $organization_id Organization id.
     * @return string
     */
    private function get_organization_label( $access_token, $organization_id ) {
        $response = wp_remote_get( self::REST_BASE . 'organizations/' . rawurlencode( $organization_id ), array(
            'headers' => $this->get_rest_headers( $access_token, true ),
            'timeout' => 20,
        ) );
        if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) < 200 || wp_remote_retrieve_response_code( $response ) >= 300 ) {
            return $organization_id;
        }
        $data = json_decode( wp_remote_retrieve_body( $response ), true );
        if ( !is_array( $data ) ) {
            return $organization_id;
        }
        if ( !empty( $data['localizedName'] ) ) {
            return sanitize_text_field( $data['localizedName'] );
        }
        if ( !empty( $data['name']['localized'] ) && is_array( $data['name']['localized'] ) ) {
            $name = reset( $data['name']['localized'] );
            return sanitize_text_field( $name );
        }
        if ( !empty( $data['name'] ) && is_string( $data['name'] ) ) {
            return sanitize_text_field( $data['name'] );
        }
        return $organization_id;
    }

    /**
     * Pull a useful error message from a LinkedIn response.
     *
     * @param array|WP_HTTP_Requests_Response $response WordPress HTTP response.
     * @return string
     */
    private function get_error_message( $response ) {
        $body = wp_remote_retrieve_body( $response );
        $data = json_decode( $body, true );
        if ( is_array( $data ) ) {
            if ( !empty( $data['message'] ) ) {
                return (string) $data['message'];
            }
            if ( !empty( $data['error_description'] ) ) {
                return (string) $data['error_description'];
            }
            if ( !empty( $data['serviceErrorCode'] ) ) {
                return 'LinkedIn service error ' . (string) $data['serviceErrorCode'];
            }
        }
        return ( !empty( $body ) ? (string) $body : 'Unknown LinkedIn API error.' );
    }

}
