<?php
namespace boru\boruai\Models;

use boru\boruai\BoruAI;
use boru\boruai\Openai\Api\Endpoints\ResponsesAPI;
use boru\boruai\Openai\Response\Parts\FunctionCall;
use boru\boruai\Openai\Response\ResponseObject;
use boru\boruai\Openai\Stream\Response\OutputText\Delta;
use boru\output\Output;
use boru\boruai\Openai\Stream\StreamEvent;
use boru\boruai\Models\Tool\ToolHandler;
use boru\query\models\Value;
use boru\query\Query;
use GuzzleHttp\Psr7\Response as GuzzleResponse;

use boru\boruai\Openai\Stream\SimpleStreamEventDispatcher;
use boru\boruai\Openai\Stream\Listener\OutputBufferListener;
use boru\boruai\Openai\Stream\Listener\CompletionStatusListener;
use boru\boruai\Openai\Stream\Listener\ReasoningTextListener;
use boru\boruai\Openai\Stream\Listener\ReasoningSummaryTextListener;


class Response extends ResponseObject {
    /** @var int */
    private $iterations = 0;
    /** @var StreamEvent[] */
    private $events = [];

    /** @var string */
    private $partialLine = "";

    /** @var callable */
    private $callback = null;

    /** @var ResponseObject */
    private $response;

    private $saveHistory = true;
    private $historyRecord = null;

    private $eventCallbacks = [];

    private $reference = null;
    private $referenceUser;
    private $templateId;
    private $responseGroup;

    private $streamOutputBuffer = "";
    private $streamOutputDone = false;

    private $throwExceptionOnError = true;

    /** @var \boru\boruai\Models\Tool\ToolContext|null */
    private $toolContext = null;


    /** @var \boru\boruai\Openai\Stream\Contract\StreamEventDispatcherInterface|null */
    private $eventDispatcher = null;


    public function __construct($input=null) {
        if(!is_null($input)) {
            if(is_array($input)) {
                if(isset($input['reference'])) {
                    $this->reference($input['reference']);
                    unset($input['reference']);
                }
            } else {
                if(!is_object($input)) {
                    $input = json_decode($input,true);
                    if(!is_array($input)) {
                        $input = [];
                    }
                }
            }
            parent::__construct($input);
        }
    }

    public function setEventDispatcher($dispatcher)
    {
        // Keep this loose enough for older PHP, but still type-check.
        if ($dispatcher === null) {
            $this->eventDispatcher = null;
        } else {
            if (!$dispatcher instanceof \boru\boruai\Openai\Stream\Contract\StreamEventDispatcherInterface) {
                throw new \Exception("Dispatcher must implement StreamEventDispatcherInterface");
            }
            $this->eventDispatcher = $dispatcher;
        }
        return $this;
    }

    public function getEventDispatcher()
    {
        return $this->eventDispatcher;
    }

    public function withDefaultDispatcher()
    {
        $dispatcher = new SimpleStreamEventDispatcher(false);

        // Core text output buffer
        $outputListener = new OutputBufferListener();
        $dispatcher->addListener($outputListener);

        // Overall completion / error status
        $statusListener = new CompletionStatusListener();
        $dispatcher->addListener($statusListener);

        // Optional: reasoning text capture
        $reasoningListener = new ReasoningTextListener();
        $dispatcher->addListener($reasoningListener);

        // Optional: reasoning summary capture
        $reasoningSummaryListener = new ReasoningSummaryTextListener();
        $dispatcher->addListener($reasoningSummaryListener);

        // You might expose accessors later; for now just attach dispatcher
        $this->setEventDispatcher($dispatcher);

        return $this;
    }

