<?php

/**
 * SocialPromoter Admin
 *
 * Registers admin menus, handles OAuth callbacks, enqueues assets,
 * and renders the accounts and queue admin pages.
 *
 * @package PublisherKits_SocialPromoter
 */
if ( !defined( 'ABSPATH' ) ) {
    exit;
}
/**
 * Class PKITS_SP_Admin
 */
class PKITS_SP_Admin {
    /**
     * Hook suffixes of the admin screens registered by register_menus(),
     * keyed by menu slug.
     *
     * Recorded as each screen is registered, so enqueue_assets() never carries a
     * second, hand-maintained copy of the page list. That duplicate list is why
     * the Support screen (1.52.0) and the Pro Channel report both shipped with no
     * plugin CSS or JS: each was registered but neither was added to the list, and
     * a screen missing from it fails silently -- the page just renders unstyled.
     * Screens contributed through the pkits_sp_admin_submenu_pages filter are
     * recorded here too, so a satellite feature gets the plugin's assets without
     * this class knowing it exists.
     *
     * @since 1.53.0
     * @var array<string,string> Menu slug => admin page hook suffix.
     */
    private $page_hooks = array();

    /**
     * Callback URL for all platforms (just our accounts page URL).
     *
     * @return string
     */
    public static function callback_url() {
        return admin_url( 'admin.php?page=publisherkits-socialpromoter-accounts' );
    }

    /**
     * Dedicated LinkedIn relay callback URL.
     *
     * Using admin-post avoids the admin page refresh silently dropping the
     * LinkedIn ticket callback on some hosts/security layers.
     *
     * @return string
     */
    public static function linkedin_callback_url() {
        return admin_url( 'admin-post.php?action=pkits_sp_linkedin_callback' );
    }

    /**
     * Register admin menus.
     *
     * SocialPromoter registers as its own top-level menu with Accounts,
     * Queue, and History as sub-pages.
     *
     * @return void
     */
    public function register_menus() {
        $this->page_hooks['publisherkits-socialpromoter-accounts'] = add_menu_page(
            esc_html__( 'SocialPromoter', 'publisherkits-socialpromoter' ),
            esc_html__( 'SocialPromoter', 'publisherkits-socialpromoter' ),
            'manage_options',
            'publisherkits-socialpromoter-accounts',
            array($this, 'render_accounts_page'),
            'dashicons-megaphone',
            59
        );
        // Same slug as the parent, so WordPress returns the toplevel_page_ hook
        // already recorded above; nothing to capture here.
        add_submenu_page(
            'publisherkits-socialpromoter-accounts',
            esc_html__( 'SocialPromoter — Platforms', 'publisherkits-socialpromoter' ),
            esc_html__( 'Platforms', 'publisherkits-socialpromoter' ),
            'manage_options',
            'publisherkits-socialpromoter-accounts',
            array($this, 'render_accounts_page')
        );
        $this->page_hooks['publisherkits-socialpromoter-compose'] = add_submenu_page(
            'publisherkits-socialpromoter-accounts',
            esc_html__( 'SocialPromoter — Compose', 'publisherkits-socialpromoter' ),
            esc_html__( 'Compose', 'publisherkits-socialpromoter' ),
            'publish_posts',
            'publisherkits-socialpromoter-compose',
            array($this, 'render_compose_page')
        );
        $this->page_hooks['publisherkits-socialpromoter-queue'] = add_submenu_page(
            'publisherkits-socialpromoter-accounts',
            esc_html__( 'SocialPromoter — Activity', 'publisherkits-socialpromoter' ),
            esc_html__( 'Activity', 'publisherkits-socialpromoter' ),
            'manage_options',
            'publisherkits-socialpromoter-queue',
            array($this, 'render_queue_page')
        );
        $this->page_hooks['publisherkits-socialpromoter-settings'] = add_submenu_page(
            'publisherkits-socialpromoter-accounts',
            esc_html__( 'SocialPromoter — Settings', 'publisherkits-socialpromoter' ),
            esc_html__( 'Settings', 'publisherkits-socialpromoter' ),
            'manage_options',
            'publisherkits-socialpromoter-settings',
            array($this, 'render_settings_page')
        );
        $this->page_hooks['publisherkits-socialpromoter-history'] = add_submenu_page(
            'publisherkits-socialpromoter-accounts',
            esc_html__( 'SocialPromoter — Post History', 'publisherkits-socialpromoter' ),
            esc_html__( 'History', 'publisherkits-socialpromoter' ),
            'manage_options',
            'publisherkits-socialpromoter-history',
            array($this, 'render_history_page')
        );
        // (Premium registers a hidden campaign-editor page; nothing here in free.)
        // style enqueued in enqueue_assets() (CSS hide, not remove_submenu_page(),
        // which would break direct-URL capability checks).
        /**
         * Submenu pages contributed by satellite features, so this class carries no
         * knowledge of them. Used by the Support screen.
         *
         * Deliberately apply_filters and never do_action: do_action passes the array
         * by value, so a listener could not add anything to it.
         *
         * @since 1.52.0
         *
         * @param array $pages List of page definitions, each with page_title,
         *                     menu_title, capability, menu_slug and callback.
         */
        $sp_extra_pages = apply_filters( 'pkits_sp_admin_submenu_pages', array() );
        foreach ( (array) $sp_extra_pages as $sp_page ) {
            if ( empty( $sp_page['menu_slug'] ) || empty( $sp_page['callback'] ) ) {
                continue;
            }
            $sp_hook = add_submenu_page(
                'publisherkits-socialpromoter-accounts',
                ( isset( $sp_page['page_title'] ) ? esc_html( $sp_page['page_title'] ) : '' ),
                ( isset( $sp_page['menu_title'] ) ? esc_html( $sp_page['menu_title'] ) : '' ),
                ( isset( $sp_page['capability'] ) ? $sp_page['capability'] : 'manage_options' ),
                $sp_page['menu_slug'],
                $sp_page['callback']
            );
            // A satellite screen is one of ours and gets our assets. Without this
            // the Support screen rendered with default WordPress form styling.
            if ( $sp_hook ) {
                $this->page_hooks[$sp_page['menu_slug']] = $sp_hook;
            }
        }
    }

