<?php

/**
 * SocialPromoter Bluesky Connector
 *
 * Posts to Bluesky via the AT Protocol. Unlike the OAuth-based connectors,
 * Bluesky needs no developer app, API key, or callback URL: each account is
 * authenticated with a handle and an app password the user generates in their
 * own Bluesky settings. Bluesky does not de-promote links, so the tracked URL
 * is placed directly in the post body as a clickable rich-text facet.
 *
 * @package PublisherKits_SocialPromoter
 */
if ( !defined( 'ABSPATH' ) ) {
    exit;
}
/**
 * Class PKITS_SP_Connector_Bluesky
 */
class PKITS_SP_Connector_Bluesky implements PKITS_SP_Connector_Interface {
    /**
     * Bluesky entryway service host. Sessions, blob uploads and record writes
     * all route through here for accounts on the default PDS.
     */
    const SERVICE = 'https://bsky.social';

    /**
     * Bluesky post character limit (counted in graphemes).
     */
    const CHAR_LIMIT = 300;

    /** @inheritDoc */
    public function get_slug() {
        return 'bluesky';
    }

    /** @inheritDoc */
    public function get_label() {
        return 'Bluesky';
    }

    /** @inheritDoc */
    public function get_badge() {
        return 'BS';
    }

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

    /** @inheritDoc */
    public function get_char_limit() {
        return self::CHAR_LIMIT;
    }

    /** @inheritDoc */
    public function get_auth_type() {
        return 'apppassword';
    }

    /** @inheritDoc */
    public function get_credential_fields() {
        // No app-level credentials: connection is per account (handle + app password).
        return array();
    }

    /** @inheritDoc */
    public function get_accounts_description() {
        return esc_html__( 'Connect with your Bluesky handle and an app password (generated under Bluesky Settings then App Passwords). No developer app or approval is needed. Bluesky does not suppress links, so the tracked URL is posted directly in the body.', 'publisherkits-socialpromoter' );
    }

    /**
     * Create an authenticated session from a handle and app password.
     *
     * @param string $handle       Bluesky handle (with or without a leading @).
     * @param string $app_password App password generated in Bluesky settings.
     * @return array|WP_Error Session array with did, handle, accessJwt, refreshJwt.
     */
    public function create_session( $handle, $app_password ) {
        $handle = ltrim( trim( (string) $handle ), '@' );
        $response = wp_remote_post( self::SERVICE . '/xrpc/com.atproto.server.createSession', array(
            'timeout' => 30,
            'headers' => array(
                'Content-Type' => 'application/json',
            ),
            'body'    => wp_json_encode( array(
                'identifier' => $handle,
                'password'   => (string) $app_password,
            ) ),
        ) );
        if ( is_wp_error( $response ) ) {
            return $response;
        }
        $code = (int) wp_remote_retrieve_response_code( $response );
        $data = json_decode( wp_remote_retrieve_body( $response ), true );
        if ( 200 !== $code || empty( $data['accessJwt'] ) || empty( $data['did'] ) ) {
            $message = ( !empty( $data['message'] ) ? $data['message'] : 'Authentication failed (HTTP ' . $code . ').' );
            return new WP_Error('pkits_sp_bluesky_auth', $message);
        }
        return array(
            'did'        => $data['did'],
            'handle'     => ( !empty( $data['handle'] ) ? $data['handle'] : $handle ),
            'accessJwt'  => $data['accessJwt'],
            'refreshJwt' => ( isset( $data['refreshJwt'] ) ? $data['refreshJwt'] : '' ),
        );
    }

    /**
     * Get an authenticated session for an account, reusing a cached accessJwt
     * where possible.
     *
     * createSession is heavily rate-limited (~30 / 5 min and 300 / day per
     * account). The daily stats refresh loops over many sent posts, so without
     * caching a busy account would create a session per post, exhaust the limit,
     * and 429-block real posting. The cache TTL stays well inside the ~2h access
     * token life; an auth failure calls forget_session() to force a refresh.
     *
     * @param array $account Account data (handle, app_password).
     * @return array|WP_Error
     */
    private function get_session( array $account ) {
        $handle = ( isset( $account['handle'] ) ? $account['handle'] : '' );
        $cache_key = self::session_cache_key( $handle );
        $cached = get_transient( $cache_key );
        if ( is_array( $cached ) && !empty( $cached['accessJwt'] ) && !empty( $cached['did'] ) ) {
            return $cached;
        }
        $session = $this->create_session( $handle, ( isset( $account['app_password'] ) ? $account['app_password'] : '' ) );
        if ( is_wp_error( $session ) ) {
            return $session;
        }
        set_transient( $cache_key, $session, 90 * MINUTE_IN_SECONDS );
        return $session;
    }