    /**
     * Get or set the ToolContext for this Response.
     *
     * Usage:
     *   $response->toolContext(new ToolContext([...]));
     *   $ctx = $response->toolContext();
     *
     * If you pass an array, it will be wrapped into a ToolContext.
     *
     * @param \boru\boruai\Models\Tool\ToolContext|array|null $context
     * @return \boru\boruai\Models\Tool\ToolContext|null
     */
    public function toolContext($context = null)
    {
        if ($context !== null) {
            if ($context instanceof \boru\boruai\Models\Tool\ToolContext) {
                $this->toolContext = $context;
            } elseif (is_array($context)) {
                $this->toolContext = new \boru\boruai\Models\Tool\ToolContext($context);
            } else {
                throw new \Exception("toolContext must be a ToolContext instance or an array");
            }
        }
        if(!$this->toolContext) {
            $this->toolContext = new \boru\boruai\Models\Tool\ToolContext();
        }
        return $this->toolContext;
    }

    public function setContext($key,$value=null) {
        if($key instanceof \boru\boruai\Models\Tool\ToolContext) {
            $this->toolContext = $key;
            return $this;
        }
        if(is_array($key)) {
            foreach($key as $k => $v) {
                $this->setContext($k, $v);
            }
        } else {
            if(!$this->toolContext) {
                $this->toolContext();
            }
            if($this->toolContext) {
                $this->toolContext->set($key, $value);
            }
        }
        return $this;
    }

    public function callback($callback=null) {
        if ($callback === null) {
            $this->callback = null;
        }
        if (is_callable($callback)) {
            $this->callback = $callback;
        } else {
            throw new \Exception("Callback is not callable");
        }
        return $this;
    }

    public function throwExceptionOnError($throw=null) {
        if($throw !== null) {
            $this->throwExceptionOnError = $throw ? true : false;
        }
        return $this->throwExceptionOnError ? true : false;
    }

    public function saveHistory($save=null) {
        if($save !== null) {
            $this->saveHistory = $save;
        }
        return $this->saveHistory ? true : false;
    }
    public function getHistoryRecord($responseId=null) {
        if($this->historyRecord) {
            return $this->historyRecord;
        }
        if($responseId) {
            $this->historyRecord = ResponseHistory::fromResponseId($responseId);
            if(!$this->historyRecord) {
                $this->historyRecord = new ResponseHistory();
                $this->historyRecord->responseId($responseId);
            }
        }
        if($this->historyRecord) {
            $this->historyRecord->model($this->model());
            $this->historyRecord->input($this);
        }
        return $this->historyRecord;

    }

    public function on($eventType, $callback) {
        if (is_callable($callback)) {
            $eventType = strtolower($eventType);
            $this->eventCallbacks[$eventType][] = $callback;
        } else {
            throw new \Exception("Callback is not callable");
        }
        return $this;
    }

    public function events() {
        return $this->events;
    }
    public function reference($reference=null) {
        if($reference !== null) {
            $this->reference = $reference;
        }
        return $this->reference;
    }
    public function referenceUser($user=null) {
        if($user !== null) {
            $this->referenceUser = $user;
        }
        return $this->referenceUser;
    }
    public function templateId($id=null) {
        if($id !== null) {
            $this->templateId = $id;
        }
        return $this->templateId;
    }
    public function response() {
        return $this->response;
    }
    public function getResult($asString=true) {
        if($this->response) {
            return $this->response->getResult($asString);
        }
        return null;
    }

    public function setResponse($response) {
        if($response instanceof ResponseObject) {
            $this->response = $response;
        } else {
            throw new \Exception("Response is not a ResponseObject");
        }
        if($this->saveHistory()) {
            $this->getHistoryRecord($response->id());
            if($this->historyRecord) {
                $this->historyRecord->output($response);
                if($response->error()) {
                    $error = $response->error();
                    if(!is_array($error)) {
                        $tryError = json_decode($error,true);
                        if($tryError) {
                            $error = $tryError;
                        }
                    }
                    if(is_array($error)) {
                        $this->historyRecord->result($error["message"]);
                    } else {
                        $this->historyRecord->result($error);
                    }
                }
                //$this->historyRecord->result($guzzleArray["error"]["message"]);
                
                $this->historyRecord->reference($this->reference());
                $this->historyRecord->user($this->referenceUser());
                $this->historyRecord->templateId($this->templateId());
                $this->historyRecord->save();
            }
        }
    }