    /**
     * Handle OAuth callbacks on admin_init.
     *
     * Runs before any output. Checks for OAuth callback parameters and
     * processes them, then redirects cleanly to the accounts page.
     *
     * @return void
     */
    public function handle_oauth_callback() {
        // phpcs:disable WordPress.Security.NonceVerification.Recommended
        if ( empty( $_GET['page'] ) || 'publisherkits-socialpromoter-accounts' !== $_GET['page'] ) {
            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
            return;
        }
        // Connecting accounts is an administrator action (the Accounts screen is
        // manage_options). This runs on admin_init, before any menu-page cap
        // check, so gate every callback branch here — otherwise a low-privilege
        // or CSRF'd request could complete an account connection (notably the X
        // branch, which has no state param) or plant an admin-notice string.
        if ( !current_user_can( 'manage_options' ) ) {
            return;
        }
        // X / Twitter OAuth 1.0a callback.
        if ( !empty( $_GET['oauth_token'] ) && !empty( $_GET['oauth_verifier'] ) ) {
            $this->process_x_callback();
            return;
        }
        // Facebook OAuth 2.0 callback.
        if ( !empty( $_GET['code'] ) && !empty( $_GET['state'] ) ) {
            $state = sanitize_text_field( wp_unslash( $_GET['state'] ) );
            if ( 0 === strpos( $state, 'facebook_' ) ) {
                $this->process_facebook_callback( $state );
            }
            return;
        }
        // LinkedIn relay callback. Supports the legacy accounts payload and the
        // ticket-exchange relay used from v1.7.3 onwards.
        if ( !empty( $_GET['pkits_sp_li_nonce'] ) && (!empty( $_GET['pkits_sp_li_accounts'] ) || !empty( $_GET['pkits_sp_li_ticket'] )) ) {
            $this->process_linkedin_relay_callback();
            return;
        }
        // LinkedIn relay error.
        if ( !empty( $_GET['pkits_sp_li_error'] ) ) {
            $error = sanitize_text_field( wp_unslash( $_GET['pkits_sp_li_error'] ) );
            set_transient( 'pkits_sp_admin_notice', array(
                'type'    => 'error',
                'message' => 'LinkedIn error: ' . $error,
            ), 60 );
            wp_safe_redirect( admin_url( 'admin.php?page=publisherkits-socialpromoter-accounts' ) );
            exit;
        }
        // OAuth error returned by a platform.
        if ( !empty( $_GET['error'] ) ) {
            $error_key = ( isset( $_GET['error_description'] ) ? wp_unslash( $_GET['error_description'] ) : wp_unslash( $_GET['error'] ) );
            $error = sanitize_text_field( wp_unslash( $error_key ) );
            set_transient( 'pkits_sp_admin_notice', array(
                'type'    => 'error',
                'message' => $error,
            ), 60 );
        }
        // phpcs:enable WordPress.Security.NonceVerification.Recommended
    }

    /**
     * Process the X OAuth 1.0a callback.
     *
     * @return void
     */
    private function process_x_callback() {
        // phpcs:disable WordPress.Security.NonceVerification.Recommended
        $oauth_token = sanitize_text_field( wp_unslash( $_GET['oauth_token'] ) );
        $oauth_verifier = sanitize_text_field( wp_unslash( $_GET['oauth_verifier'] ) );
        // phpcs:enable WordPress.Security.NonceVerification.Recommended
        // Retrieve the app credentials that initiated this flow.
        $creds = PKITS_SP_Accounts::get_app_credentials( 'x' );
        if ( empty( $creds['consumer_key'] ) || empty( $creds['consumer_secret'] ) ) {
            set_transient( 'pkits_sp_admin_notice', array(
                'type'    => 'error',
                'message' => 'X app credentials not found. Please re-enter them.',
            ), 60 );
            wp_safe_redirect( admin_url( 'admin.php?page=publisherkits-socialpromoter-accounts' ) );
            exit;
        }
        $connector = new PKITS_SP_Connector_X();
        $result = $connector->get_access_token(
            $creds['consumer_key'],
            $creds['consumer_secret'],
            $oauth_token,
            $oauth_verifier
        );
        if ( is_wp_error( $result ) ) {
            set_transient( 'pkits_sp_admin_notice', array(
                'type'    => 'error',
                'message' => 'X connection failed: ' . $result->get_error_message(),
            ), 60 );
        } else {
            PKITS_SP_Accounts::save( $result );
            set_transient( 'pkits_sp_admin_notice', array(
                'type'    => 'success',
                'message' => 'X account connected: ' . $result['label'],
            ), 60 );
        }
        wp_safe_redirect( admin_url( 'admin.php?page=publisherkits-socialpromoter-accounts' ) );
        exit;
    }

    /**
     * Process the Facebook OAuth 2.0 callback.
     *
     * @param string $state State parameter from callback.
     * @return void
     */
    private function process_facebook_callback( $state ) {
        $stored_state = get_transient( 'pkits_sp_oauth_state_' . md5( $state ) );
        if ( empty( $stored_state ) ) {
            set_transient( 'pkits_sp_admin_notice', array(
                'type'    => 'error',
                'message' => 'OAuth session expired. Please try connecting again.',
            ), 60 );
            wp_safe_redirect( admin_url( 'admin.php?page=publisherkits-socialpromoter-accounts' ) );
            exit;
        }
        delete_transient( 'pkits_sp_oauth_state_' . md5( $state ) );
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
        $code = sanitize_text_field( wp_unslash( $_GET['code'] ) );
        $creds = PKITS_SP_Accounts::get_app_credentials( 'facebook' );
        $connector = new PKITS_SP_Connector_Facebook();
        $accounts = $connector->get_accounts(
            $creds['app_id'],
            $creds['app_secret'],
            $code,
            self::callback_url()
        );
        if ( is_wp_error( $accounts ) ) {
            set_transient( 'pkits_sp_admin_notice', array(
                'type'    => 'error',
                'message' => 'Facebook connection failed: ' . $accounts->get_error_message(),
            ), 60 );
        } else {
            $count = 0;
            foreach ( $accounts as $account ) {
                PKITS_SP_Accounts::save( $account );
                $count++;
            }
            set_transient( 'pkits_sp_admin_notice', array(
                'type'    => 'success',
                'message' => $count . ' Facebook Page(s) connected.',
            ), 60 );
        }
        wp_safe_redirect( admin_url( 'admin.php?page=publisherkits-socialpromoter-accounts' ) );
        exit;
    }

