<?php

namespace boru\boruai\Agent;

use boru\boruai\Models\Response;
use boru\boruai\Models\Tool\ToolContext;
use boru\boruai\Models\File;
use boru\boruai\Openai\Stream\SimpleStreamEventDispatcher;
use boru\boruai\Openai\Stream\Listener\OutputBufferListener;
use boru\boruai\Openai\Stream\Listener\FunctionCallListener;
use boru\boruai\Models\ToolDefinition;
use boru\boruai\Models\ToolParameters;

/**
 * Generic convenience wrapper around the Response model.
 *
 * Responsibilities:
 * - Manage a Response instance (with optional reference)
 * - Handle model selection
 * - Attach tools & tool context
 * - Optionally send a firstMessage on a fresh conversation
 * - Run and return a simple string result, with basic streaming support
 *
 * Extend this in your projects and implement:
 *   - generateReference()
 *   - getInstructions()
 *   - getPrompt()
 *   - getTools()   (optional, can return empty array)
 *
 * Optionally override:
 *   - firstMessage()
 *   - buildToolContext()
 *   - setupEventDispatcher()
 */
abstract class BaseAgent
{
    /** @var Response|null */
    protected $response;

    /** @var string */
    protected $model = 'gpt-4.1';

    /** @var string|null */
    protected $reference = null;

    /** @var ToolContext|null */
    protected $toolContext = null;

    /** @var \boru\boruai\Openai\Stream\Contract\StreamEventDispatcherInterface|null */
    protected $eventDispatcher = null;
    /** @var \boru\boruai\Openai\Stream\Listener\FunctionCallListener|null */
    protected $functionCallListener = null;
    /** @var \boru\boruai\Openai\Stream\Listener\OutputBufferListener|null */
    protected $outputBufferListener = null;

    /** @var bool */
    protected $noMessages = true;

    /** @var mixed */
    protected $result = null;

    /** @var string */
    protected $buffer = '';

    /**
     * Files that have been uploaded by this agent (File models).
     *
     * @var File[]
     */
    protected $uploadedFiles = [];

    /**
     * Raw file inputs that have been attached to the Response
     * (file IDs, arrays, etc. – whatever was passed to addFile/addImage).
     *
     * @var array
     */
    protected $attachedFiles = [];

    public function __construct($reference = null, ToolContext $toolContext = null)
    {
        $this->reference    = $reference;
        $this->toolContext  = $toolContext;
    }

    // ---- Abstracts to implement in concrete agents ----

    /**
     * Generate a unique reference for this agent / conversation.
     */
    abstract public function generateReference();

    /**
     * System / instructions to send to the model.
     */
    abstract public function getInstructions();

    /**
     * Prompt to send when there are no explicit messages added before run().
     */
    abstract public function getPrompt();

    /**
     * Return an array of ToolDefinition instances (or [] if none).
     */
    public function getTools()
    {
        return [];
    }

    /**
     * Optional warm-up message when starting a completely new conversation.
     * Return a string to send, or false to skip.
     *
     * @return string|false
     */
    public function firstMessage()
    {
        return false;
    }

    // ---- Reference & model helpers ----

    public function setReference($reference)
    {
        $this->reference = $reference;
        return $this;
    }

    public function getReference()
    {
        if ($this->reference === null) {
            $this->reference = $this->generateReference();
        }
        return $this->reference;
    }

    public function getModel()
    {
        return $this->model;
    }

    public function setModel($model)
    {
        $this->model = (string)$model;
        return $this;
    }

    // ---- Tool context helpers ----

    public function setToolContext(ToolContext $ctx = null)
    {
        $this->toolContext = $ctx;
        return $this;
    }

    public function addContext($key, $value)
    {
        if (!$this->toolContext) {
            $this->toolContext = new ToolContext();
        }
        $this->toolContext->set($key, $value);
        return $this;
    }

    /**
     * Override if you want to lazily assemble context; by default just returns $this->toolContext.
     */
    protected function buildToolContext()
    {
        if (!$this->toolContext) {
            $this->toolContext = new ToolContext();
        }
        return $this->toolContext;
    }

    // ---- Event dispatcher / streaming ----

