<?php
/**
 * Session Endpoint for BoostBot REST API.
 *
 * Handles JWT session tokens for direct browser-to-backend streaming
 * and conversation completion/history saving.
 *
 * @package BoostBot
 * @since 1.15.0
 */

// Prevent direct access to the file.
if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

/**
 * BoostBot_Session_Endpoint class.
 *
 * Provides session token generation and conversation completion endpoints.
 *
 * @since 1.15.0
 */
class BoostBot_Session_Endpoint extends BoostBot_Base_Endpoint {

    /**
     * The base path for this endpoint.
     *
     * @since 1.15.0
     * @access protected
     * @var string $rest_base
     */
    protected $rest_base = 'session';

    /**
     * Register the routes for this endpoint.
     *
     * @since 1.15.0
     */
    public function register() {
        // POST /wp-json/boostbot/v1/session/token - Get JWT session token
        $this->register_route(
            '/token',
            'POST',
            array( $this, 'get_session_token' ),
            '__return_true', // Must be truly public - no auth required
            $this->get_token_args()
        );

        // POST /wp-json/boostbot/v1/session/complete - Save conversation after streaming
        $this->register_route(
            '/complete',
            'POST',
            array( $this, 'complete_conversation' ),
            '__return_true', // Must be truly public - no auth required
            $this->get_complete_args()
        );
    }

    /**
     * Get JWT session token for direct backend streaming.
     *
     * @since 1.15.0
     * @param WP_REST_Request $request The REST request object.
     * @return WP_REST_Response|WP_Error The response or error.
     */
    public function get_session_token( $request ) {
        // Check rate limiting (moved from permission callback to avoid 403 errors)
        $identifier = $this->get_visitor_identifier( $request );
        if ( class_exists( 'BoostBot_Rate_Limiter' ) && BoostBot_Rate_Limiter::is_limited( $identifier, 'chat' ) ) {
            return $this->error( $this->get_rate_limit_message( $request ), 429 );
        }

        $visitor_id = sanitize_text_field( $request->get_param( 'visitor_id' ) );
        
        if ( empty( $visitor_id ) ) {
            $visitor_id = $identifier;
        }

        // Get backend configuration
        $backend_base_url = (string) BoostBot_Settings::get( 'backend_base_url', '' );
        $backend_base_url = trim( $backend_base_url );
        
        if ( empty( $backend_base_url ) ) {
            return $this->error( __( 'Streaming not configured', 'boostbot' ), 503 );
        }

        // Check credits status (cached)
        $credits_ok = true;
        if ( class_exists( 'BoostBot_Credits_Backend_Client' ) ) {
            $credits_client = new BoostBot_Credits_Backend_Client();
            $site_id = $credits_client->ensure_site_id();
            
            if ( is_wp_error( $site_id ) ) {
                return $this->error( __( 'Credits system error', 'boostbot' ), 500 );
            }

            // Use cached credits status
            $cache_key = 'boostbot_credits_status_' . $site_id;
            $status = get_transient( $cache_key );
            
            if ( false === $status ) {
                $status = $credits_client->get_status( (string) $site_id );
                if ( ! is_wp_error( $status ) ) {
                    set_transient( $cache_key, $status, 60 );
                }
            }
            
            if ( ! is_wp_error( $status ) ) {
                $credits_ok = ! empty( $status['allowed'] );
            }
        } else {
            // No credits client - get site_id from settings
            $site_id = BoostBot_Settings::get( 'site_id', '' );
            if ( empty( $site_id ) ) {
                return $this->error( __( 'Site not registered', 'boostbot' ), 500 );
            }
        }

        // Get site API key for JWT signing
        $site_api_key = (string) BoostBot_Settings::get( 'backend_site_api_key', '' );
        if ( empty( $site_api_key ) ) {
            return $this->error( __( 'Site API key not configured', 'boostbot' ), 500 );
        }

        // Generate a real JWT with 15-minute expiry
        $now = time();
        $expires_at = $now + 900; // 15 minutes
        
        $jwt = $this->generate_jwt(
            (string) $site_id,
            $visitor_id,
            $site_api_key,
            $now,
            $expires_at
        );

        return $this->success( array(
            'token'         => $jwt,
            'expires_at'    => $expires_at,
            'stream_url'    => rtrim( $backend_base_url, '/' ) . '/api/v1/sites/' . rawurlencode( (string) $site_id ) . '/chat/stream',
            'credits_ok'    => $credits_ok,
        ) );
    }