    /**
     * 
     * @param StreamEvent|Delta $event 
     * @return void
     */
    public function processEventCallback($event) {
        $this->parseResponseObjectEvent($event);
        $this->handleStreamEvent($event);
        if($this->callback && is_callable($this->callback)) {
            $callback = $this->callback;
            $callback($event);
        }
        
        // Dispatch through optional event dispatcher
        if ($this->eventDispatcher instanceof \boru\boruai\Openai\Stream\Contract\StreamEventDispatcherInterface) {
            // Only dispatch StreamEvent instances; Delta types may or may not extend it.
            if ($event instanceof \boru\boruai\Openai\Stream\StreamEvent) {
                $this->eventDispatcher->dispatch($event);
            }
        }

        $etLower = strtolower($event->type());
        if(isset($this->eventCallbacks[$etLower])) {
            foreach($this->eventCallbacks[$etLower] as $callback) {
                if(is_callable($callback)) {
                    $callback($event);
                }
            }
        }
    }
    private function handleStreamEvent($event) {
        if(is_object($event) && method_exists($event, "type")) {
            $type = $event->type();
            if($type == StreamEvent::RESPONSE_OUTPUT_TEXT_DELTA) {
                // Delta might be string or an object; keep current behavior if it's string.
                $delta = $event->delta();
                if (is_string($delta)) {
                    $this->streamOutputBuffer .= $delta;
                }
            }
            if($type == StreamEvent::RESPONSE_OUTPUT_TEXT_DONE) {
                // Mark that streaming of output text completed
                $this->streamOutputDone = true; 
            }
        }
    }
    private function parseResponseObjectEvent($event) {
        if(is_object($event) && method_exists($event, "response")) {
            $obj = $event->response();
            if(!$obj instanceof ResponseObject) {
                $obj = new ResponseObject($obj);
            }
            $this->setResponse($obj);
        }
    }

    private $sseBuffer = '';
    private $eventDataLines = [];
    private $eventType = null;
    private $eventId = null;

    /**
     * @param guzzleResponse
     */
    public function processStream($guzzleResponse) {
        $this->events = [];
        $this->partialLine = "";
        $previousResponse = $this->response;
        $this->response = null;
        //$this->handleStream($guzzleResponse);
        if($guzzleResponse->getStatusCode() !== 200) {
            $headers = $guzzleResponse->getHeaders();
            //print error?
            $guzzleContent = $guzzleResponse->getBody()->getContents();
            $guzzleArray = json_decode($guzzleContent, true);
            if($guzzleArray && isset($guzzleArray['error'])) {
                //all set
                $guzzleArray['error']['content'] = $guzzleContent;
            } else {
                $guzzleArray = [
                    'error' => [
                        'message' => "Unknown error",
                        'type' => "unknown",
                        'code' => $guzzleResponse->getStatusCode(),
                        "content" => $guzzleContent,
                    ]
                ];
                if(is_array($guzzleArray)) {
                    $guzzleArray['error']['content'] = $guzzleArray;
                }
            }
            $guzzleArray['error']['headers'] = $headers;
            if(!isset($guzzleArray['id'])) {
                $guzzleArray['id'] = "error-".time()."_".uniqid();
            }
            $this->makeEvent($guzzleArray, "response.error");
            $this->setResponse(new ResponseObject($guzzleArray));
            if($this->throwExceptionOnError) {
                $code = isset($guzzleArray['error']['code']) ? intval($guzzleArray['error']['code']) : 500;
                $message = isset($guzzleArray['error']['message']) ? $guzzleArray['error']['message'] : "An error occurred";
                if(empty($message)) {
                    $message = "An error occurred";
                }
                if($code <= 0) {
                    $code = 500;
                }
                throw new \Exception($message, $code);
            }
            return false;
        }
        $body = $guzzleResponse->getBody();

        // Parser state
        $this->sseBuffer      = '';    // raw incoming buffer across reads
        $this->eventDataLines = [];    // collected "data:" lines for current event
        $this->eventType      = null;  // from "event:"
        $this->eventId        = null;  // from "id:"

        while (!$body->eof()) {
            $chunk = $body->read(8192);      // bigger reads = fewer syscalls
            if ($chunk === '') {
                usleep(10000);
                continue;
            }
            $this->feedSSEChunk($chunk);
        }
        // >>> drain any remaining partial line (no trailing \n) <<<
        if ($this->sseBuffer !== '') {
            $this->handleSSELine($this->sseBuffer);
            $this->sseBuffer = '';
        }

        // flush a trailing event (if any)
        $this->finalizeSSEEvent();
    }

