<?php

namespace App\Livewire\ApiLogs;

use App\Models\ApiRequestLog;
use App\Models\Environments\Environment;
use App\Services\Api\HostNormalizer;
use App\Services\Api\Severidad;
use App\Support\SaludDelEntorno;
use LivewireUI\Modal\ModalComponent;

/**
 * El detalle de una petición de la API.
 *
 * **Lo que había era un volcado del esquema.** Dos rejillas de `dt`/`dd` con los nombres
 * de las columnas —`started_at`, `duration_ms`, `response_size`, `license_token_id`— y sus
 * valores en crudo. Eso sirve para depurar si ya sabes lo que buscas; no sirve para la
 * pregunta con la que se abre una petición, que es siempre una de estas dos:
 *
 * - **¿Qué le ha pasado a este sitio?**
 * - **¿Le pasa siempre, o ha sido esta vez?**
 *
 * Ninguna de las dos se responde con los valores de una fila. La primera necesita que el
 * código se traduzca —`3002` no significa nada para quien no se sepa las cuatro familias
 * de memoria— y la segunda necesita **las otras peticiones**, que es justo lo que una
 * ficha de una fila no miraba.
 *
 * Y había una tercera cosa mal: el estado se pintaba con `http_status >= 400 ? ámbar :
 * verde`, así que un `3002` —«no hay contenido publicado para esa versión», que es una
 * respuesta correcta— salía en ámbar como una avería. Es lo que documenta
 * `norma-cero-errores.md`, y lo resuelve {@see Severidad}: cuatro severidades, calculadas
 * por el propio modelo al crear la fila.
 */
class LogDetailModal extends ModalComponent
{
    /**
     * **El mapa de anchos del paquete se queda corto donde importa.** Su `7xl` es
     * `sm:max-w-md md:max-w-xl lg:max-w-3xl xl:max-w-5xl 2xl:max-w-7xl`: en una
     * pantalla de 1440 px —el tramo `xl`, que es el caso normal— la modal se queda en
     * **1024 px**, y el `7xl` no entra hasta los 1536. Por eso se veía estrecha por
     * mucho que se subiera la clave.
     *
     * Se sobrescribe la propiedad en esta clase —`static::$maxWidths` resuelve a la
     * del hijo— para añadir `visor`, que sí crece en `xl`. No se toca el paquete ni
     * el resto de modales del panel.
     *
     * Las clases van en el `safelist` de Tailwind: las aplica Alpine por JS, así que
     * el escáner no las encuentra en ninguna plantilla.
     */
    protected static array $maxWidths = [
        'sm' => 'sm:max-w-sm',
        'md' => 'sm:max-w-md',
        'lg' => 'sm:max-w-md md:max-w-lg',
        'xl' => 'sm:max-w-md md:max-w-xl',
        '2xl' => 'sm:max-w-md md:max-w-xl lg:max-w-2xl',
        '3xl' => 'sm:max-w-md md:max-w-xl lg:max-w-3xl',
        '4xl' => 'sm:max-w-md md:max-w-xl lg:max-w-3xl xl:max-w-4xl',
        '5xl' => 'sm:max-w-md md:max-w-xl lg:max-w-3xl xl:max-w-5xl',
        '6xl' => 'sm:max-w-md md:max-w-xl lg:max-w-3xl xl:max-w-5xl 2xl:max-w-6xl',
        '7xl' => 'sm:max-w-md md:max-w-xl lg:max-w-3xl xl:max-w-5xl 2xl:max-w-7xl',
        // **El nuestro, y el porqué del ancho exacto.** Probado en tres pasos: con el
        // `7xl` del paquete se quedaba en 1024 px a 1440 de pantalla —su `xl` es
        // `max-w-5xl`—; llevándolo al ancho de pantalla se pasó de largo, porque una
        // ficha de 1536 px obliga a barrer la cabeza para leer una frase; y con los
        // bloques en fila cada uno quedaba apretado en su columna.
        //
        // Al final: **estrecha y leyéndose hacia abajo**, que es como se lee una ficha.
        // 896 px es el ancho al que una línea de texto se lee sin barrer.
        'visor' => 'sm:max-w-md md:max-w-xl lg:max-w-3xl xl:max-w-4xl 2xl:max-w-4xl',
    ];