    /**
     * Get JWT signing secret.
     * Uses BOOSTBOT_JWT_SECRET constant if defined, otherwise derives from site API key hash.
     *
     * @param string $site_api_key The site API key.
     * @return string The signing secret.
     */
    private function get_jwt_secret( string $site_api_key ): string {
        // Prefer dedicated JWT secret from wp-config.php constant
        if ( defined( 'BOOSTBOT_JWT_SECRET' ) && strlen( BOOSTBOT_JWT_SECRET ) >= 32 ) {
            return BOOSTBOT_JWT_SECRET;
        }
        // Fallback: derive from site API key hash (must match backend side)
        $hash = hash( 'sha256', $site_api_key );
        return 'jwt_' . $hash;
    }

    /**
     * Generate a JWT token for backend authentication.
     *
     * @param string $site_id    The site ID.
     * @param string $visitor_id The visitor ID.
     * @param string $site_api_key The site API key (used to derive signing secret).
     * @param int    $iat        Issued at timestamp.
     * @param int    $exp        Expiration timestamp.
     * @return string The JWT token.
     */
    private function generate_jwt( string $site_id, string $visitor_id, string $site_api_key, int $iat, int $exp ): string {
        $secret = $this->get_jwt_secret( $site_api_key );
        // JWT Header
        $header = array(
            'alg' => 'HS256',
            'typ' => 'JWT',
        );

        // JWT Payload
        $payload = array(
            'sub' => $site_id,           // Subject: site ID
            'vid' => $visitor_id,        // Visitor ID
            'iat' => $iat,               // Issued at
            'exp' => $exp,               // Expires at
            'jti' => wp_generate_uuid4(), // Unique token ID
        );

        // Encode header and payload
        $header_b64 = $this->base64url_encode( wp_json_encode( $header ) );
        $payload_b64 = $this->base64url_encode( wp_json_encode( $payload ) );

        // Create signature
        $signature_input = $header_b64 . '.' . $payload_b64;
        $signature = hash_hmac( 'sha256', $signature_input, $secret, true );
        $signature_b64 = $this->base64url_encode( $signature );

        return $header_b64 . '.' . $payload_b64 . '.' . $signature_b64;
    }

    /**
     * Base64URL encode (URL-safe base64 without padding).
     *
     * @param string $data The data to encode.
     * @return string The encoded string.
     */
    private function base64url_encode( string $data ): string {
        // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- JWT base64url encoding, not obfuscation
        return rtrim( strtr( base64_encode( $data ), '+/', '-_' ), '=' );
    }

