<?php
/**
 * Backend AI Client (Week 26)
 *
 * Instead of BYOK (Claude/OpenAI keys), we proxy generation through the backend service.
 * The backend enforces credits + selects provider/model (Gemini for free, GPT-4o for paid).
 *
 * @package BoostBot
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

class BoostBot_Backend_AI_Client {

    /**
     * @var BoostBot_Credits_Backend_Client
     */
    private $backend;

    public function __construct() {
        if ( ! class_exists( 'BoostBot_Credits_Backend_Client' ) ) {
            throw new Exception( 'Credits backend client not available.' );
        }
        $this->backend = new BoostBot_Credits_Backend_Client();
    }

    /**
     * Send a message for completion via backend proxy.
     *
     * Signature intentionally mirrors BoostBot_Claude_Client::send_message usage in Chat Handler.
     *
     * @param array $messages Claude-style messages: [{role:'user'|'assistant', content:'...'}]
     * @param array $options  system, max_tokens, temperature
     * @return array { content: string, usage?: mixed, stop_reason?: string }
     * @throws Exception on backend error
     */
    public function send_message( array $messages, array $options = array() ) {
        $t0 = microtime( true );
        $site_id = $this->backend->ensure_site_id();
        BoostBot_Logger::info( sprintf( '[TIMING] backend_ai.ensure_site_id: %dms', round( ( microtime( true ) - $t0 ) * 1000 ) ) );
        if ( is_wp_error( $site_id ) ) {
            throw new Exception( esc_html( $site_id->get_error_message() ) );
        }

        $payload = array(
            'messages'     => $this->normalize_messages( $messages ),
            'system'       => isset( $options['system'] ) ? (string) $options['system'] : '',
            'max_tokens'   => isset( $options['max_tokens'] ) ? (int) $options['max_tokens'] : null,
            'temperature'  => isset( $options['temperature'] ) ? (float) $options['temperature'] : null,
            // Week 28: per-message metering/idempotency + output cap (chars)
            'conversation_id' => isset( $options['conversation_id'] ) ? (string) $options['conversation_id'] : null,
            'idempotency_key' => isset( $options['idempotency_key'] ) ? (string) $options['idempotency_key'] : null,
            'output_char_limit' => isset( $options['output_char_limit'] ) ? (int) $options['output_char_limit'] : null,
        );

        // Remove nulls to keep backend schema simple.
        if ( null === $payload['max_tokens'] ) {
            unset( $payload['max_tokens'] );
        }
        if ( null === $payload['temperature'] ) {
            unset( $payload['temperature'] );
        }
        if ( empty( $payload['conversation_id'] ) ) {
            unset( $payload['conversation_id'] );
        }
        if ( empty( $payload['idempotency_key'] ) ) {
            unset( $payload['idempotency_key'] );
        }
        if ( null === $payload['output_char_limit'] ) {
            unset( $payload['output_char_limit'] );
        }
        if ( '' === $payload['system'] ) {
            unset( $payload['system'] );
        }

        $t1 = microtime( true );
        $resp = $this->backend->chat( (string) $site_id, $payload );
        BoostBot_Logger::info( sprintf( '[TIMING] backend_ai.chat_call: %dms', round( ( microtime( true ) - $t1 ) * 1000 ) ) );
        BoostBot_Logger::info( sprintf( '[TIMING] backend_ai.total: %dms', round( ( microtime( true ) - $t0 ) * 1000 ) ) );
        if ( is_wp_error( $resp ) ) {
            throw new Exception( esc_html( $resp->get_error_message() ) );
        }

        $content = isset( $resp['content'] ) ? (string) $resp['content'] : '';

        return array(
            'content'     => $content,
            'usage'       => isset( $resp['usage'] ) ? $resp['usage'] : null,
            'stop_reason' => null,
            'provider'    => isset( $resp['provider'] ) ? $resp['provider'] : null,
            'model'       => isset( $resp['model'] ) ? $resp['model'] : null,
        );
    }

    /**
     * Send a streaming message for completion via backend proxy.
     * 
     * This method streams SSE events directly to the browser for improved perceived latency.
     * It sets proper headers and flushes output progressively.
     *
     * @param array    $messages Claude-style messages
     * @param array    $options  Options including system, max_tokens, etc.
     * @param callable $on_chunk Callback function($chunk_content) for each content chunk
     * @return array Final result with accumulated content
     * @throws Exception on backend error
     */
    public function send_message_streaming( array $messages, array $options = array(), callable $on_chunk = null ) {
        $site_id = $this->backend->ensure_site_id();
        if ( is_wp_error( $site_id ) ) {
            throw new Exception( esc_html( $site_id->get_error_message() ) );
        }

        $backend_url = (string) BoostBot_Settings::get( 'backend_base_url', '' );
        $backend_url = rtrim( $backend_url, '/' );
        $api_key     = (string) BoostBot_Settings::get( 'backend_api_key', '' );

        if ( empty( $backend_url ) ) {
            throw new Exception( 'Backend URL not configured for streaming' );
        }

        $url = sprintf( '%s/api/v1/sites/%s/chat', $backend_url, rawurlencode( (string) $site_id ) );

        $payload = array(
            'messages'     => $this->normalize_messages( $messages ),
            'system'       => isset( $options['system'] ) ? (string) $options['system'] : '',
            'max_tokens'   => isset( $options['max_tokens'] ) ? (int) $options['max_tokens'] : null,
            'temperature'  => isset( $options['temperature'] ) ? (float) $options['temperature'] : null,
            'conversation_id' => isset( $options['conversation_id'] ) ? (string) $options['conversation_id'] : null,
            'idempotency_key' => isset( $options['idempotency_key'] ) ? (string) $options['idempotency_key'] : null,
            'output_char_limit' => isset( $options['output_char_limit'] ) ? (int) $options['output_char_limit'] : null,
            'stream'       => true, // Request streaming
        );

        // Clean up nulls
        $payload = array_filter( $payload, function( $v ) {
            return null !== $v && '' !== $v;
        } );

        // IMPORTANT: direct cURL is required here for Server-Sent Events (SSE) streaming.
        //
        // The WordPress HTTP API (wp_remote_get / wp_remote_post) always buffers the
        // complete response body before returning, so it cannot deliver incremental
        // chunks to a write callback. For a chat widget this would mean the user waits
        // for the full AI response before seeing anything — defeating the purpose of
        // streaming. CURLOPT_WRITEFUNCTION is the only portable way to read chunks off
        // the socket as they arrive, which @see https://core.trac.wordpress.org/ticket/47790
        // also describes as an unsupported use case for the WP HTTP API.
        //
        // The http_api_curl filter is not a workable substitute: it only mutates the
        // handle and does not let user code register a chunk-level write callback.
        //
        // This is the ONLY call site in the plugin that uses raw cURL; every other
        // HTTP request goes through wp_remote_*. The plugin degrades gracefully if
        // cURL is disabled (the caller falls back to a non-streaming path).
        // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_init -- SSE streaming; see block comment above.
        $ch = curl_init( $url );
        if ( false === $ch ) {
            throw new Exception( 'Failed to initialize cURL for streaming' );
        }

        $headers = array(
            'Content-Type: application/json',
            'Accept: text/event-stream',
        );
        if ( ! empty( $api_key ) ) {
            $headers[] = 'Authorization: Bearer ' . $api_key;
        }

        $accumulated_content = '';
        $stream_error = null;

        // Streaming callback - called for each chunk received
        $write_callback = function( $ch, $data ) use ( &$accumulated_content, &$stream_error, $on_chunk ) {
            $lines = explode( "\n", $data );
            foreach ( $lines as $line ) {
                $line = trim( $line );
                if ( empty( $line ) ) {
                    continue;
                }
                if ( strpos( $line, 'data: ' ) !== 0 ) {
                    continue;
                }
                $json_str = substr( $line, 6 ); // Remove "data: " prefix
                if ( '[DONE]' === $json_str ) {
                    continue;
                }
                $json = json_decode( $json_str, true );
                if ( is_array( $json ) && isset( $json['content'] ) ) {
                    $chunk_content = (string) $json['content'];
                    $accumulated_content .= $chunk_content;
                    
                    // Call the chunk callback if provided
                    if ( null !== $on_chunk ) {
                        try {
                            call_user_func( $on_chunk, $chunk_content );
                        } catch ( Exception $e ) {
                            $stream_error = $e->getMessage();
                        }
                    }
                }
            }
            return strlen( $data ); // Must return number of bytes processed
        };

        // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt_array -- SSE streaming requires CURLOPT_WRITEFUNCTION; WP HTTP API buffers entire response
        curl_setopt_array( $ch, array(
            CURLOPT_POST           => true,
            CURLOPT_POSTFIELDS     => wp_json_encode( $payload ),
            CURLOPT_HTTPHEADER     => $headers,
            CURLOPT_RETURNTRANSFER => false,
            CURLOPT_WRITEFUNCTION  => $write_callback,
            CURLOPT_TIMEOUT        => 120,
            CURLOPT_CONNECTTIMEOUT => 10,
        ) );

        // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_exec -- SSE streaming not supported by WP HTTP API
        $result = curl_exec( $ch );
        // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_getinfo
        $http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
        // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_error
        $curl_error = curl_error( $ch );
        // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_close
        curl_close( $ch );

        if ( false === $result || ! empty( $curl_error ) ) {
            throw new Exception( 'Streaming request failed: ' . esc_html( $curl_error ) );
        }

        if ( $http_code >= 400 ) {
            throw new Exception( 'Streaming request failed with HTTP ' . absint( $http_code ) );
        }

        if ( null !== $stream_error ) {
            throw new Exception( 'Stream processing error: ' . esc_html( $stream_error ) );
        }

        return array(
            'content'     => $accumulated_content,
            'usage'       => null, // Usage not available in streaming mode
            'stop_reason' => null,
            'streaming'   => true,
        );
    }

    /**
     * Start streaming response directly to the browser.
     * 
     * This method sets up SSE headers and streams content directly to the client.
     * Should be called early in the request lifecycle before any output.
     *
     * @param array $messages Claude-style messages
     * @param array $options  Options
     * @return void Streams directly to output
     * @throws Exception on error
     */
    public function stream_to_browser( array $messages, array $options = array() ) {
        // Set SSE headers
        header( 'Content-Type: text/event-stream' );
        header( 'Cache-Control: no-cache' );
        header( 'Connection: keep-alive' );
        header( 'X-Accel-Buffering: no' ); // Disable nginx buffering
        
        // Disable output buffering
        if ( function_exists( 'apache_setenv' ) ) {
            apache_setenv( 'no-gzip', '1' );
        }
        while ( ob_get_level() ) {
            ob_end_flush();
        }
        
        $accumulated = '';
        
        // Stream with callback that echoes to browser
        $result = $this->send_message_streaming( $messages, $options, function( $chunk ) use ( &$accumulated ) {
            $accumulated .= $chunk;
            echo 'data: ' . wp_json_encode( array( 'content' => $chunk ) ) . "\n\n";
            if ( function_exists( 'ob_flush' ) ) {
                @ob_flush();
            }
            flush();
        } );
        
        // Send completion event
        echo "data: [DONE]\n\n";
        if ( function_exists( 'ob_flush' ) ) {
            @ob_flush();
        }
        flush();
        
        return $result;
    }

    private function normalize_messages( array $messages ): array {
        $out = array();

        foreach ( $messages as $m ) {
            if ( ! is_array( $m ) ) {
                continue;
            }
            $role    = isset( $m['role'] ) ? (string) $m['role'] : '';
            $content = isset( $m['content'] ) ? (string) $m['content'] : '';

            if ( '' === $role || '' === $content ) {
                continue;
            }

            // Backend supports: system/user/assistant; system is passed separately.
            if ( 'system' === $role ) {
                continue;
            }

            $out[] = array(
                'role'    => $role,
                'content' => $content,
            );
        }

        // Ensure there's at least one message (backend requires min(1)).
        if ( empty( $out ) ) {
            $out[] = array(
                'role'    => 'user',
                'content' => 'Hello',
            );
        }

        return $out;
    }
}