    /**
     * Process the LinkedIn relay callback.
     *
     * The PublisherKits relay exchanges the OAuth code. Older relays returned the
     * encoded accounts payload in the browser URL. The current relay returns a
     * short ticket, then this site exchanges that ticket server-to-server.
     *
     * @return void
     */
    public function process_linkedin_relay_callback() {
        if ( !current_user_can( 'manage_options' ) ) {
            wp_die( esc_html__( 'Permission denied.', 'publisherkits-socialpromoter' ) );
        }
        // phpcs:disable WordPress.Security.NonceVerification.Recommended
        // The relay returns errors to this same callback URL with only an error
        // param (no nonce). Surface that real message instead of letting the
        // nonce check below overwrite it with a generic "no session nonce".
        if ( !empty( $_GET['pkits_sp_li_error'] ) ) {
            $relay_error = sanitize_text_field( wp_unslash( $_GET['pkits_sp_li_error'] ) );
            set_transient( 'pkits_sp_admin_notice', array(
                'type'    => 'error',
                'message' => 'LinkedIn connection failed: ' . $relay_error,
            ), 60 );
            wp_safe_redirect( admin_url( 'admin.php?page=publisherkits-socialpromoter-accounts' ) );
            exit;
        }
        $nonce = sanitize_text_field( wp_unslash( $_GET['pkits_sp_li_nonce'] ?? '' ) );
        $ticket = sanitize_text_field( wp_unslash( $_GET['pkits_sp_li_ticket'] ?? '' ) );
        // The nonce is carried as a URL param across a cross-domain redirect chain
        // and some hosts/security layers strip it. If it is missing but the relay
        // ticket survived, recover the nonce from the server-side pending slot so
        // the flow is not broken by a dropped query param.
        if ( '' === $nonce && '' !== $ticket ) {
            $nonce = (string) get_transient( 'pkits_sp_li_pending_nonce' );
        }
        if ( '' === $nonce ) {
            $why = ( '' === $ticket ? 'LinkedIn callback arrived without its ticket or session details. A security or caching layer may be stripping query parameters from the admin-post.php URL. Please try again, and if it persists, allow the pkits_sp_li_ticket and pkits_sp_li_nonce parameters through any firewall or page-cache rules.' : 'LinkedIn callback did not include a session nonce, and the saved session could not be recovered (it may have expired). Please try connecting again.' );
            set_transient( 'pkits_sp_admin_notice', array(
                'type'    => 'error',
                'message' => $why,
            ), 60 );
            wp_safe_redirect( admin_url( 'admin.php?page=publisherkits-socialpromoter-accounts' ) );
            exit;
        }
        // phpcs:enable WordPress.Security.NonceVerification.Recommended
        $stored = get_transient( 'pkits_sp_li_nonce_' . $nonce );
        if ( empty( $stored ) ) {
            set_transient( 'pkits_sp_admin_notice', array(
                'type'    => 'error',
                'message' => 'LinkedIn session expired or invalid. Please try again.',
            ), 60 );
            wp_safe_redirect( admin_url( 'admin.php?page=publisherkits-socialpromoter-accounts' ) );
            exit;
        }
        $accounts = $this->get_linkedin_relay_accounts( $nonce );
        if ( is_wp_error( $accounts ) ) {
            set_transient( 'pkits_sp_admin_notice', array(
                'type'    => 'error',
                'message' => 'LinkedIn connection failed: ' . $accounts->get_error_message(),
            ), 60 );
            wp_safe_redirect( admin_url( 'admin.php?page=publisherkits-socialpromoter-accounts' ) );
            exit;
        }
        delete_transient( 'pkits_sp_li_nonce_' . $nonce );
        delete_transient( 'pkits_sp_li_pending_nonce' );
        $count = 0;
        $company_count = 0;
        foreach ( $accounts as $account ) {
            if ( !empty( $account['platform'] ) && !empty( $account['account_id'] ) ) {
                PKITS_SP_Accounts::save( $account );
                $count++;
                if ( !empty( $account['account_type'] ) && 'company' === $account['account_type'] ) {
                    $company_count++;
                }
            }
        }
        if ( 0 === $count ) {
            set_transient( 'pkits_sp_admin_notice', array(
                'type'    => 'error',
                'message' => 'LinkedIn connection returned no usable accounts.',
            ), 60 );
            wp_safe_redirect( admin_url( 'admin.php?page=publisherkits-socialpromoter-accounts' ) );
            exit;
        }
        $message = $count . ' LinkedIn account(s) connected.';
        if ( 0 === $company_count ) {
            $message .= ' No LinkedIn company pages were returned.';
        }
        set_transient( 'pkits_sp_admin_notice', array(
            'type'    => 'success',
            'message' => $message,
        ), 60 );
        wp_safe_redirect( admin_url( 'admin.php?page=publisherkits-socialpromoter-accounts' ) );
        exit;
    }

    /**
     * Retrieve LinkedIn accounts from the relay callback.
     *
     * @param string $nonce OAuth nonce stored before redirecting to the relay.
     * @return array|WP_Error
     */
    private function get_linkedin_relay_accounts( $nonce ) {
        // phpcs:disable WordPress.Security.NonceVerification.Recommended
        if ( !empty( $_GET['pkits_sp_li_ticket'] ) ) {
            $ticket = sanitize_text_field( wp_unslash( $_GET['pkits_sp_li_ticket'] ) );
            $sig = sanitize_text_field( wp_unslash( $_GET['pkits_sp_li_sig'] ?? '' ) );
            // phpcs:enable WordPress.Security.NonceVerification.Recommended
            return $this->exchange_linkedin_relay_ticket( $nonce, $ticket, $sig );
        }
        $raw_data = sanitize_text_field( wp_unslash( $_GET['pkits_sp_li_accounts'] ?? '' ) );
        // phpcs:enable WordPress.Security.NonceVerification.Recommended
        if ( '' === $raw_data ) {
            return new WP_Error('pkits_sp_linkedin_missing_payload', 'LinkedIn relay returned no account payload.');
        }
        $decoded = base64_decode( $raw_data, true );
        // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
        if ( false === $decoded ) {
            return new WP_Error('pkits_sp_linkedin_bad_payload', 'LinkedIn relay returned invalid account encoding.');
        }
        $accounts = json_decode( $decoded, true );
        if ( empty( $accounts ) || !is_array( $accounts ) ) {
            return new WP_Error('pkits_sp_linkedin_invalid_accounts', 'LinkedIn connection returned invalid data.');
        }
        return $accounts;
    }

    /**
     * Exchange a short LinkedIn relay ticket for account data.
     *
     * @param string $nonce OAuth nonce stored before redirecting to the relay.
     * @param string $ticket Relay ticket.
     * @param string $sig Relay ticket signature.
     * @return array|WP_Error
     */
    private function exchange_linkedin_relay_ticket( $nonce, $ticket, $sig ) {
        if ( '' === $ticket || '' === $sig ) {
            return new WP_Error('pkits_sp_linkedin_missing_ticket', 'LinkedIn relay returned an incomplete ticket.');
        }
        $relay_url = apply_filters( 'pkits_sp_linkedin_relay_url', 'https://pkits.org/linkedin-connect.php' );
        $response = wp_remote_post( $relay_url, array(
            'timeout' => 30,
            'body'    => array(
                'action' => 'exchange',
                'ticket' => $ticket,
                'nonce'  => $nonce,
                'sig'    => $sig,
            ),
        ) );
        if ( is_wp_error( $response ) ) {
            return $response;
        }
        $code = (int) wp_remote_retrieve_response_code( $response );
        $body = (string) wp_remote_retrieve_body( $response );
        $data = json_decode( $body, true );
        if ( $code < 200 || $code >= 300 ) {
            $message = 'LinkedIn relay ticket exchange failed.';
            if ( is_array( $data ) && !empty( $data['error'] ) ) {
                $message = (string) $data['error'];
            }
            return new WP_Error('pkits_sp_linkedin_ticket_exchange_failed', $message);
        }
        if ( empty( $data['accounts'] ) || !is_array( $data['accounts'] ) ) {
            return new WP_Error('pkits_sp_linkedin_ticket_payload_invalid', 'LinkedIn relay ticket returned invalid account data.');
        }
        return $data['accounts'];
    }