    /**
     * Complete a conversation after streaming (save history).
     *
     * @since 1.15.0
     * @param WP_REST_Request $request The REST request object.
     * @return WP_REST_Response|WP_Error The response or error.
     */
    public function complete_conversation( $request ) {
        // Check rate limiting (moved from permission callback to avoid 403 errors)
        $identifier = $this->get_visitor_identifier( $request );
        if ( class_exists( 'BoostBot_Rate_Limiter' ) && BoostBot_Rate_Limiter::is_limited( $identifier, 'chat' ) ) {
            return $this->error( $this->get_rate_limit_message( $request ), 429 );
        }

        $conversation_id = sanitize_text_field( $request->get_param( 'conversation_id' ) );
        $visitor_id = sanitize_text_field( $request->get_param( 'visitor_id' ) );
        $user_message = sanitize_textarea_field( $request->get_param( 'user_message' ) );
        $bot_response = sanitize_textarea_field( $request->get_param( 'bot_response' ) );
        $bot_name = sanitize_text_field( (string) $request->get_param( 'bot_name' ) );
        $bot_id = $request->get_param( 'bot_id' );
        $incoming_context = $request->get_param( 'context' );

        $language = BoostBot_Language::sanitize( $request->get_param( 'language' ) );
        if ( '' === $language && null !== $bot_id && '' !== $bot_id && class_exists( 'BoostBot_Bot_Model' ) ) {
            $bot_model = new BoostBot_Bot_Model();
            $bot = $bot_model->get( absint( $bot_id ) );
            if ( $bot && ! empty( $bot->language ) ) {
                $language = $bot->language;
            }
        }
        if ( '' === $language ) {
            $language = BoostBot_Language::get_primary();
        }

        if ( empty( $conversation_id ) || empty( $user_message ) || empty( $bot_response ) ) {
            return $this->error( __( 'Missing required fields', 'boostbot' ), 400 );
        }

        // Get or create conversation
        $conversation_model = new BoostBot_Conversation_Model();
        $conversation = $conversation_model->get_by_conversation_id( $conversation_id );

        // Security (IDOR): this endpoint is public and mutates a conversation by its
        // conversation_id. If the stored conversation is already owned by a known
        // visitor_id, require the request's visitor_id (or derived identifier) to match
        // before mutating, so one visitor cannot write into another's conversation.
        if ( $conversation && ! empty( $conversation->visitor_id ) ) {
            $request_visitor = $visitor_id ?: $this->get_visitor_identifier( $request );
            if ( (string) $request_visitor !== (string) $conversation->visitor_id ) {
                return $this->error( __( 'You are not authorized to modify this conversation', 'boostbot' ), 403 );
            }
        }

        if ( ! $conversation ) {
            // Create new conversation
            $result = $conversation_model->create( array(
                'language'        => $language,
                'conversation_id' => $conversation_id,
                'visitor_id'      => $visitor_id ?: $this->get_visitor_identifier( $request ),
                'session_data'    => array( 'messages' => array() ),
                'context_data'    => array(),
            ) );
            
            if ( ! $result ) {
                return $this->error( __( 'Failed to create conversation', 'boostbot' ), 500 );
            }
            
            $conversation = $conversation_model->get_by_conversation_id( $conversation_id );
        }

        // Add messages to history
        $session_data = json_decode( $conversation->session_data, true ) ?: array();
        $messages = $session_data['messages'] ?? array();

        $user_entry = array(
            'role'      => 'user',
            'content'   => $user_message,
            'timestamp' => current_time( 'mysql' ),
        );
        $bot_entry = array(
            'role'      => 'bot',
            'content'   => $bot_response,
            'timestamp' => current_time( 'mysql' ),
        );

        $messages[] = $user_entry;
        $messages[] = $bot_entry;

        $session_data['messages'] = $messages;

        // Merge page/bot metadata into context_data so admin conversations can show source information.
        $context_data = ! empty( $conversation->context_data ) ? json_decode( $conversation->context_data, true ) : array();
        if ( ! is_array( $context_data ) ) {
            $context_data = array();
        }

        if ( is_array( $incoming_context ) ) {
            $page_title = sanitize_text_field( (string) ( $incoming_context['title'] ?? '' ) );
            $page_url = esc_url_raw( (string) ( $incoming_context['url'] ?? '' ) );

            if ( '' !== $page_title ) {
                $context_data['page_title'] = $page_title;
            }
            if ( '' !== $page_url ) {
                $context_data['page_url'] = $page_url;
            }
        }

        if ( '' !== $bot_name ) {
            $context_data['bot_name'] = $bot_name;
        }

        if ( null !== $bot_id && '' !== $bot_id ) {
            $context_data['bot_id'] = absint( $bot_id );
        }

        // Generate a human-friendly conversation title once (Gemini-backed, fallback to first user message).
        if ( empty( $context_data['conversation_title'] ) ) {
            $generated_title = $this->generate_conversation_title( $messages, $context_data );
            if ( '' !== $generated_title ) {
                $context_data['conversation_title'] = $generated_title;
            }
        }

        // Concurrency mitigation: the session_data above was read at the start of the
        // request, so two overlapping turns could clobber each other's messages.
        // Immediately before writing, re-fetch the latest row and append the two new
        // messages to THAT snapshot to shrink the read-modify-write race window.
        // NOTE: still not fully atomic (no row lock), but much tighter than before.
        $fresh = $conversation_model->get_by_conversation_id( $conversation_id );
        if ( $fresh && ! empty( $fresh->session_data ) ) {
            $fresh_session = json_decode( $fresh->session_data, true );
            if ( is_array( $fresh_session ) && isset( $fresh_session['messages'] ) && is_array( $fresh_session['messages'] ) ) {
                $messages = array_merge( $fresh_session['messages'], array( $user_entry, $bot_entry ) );
                $session_data['messages'] = $messages;
            }
        }

        // Update conversation
        $update_data = array(
            'session_data'    => wp_json_encode( $session_data ),
            'context_data'    => wp_json_encode( $context_data ),
            'last_message_at' => current_time( 'mysql' ),
            'total_messages'  => count( $messages ),
        );
        // Set language on existing conversations that may not have it yet.
        // Only backfill when genuinely empty — do NOT treat 'he' as an "unset"
        // sentinel, which would overwrite real Hebrew conversations.
        if ( empty( $conversation->language ) ) {
            $update_data['language'] = $language;
        }
        $conversation_model->update( $conversation->id, $update_data );

        // Track usage
        if ( class_exists( 'BoostBot_Usage_Tracker' ) ) {
            BoostBot_Usage_Tracker::increment( 'messages', 2 );
        }

        return $this->success( array(
            'saved'           => true,
            'conversation_id' => $conversation_id,
            'message_count'   => count( $messages ),
        ) );
    }

