<?php

namespace App\Libraries;

use App\Models\AiCallLogModel;
use CodeIgniter\HTTP\CURLRequest;
use Throwable;

/**
 * The single entry point for every Claude API call in this app — see
 * the Phase 5 AI brief's non-negotiable guardrails. Every feature
 * (KPI narration, maintenance triage, and everything queued after them)
 * calls chat() rather than hitting the Anthropic API directly, so
 * logging/error-handling/tool-use looping can never drift between
 * features. Credentials come from the current tenant's Settings, same
 * pattern as HubtelService/WhatsAppService — a hotel with nothing
 * configured yet gets a clear exception, never a silently faked reply.
 *
 * Tool use: each *feature* defines its own small tool schema + a
 * matching PHP executor closure (calling real models directly) and
 * hands both to chat(). This class only runs the request/tool-result
 * loop generically — it has no opinion on what any tool does. Capped at
 * MAX_TOOL_ROUNDS so a confused model can't loop forever; hitting the
 * cap fails the call visibly (logged, success=false) rather than
 * returning a truncated/guessed answer.
 */
class ClaudeService
{
    private const API_URL     = 'https://api.anthropic.com/v1/messages';
    private const API_VERSION = '2023-06-01';
    private const DEFAULT_MODEL = 'claude-sonnet-5';
    private const DEFAULT_MAX_TOKENS = 1024;
    private const MAX_TOOL_ROUNDS = 5;

    // Same reasoning as HubtelService/WhatsAppService's identical
    // constants — an explicit cap so a stalled connection fails fast
    // instead of hanging the request. REQUEST_TIMEOUT is longer than
    // those services' 10s since an LLM response genuinely takes longer
    // to generate than a payment/SMS API's ack. A feature that uses
    // several rounds of tool_use (this app has none yet — Tier 1's
    // first two features, KPI narration and maintenance triage, call
    // with zero tools) will take up to MAX_TOOL_ROUNDS × REQUEST_TIMEOUT
    // in the worst case, which is worth keeping in mind against PHP's
    // max_execution_time once a tool-using feature is built.
    private const CONNECT_TIMEOUT = 5;
    private const REQUEST_TIMEOUT = 30;

    public function __construct(
        private readonly Settings $settings,
        private readonly CURLRequest $client,
        private readonly AiCallLogModel $log,
    ) {
    }