    /**
     * Handle credential save and Connect Account form submissions.
     *
     * Called from admin_post hooks.
     *
     * @return void
     */
    public function handle_save_credentials() {
        if ( !current_user_can( 'manage_options' ) ) {
            wp_die( esc_html__( 'Permission denied.', 'publisherkits-socialpromoter' ) );
        }
        check_admin_referer( 'pkits_sp_save_credentials' );
        $platform = sanitize_key( wp_unslash( $_POST['pkits_sp_platform'] ?? '' ) );
        if ( !in_array( $platform, PKITS_SP_Accounts::PLATFORMS, true ) ) {
            wp_safe_redirect( admin_url( 'admin.php?page=publisherkits-socialpromoter-accounts&pkits_error=1' ) );
            exit;
        }
        switch ( $platform ) {
            case 'x':
                $creds = array(
                    'consumer_key'    => sanitize_text_field( wp_unslash( $_POST['consumer_key'] ?? '' ) ),
                    'consumer_secret' => sanitize_text_field( wp_unslash( $_POST['consumer_secret'] ?? '' ) ),
                );
                PKITS_SP_Accounts::save_app_credentials( 'x', $creds );
                // Initiate OAuth flow.
                if ( !empty( $creds['consumer_key'] ) && !empty( $creds['consumer_secret'] ) ) {
                    $connector = new PKITS_SP_Connector_X();
                    $result = $connector->get_auth_url( $creds['consumer_key'], $creds['consumer_secret'], self::callback_url() );
                    if ( is_wp_error( $result ) ) {
                        set_transient( 'pkits_sp_admin_notice', array(
                            'type'    => 'error',
                            'message' => 'Could not initiate X OAuth: ' . $result->get_error_message(),
                        ), 60 );
                        wp_safe_redirect( admin_url( 'admin.php?page=publisherkits-socialpromoter-accounts' ) );
                        exit;
                    }
                    wp_redirect( esc_url_raw( $result['auth_url'] ) );
                    // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect
                    exit;
                }
                break;
            case 'facebook':
                $creds = array(
                    'app_id'     => sanitize_text_field( wp_unslash( $_POST['app_id'] ?? '' ) ),
                    'app_secret' => sanitize_text_field( wp_unslash( $_POST['app_secret'] ?? '' ) ),
                );
                PKITS_SP_Accounts::save_app_credentials( 'facebook', $creds );
                if ( !empty( $creds['app_id'] ) && !empty( $creds['app_secret'] ) ) {
                    $state = 'facebook_' . wp_generate_password( 16, false );
                    set_transient( 'pkits_sp_oauth_state_' . md5( $state ), 'facebook', 15 * MINUTE_IN_SECONDS );
                    $connector = new PKITS_SP_Connector_Facebook();
                    $auth_url = $connector->get_auth_url( $creds['app_id'], self::callback_url(), $state );
                    wp_redirect( esc_url_raw( $auth_url ) );
                    // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect
                    exit;
                }
                break;
            case 'linkedin':
                // LinkedIn uses the PublisherKits relay — no local credentials needed.
                // Redirect to the relay which handles the full OAuth flow.
                $nonce = wp_generate_password( 24, false );
                set_transient( 'pkits_sp_li_nonce_' . $nonce, '1', 15 * MINUTE_IN_SECONDS );
                // Also store the nonce in a single recoverable slot so the callback
                // can proceed even if a security/caching layer strips the nonce
                // query param from the admin-post return URL. Only one connect flow
                // runs at a time per site, so a single slot is safe.
                set_transient( 'pkits_sp_li_pending_nonce', $nonce, 15 * MINUTE_IN_SECONDS );
                $relay_base = apply_filters( 'pkits_sp_linkedin_relay_url', 'https://pkits.org/linkedin-connect.php' );
                $relay_url = add_query_arg( array(
                    'return' => self::linkedin_callback_url(),
                    'nonce'  => $nonce,
                ), $relay_base );
                wp_redirect( esc_url_raw( $relay_url ) );
                // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect
                exit;
        }
        set_transient( 'pkits_sp_admin_notice', array(
            'type'    => 'success',
            'message' => 'Credentials saved.',
        ), 60 );
        wp_safe_redirect( admin_url( 'admin.php?page=publisherkits-socialpromoter-accounts' ) );
        exit;
    }

    /**
     * Handle the "Save posting selection" form: set which connected accounts
     * are active for posting. Unticked accounts stay connected but are hidden
     * from the posting surfaces.
     *
     * @return void
     */
    public function handle_save_active_accounts() {
        if ( !current_user_can( 'manage_options' ) ) {
            wp_die( esc_html__( 'Permission denied.', 'publisherkits-socialpromoter' ) );
        }
        check_admin_referer( 'pkits_sp_save_active_accounts' );
        $platform = ( isset( $_POST['pkits_sp_platform'] ) ? sanitize_key( wp_unslash( $_POST['pkits_sp_platform'] ) ) : '' );
        if ( '' === $platform ) {
            wp_safe_redirect( admin_url( 'admin.php?page=publisherkits-socialpromoter-accounts' ) );
            exit;
        }
        $raw_checked = ( isset( $_POST['pkits_sp_active_accounts'] ) ? wp_unslash( $_POST['pkits_sp_active_accounts'] ) : array() );
        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
        $checked = array_map( 'sanitize_text_field', (array) $raw_checked );
        $checked = array_fill_keys( $checked, true );
        // Apply only within the submitted platform so other platforms are untouched.
        $platform_accounts = PKITS_SP_Accounts::get_by_platform( $platform );
        $active_count = 0;
        foreach ( $platform_accounts as $storage_id => $account ) {
            $is_active = isset( $checked[$storage_id] );
            PKITS_SP_Accounts::set_active( $storage_id, $is_active );
            if ( $is_active ) {
                $active_count++;
            }
        }
        // Standalone statement (not an array element) so the translators comment
        // survives the Freemius deploy processor, which drops comments inside
        // reprinted array literals (PCP then errors on the free build).
        /* translators: %d: number of accounts active for posting */
        $active_message = sprintf( _n(
            '%d account is now active for posting.',
            '%d accounts are now active for posting.',
            $active_count,
            'publisherkits-socialpromoter'
        ), $active_count );
        set_transient( 'pkits_sp_admin_notice', array(
            'type'    => 'success',
            'message' => $active_message,
        ), 60 );
        wp_safe_redirect( admin_url( 'admin.php?page=publisherkits-socialpromoter-accounts' ) );
        exit;
    }

    /**
     * Handle Delete Account form submission.
     *
     * @return void
     */
    /**
     * Connect a Bluesky account from a handle and app password.
     *
     * Bluesky needs no developer app: we validate the credentials by creating a
     * session, then store the account (the app password is encrypted at rest).
     *
     * @return void
     */
    public function handle_bluesky_connect() {
        if ( !current_user_can( 'manage_options' ) ) {
            wp_die( esc_html__( 'Permission denied.', 'publisherkits-socialpromoter' ) );
        }
        check_admin_referer( 'pkits_sp_bluesky_connect' );
        $redirect = admin_url( 'admin.php?page=publisherkits-socialpromoter-accounts' );
        $handle = sanitize_text_field( wp_unslash( $_POST['bluesky_handle'] ?? '' ) );
        $app_pw = sanitize_text_field( wp_unslash( $_POST['bluesky_app_password'] ?? '' ) );
        if ( '' === $handle || '' === $app_pw ) {
            set_transient( 'pkits_sp_admin_notice', array(
                'type'    => 'error',
                'message' => 'Enter your Bluesky handle and app password.',
            ), 60 );
            wp_safe_redirect( $redirect );
            exit;
        }
        $connector = new PKITS_SP_Connector_Bluesky();
        $session = $connector->create_session( $handle, $app_pw );
        if ( is_wp_error( $session ) ) {
            set_transient( 'pkits_sp_admin_notice', array(
                'type'    => 'error',
                'message' => 'Bluesky connection failed: ' . $session->get_error_message(),
            ), 60 );
            wp_safe_redirect( $redirect );
            exit;
        }
        PKITS_SP_Accounts::save( array(
            'platform'     => 'bluesky',
            'account_id'   => $session['did'],
            'handle'       => $session['handle'],
            'app_password' => $app_pw,
            'label'        => $session['handle'],
            'name'         => $session['handle'],
            'connected_at' => current_time( 'mysql' ),
        ) );
        set_transient( 'pkits_sp_admin_notice', array(
            'type'    => 'success',
            'message' => 'Bluesky account connected: ' . $session['handle'],
        ), 60 );
        wp_safe_redirect( $redirect );
        exit;
    }