    public ApiRequestLog $log;

    /** Ventana en la que se mira si esto le pasa siempre. La misma que el resto del panel. */
    private const DIAS_DE_CONTEXTO = 30;

    public function mount(int $logId): void
    {
        $this->authorize('admin.api-logs.index');

        $this->log = ApiRequestLog::with(['client', 'site', 'licenseToken', 'reviewedBy'])->findOrFail($logId);
    }

    /**
     * Anchos soportados: sm, md, lg, xl, 2xl, 3xl, 4xl, 5xl, 6xl, 7xl.
     *
     * **Al máximo a propósito.** Aquí caben cinco bloques —qué pasó, si le pasa
     * siempre, quién llamó, la secuencia y lo técnico—, y el ancho es lo que permite
     * poner el patrón y «quién llamó» lado a lado y los tres volcados de JSON en fila:
     * así se comparan de un vistazo en vez de a base de scroll. En 4xl la rejilla de
     * «quién llamó» se partía en dos columnas con los valores cortados.
     */
    public static function modalMaxWidth(): string
    {
        return 'visor';
    }

    /**
     * Marca o desmarca esta petición como vista.
     *
     * **Está aquí además de en el visor** porque una modal de Livewire es otro componente:
     * no hereda los métodos de la pantalla que la abre. Y el sitio natural para decir «ya
     * lo he mirado» es justo después de leer qué pasó, que es aquí.
     *
     * Lleva su `authorize()` porque escribe: en `/livewire/update` el `permission:` de la
     * ruta no se reaplica (MGR-005). El permiso es el de lectura del visor —marcar que has
     * mirado algo es parte de mirarlo—.
     */
    public function alternarRevision(): void
    {
        $this->authorize('admin.api-logs.index');

        $this->log->update($this->log->estaRevisada()
            ? ['reviewed_at' => null, 'reviewed_by' => null]
            : ['reviewed_at' => now(), 'reviewed_by' => auth()->id()]);

        // La relación ya está cargada con el valor viejo: sin esto, el pie seguiría
        // ofreciendo «marcar» después de haber marcado.
        $this->log->refresh()->load('reviewedBy');

        // El visor de detrás tiene los indicadores cacheados cinco minutos y uno cuenta
        // los errores sin revisar. Se avisa para que se refresque al cerrar.
        $this->dispatch('api-logs-revision-cambiada');
    }

    public function copyUuid(string $uuid): void
    {
        $this->dispatch('copy-to-clipboard', text: $uuid);
    }

    public function filterBySiteOrHostAndClose(?int $environmentId = null, ?string $host = null): void
    {
        $this->dispatch('api-logs-filter-by-site-or-host', environmentId: $environmentId, host: $host);
        $this->closeModal();
    }