    public function setEventDispatcher($dispatcher)
    {
        $this->eventDispatcher = $dispatcher;
        return $this;
    }

    public function getEventDispatcher()
    {
        if (!$this->eventDispatcher) {
            $this->eventDispatcher = $this->createDefaultDispatcher();
        }
        return $this->eventDispatcher;
    }

    public function getFunctionCallListener()
    {
        if (!$this->functionCallListener) {
            $this->functionCallListener = new FunctionCallListener();
        }
        return $this->functionCallListener;
    }

    public function getOutputBufferListener()
    {
        if (!$this->outputBufferListener) {
            $this->outputBufferListener = new OutputBufferListener();
        }
        return $this->outputBufferListener;
    }

    /**
     * Create a dispatcher similar to tests/weatherbot_callbacks.php
     * and to what your project BaseAgent is doing.
     */
    protected function createDefaultDispatcher()
    {
        $dispatcher = new SimpleStreamEventDispatcher(false); // don't stop on handled

        $outputListener = $this->getOutputBufferListener();
        $outputListener->onDelta(function ($event, $delta, $buffer) {
            $this->buffer = $buffer;
        });
        $outputListener->onDone(function ($event, $buffer) {
            $this->buffer = $buffer;
        });
        $dispatcher->addListener($outputListener);

        // Optional: hook up function call listener with no-op handlers;
        // projects can override setupEventDispatcher() to customize.
        $functionListener = $this->getFunctionCallListener();
        $dispatcher->addListener($functionListener);

        return $dispatcher;
    }

    /**
     * Final hook for subclasses that want to customize listeners etc.
     */
    protected function setupEventDispatcher()
    {
        // By default, just ensure we have a dispatcher and attach it to the Response.
        $this->response()->setEventDispatcher($this->getEventDispatcher());
    }

    // ---- Response lifecycle ----

    /**
     * Lazily build or return the underlying Response.
     */
    public function response()
    {
        if ($this->response instanceof Response) {
            return $this->response;
        }

        $ref = $this->getReference();
        $this->response = $ref
            ? new Response(['reference' => $ref])
            : new Response();

        $this->response->model($this->model);

        // If this is a fresh conversation, optionally send a first message right away
        if (!$this->response->previousResponseId()) {
            $first = $this->firstMessage();
            if ($first) {
                $this->response->addMessage($first);
                $created = $this->response->create();
                if ($created) {
                    $this->response = $created;
                }
            }
        }

        // Attach tool context
        $ctx = $this->buildToolContext();
        if ($ctx) {
            $this->response->toolContext($ctx);
        }

        // Attach event dispatcher (listeners etc.)
        $this->setupEventDispatcher();

        return $this->response;
    }

    // ---- Message / tools / files convenience ----

    public function addMessage($message, $newMessage = false, $prepend = false)
    {
        $this->noMessages = false;
        $this->response()->addMessage($message, $newMessage, $prepend);
        return $this;
    }

    /**
     * Attach a file to the Response.
     *
     * - If you pass a File model, its fileid() will be added to the Response.
     * - Otherwise, the value is forwarded directly to Response::addFile.
     *
     * This method does NOT perform an upload; use uploadAndAddFile()
     * if you want to create+upload from a local path.
     *
     * @param mixed $file
     * @return $this
     */
    public function addFile($file)
    {
        $this->noMessages = false;

        if ($file instanceof File) {
            $fileId = $file->fileid();
            if ($fileId) {
                $this->attachedFiles[] = $fileId;
                $this->response()->addFile($fileId);
            }
        } else {
            $this->attachedFiles[] = $file;
            $this->response()->addFile($file);
        }

        return $this;
    }

    /**
     * Attach an image to the Response.
     *
     * - If you pass a File model, its fileid() will be used as an image ID.
     * - Otherwise, the value is forwarded directly to Response::addImage.
     *
     * This method does NOT perform an upload; use uploadAndAddImage()
     * if you want to create+upload from a local path.
     *
     * @param mixed $image
     * @param string|null $detail
     * @return $this
     */
    public function addImage($image, $detail = null)
    {
        $this->noMessages = false;

        if ($image instanceof File) {
            $fileId = $image->fileid();
            if ($fileId) {
                $this->attachedFiles[] = ['fileId' => $fileId, 'type' => 'image'];
                $this->response()->addImage($fileId, $detail);
            }
        } else {
            $this->attachedFiles[] = $image;
            $this->response()->addImage($image, $detail);
        }

        return $this;
    }