    /**
     * Generate a concise conversation title from messages and context.
     *
     * Uses backend quick Gemini call when available, and falls back to the first user message.
     *
     * @param array $messages Conversation messages.
     * @param array $context_data Stored conversation context.
     * @return string
     */
    private function generate_conversation_title( array $messages, array $context_data ): string {
        $fallback = $this->build_fallback_title( $messages );

        if ( ! class_exists( 'BoostBot_Credits_Backend_Client' ) ) {
            BoostBot_Logger::info( '[ConversationTitle] quick_llm_call skipped: credits backend client missing. Using fallback title.' );
            return $fallback;
        }

        try {
            $credits_client = new BoostBot_Credits_Backend_Client();
            $site_id = $credits_client->ensure_site_id();

            if ( is_wp_error( $site_id ) || empty( $site_id ) ) {
                $reason = is_wp_error( $site_id ) ? $site_id->get_error_message() : 'empty site_id';
                BoostBot_Logger::warning( '[ConversationTitle] quick_llm_call skipped: ' . $reason . '. Using fallback title.' );
                return $fallback;
            }

            $transcript_lines = array();
            $recent_messages = array_slice( $messages, -14 );
            foreach ( $recent_messages as $msg ) {
                $role = isset( $msg['role'] ) ? (string) $msg['role'] : 'user';
                $content = isset( $msg['content'] ) ? trim( (string) $msg['content'] ) : '';
                if ( '' === $content ) {
                    continue;
                }
                $speaker = ( 'bot' === $role || 'assistant' === $role ) ? 'Assistant' : 'User';
                $transcript_lines[] = $speaker . ': ' . $content;
            }

            if ( empty( $transcript_lines ) ) {
                BoostBot_Logger::warning( '[ConversationTitle] quick_llm_call skipped: empty transcript lines. Using fallback title.' );
                return $fallback;
            }

            $prompt_parts = array(
                'Create a short title for this customer-support conversation.',
                'Requirements:',
                '- 2-6 words',
                '- Maximum 60 characters',
                '- Clear and specific topic',
                '- No quotes, no emojis, no trailing punctuation',
                '- Return title text only',
            );

            if ( ! empty( $context_data['page_title'] ) ) {
                $prompt_parts[] = 'Page title: ' . (string) $context_data['page_title'];
            }
            if ( ! empty( $context_data['bot_name'] ) ) {
                $prompt_parts[] = 'Bot name: ' . (string) $context_data['bot_name'];
            }

            $prompt_parts[] = '';
            $prompt_parts[] = "Conversation transcript:\n" . implode( "\n", $transcript_lines );
            $prompt = implode( "\n", $prompt_parts );
            $prompt = $this->truncate_prompt_for_quick_llm( $prompt );

            $llm_result = $credits_client->quick_llm_call(
                (string) $site_id,
                array(
                    'model'      => 'gemini-2.5-flash',
                    'prompt'     => $prompt,
                    'max_tokens' => 40,
                    'purpose'    => 'conversation_title',
                )
            );

            if ( is_wp_error( $llm_result ) ) {
                BoostBot_Logger::warning( '[ConversationTitle] quick_llm_call failed: ' . $llm_result->get_error_message() . '. Using fallback title.' );
                return $fallback;
            }

            $title = '';
            if ( isset( $llm_result['response'] ) ) {
                $title = (string) $llm_result['response'];
            } elseif ( isset( $llm_result['content'] ) ) {
                $title = (string) $llm_result['content'];
            }

            $title = $this->normalize_title( $title );
            if ( '' === $title ) {
                BoostBot_Logger::warning( '[ConversationTitle] quick_llm_call returned empty/invalid title. Using fallback title.' );
                return $fallback;
            }

            BoostBot_Logger::info( '[ConversationTitle] quick_llm_call success. Generated title: ' . $title );
            return $title;
        } catch ( Exception $e ) {
            BoostBot_Logger::error( '[ConversationTitle] quick_llm_call exception: ' . $e->getMessage() . '. Using fallback title.' );
            return $fallback;
        }
    }