    /**
     * ¿Le pasa siempre a este sitio con esta acción, o ha sido esta vez?
     *
     * **Se filtra por acción Y por plugin, no solo por acción.** La acción `features` la
     * piden todos los productos, así que agrupando solo por acción se mezclan las
     * peticiones de unos con las de otros y el número no dice nada del caso que se está
     * mirando. Es el mismo criterio que el reparto de versiones de contenido.
     *
     * Y se acota al mismo interlocutor: si la petición tiene entorno reconocido, a ese
     * entorno; si no —un token que no resuelve a ningún sitio—, al mismo `host`, porque
     * «alguien está llamando desde ahí» también es información.
     *
     * @return array{total: int, desglose: array<string,int>, resuelto: bool, posteriores: int}
     */
    public function contexto(): array
    {
        $consulta = ApiRequestLog::query()
            ->where('action', $this->log->action)
            ->where('started_at', '>=', now()->subDays(self::DIAS_DE_CONTEXTO));

        // El plugin distingue peticiones de la misma acción hechas por productos distintos.
        if ($this->log->plugin !== null) {
            $consulta->where('plugin', $this->log->plugin);
        }

        if ($this->log->environment_id !== null) {
            $consulta->where('environment_id', $this->log->environment_id);
        } elseif ($this->log->host !== null) {
            $consulta->where('host', $this->log->host)->whereNull('environment_id');
        }

        $desglose = (clone $consulta)
            ->selectRaw('severity, count(*) as c')
            ->groupBy('severity')
            ->pluck('c', 'severity')
            ->all();

        // **«Falló, y ya está resuelto» no es lo mismo que «está roto».** Si después de
        // esta petición el mismo sitio pidió lo mismo y se le respondió bien, mandar a
        // alguien a arreglarlo es mandarlo a arreglar algo que funciona. Pasó de verdad
        // con el producto del SEPE: cuatro fallos seguidos de OK, y lo que había cambiado
        // entre medias es que se publicó la versión.
        $posteriores = (clone $consulta)
            ->where('started_at', '>', $this->log->started_at)
            ->where('severity', Severidad::OK)
            ->count();

        return [
            'total' => array_sum($desglose),
            'desglose' => $desglose,
            'resuelto' => $this->log->severity !== Severidad::OK && $posteriores > 0,
            'posteriores' => $posteriores,
            'dias' => self::DIAS_DE_CONTEXTO,
        ];
    }

    /**
     * El entorno que hay detrás de este host, cuando la petición no trae ninguno.
     *
     * **La columna `environment_id` dice quién ATENDIÓ la petición**, y en un rechazo por
     * host no la atendió nadie: el middleware busca solo entre los entornos de la licencia
     * que llama, así que se guarda `null`. Correcto para esa columna —rellenarla con un
     * entorno que no sirvió nada falsearía todas las métricas por entorno—, pero deja la
     * ficha diciendo «ninguno reconocido para este host» cuando el sitio **sí está dado de
     * alta**, solo que en otra licencia. El propio mensaje de la respuesta lo dice, y la
     * ficha lo contradecía dos líneas más abajo.
     *
     * Así que se resuelve aquí, para enseñarlo: el dato lo tiene el Manager y quien abre
     * esta pantalla ya tiene permiso para verlo. **No sale a la API** —ahí sigue valiendo
     * MGR-009: los dominios de un cliente no se le confirman a quien llama— porque esto es
     * el panel, no una respuesta.
     *
     * Se compara normalizado, que es como decide el middleware: comparar la columna en
     * crudo dejaría fuera un dominio guardado con otra caja o con barra final, justo
     * cuando más ayuda.
     *
     * @return ?array{entorno: \App\Models\Environments\Environment, mismaLicencia: bool}
     */
    public function entornoDetrasDelHost(): ?array
    {
        if ($this->log->environment_id !== null || ! $this->log->host) {
            return null;
        }

        $buscado = HostNormalizer::normalize((string) $this->log->host);

        $entorno = Environment::with(['client:id,name', 'licenseToken:id,name'])
            ->get()
            ->first(fn (Environment $c) => HostNormalizer::normalize((string) $c->domain) === $buscado);

        if (! $entorno) {
            return null;
        }

        return [
            'entorno' => $entorno,
            // Si es de otro cliente, la ficha lo dice pero no invita a «arreglarlo»: ahí
            // el problema es que alguien está llamando con un token que no es suyo.
            'mismoCliente' => $this->log->client_id !== null
                && (int) $entorno->client_id === (int) $this->log->client_id,
        ];
    }