    public function handle_delete_account() {
        if ( !current_user_can( 'manage_options' ) ) {
            wp_die( esc_html__( 'Permission denied.', 'publisherkits-socialpromoter' ) );
        }
        check_admin_referer( 'pkits_sp_delete_account' );
        $storage_id = sanitize_key( wp_unslash( $_POST['pkits_sp_storage_id'] ?? '' ) );
        if ( !empty( $storage_id ) ) {
            PKITS_SP_Accounts::delete( $storage_id );
            set_transient( 'pkits_sp_admin_notice', array(
                'type'    => 'success',
                'message' => 'Account disconnected.',
            ), 60 );
        }
        wp_safe_redirect( admin_url( 'admin.php?page=publisherkits-socialpromoter-accounts' ) );
        exit;
    }

    /**
     * Handle Cancel Queue Item form submission from the Queue admin page.
     *
     * @return void
     */
    public function handle_cancel_queue_item() {
        if ( !current_user_can( 'manage_options' ) ) {
            wp_die( esc_html__( 'Permission denied.', 'publisherkits-socialpromoter' ) );
        }
        check_admin_referer( 'pkits_sp_cancel_queue' );
        $queue_id = absint( wp_unslash( $_POST['queue_id'] ?? 0 ) );
        $queue = new PKITS_SP_Queue();
        if ( $queue_id && $queue->cancel( $queue_id ) ) {
            set_transient( 'pkits_sp_admin_notice', array(
                'type'    => 'success',
                'message' => 'Scheduled post cancelled.',
            ), 60 );
        }
        wp_safe_redirect( admin_url( 'admin.php?page=publisherkits-socialpromoter-queue' ) );
        exit;
    }

    /**
     *
     * @param string $hook Current admin page hook.
     * @return void
     */
    public function enqueue_assets( $hook ) {
        // Every screen register_menus() registered, in whichever build and licence
        // state we are running, including any contributed through the
        // pkits_sp_admin_submenu_pages filter. admin_menu always fires before
        // admin_enqueue_scripts, and both hooks carry the same instance, so this is
        // populated by the time we are asked. Never hand-list hooks here: see the
        // $page_hooks docblock for what that cost.
        $is_sp_page = in_array( $hook, $this->page_hooks, true );
        $is_post_edit = in_array( $hook, array('post.php', 'post-new.php'), true );
        if ( !$is_sp_page && !$is_post_edit ) {
            return;
        }
        wp_enqueue_style(
            'pkits-sp-admin',
            PKITS_SP_URL . 'admin/css/admin.css',
            array(),
            PKITS_SP_VERSION
        );
        wp_enqueue_script(
            'pkits-sp-admin',
            PKITS_SP_URL . 'admin/js/admin.js',
            array('jquery'),
            PKITS_SP_VERSION,
            true
        );
        // Enqueue WordPress media uploader on settings, compose and campaign
        // editor pages (the campaign editor has image pool + house image fields).
        $media_slugs = array('publisherkits-socialpromoter-settings', 'publisherkits-socialpromoter-compose');
        $media_pages = array();
        foreach ( $media_slugs as $sp_media_slug ) {
            if ( isset( $this->page_hooks[$sp_media_slug] ) ) {
                $media_pages[] = $this->page_hooks[$sp_media_slug];
            }
        }
        if ( in_array( $hook, $media_pages, true ) ) {
            wp_enqueue_media();
        }
        wp_localize_script( 'pkits-sp-admin', 'pkitsSpAdmin', array(
            'ajaxUrl' => admin_url( 'admin-ajax.php' ),
            'nonce'   => wp_create_nonce( 'pkits_sp_metabox' ),
            'strings' => array(
                'posting'    => esc_html__( 'Posting...', 'publisherkits-socialpromoter' ),
                'scheduling' => esc_html__( 'Scheduling...', 'publisherkits-socialpromoter' ),
                'cancelling' => esc_html__( 'Cancelling...', 'publisherkits-socialpromoter' ),
                'xLimit'     => esc_html__( 'X (Twitter) limit: 280 characters', 'publisherkits-socialpromoter' ),
            ),
        ) );
    }

    /**
     * Render the Accounts admin page.
     *
     * @return void
     */
    public function render_accounts_page() {
        if ( !current_user_can( 'manage_options' ) ) {
            wp_die( esc_html__( 'Permission denied.', 'publisherkits-socialpromoter' ) );
        }
        require_once PKITS_SP_DIR . 'admin/views/page-accounts.php';
    }

    /**
     * Render the Queue admin page.
     *
     * @return void
     */
    public function render_queue_page() {
        if ( !current_user_can( 'manage_options' ) ) {
            wp_die( esc_html__( 'Permission denied.', 'publisherkits-socialpromoter' ) );
        }
        require_once PKITS_SP_DIR . 'admin/views/page-queue.php';
    }

    /**
     * Render the Compose admin page.
     *
     * @return void
     */
    public function render_compose_page() {
        if ( !current_user_can( 'edit_posts' ) ) {
            wp_die( esc_html__( 'Permission denied.', 'publisherkits-socialpromoter' ) );
        }
        require_once PKITS_SP_DIR . 'admin/views/page-compose.php';
    }

    /**
     * Register compose AJAX handler.
     *
     * @return void
     */
    public function register_compose_ajax_handlers() {
        add_action( 'wp_ajax_pkits_sp_compose_post', array($this, 'ajax_compose_post') );
    }