    /**
     * Generic entrypoint to add a tool to this Response.
     *
     * Supported usages:
     *   - addTool('Browse');                // class name, resolved via BoruAI::loadTool()
     *   - addTool($toolDefinition);         // ToolDefinition instance
     *   - addTool([$def1, $def2]);          // array of ToolDefinition instances
     *   - addTool(['name' => ..., 'parameters' => ...]); // raw schema array
     *   - addTool([
     *         'name'        => 'get_weather',
     *         'description' => '...',
     *         'schema'      => $toolParams,      // ToolParameters or array
     *         'callback'    => $closure,         // callable
     *     ]);
     *
     * @param mixed $tool
     * @return $this
     * @throws \Exception
     */
    public function addTool($tool)
    {
        $this->response()->addTool($tool);
        return $this;
    }

    /**
     * Register a callback for function call started events.
     *
     * Signature: function (StreamEvent $event, $functionName, $args): void
     *
     * @param string   $functionName
     * @param callable $callback
     * @return $this
     */
    public function onFunctionStarted($functionName, $callback)
    {
        $functionListener = $this->getFunctionCallListener();
        $functionListener->onStarted($functionName, $callback);
        return $this;
    }

    /**
     * Register a callback for function call completed events.
     *
     * Signature: function (StreamEvent $event, $functionName, $args, $output): void
     *
     * @param string   $functionName
     * @param callable $callback
     * @return $this
     */
    public function onFunctionCompleted($functionName, $callback)
    {
        $functionListener = $this->getFunctionCallListener();
        $functionListener->onCompleted($functionName, $callback);
        return $this;
    }

    /**
     * Register a callback for function call failed events.
     *
     * Signature: function (StreamEvent $event, $functionName, $args): void
     *
     * @param string   $functionName
     * @param callable $callback
     * @return $this
     */
    public function onFunctionFailed($functionName, $callback)
    {
        $functionListener = $this->getFunctionCallListener();
        $functionListener->onFailed($functionName, $callback);
        return $this;
    }

    /**
     * Register a callback for output_text deltas.
     *
     * Signature: function (StreamEvent $event, $delta, $bufferSoFar): void
     *
     * @param callable $callback
     * @return $this
     * @throws \Exception
     */
    public function onDelta($callback)
    {
        $outputListener = $this->getOutputBufferListener();
        $outputListener->onDelta($callback);
        return $this;
    }

    /**
     * Register a callback when output_text streaming is done.
     *
     * Signature: function (StreamEvent $event, $finalBuffer): void
     *
     * @param callable $callback
     * @return $this
     * @throws \Exception
     */
    public function onDone($callback)
    {
        $outputListener = $this->getOutputBufferListener();
        $outputListener->onDone($callback);
        return $this;
    }

    /**
     * Add a tool definition to the agent's response.
     * @param ToolDefinition $toolDefinition 
     * @param mixed $callback 
     * @return $this 
     */
    public function addToolDefinition(ToolDefinition $toolDefinition, $callback) {
        $this->response()->addToolDefinition($toolDefinition, $callback);
        return $this;
    }

    /**
     * Add a closure-based tool to this agent.
     *
     * @param string                 $name
     * @param string                 $description
     * @param callable               $callback   function (Tool $tool) { ... }
     * @param array|ToolParameters   $schema     Optional schema / parameters
     * @return $this
     * @throws \Exception
     */
    public function addClosureTool($name, $description, $callback, $properties = []) {
        $this->response()->addClosureTool($name, $description, $properties, $callback);
        return $this;
    }