    /**
     * La petición anterior y la siguiente del mismo interlocutor.
     *
     * Una petición aislada no cuenta nada: lo que explica un fallo suele ser **la
     * secuencia**. Un `sync` que va bien y detrás tres `licence` con error dice dónde
     * empezó, y en el caso del SEPE la secuencia era la prueba de que el problema estaba
     * resuelto.
     *
     * Aquí no se filtra por acción a propósito: lo que se quiere ver es qué estaba
     * haciendo ese sitio alrededor, no solo lo mismo otra vez.
     *
     * @return array{anterior: ?ApiRequestLog, siguiente: ?ApiRequestLog}
     */
    public function alrededor(): array
    {
        $delMismo = function () {
            $c = ApiRequestLog::query()->where('id', '!=', $this->log->id);

            if ($this->log->environment_id !== null) {
                $c->where('environment_id', $this->log->environment_id);
            } elseif ($this->log->host !== null) {
                $c->where('host', $this->log->host);
            } else {
                // Sin entorno ni host no hay «el mismo»: se devuelve vacío en lugar de
                // enseñar peticiones de cualquiera como si fueran suyas.
                $c->whereRaw('1 = 0');
            }

            return $c;
        };

        return [
            'anterior' => $delMismo()
                ->where('started_at', '<=', $this->log->started_at)
                ->orderByDesc('started_at')
                ->first(['id', 'action', 'plugin', 'severity', 'error_code', 'started_at', 'duration_ms']),
            'siguiente' => $delMismo()
                ->where('started_at', '>=', $this->log->started_at)
                ->orderBy('started_at')
                ->first(['id', 'action', 'plugin', 'severity', 'error_code', 'started_at', 'duration_ms']),
        ];
    }