    /**
     * AJAX: submit a composed post to one or more accounts.
     *
     * Accepts per-platform copy keyed by storage_id, creates one queue
     * item per account. Optionally links to a campaign.
     *
     * @return void
     */
    public function ajax_compose_post() {
        check_ajax_referer( 'pkits_sp_metabox', 'nonce' );
        if ( !current_user_can( 'publish_posts' ) ) {
            wp_send_json_error( array(
                'message' => esc_html__( 'Permission denied.', 'publisherkits-socialpromoter' ),
            ) );
        }
        // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
        $copies = ( isset( $_POST['copies'] ) ? wp_unslash( $_POST['copies'] ) : array() );
        // phpcs:enable
        $url = esc_url_raw( wp_unslash( $_POST['url'] ?? '' ) );
        $image_url = esc_url_raw( wp_unslash( $_POST['image_url'] ?? '' ) );
        $scheduled = sanitize_text_field( wp_unslash( $_POST['scheduled_at'] ?? '' ) );
        // The scheduler input is a datetime-local value = the user's SITE-LOCAL
        // wall time. WordPress runs PHP in UTC, so convert to UTC before storing,
        // otherwise the send is offset by the site's UTC offset. Empty = post now.
        if ( '' !== $scheduled ) {
            $scheduled = get_gmt_from_date( str_replace( 'T', ' ', $scheduled ) );
        }
        // Campaigns exist only in the premium build; free composes standalone.
        $campaign_id = 0;
        $post_id = absint( wp_unslash( $_POST['post_id'] ?? 0 ) );
        if ( $post_id && !current_user_can( 'edit_post', $post_id ) ) {
            wp_send_json_error( array(
                'message' => __( 'You cannot promote this post.', 'publisherkits-socialpromoter' ),
            ), 403 );
        }
        if ( empty( $copies ) || !is_array( $copies ) ) {
            wp_send_json_error( array(
                'message' => esc_html__( 'No accounts selected.', 'publisherkits-socialpromoter' ),
            ) );
        }
        $queue = new PKITS_SP_Queue();
        $created = array();
        $errors = array();
        $now = current_time( 'mysql', 1 );
        foreach ( $copies as $storage_id => $copy_text ) {
            $storage_id = sanitize_key( $storage_id );
            $copy_text = sanitize_textarea_field( $copy_text );
            if ( empty( $copy_text ) ) {
                continue;
            }
            $account = PKITS_SP_Accounts::get( $storage_id );
            if ( !$account ) {
                $errors[] = sprintf( 
                    /* translators: %s: account ID */
                    esc_html__( 'Account not found: %s', 'publisherkits-socialpromoter' ),
                    $storage_id
                 );
                continue;
            }
            $platform = sanitize_key( $account['platform'] );
            if ( $scheduled ) {
                // Scheduled post.
                $queue_id = $queue->insert( array(
                    'post_id'      => ( $post_id ?: 0 ),
                    'campaign_id'  => ( $campaign_id ?: null ),
                    'platform'     => $platform,
                    'account_id'   => $storage_id,
                    'copy_text'    => $copy_text,
                    'image_url'    => ( $image_url ?: null ),
                    'scheduled_at' => $scheduled,
                    'status'       => 'pending',
                ) );
                if ( $queue_id && !is_wp_error( $queue_id ) ) {
                    $created[] = $queue_id;
                    // Link the row we just inserted into the campaign. Do NOT call
                    // add_post() here — it would insert a SECOND (draft) queue row
                    // and link that instead, leaving a phantom post and the real
                    // send unlinked from campaign_posts.
                    if ( $campaign_id && class_exists( 'PKITS_SP_Campaign' ) ) {
                        PKITS_SP_Campaign::link_queue_item(
                            $campaign_id,
                            $queue_id,
                            $post_id,
                            $url
                        );
                    }
                } elseif ( is_wp_error( $queue_id ) ) {
                    $errors[] = $queue_id->get_error_message();
                }
            } else {
                // Post immediately.
                $poster = new PKITS_SP_Poster();
                $queue_id = $queue->insert( array(
                    'post_id'      => ( $post_id ?: 0 ),
                    'campaign_id'  => ( $campaign_id ?: null ),
                    'platform'     => $platform,
                    'account_id'   => $storage_id,
                    'copy_text'    => $copy_text,
                    'image_url'    => ( $image_url ?: null ),
                    'scheduled_at' => $now,
                    'status'       => 'pending',
                ) );
                if ( is_wp_error( $queue_id ) ) {
                    $errors[] = $queue_id->get_error_message();
                } elseif ( $queue_id ) {
                    $item = $queue->get( $queue_id );
                    if ( $item ) {
                        $result = $poster->execute( $item );
                        if ( $result ) {
                            $created[] = $queue_id;
                        } else {
                            $errors[] = sprintf( 
                                /* translators: %s: platform name */
                                esc_html__( 'Failed to post to %s.', 'publisherkits-socialpromoter' ),
                                ucfirst( $platform )
                             );
                        }
                    }
                }
            }
        }
        if ( !empty( $created ) ) {
            wp_send_json_success( array(
                'created'   => $created,
                'scheduled' => !empty( $scheduled ),
                'message'   => ( !empty( $scheduled ) ? esc_html__( 'Posts scheduled successfully.', 'publisherkits-socialpromoter' ) : esc_html__( 'Posts sent successfully.', 'publisherkits-socialpromoter' ) ),
                'errors'    => $errors,
            ) );
        } else {
            wp_send_json_error( array(
                'message' => ( !empty( $errors ) ? implode( ' ', $errors ) : esc_html__( 'No posts were created.', 'publisherkits-socialpromoter' ) ),
            ) );
        }
    }

    /**
     * Post types that are infrastructure/plugin internals and never
     * appropriate for social promotion. Excluded by default from the
     * Compose selector but user can override in Settings.
     *
     * @return string[]
     */
    public static function get_infrastructure_post_types() {
        return array(
            // WordPress core internals
            'attachment',
            'revision',
            'nav_menu_item',
            'custom_css',
            'customize_changeset',
            'oembed_cache',
            'user_request',
            'wp_block',
            'wp_template',
            'wp_template_part',
            'wp_global_styles',
            'wp_navigation',
            'wp_font_face',
            'wp_font_family',
            // Popup builders (Popup Maker, WP Popups etc)
            'popup',
            'popup_theme',
            'popupbuilder',
            'pum-popup',
            // Cloud / template libraries
            'cloud_template',
            'ct_template',
            'jet-smart-filters',
            // Page builder templates
            'elementor_library',
            'fl-builder-template',
            'vc_grid_item',
            // Tabs / accordions / UI widgets
            'tabs',
            'accordion',
            // Form plugins
            'wpcf7_contact_form',
            'gravityform',
            'mc4wp-form',
            'frm_display',
            // WooCommerce infrastructure (keep product, remove internals)
            'shop_order',
            'shop_order_refund',
            'shop_coupon',
            'shop_webhook',
            // Events internals
            'tribe_organizer',
            'tribe_venue',
            // ACF
            'acf-field-group',
            'acf-field',
            // LMS
            'llms_quiz',
            'llms_lesson',
            'llms_section',
            // Misc plugin internals
            'tablepress_table',
            'envira-gallery',
            'soliloquy',
        );
    }

    /**
     * Get the list of post types enabled for the Compose selector.
     *
     * Falls back to all public types minus the infrastructure blocklist
     * if no setting has been saved yet.
     *
     * @return string[] Array of post type slugs.
     */
    public static function get_compose_post_types() {
        $settings = get_option( 'pkits_sp_settings', array() );
        $settings = ( is_array( $settings ) ? $settings : array() );
        if ( isset( $settings['compose_post_types'] ) && is_array( $settings['compose_post_types'] ) ) {
            return array_filter( array_map( 'sanitize_key', $settings['compose_post_types'] ) );
        }
        // Default: all public types minus the infrastructure blocklist.
        $all = get_post_types( array(
            'public' => true,
        ), 'names' );
        $excluded = self::get_infrastructure_post_types();
        return array_values( array_diff( $all, $excluded ) );
    }