    /**
     * @param string $feature Identifies which feature is calling, for
     *        ai_call_log.feature (e.g. 'kpi_narration') — not sent to Claude.
     * @param list<array{role: string, content: mixed}> $messages Conversation so far
     * @param list<array{name: string, description: string, input_schema: array}> $tools
     * @param ?callable(string $toolName, array $input): string $toolExecutor Runs one tool call
     *        against real data and returns a string result. Required if $tools is non-empty.
     *
     * @return array{success: bool, text: ?string, raw: ?array, toolCalls: array, usage: array, error: ?string}
     */
    public function chat(
        string $feature,
        string $systemPrompt,
        array $messages,
        array $tools = [],
        $toolExecutor = null,
        ?string $relatedType = null,
        ?int $relatedId = null,
        ?int $triggeredBy = null,
    ): array {
        $apiToken = $this->settings->get('ai.claude_api_token');

        if (! $apiToken) {
            $error = 'AI is not configured yet — add your Anthropic API key under Settings > AI Features.';

            $this->logCall($feature, $relatedType, $relatedId, null, $systemPrompt, $messages, [], null, null, null, false, $error, $triggeredBy);

            return ['success' => false, 'text' => null, 'raw' => null, 'toolCalls' => [], 'usage' => [], 'error' => $error];
        }

        $model = $this->settings->get('ai.claude_model') ?: self::DEFAULT_MODEL;
        $allToolCalls = [];
        $round = 0;

        while (true) {
            $round++;

            if ($round > self::MAX_TOOL_ROUNDS) {
                $error = 'AI request exceeded the maximum tool-use rounds (' . self::MAX_TOOL_ROUNDS . ').';

                $this->logCall($feature, $relatedType, $relatedId, $model, $systemPrompt, $messages, $allToolCalls, null, null, null, false, $error, $triggeredBy);

                return ['success' => false, 'text' => null, 'raw' => null, 'toolCalls' => $allToolCalls, 'usage' => [], 'error' => $error];
            }

            try {
                $response = $this->client->request('POST', self::API_URL, [
                    'headers' => [
                        'x-api-key'         => $apiToken,
                        'anthropic-version' => self::API_VERSION,
                        'content-type'      => 'application/json',
                    ],
                    'json' => array_filter([
                        'model'      => $model,
                        'max_tokens' => self::DEFAULT_MAX_TOKENS,
                        'system'     => $systemPrompt,
                        'messages'   => $messages,
                        'tools'      => $tools !== [] ? $tools : null,
                    ], static fn ($v) => $v !== null),
                    'connect_timeout' => self::CONNECT_TIMEOUT,
                    'timeout'         => self::REQUEST_TIMEOUT,
                    'http_errors'     => false,
                ]);
            } catch (Throwable $e) {
                log_message('error', 'ClaudeService: connection failure — ' . $e->getMessage());
                $error = 'Could not reach the AI service — please try again in a moment.';

                $this->logCall($feature, $relatedType, $relatedId, $model, $systemPrompt, $messages, $allToolCalls, null, null, null, false, $error, $triggeredBy);

                return ['success' => false, 'text' => null, 'raw' => null, 'toolCalls' => $allToolCalls, 'usage' => [], 'error' => $error];
            }

            $status = $response->getStatusCode();
            $body   = json_decode((string) $response->getBody(), true) ?? [];

            if ($status < 200 || $status >= 300) {
                $error = 'Claude API error (' . $status . '): ' . ($body['error']['message'] ?? (string) $response->getBody());
                log_message('error', 'ClaudeService: ' . $error);

                $this->logCall($feature, $relatedType, $relatedId, $model, $systemPrompt, $messages, $allToolCalls, json_encode($body), null, null, false, $error, $triggeredBy);

                return ['success' => false, 'text' => null, 'raw' => $body, 'toolCalls' => $allToolCalls, 'usage' => [], 'error' => $error];
            }

            $content    = $body['content'] ?? [];
            $stopReason = $body['stop_reason'] ?? null;
            $usage      = $body['usage'] ?? [];

            if ($stopReason !== 'tool_use') {
                $text = $this->extractText($content);

                $this->logCall($feature, $relatedType, $relatedId, $model, $systemPrompt, $messages, $allToolCalls, $text, $usage['input_tokens'] ?? null, $usage['output_tokens'] ?? null, true, null, $triggeredBy);

                return ['success' => true, 'text' => $text, 'raw' => $body, 'toolCalls' => $allToolCalls, 'usage' => $usage, 'error' => null];
            }

            // stop_reason === 'tool_use': Claude wants one or more tools run
            // before it can finish. A tools request with no executor is a
            // programming error in the calling feature, not a transient
            // failure — surfaced the same way as any other failure so it's
            // never silently swallowed.
            if ($toolExecutor === null) {
                $error = 'Claude requested a tool call but this feature supplied no tool executor.';

                $this->logCall($feature, $relatedType, $relatedId, $model, $systemPrompt, $messages, $allToolCalls, null, $usage['input_tokens'] ?? null, $usage['output_tokens'] ?? null, false, $error, $triggeredBy);

                return ['success' => false, 'text' => null, 'raw' => $body, 'toolCalls' => $allToolCalls, 'usage' => $usage, 'error' => $error];
            }
            // A tool_use block's input decoded via getJSON(..., true)/
            // json_decode(..., true) turns a zero-argument call's real
            // JSON "{}" into a PHP empty array — re-encoding that for the
            // next round would send back "[]", which the API rejects
            // ("Input should be an object"). Only ever bites a tool with
            // an empty input_schema (this app's first is the WhatsApp
            // Concierge's get_hotel_info/get_guest_status/etc.) — every
            // tool before that always had at least one required property,
            // so this was latent until now. Fixed up on a COPY used only
            // for the outgoing message, never on $content itself — the
            // executor loop below still needs a real (possibly empty)
            // array for $toolExecutor's `array $input` parameter.
            $contentForMessage = $content;

            foreach ($contentForMessage as &$block) {
                if (($block['type'] ?? null) === 'tool_use' && ($block['input'] ?? null) === []) {
                    $block['input'] = new \stdClass();
                }
            }
            unset($block);

            $messages[] = ['role' => 'assistant', 'content' => $contentForMessage];
            $toolResults = [];

            foreach ($content as $block) {
                if (($block['type'] ?? null) !== 'tool_use') {
                    continue;
                }

                try {
                    $result = (string) $toolExecutor($block['name'], $block['input'] ?? []);
                } catch (Throwable $e) {
                    // A tool failing (e.g. a bad query) must not let Claude
                    // guess an answer instead — it gets told the tool
                    // failed and must say so, per the guardrail that a
                    // failed tool call fails the feature visibly.
                    $result = 'Error: this tool call failed (' . $e->getMessage() . '). Do not guess or estimate the answer — tell the user this information could not be retrieved.';
                }

                $allToolCalls[] = ['name' => $block['name'], 'input' => $block['input'] ?? [], 'result' => $result];
                $toolResults[]  = ['type' => 'tool_result', 'tool_use_id' => $block['id'], 'content' => $result];
            }

            $messages[] = ['role' => 'user', 'content' => $toolResults];
        }
    }

    private function extractText(array $content): ?string
    {
        $parts = [];

        foreach ($content as $block) {
            if (($block['type'] ?? null) === 'text') {
                $parts[] = $block['text'];
            }
        }

        return $parts !== [] ? implode("\n", $parts) : null;
    }

    private function logCall(
        string $feature,
        ?string $relatedType,
        ?int $relatedId,
        ?string $model,
        string $systemPrompt,
        array $messages,
        array $toolCalls,
        ?string $responseText,
        ?int $inputTokens,
        ?int $outputTokens,
        bool $success,
        ?string $errorMessage,
        ?int $triggeredBy,
    ): void {
        $this->log->insert([
            'feature'          => $feature,
            'related_type'     => $relatedType,
            'related_id'       => $relatedId,
            'model'            => $model,
            'system_prompt'    => $systemPrompt,
            'request_messages' => json_encode($messages),
            'tool_calls'       => $toolCalls !== [] ? json_encode($toolCalls) : null,
            'response_text'    => $responseText,
            'input_tokens'     => $inputTokens,
            'output_tokens'    => $outputTokens,
            'success'          => $success ? 1 : 0,
            'error_message'    => $errorMessage,
            'triggered_by'     => $triggeredBy,
        ]);
    }
}