    /**
     * Create a File record from a local path, upload it immediately,
     * then attach it to the Response via addFile().
     *
     * Typical usage:
     *   $agent->uploadAndAddFile('/tmp/doc.pdf');
     *
     * @param string      $path
     * @param bool        $delete  If true, delete the local file after upload.
     * @param string|null $purpose Optional OpenAI file purpose; falls back to File::create() / Document->purpose().
     * @param int|null    $docId   Optional Document id to associate.
     * @return File       The uploaded File model.
     * @throws \Exception If the file does not exist or upload fails.
     */
    public function uploadAndAddFile($path, $delete = false, $purpose = null, $docId = null)
    {
        if (!is_string($path) || $path === '') {
            throw new \InvalidArgumentException('uploadAndAddFile: $path must be a non-empty string');
        }
        if (!file_exists($path)) {
            throw new \RuntimeException('uploadAndAddFile: file not found at path: ' . $path);
        }

        // Create the File entity
        $file = File::create($path, $docId, $purpose);

        // Upload to OpenAI and update local state
        $file->upload($delete);

        // Track in this agent
        $this->uploadedFiles[] = $file;

        // Attach to the Response (this will push fileid() to the API)
        $this->addFile($file);

        return $file;
    }

    /**
     * Same as uploadAndAddFile(), but intended for image content.
     * Currently behaves identically except it calls addImage() rather than addFile().
     *
     * @param string      $path
     * @param bool        $delete
     * @param string|null $purpose
     * @param int|null    $docId
     * @return File
     */
    public function uploadAndAddImage($path, $delete = false, $purpose = null, $docId = null)
    {
        if (!is_string($path) || $path === '') {
            throw new \InvalidArgumentException('uploadAndAddImage: $path must be a non-empty string');
        }
        if (!file_exists($path)) {
            throw new \RuntimeException('uploadAndAddImage: file not found at path: ' . $path);
        }

        $file = File::create($path, $docId, $purpose);
        $file->upload($delete);

        $this->uploadedFiles[] = $file;
        $this->addImage($file);

        return $file;
    }

    /**
     * Return all File models that this agent has uploaded during its lifetime.
     *
     * @return File[]
     */
    public function getUploadedFiles()
    {
        return $this->uploadedFiles;
    }

    /**
     * Clear the list of uploaded file models tracked on this agent.
     *
     * Does NOT delete anything remotely or from the DB, just resets the
     * in-memory list.
     *
     * @return $this
     */
    public function clearUploadedFiles()
    {
        $this->uploadedFiles = [];
        return $this;
    }

    /**
     * Return the raw values that have been attached to the Response
     * via addFile/addImage (file IDs, arrays, File objects that were converted, etc.).
     *
     * @return array
     */
    public function getAttachedFiles()
    {
        return $this->attachedFiles;
    }

    // ---- Fluent helpers ----
    /**
     * Optional helper to create a ToolParameters builder easily.
     *
     * @return ToolParameters
     */
    public function toolParams()
    {
        return new ToolParameters();
    }

    /**
     * Optional helper to create a ToolDefinition builder easily.
     *
     * @return ToolDefinition
     */
    public function tool()
    {
        return new ToolDefinition();
    }

    // ---- Running the agent ----

    /**
     * Run the agent and return the result string (or null/false on error).
     *
     * @param callable|null $callbackFunction Optional streaming callback: function(StreamEvent $ev) {}
     * @return string|false|null
     */
    public function run($callbackFunction = null)
    {
        // Initialize response (model, tool context, dispatcher, optional firstMessage)
        $response = $this->response();

        // Instructions from concrete agent
        $response->instructions($this->getInstructions());

        // If no explicit messages were added, send the base prompt
        if ($this->noMessages) {
            $prompt = $this->getPrompt();
            if ($prompt) {
                $this->addMessage($prompt);
            }
        }

        // Attach tools, if any
        $tools = $this->getTools();
        if (is_array($tools) && count($tools) > 0) {
            foreach ($tools as $tool) {
                $response->addTool($tool);
            }
        }

        $this->result = null;

        try {
            $response->throwExceptionOnError(false);
            $resultObj = $response->create(null, $callbackFunction);
            if ($resultObj) {
                $this->result = $resultObj->getResult(true);
            }
        } catch (\Exception $e) {
            // On error, fall back to streamed buffer if we have one.
            if ($this->buffer !== '') {
                $this->result = $this->buffer;
            } else {
                return false;
            }
        }

        if (!$this->result && $this->buffer !== '') {
            $this->result = $this->buffer;
        }

        return $this->result;
    }
}