    /**
     * Drop a cached session so the next call re-authenticates (used after an
     * auth failure, e.g. an access token that expired inside the cache window).
     *
     * @param array $account Account data.
     * @return void
     */
    private function forget_session( array $account ) {
        delete_transient( self::session_cache_key( ( isset( $account['handle'] ) ? $account['handle'] : '' ) ) );
    }

    /**
     * Transient key for a handle's cached session.
     *
     * @param string $handle Bluesky handle.
     * @return string
     */
    private static function session_cache_key( $handle ) {
        return 'pkits_sp_bsky_sess_' . md5( strtolower( ltrim( trim( (string) $handle ), '@' ) ) );
    }

    /** @inheritDoc */
    public function post(
        array $account,
        $text,
        $url = '',
        $image_data = null
    ) {
        $session = $this->get_session( $account );
        if ( is_wp_error( $session ) ) {
            return $session;
        }
        $jwt = $session['accessJwt'];
        $did = $session['did'];
        $body_text = trim( (string) $text );
        $link = trim( (string) $url );
        // The composer has already placed the tracked link inside the copy, so
        // (like the X and LinkedIn connectors) we post the text as-is. Enforce
        // the 300-grapheme cap, preserving the link when the copy is too long.
        if ( $this->grapheme_len( $body_text ) > self::CHAR_LIMIT ) {
            if ( '' !== $link && false !== strpos( $body_text, $link ) ) {
                $pos_byte = strpos( $body_text, $link );
                $pre = rtrim( substr( $body_text, 0, $pos_byte ) );
                $budget = self::CHAR_LIMIT - $this->grapheme_len( $link ) - 1;
                $pre = $this->truncate_graphemes( $pre, max( 0, $budget ) );
                $body_text = ( '' !== $pre ? $pre . ' ' . $link : $link );
            } else {
                $body_text = $this->truncate_graphemes( $body_text, self::CHAR_LIMIT );
            }
        }
        // Mark the link as a clickable rich-text facet (UTF-8 byte offsets).
        $facets = array();
        if ( '' !== $link ) {
            $pos = strpos( $body_text, $link );
            if ( false !== $pos ) {
                $facets[] = array(
                    'index'    => array(
                        'byteStart' => $pos,
                        'byteEnd'   => $pos + strlen( $link ),
                    ),
                    'features' => array(array(
                        '$type' => 'app.bsky.richtext.facet#link',
                        'uri'   => $link,
                    )),
                );
            }
        }
        $record = array(
            '$type'     => 'app.bsky.feed.post',
            'text'      => $body_text,
            'createdAt' => gmdate( 'Y-m-d\\TH:i:s\\Z' ),
        );
        if ( !empty( $facets ) ) {
            $record['facets'] = $facets;
        }
        // Branded image as an images embed (optional).
        $image_error = '';
        if ( is_array( $image_data ) && !empty( $image_data['local_path'] ) && file_exists( $image_data['local_path'] ) ) {
            $blob = $this->upload_blob( $jwt, $image_data );
            if ( is_wp_error( $blob ) ) {
                $image_error = $blob->get_error_message();
            } else {
                $image_embed = array(
                    'alt'   => $this->grapheme_substr( $body_text, 280 ),
                    'image' => $blob,
                );
                // Declare the true aspect ratio. Without it Bluesky cannot size the
                // image before it loads, so it frames it in a default squarish box
                // and letterboxes with the app's pale-grey background. Supplying the
                // real dimensions makes it render at the correct shape (e.g. 16:9).
                $dims = @getimagesize( $image_data['local_path'] );
                // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
                if ( is_array( $dims ) && (int) $dims[0] > 0 && (int) $dims[1] > 0 ) {
                    $image_embed['aspectRatio'] = array(
                        'width'  => (int) $dims[0],
                        'height' => (int) $dims[1],
                    );
                }
                $record['embed'] = array(
                    '$type'  => 'app.bsky.embed.images',
                    'images' => array($image_embed),
                );
            }
        }
        $response = wp_remote_post( self::SERVICE . '/xrpc/com.atproto.repo.createRecord', array(
            'timeout' => 30,
            'headers' => array(
                'Content-Type'  => 'application/json',
                'Authorization' => 'Bearer ' . $jwt,
            ),
            'body'    => wp_json_encode( array(
                'repo'       => $did,
                'collection' => 'app.bsky.feed.post',
                'record'     => $record,
            ) ),
        ) );
        if ( is_wp_error( $response ) ) {
            return $response;
        }
        $code = (int) wp_remote_retrieve_response_code( $response );
        $data = json_decode( wp_remote_retrieve_body( $response ), true );
        if ( 200 !== $code || empty( $data['uri'] ) ) {
            // A cached access token that expired inside the cache window shows as
            // 401; drop it so the next attempt re-authenticates.
            if ( 401 === $code ) {
                $this->forget_session( $account );
            }
            $message = ( !empty( $data['message'] ) ? $data['message'] : 'Post failed (HTTP ' . $code . ').' );
            return new WP_Error('pkits_sp_bluesky_post', $message);
        }
        $out = array(
            'platform_post_id' => $data['uri'],
        );
        if ( '' !== $image_error ) {
            $out['_image_error'] = $image_error;
        }
        return $out;
    }