    /**
     * Register preview image AJAX handler.
     *
     * @return void
     */
    public function register_preview_ajax_handlers() {
        add_action( 'wp_ajax_pkits_sp_preview_image', array($this, 'ajax_preview_image') );
    }

    /**
     * AJAX: fetch and validate a post image and return it as a base64 data URL
     * for the Compose preview card. Free shows the image exactly as it will
     * post; the premium build's branding pipeline hooks in separately.
     *
     * @return void
     */
    public function ajax_preview_image() {
        check_ajax_referer( 'pkits_sp_metabox', 'nonce' );
        if ( !current_user_can( 'edit_posts' ) ) {
            wp_send_json_error( array(
                'message' => 'Permission denied.',
            ) );
        }
        $image_url = esc_url_raw( wp_unslash( $_POST['image_url'] ?? '' ) );
        $platform = sanitize_key( wp_unslash( $_POST['platform'] ?? 'x' ) );
        $post_id = absint( wp_unslash( $_POST['post_id'] ?? 0 ) );
        if ( !$image_url ) {
            wp_send_json_error( array(
                'message' => 'No image URL provided.',
            ) );
        }
        // Get image data.
        $attachment_id = attachment_url_to_postid( $image_url );
        if ( $attachment_id ) {
            $image_data = PKITS_SP_Image_Handler::get_attachment_data( $attachment_id );
        } else {
            $image_data = PKITS_SP_Image_Handler::get_image_data_from_url( $image_url );
        }
        if ( is_wp_error( $image_data ) ) {
            wp_send_json_error( array(
                'message' => $image_data->get_error_message(),
            ) );
        }
        // Read the preview image and encode as base64.
        $local_path = $image_data['local_path'] ?? '';
        if ( !$local_path || !file_exists( $local_path ) ) {
            wp_send_json_error( array(
                'message' => 'Could not load image file.',
            ) );
        }
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
        $raw = file_get_contents( $local_path );
        $mime = $image_data['mime_type'] ?? 'image/jpeg';
        $data_url = 'data:' . $mime . ';base64,' . base64_encode( $raw );
        // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
        // Clean up temp files.
        if ( !empty( $image_data['temp_file'] ) && file_exists( $local_path ) ) {
            PKITS_SP_Image_Handler::cleanup_temp( $image_data );
        }
        $sp_preview_response = array(
            'data_url' => $data_url,
            'platform' => $platform,
        );
        wp_send_json_success( $sp_preview_response );
    }

    /**
     * Register post search AJAX handler for Compose page.
     *
     * @return void
     */
    public function register_post_search_ajax_handlers() {
        add_action( 'wp_ajax_pkits_sp_get_post_types', array($this, 'ajax_get_post_types') );
        add_action( 'wp_ajax_pkits_sp_search_posts', array($this, 'ajax_search_posts') );
    }

    /**
     * AJAX: get available public post types for the Compose selector.
     *
     * @return void
     */
    public function ajax_get_post_types() {
        check_ajax_referer( 'pkits_sp_metabox', 'nonce' );
        if ( !current_user_can( 'edit_posts' ) ) {
            wp_send_json_error();
        }
        $enabled = self::get_compose_post_types();
        $types = get_post_types( array(
            'public' => true,
        ), 'objects' );
        $result = array();
        foreach ( $types as $slug => $obj ) {
            if ( !in_array( $slug, $enabled, true ) ) {
                continue;
            }
            $result[] = array(
                'slug'  => $slug,
                'label' => $obj->labels->name,
            );
        }
        wp_send_json_success( $result );
    }