    private function feedSSEChunk($chunk) {
        // Normalize line endings early
        $chunk = str_replace("\r\n", "\n", $chunk);

        // DEFENSIVE: if "data:" or "event:" or "id:" appears without a preceding newline,
        // inject a newline so we don't glue fields together across chunk boundaries.
        // Example broken input: "...}\n}data: {\"type\":...}"
        // The (?<!\n) ensures we don't double-insert before legit newlines.
        //$chunk = preg_replace('/(?<!\n)(?=data:\s)/',  "\n", $chunk);
        //$chunk = preg_replace('/(?<!\n)(?=event:\s)/', "\n", $chunk);
        //$chunk = preg_replace('/(?<!\n)(?=id:\s)/',    "\n", $chunk);
        // Keep-alive comments like ": ping" are fine; no need to pre-insert for ":".

        $this->sseBuffer .= $chunk;

        // Process complete lines from the buffer
        while (true) {
            $pos = strpos($this->sseBuffer, "\n");
            if ($pos === false) {
                // no full line yet; keep accumulating
                break;
            }
            $line = substr($this->sseBuffer, 0, $pos);
            $this->sseBuffer = substr($this->sseBuffer, $pos + 1);

            $this->handleSSELine($line);
        }
    }

    private function handleSSELine($line) {
        // Empty line = end of one SSE event
        if ($line === '') {
            $this->finalizeSSEEvent();
            return;
        }

        // Comment line (keep-alive): starts with ":" per SSE spec
        if (isset($line[0]) && $line[0] === ':') {
            // ignore or handle keep-alives here
            return;
        }

        // Parse "field: value"
        $field = $line;
        $value = '';
        $colon = strpos($line, ':');
        if ($colon !== false) {
            $field = substr($line, 0, $colon);
            // Spec: if there’s a space after ":", strip it
            $value = substr($line, $colon + 1);
            if (isset($value[0]) && $value[0] === ' ') {
                $value = substr($value, 1);
            }
        }

        switch ($field) {
            case 'event':
                $this->eventType = $value;
                break;

            case 'data':
                // Collect multi-line data; join with "\n" on finalize
                $this->eventDataLines[] = $value;
                break;

            case 'id':
                $this->eventId = $value;
                break;

            case 'retry':
                // optional: parse to adjust reconnection delay on client
                break;

            default:
                // Unknown field; ignore
                break;
        }
    }

    private function finalizeSSEEvent() {
        if (empty($this->eventDataLines)) {
            // nothing to emit
            $this->eventType = null;
            $this->eventId   = null;
            return;
        }

        // Join data lines with "\n" (SSE spec)
        $data = implode("\n", $this->eventDataLines);

        // Your existing JSON handler expects one JSON string per event
        // If server sometimes sends non-JSON (e.g., logs), your processLine() already prints with "!!"
        $this->partialLine = $data;     // preserve your public contract
        $this->processLine();

        // Reset state for next event
        $this->eventDataLines = [];
        $this->eventType = null;
        $this->eventId   = null;
    }

    public function processLine() {
        $line = $this->partialLine;
        if(empty($line)) {
            return;
        }
        $data = json_decode($line, true);
        if($data && isset($data['type'])) {
            $this->makeEvent($data);
        } else {
            //Malformed, but We're going to continue doing nothing for now..
        }
    }