    /**
     * Upload an image as a blob and return the blob reference for embedding.
     *
     * @param string $jwt        Access JWT.
     * @param array  $image_data Image data with local_path and mime_type.
     * @return array|WP_Error Blob reference array, or WP_Error.
     */
    private function upload_blob( $jwt, array $image_data ) {
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
        $bytes = file_get_contents( $image_data['local_path'] );
        if ( false === $bytes ) {
            return new WP_Error('pkits_sp_bluesky_blob', 'Could not read the image file.');
        }
        $mime = ( !empty( $image_data['mime_type'] ) ? $image_data['mime_type'] : 'image/jpeg' );
        $response = wp_remote_post( self::SERVICE . '/xrpc/com.atproto.repo.uploadBlob', array(
            'timeout' => 60,
            'headers' => array(
                'Content-Type'  => $mime,
                'Authorization' => 'Bearer ' . $jwt,
            ),
            'body'    => $bytes,
        ) );
        if ( is_wp_error( $response ) ) {
            return $response;
        }
        $code = (int) wp_remote_retrieve_response_code( $response );
        $data = json_decode( wp_remote_retrieve_body( $response ), true );
        if ( 200 !== $code || empty( $data['blob'] ) ) {
            $message = ( !empty( $data['message'] ) ? $data['message'] : 'Image upload failed.' );
            // BlueSky rejects blobs over ~976KB (BlobTooLarge); surface the actual
            // size and dimensions so that cause is obvious from the history record.
            return new WP_Error('pkits_sp_bluesky_blob', 'BlueSky image upload failed (HTTP ' . $code . '): ' . esc_html( $message ) . ' [' . PKITS_SP_Image_Handler::describe_for_log( $image_data ) . ']');
        }
        return $data['blob'];
    }

    /**
     * Grapheme length, falling back to multibyte length when intl is absent.
     *
     * @param string $string String to measure.
     * @return int
     */
    private function grapheme_len( $string ) {
        if ( function_exists( 'grapheme_strlen' ) ) {
            $n = grapheme_strlen( $string );
            if ( null !== $n && false !== $n ) {
                return (int) $n;
            }
        }
        return (int) mb_strlen( $string, 'UTF-8' );
    }

    /**
     * Grapheme-aware substring from the start.
     *
     * @param string $string String to cut.
     * @param int    $length Number of graphemes.
     * @return string
     */
    private function grapheme_substr( $string, $length ) {
        if ( function_exists( 'grapheme_substr' ) ) {
            $r = grapheme_substr( $string, 0, $length );
            if ( null !== $r && false !== $r ) {
                return $r;
            }
        }
        return mb_substr(
            $string,
            0,
            $length,
            'UTF-8'
        );
    }

    /**
     * Truncate to a grapheme budget, appending an ellipsis when shortened.
     *
     * @param string $string String to truncate.
     * @param int    $length Grapheme budget (inclusive of the ellipsis).
     * @return string
     */
    private function truncate_graphemes( $string, $length ) {
        if ( $length <= 0 ) {
            return '';
        }
        if ( $this->grapheme_len( $string ) <= $length ) {
            return $string;
        }
        return rtrim( $this->grapheme_substr( $string, max( 0, $length - 1 ) ) ) . '…';
    }

}