    /**
     * AJAX: search/list published posts for the Compose post selector.
     *
     * Supports:
     *  - Listing recent posts of a given type (no search term)
     *  - Filtering by search term within a type or across all types
     *
     * @return void
     */
    public function ajax_search_posts() {
        check_ajax_referer( 'pkits_sp_metabox', 'nonce' );
        if ( !current_user_can( 'edit_posts' ) ) {
            wp_send_json_error( array(
                'message' => 'Permission denied.',
            ) );
        }
        $query_str = sanitize_text_field( wp_unslash( $_POST['query'] ?? '' ) );
        $type = sanitize_key( wp_unslash( $_POST['post_type'] ?? '' ) );
        // Resolve post type(s) - always scoped to user-enabled types.
        $enabled = self::get_compose_post_types();
        if ( $type && 'all' !== $type && in_array( $type, $enabled, true ) ) {
            $post_types = array($type);
        } else {
            $post_types = $enabled;
        }
        $args = array(
            'post_type'      => $post_types,
            'post_status'    => 'publish',
            'posts_per_page' => 20,
            'no_found_rows'  => true,
        );
        if ( mb_strlen( $query_str ) >= 2 ) {
            $args['s'] = $query_str;
            $args['orderby'] = 'relevance';
        } else {
            $args['orderby'] = 'date';
            $args['order'] = 'DESC';
        }
        $wp_query = new WP_Query($args);
        $results = array();
        foreach ( $wp_query->posts as $post ) {
            $type_obj = get_post_type_object( $post->post_type );
            $type_label = ( $type_obj ? $type_obj->labels->singular_name : $post->post_type );
            // Small square crop for the search result UI only.
            $thumb = get_the_post_thumbnail_url( $post->ID, 'thumbnail' );
            // Full, uncropped featured image at full resolution. The 'thumbnail'
            // size is a 150px hard square crop and must never be the source.
            $full = get_the_post_thumbnail_url( $post->ID, 'full' );
            $results[] = array(
                'id'         => $post->ID,
                'title'      => html_entity_decode( get_the_title( $post ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ),
                'url'        => get_permalink( $post ),
                'type'       => $type_label,
                'post_type'  => $post->post_type,
                'date'       => get_the_date( 'd M Y', $post ),
                'thumbnail'  => ( $thumb ?: '' ),
                'full_image' => ( $full ?: '' ),
            );
        }
        wp_send_json_success( $results );
    }

    /**
     * Whether to show the "upgrade to Pro" notices. Always true in the free
     * package (it is the free tier). The premium build narrows this to unlicensed
     * sites; the licence check is stripped from the free build entirely.
     *
     * @return bool
     */
    public static function show_upgrade_notice() {
        return true;
    }

    /**
     * Render the History admin page (free; engagement stats within it are Pro).
     *
     * @return void
     */
    public function render_history_page() {
        if ( !current_user_can( 'manage_options' ) ) {
            wp_die( esc_html__( 'Permission denied.', 'publisherkits-socialpromoter' ) );
        }
        require_once PKITS_SP_DIR . 'admin/views/page-history.php';
    }

    /**
     * Render the Settings admin page.
     *
     * @return void
     */
    public function render_settings_page() {
        if ( !current_user_can( 'manage_options' ) ) {
            wp_die( esc_html__( 'Permission denied.', 'publisherkits-socialpromoter' ) );
        }
        require_once PKITS_SP_DIR . 'admin/views/page-settings.php';
    }

    /**
     * Handle Settings form save.
     *
     * @return void
     */
    public function handle_save_settings() {
        if ( !current_user_can( 'manage_options' ) ) {
            wp_die( esc_html__( 'Permission denied.', 'publisherkits-socialpromoter' ) );
        }
        check_admin_referer( 'pkits_sp_save_settings' );
        $existing = get_option( 'pkits_sp_settings', array() );
        $settings = ( is_array( $existing ) ? $existing : array() );
        // Nudge settings.
        $settings['nudge_enabled'] = ( isset( $_POST['nudge_enabled'] ) ? 1 : 0 );
        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
        $settings['nudge_banner_enabled'] = ( isset( $_POST['nudge_banner_enabled'] ) ? 1 : 0 );
        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
        // Post types.
        /** This filter is documented in includes/class-sp-metabox.php */
        $allowed_types = apply_filters( 'pkits_sp_nudge_allowed_types', array('post', 'page') );
        $raw_post_types = ( isset( $_POST['nudge_post_types'] ) ? wp_unslash( $_POST['nudge_post_types'] ) : null );
        $post_types = ( is_array( $raw_post_types ) ? array_intersect( array_map( 'sanitize_key', $raw_post_types ), $allowed_types ) : array('post') );
        $settings['nudge_post_types'] = $post_types;
        // User roles.
        $allowed_roles = array_keys( get_editable_roles() );
        $raw_roles = ( isset( $_POST['nudge_roles'] ) ? wp_unslash( $_POST['nudge_roles'] ) : null );
        $roles = ( is_array( $raw_roles ) ? array_intersect( array_map( 'sanitize_key', $raw_roles ), $allowed_roles ) : array('administrator', 'editor') );
        $settings['nudge_roles'] = $roles;
        // Copy template.
        $template = ( isset( $_POST['copy_template'] ) ? sanitize_textarea_field( wp_unslash( $_POST['copy_template'] ) ) : '{post_title} {post_url}' );
        $settings['copy_template'] = $template;
        // Default accounts.
        $raw_default_accounts = ( isset( $_POST['default_accounts'] ) ? wp_unslash( $_POST['default_accounts'] ) : null );
        $default_accounts = ( is_array( $raw_default_accounts ) ? array_map( 'sanitize_key', $raw_default_accounts ) : array() );
        $settings['default_accounts'] = $default_accounts;
        // Queue settings.
        $settings['auto_cancel_on_delete'] = ( isset( $_POST['auto_cancel_on_delete'] ) ? 1 : 0 );
        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
        // Advanced.
        $settings['debug_logging'] = ( isset( $_POST['debug_logging'] ) ? 1 : 0 );
        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
        $settings['delete_data_on_uninstall'] = ( isset( $_POST['delete_data_on_uninstall'] ) ? 1 : 0 );
        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
        // Publication context (brand voice) - since the PK Core adoption this
        // is the shared Site Profile's context. Writing it to core is the whole
        // job: PKCORE_Mirror hooks pkcore_profile_updated and projects the value
        // onto the ownerless publisherkits_site_context option for anything still
        // reading that directly, so this handler must NOT write it a second time
        // (one value, several edit surfaces - here, the Classifier's
        // Configuration screen, and PublisherKits → Site Profile).
        $site_context = sanitize_textarea_field( wp_unslash( $_POST['site_context'] ?? '' ) );
        $settings['site_context'] = $site_context;
        if ( class_exists( 'PKCORE_Profile' ) ) {
            PKCORE_Profile::set( 'context', $site_context );
        }
        // Lightweight site profile (primary goal + audience) - only present in the
        // form when Insight Engine has not already populated the shared profile.
        // Partial-merge into the shared publisherkits_site_profile so richer fields
        // written by Insight Engine are preserved.
        // Send ONLY the keys this request actually carried. Passing both and
        // defaulting the missing one to '' blanked whichever field the form did
        // not render - on hrzone.com that wiped Insight Engine's primary_goal
        // while content_goals still remembered it, and IE's dashboard then showed
        // "Set your site goals" on a fully populated profile (PK-CORE-SPEC-01,
        // Defect 3). save_subset() also refuses to blank a populated value, so
        // this is guarded on both sides.
        $sp_profile_subset = array();
        if ( isset( $_POST['sp_primary_goal'] ) ) {
            $sp_profile_subset['primary_goal'] = sanitize_key( wp_unslash( $_POST['sp_primary_goal'] ) );
        }
        if ( isset( $_POST['sp_audience_description'] ) ) {
            $sp_profile_subset['audience_description'] = sanitize_textarea_field( wp_unslash( $_POST['sp_audience_description'] ) );
        }
        if ( !empty( $sp_profile_subset ) ) {
            PKITS_SP_Site_Profile::save_subset( $sp_profile_subset );
        }
        // Compose post type selector.
        $raw_compose_types = ( isset( $_POST['compose_post_types'] ) ? wp_unslash( $_POST['compose_post_types'] ) : array() );
        $settings['compose_post_types'] = array_values( array_filter( array_map( 'sanitize_key', (array) $raw_compose_types ) ) );
        // Brand hashtags (free) - drive the Compose "Story hashtags" chips.
        $settings['brand_default_hashtags'] = sanitize_text_field( wp_unslash( $_POST['brand_default_hashtags'] ?? '' ) );
        $sp_tags_mode = sanitize_key( wp_unslash( $_POST['brand_tags_mode'] ?? 'off' ) );
        $settings['brand_tags_mode'] = ( in_array( $sp_tags_mode, array(
            'off',
            'always',
            'fallback',
            'only'
        ), true ) ? $sp_tags_mode : 'off' );
        // SEO keywords - saved to plugin settings and shared option.
        $raw_primary = ( isset( $_POST['seo_primary'] ) ? wp_unslash( $_POST['seo_primary'] ) : array() );
        $raw_secondary = ( isset( $_POST['seo_secondary'] ) ? wp_unslash( $_POST['seo_secondary'] ) : array() );
        $seo_primary = array_values( array_filter( array_map( 'sanitize_text_field', (array) $raw_primary ) ) );
        $seo_secondary = array_values( array_filter( array_map( 'sanitize_text_field', (array) $raw_secondary ) ) );
        $settings['seo_primary'] = $seo_primary;
        $settings['seo_secondary'] = $seo_secondary;
        $seo_shared = array(
            'primary'   => $seo_primary,
            'secondary' => $seo_secondary,
        );
        update_option( 'publisherkits_seo_keywords', $seo_shared, false );
        update_option( 'pkits_sp_settings', $settings, false );
        set_transient( 'pkits_sp_admin_notice', array(
            'type'    => 'success',
            'message' => 'Settings saved.',
        ), 60 );
        wp_safe_redirect( admin_url( 'admin.php?page=publisherkits-socialpromoter-settings' ) );
        exit;
    }

    /**
     * Display a stored admin notice (from OAuth callbacks / form actions).
     *
     * @return void
     */
    public function show_admin_notice() {
        $notice = get_transient( 'pkits_sp_admin_notice' );
        if ( empty( $notice ) ) {
            return;
        }
        delete_transient( 'pkits_sp_admin_notice' );
        $type = ( in_array( $notice['type'], array('success', 'error', 'warning'), true ) ? $notice['type'] : 'info' );
        $message = sanitize_text_field( $notice['message'] );
        printf( '<div class="notice notice-%s is-dismissible"><p>%s</p></div>', esc_attr( $type ), esc_html( $message ) );
    }

}