    public function initResponseGroup() {
        if($this->responseGroup) {
            return $this->responseGroup;
        }
        if($this->reference() || $this->referenceUser()) {
            $this->responseGroup = ResponseGroup::forReference($this->reference(),$this->referenceUser());
            if(!$this->instructions() && $this->responseGroup->instructions()) {
                $this->instructions($this->responseGroup->instructions());
            }
            //$this->addToInput($this->responseGroup->getInput());
        }
    }

    public function create($input=null,$callback=null,$stream=true) {
        $this->iterations = 0;
        if($callback && is_callable($callback)) {
            $this->callback($callback);
        }
        if ($input) {
            $this->addMessage($input);
        }
        if(!$this->model()) {
            $this->model(BoruAI::defaultModel("default","gpt-4.1"));
        }
        $this->initResponseGroup();

        //clear buffer and reset stream output flag
        $this->streamOutputBuffer = "";
        $this->streamOutputDone = false;

        $result = $this->executeStream($stream);
        if($result && $this->saveHistory()) {
            $this->getHistoryRecord($result->response()->id());
            if($this->historyRecord) {
                $this->historyRecord->output($result->response());
                $this->historyRecord->result($result->getResult());
                $this->historyRecord->reference($this->reference());
                $this->historyRecord->user($this->referenceUser());
                $this->historyRecord->templateId($this->templateId());
                $this->historyRecord->input($this);
                $this->historyRecord->save();
            }            
        }
        return $result;
    }
    /**
     * 
     * @return self|false
     * @throws Exception 
     */
    private function executeStream($stream=true) {
        $result = null;
        if(!$stream) {
            $response = ResponsesAPI::create($this->forInput(),false);
            if($response instanceof ResponseObject) {
                $this->setResponse($response);
                $result = $this->response();
            }
        } else {
            try {
                $streamedResult = ResponsesAPI::create($this->forInput(),true);
                $this->processStream($streamedResult);
                $result = $this->response();
            } catch (\Exception $e) {
                Output::outLine("Requested with:",$this->forInput());
                Output::outLine("Error: ",$e->getMessage());
                // Ensure we always log an error response for history, even
                // when an exception is thrown here.
                $errorData = array(
                    "id"     => "error-".time()."_".uniqid(),
                    "type"   => "response.error",
                    "error"  => array(
                        "message" => $e->getMessage(),
                        "type"    => get_class($e),
                        "code"    => (int)$e->getCode(),
                        "content" => null,
                    ),
                );
                $this->setResponse(new ResponseObject($errorData));
                throw $e;
            }
        }
        if($result) {
            $toolHandler = new ToolHandler($this, $this->toolContext());
            $output = $result->output();
            if(!is_array($output)) {
                $output = [$output];
            }
            $toolHandler->handle($output);
            $haveFunctionCalls = $toolHandler->haveToolCalls();
            if($haveFunctionCalls) {
                $this->toolChoice("auto");
                return $this->executeStream($stream);
            }
            return $this;
        }
        /**
         * At this point, no ResponseObject was produced.
         * If we did receive streamed text (buffer non-empty or marked done),
         * synthesize a minimal ResponseObject from that so history and
         * getResult() have something meaningful.
         */
        if ($this->streamOutputBuffer !== "" || $this->streamOutputDone) {
            $syntheticData = array(
                "id"   => "synthetic-".time()."_".uniqid(),
                "type" => "response.completed",
                "output" => array(
                    array(
                        "type"    => "output_text",
                        "role"    => "assistant",
                        // store the raw concatenated buffer; ResponseObject::getResult()
                        // should know how to extract content from output_text messages.
                        "content" => $this->streamOutputBuffer,
                    ),
                ),
            );

            $synthetic = new ResponseObject($syntheticData);
            $this->setResponse($synthetic);

            // Mimic the "success" return so create() and callers behave normally.
            return $this;
        }
        // No usable $result; create a synthetic error response so that we can
        // log something to history instead of returning a silent false.
        $errorData = array(
            "id"     => "error-".time()."_".uniqid(),
            "type"   => "response.error",
            "error"  => array(
                "message" => "No response object was produced by executeStream()",
                "type"    => "empty_result",
                "code"    => 0,
                "content" => $this->forInput(),
            ),
        );
        $this->setResponse(new ResponseObject($errorData));
        return false;
    }
    public function makeEvent($data = [], $type = null) {
        $eventData = $data;
        if (is_string($data)) {
            $eventData = json_decode($data, true);
        }
        if($type !== null) {
            $eventData['type'] = $type;
        }
        $event = StreamEvent::fromEvent($eventData,[$this, "processEventCallback"]);
        if($event) {
            $this->events[] = $event;
            return;
        }
    }
    public static function fromHistoryId($id) {
        $history = ResponseHistory::fromId($id);
        if($history) {
            $response = $history->get("input");
            if($response instanceof ResponseObject) {
                return $response;
            } else {
                $response = new Response($response);
                return $response;
            }
        }
        return false;
    }
    public static function fromId($id) {
        return ResponsesAPI::get($id);
    }
    public static function fromResponseHistory($history) {
        if($history instanceof ResponseHistory) {
            $response = $history->input();
            if($response instanceof ResponseObject) {
                return $response;
            } else {
                $response = new Response($response);
                return $response;
            }
        } elseif(is_numeric($history)) {
            $history = ResponseHistory::fromId($history);
            if($history) {
                return self::fromResponseHistory($history);
            }
        } elseif(is_string($history)) {
            $history = ResponseHistory::fromId($history);
            if($history) {
                return self::fromResponseHistory($history);
            }
        }
        return false;
    }
    public static function fromReference($reference,$user=null) {
        $instance = self::withReference($reference,$user);
        if($instance) {
            return $instance;
        }
        return false;
    }
    public static function withReference($reference,$user=null,$createIfNotExists=true) {
        $db = BoruAI::db();
        //$db->printDebug(true);
        $tableName = BoruAI::table('responses');
        $query = Query::create()->select("id","model","response_id","template_id")->from($tableName);
        $query->where("reference","=",$reference)->where(Value::functionValue("LEFT(response_id,3)"),"!=","err")->where("result","!=","");
        if($user) {
            if(is_numeric($user)) {
                $query->where("user","=",$user);
            }
        }
        $query->limit(0,1)->orderBy("id","desc");
        $result = $query->toRow();
        if($result) {
            $responseId = $result->get("response_id");
            $model = $result->get("model");
            $templateId = $result->get("template_id");
            $instance = new self();
            $instance->previousResponseId($responseId);
            $instance->model($model);
            $instance->reference($reference);
            if($templateId) {
                $instance->templateId($templateId);
            }

            if($user) {
                if(is_numeric($user)) {
                    $instance->referenceUser($user);
                }
            }
            return $instance;
        } elseif($createIfNotExists) {
            $instance = new self();
            $instance->reference($reference);
            if($user) {
                if(is_numeric($user)) {
                    $instance->referenceUser($user);
                }
            }
            return $instance;
        }
        return false;
    }
    public static function fromResponse($response,$copyId=false) {
        if(!$response instanceof ResponseObject) {
            if(is_array($response)) {
                $response = new Response($response);
            } else {
                $response = Response::fromId($response);
            }
        }
        if(!$response instanceof ResponseObject) {
            throw new \Exception("Invalid response object");
        }
        $array = $response->toArray();
        if(!$copyId) {
            unset($array["id"]);
        }
        $instance = new self($array);
        if(!$copyId) {
            $instance->previousResponseId($response->id());
        }
        return $instance;
    }
    public static function withTemplate($template,$data=[]) {
        if($template instanceof Template) {
            $template = $template->id();
        }
        if(is_numeric($template)) {
            $instance = new self();
            $instance->templateId($template);
            if(is_array($data)) {
                foreach($data as $key => $value) {
                    $instance->addMessage($key,$value);
                }
            }
            return $instance;
        }
        return false;
    }
};