    /**
     * El texto para pegarle a Claude Code y que investigue esta petición.
     *
     * **Por qué se construye aquí y no se copia la pantalla.** Lo que hace falta para
     * investigar no es lo que se ve —la severidad ya traducida, los nombres bonitos—, es
     * el dato crudo con su contexto: la acción, el código, el mensaje del servidor, la
     * versión que manda el cliente y **si es la primera vez o le pasa siempre**. Copiando
     * la pantalla se pierde justo eso y se gana ruido.
     *
     * Lleva la pregunta hecha y los sitios donde mirar, porque un prompt que solo pega
     * datos obliga a escribir la pregunta cada vez, y ahí es donde se cuela el «arréglame
     * esto» sin contexto.
     *
     * **No lleva `headers` ni `payload` completos** aunque estén saneados: son cientos de
     * líneas que se pegan mal en un chat y casi nunca hacen falta. Si hacen falta, están a
     * un clic en «Detalle técnico».
     */
    public function promptParaClaude(array $contexto): string
    {
        $l = $this->log;

        $lineas = [
            'Una petición de la API del Tresipunt Manager que quiero entender.',
            '',
            '## Qué pasó',
            '',
            '- Acción: `' . ($l->action ?? '—') . '`' . ($l->plugin ? ' pedida por `' . $l->plugin . '`' : ''),
            '- Resultado: **' . (Severidad::ETIQUETAS[$l->severity] ?? $l->severity) . '**'
                . ' · HTTP ' . $l->http_status
                . ((int) $l->error_code !== 0 ? ' · código ' . $l->error_code : ''),
            '- Mensaje del servidor: ' . ($l->error !== null && $l->error !== '' ? '`' . $l->error . '`' : '(ninguno)'),
            '- Cuándo: ' . ($l->started_at?->format('Y-m-d H:i:s') ?? '—') . ' · ' . $l->duration_ms . ' ms',
        ];

        if ($this->log->severity !== Severidad::OK) {
            $lineas[] = '- Qué significa, según el panel: ' . SaludDelEntorno::explicar($l);
        }

        // El cuerpo, si se guardó: es lo que vio el cliente, y en un prompt vale más
        // que cualquier resumen que podamos hacer de él.
        if ($l->response_body) {
            $lineas[] = '';
            $lineas[] = '### Lo que se le respondió';
            $lineas[] = '';
            $lineas[] = '```json';
            $lineas[] = $l->response_body;
            $lineas[] = '```';
        }

        $lineas[] = '';
        $lineas[] = '## Quién llamó';
        $lineas[] = '';
        $lineas[] = '- Entorno: ' . ($l->site?->name ?? '**ninguno reconocido para este host**');

        // Si el host sí está dado de alta, decirlo: sin esto el prompt afirmaba que el
        // sitio no existe y quien lo leyera —persona o modelo— diagnosticaría un alta que
        // falta en lugar de una vinculación que está mal.
        if (($detras = $this->entornoDetrasDelHost()) !== null) {
            $lineas[] = '- **Pero ese host sí está dado de alta** como entorno «'
                . $detras['entorno']->name . '»'
                . ($detras['mismoCliente'] ? ' del mismo cliente' : ' de OTRO cliente')
                . ', vinculado a la licencia «'
                . ($detras['entorno']->licenseToken?->name ?? 'ninguna') . '».';
        }
        $lineas[] = '- Host desde el que llama: `' . ($l->host ?? '—') . '`';
        $lineas[] = '- Cliente: ' . ($l->client?->name ?? '—');
        $lineas[] = '- Licencia: ' . ($l->licenseToken?->name ?? 'ninguna reconocida');
        $lineas[] = '- Versión que envía el plugin: `' . ($l->version ?: '—') . '`';
        $lineas[] = '- projectid: `' . ($l->projectid ?? '—') . '` · IP: ' . ($l->ip ?? '—');

        // **El contexto es la mitad del valor del prompt**: sin él, cualquiera empieza a
        // investigar un caso aislado que a lo mejor ya está resuelto.
        $lineas[] = '';
        $lineas[] = '## Si le pasa siempre o no';
        $lineas[] = '';

        if ($contexto['resuelto']) {
            $lineas[] = '- **Ya está resuelto**: después de esta petición, el mismo sitio pidió lo '
                . 'mismo ' . $contexto['posteriores'] . ' vez/veces y se le respondió bien. '
                . 'Puede que no haya nada que arreglar.';
        } else {
            $lineas[] = '- ' . $contexto['total'] . ' petición(es) del mismo sitio a `' . $l->action . '`'
                . ($l->plugin ? ' con `' . $l->plugin . '`' : '')
                . ' en los últimos ' . $contexto['dias'] . ' días.';
        }

        foreach ($contexto['desglose'] as $severidad => $cuantas) {
            $lineas[] = '  - ' . $cuantas . ' ' . mb_strtolower(Severidad::ETIQUETAS[$severidad] ?? $severidad);
        }

        $lineas[] = '';
        $lineas[] = '## Lo que quiero';
        $lineas[] = '';
        $lineas[] = 'Explícame por qué esta petición ha respondido así y si hay algo que arreglar '
            . 'en el Manager, en el plugin del cliente o en su alta. **No cambies nada todavía**: '
            . 'primero el diagnóstico.';
        $lineas[] = '';
        $lineas[] = 'Dónde mirar: la acción está en `app/Services/Api/Actions/`, la clasificación '
            . 'de la respuesta en `app/Services/Api/Severidad.php`, y los códigos de error están '
            . 'documentados en `.tresipunt/.tresipunt/tresipunt/manager/api/error-codes.md`.';
        $lineas[] = '';
        $lineas[] = 'Ojo con una cosa: los códigos `3002`, `4002` y `5002` **no son errores**, '
            . 'significan «no hay contenido publicado para esa versión». Ver `norma-cero-errores.md`.';
        $lineas[] = '';
        $lineas[] = 'Identificador de la petición, para buscarla en el visor: `' . $l->request_uuid . '`.';

        return implode("\n", $lineas);
    }

    public function render()
    {
        $alrededor = $this->alrededor();
        $contexto = $this->contexto();

        return view('livewire.api-logs.log-detail-modal', [
            'contexto' => $contexto,
            'promptParaClaude' => $this->promptParaClaude($contexto),
            'anterior' => $alrededor['anterior'],
            'siguiente' => $alrededor['siguiente'],
            // La explicación en castellano de lo que pasó. Vive en `SaludDelEntorno`
            // porque es donde nació —el veredicto de la ficha del entorno la necesitaba
            // primero— y no se duplica aquí: una traducción de códigos en dos sitios
            // acaba divergiendo.
            'explicacion' => $this->log->severity !== Severidad::OK
                ? SaludDelEntorno::explicar($this->log)
                : null,
            // El entorno que hay detrás del host cuando la petición no trae ninguno.
            'detrasDelHost' => $this->entornoDetrasDelHost(),
        ]);
    }
}