    /**
     * Keep quick-LLM prompts safely below backend validation limits.
     *
     * Backend /llm/quick accepts prompt up to 8000 chars; we keep a safety margin.
     *
     * @param string $prompt Raw prompt.
     * @return string
     */
    private function truncate_prompt_for_quick_llm( string $prompt ): string {
        $max_chars = 7600;
        if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) ) {
            if ( mb_strlen( $prompt, 'UTF-8' ) <= $max_chars ) {
                return $prompt;
            }
            return mb_substr( $prompt, 0, $max_chars, 'UTF-8' );
        }

        if ( strlen( $prompt ) <= $max_chars ) {
            return $prompt;
        }

        return substr( $prompt, 0, $max_chars );
    }

    /**
     * Build fallback title from the first user message.
     *
     * @param array $messages Conversation messages.
     * @return string
     */
    private function build_fallback_title( array $messages ): string {
        foreach ( $messages as $msg ) {
            $role = isset( $msg['role'] ) ? (string) $msg['role'] : '';
            if ( 'user' !== $role ) {
                continue;
            }

            $content = trim( (string) ( $msg['content'] ?? '' ) );
            if ( '' === $content ) {
                continue;
            }

            return $this->normalize_title( $content );
        }

        return __( 'Conversation', 'boostbot' );
    }

    /**
     * Normalize generated title text.
     *
     * @param string $title Raw title.
     * @return string
     */
    private function normalize_title( string $title ): string {
        $title = trim( wp_strip_all_tags( $title ) );
        $title = trim( $title, " \t\n\r\0\x0B\"'“”‘’" );
        $title = preg_replace( '/\s+/', ' ', $title );
        $title = is_string( $title ) ? $title : '';

        if ( '' === $title ) {
            return '';
        }

        if ( function_exists( 'mb_substr' ) ) {
            if ( mb_strlen( $title, 'UTF-8' ) > 60 ) {
                $title = mb_substr( $title, 0, 60, 'UTF-8' );
            }
        } else {
            if ( strlen( $title ) > 60 ) {
                $title = substr( $title, 0, 60 );
            }
        }

        return sanitize_text_field( trim( $title ) );
    }

    /**
     * Create a JWT token.
     *
     * @since 1.15.0
     * @param array  $payload  The payload to encode.
     * @param string $secret   The secret key for signing.
     * @return string The JWT token.
     */
    private function create_jwt( array $payload, string $secret ): string {
        $payload_json = wp_json_encode( $payload );
        // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
        $payload_b64 = base64_encode( $payload_json );
        $signature = hash_hmac( 'sha256', $payload_b64, $secret );
        
        return $payload_b64 . '.' . $signature;
    }

    /**
     * Get argument schema for token endpoint.
     *
     * @since 1.15.0
     * @return array Argument schema.
     */
    private function get_token_args() {
        return array(
            'visitor_id' => array(
                'required'          => false,
                'type'              => 'string',
                'sanitize_callback' => 'sanitize_text_field',
            ),
        );
    }

    /**
     * Get argument schema for complete endpoint.
     *
     * @since 1.15.0
     * @return array Argument schema.
     */
    private function get_complete_args() {
        return array(
            'conversation_id' => array(
                'required'          => true,
                'type'              => 'string',
                'sanitize_callback' => 'sanitize_text_field',
            ),
            'visitor_id' => array(
                'required'          => false,
                'type'              => 'string',
                'sanitize_callback' => 'sanitize_text_field',
            ),
            'user_message' => array(
                'required'          => true,
                'type'              => 'string',
                'sanitize_callback' => 'sanitize_textarea_field',
            ),
            'bot_response' => array(
                'required'          => true,
                'type'              => 'string',
                'sanitize_callback' => 'sanitize_textarea_field',
            ),
            'bot_name' => array(
                'required'          => false,
                'type'              => 'string',
                'sanitize_callback' => 'sanitize_text_field',
            ),
            'context' => array(
                'required' => false,
                'type'     => 'object',
            ),
            'language' => array(
                'required'          => false,
                'type'              => 'string',
                'sanitize_callback' => 'sanitize_text_field',
            ),
        );
    }
}
