<?php

namespace App\Livewire\Environments;

use App\Models\Environments\Environment;
use App\Models\Environments\EnvironmentStat;
use Illuminate\Support\Carbon;
use Livewire\Attributes\Url;
use Livewire\Component;

/**
 * Uso del entorno — implementado desde `Data.dc.html`.
 *
 * **Qué es.** Cuánta gente y cuánto contenido tiene el Moodle de un cliente, y cómo
 * evoluciona. Es la pantalla que más se abría del Manager antiguo y que se perdió en la
 * migración.
 *
 * **El nombre importa**, porque en el panel hay tres cosas que se llamarían
 * «estadísticas»:
 *
 * - **Uso del entorno** *(esta)* — usuarios, cursos, matrículas. De `data` y
 *   `environment_stats`.
 * - **Estadísticas del parque** — qué versiones y plugins hay repartidos.
 * - **Inventario de plugins** — qué tiene instalado un entorno.
 *
 * Y «actividad» no se usa aquí: en el Dashboard, «actividad» son las peticiones a la API.
 *
 * **Y hay que saber esto antes de mirar la pantalla**: hoy solo **1 de los 21 entornos**
 * tiene fila en `data`, y `environment_stats` está **vacía**. Las métricas de uso llegan
 * únicamente por la acción `data` de la API, que lanza la tarea diaria del plugin
 * (`sync_stats_task` → `tip::stats()`), y **esa acción no se ha llamado nunca**: en el log
 * de peticiones hay `licence`, `products`, `features`, `sync`, `setup` y `scss-cdn`, y de
 * `data` ni una. `sync` no puede suplirla porque no recibe métricas de uso —solo
 * `release` y `availableupdatesfetch`, que es de donde salen las 17 columnas con valor de
 * la única fila que hay—.
 *
 * O sea que **el estado normal de esta pantalla, hoy, es «sin datos»**, y no por un fallo
 * del Manager. Por eso los estados vacíos no son un adorno: son la pantalla.
 */
class Uso extends Component
{
    public Environment $environment;

    /**
     * Contra qué pasado se compara: 7, 30, 180 o 365 días.
     *
     * **En la URL** porque el enlace se comparte: «mira la evolución de este cliente en
     * el último año» es una conversación que se tiene, y sin esto hay que explicar dónde
     * pulsar.
     */
    #[Url(as: 'rango', except: 30)]
    public int $rango = 30;

    /** Los rangos que existen. Fuera de esta lista, se vuelve al mes. */
    public const RANGOS = [
        7 => '7 días',
        30 => '1 mes',
        180 => '6 meses',
        365 => '1 año',
    ];

    /**
     * ¿Se le puede pedir la foto a este Moodle?
     *
     * Las dos condiciones, y las dos importan: el permiso, y que el sitio nos haya dado su
     * token de servicios web. Sin token el botón fallaría siempre, y un botón que siempre
     * falla es peor que no tenerlo… salvo que **el motivo es accionable**: hay que ir a
     * guardar el token. Así que se pinta igual y la modal explica qué hacer, que es lo
     * mismo que hace «Sincronizar ahora» en la ficha.
     */
    public function puedePedirDatos(): bool
    {
        return ! $this->environment->trashed()
            && auth()->user()?->can('admin.environments.refresh');
    }

    /**
     * Abre la modal que se lo pide al Moodle del cliente.
     *
     * **En modal y no en un botón que dispare directo**: esto sale de nuestro servidor
     * hacia el sitio de un cliente, y lo que hay que contar antes —que los datos llegan en
     * una llamada aparte unos segundos después— no cabe en un botón.
     */
    public function abrirPedirDatos(): void
    {
        $this->authorize('admin.environments.refresh');

        $this->dispatch('openModal',
            component: PedirDatosModal::class,
            arguments: ['environmentId' => $this->environment->id]
        );
    }

    /**
     * La foto puede haber llegado mientras se esperaba la respuesta del Moodle.
     *
     * No siempre: el Moodle contesta que acepta el encargo y nos la manda después, así que
     * esto es una recarga optimista. La modal ofrece recargar a mano por si llega tarde.
     */
    #[\Livewire\Attributes\On('datosDeUsoPedidos')]
    public function refrescar(): void
    {
        $this->environment->refresh();
    }

    public function mount(Environment $environment): void
    {
        $this->environment = $environment;

        // El rango viene de la URL, así que puede traer cualquier cosa: un enlace viejo o
        // un dedazo. Sin esto, `RANGOS[$rango]` no existiría y la pantalla saldría en
        // blanco.
        if (!array_key_exists($this->rango, self::RANGOS)) {
            $this->rango = 30;
        }
    }

    /**
     * Cambia el periodo con el que se compara y **repinta las gráficas**.
     *
     * **El `dispatch` no es opcional.** Las gráficas viven dentro de un `wire:ignore`
     * —hace falta, o Livewire borra el canvas en cada interacción—, y eso significa que
     * un `render()` nuevo **no las toca**: al cambiar el rango, los deltas y las cifras se
     * actualizaban y las cuatro líneas se quedaban dibujando el mes anterior. Sin dar
     * ningún error, que es lo peor.
     *
     * Así que se les manda las opciones nuevas por evento, una por serie, y `charts.js`
     * hace `setOption` conservando el zoom que el usuario tuviera puesto.
     */
    public function cambiarRango(int $dias): void
    {
        if (!array_key_exists($dias, self::RANGOS)) {
            return;
        }

        $this->rango = $dias;

        foreach ($this->series() as $serie) {
            if ($serie['vacia']) {
                continue;
            }

            $this->dispatch('grafica:' . $serie['metrica'], opciones: $serie['opciones']);
        }
    }

    /**
     * La última foto conocida, y de cuándo es.
     *
     * **La fecha sale del histórico, no de `data.updated_at`.** `updated_at` cambia
     * cuando `sync` toca las dos columnas de versión, así que diría «hoy» aunque las
     * métricas de uso sean de hace un mes: exactamente la mentira que esta pantalla tiene
     * que evitar.
     *
     * @return array{foto: ?\App\Models\Environments\Data, fecha: ?Carbon, dias: ?int}
     */
    public function ultimaFoto(): array
    {
        $foto = $this->environment->data;

        $fecha = EnvironmentStat::where('environment_id', $this->environment->id)
            ->max('date');

        $fecha = $fecha ? Carbon::parse($fecha) : null;

        return [
            'foto' => $foto,
            'fecha' => $fecha,
            // **`(int)` no es decorativo.** En Carbon 3 `diffInDays` devuelve un
            // float, así que `$dias === 0` era `false` para `0.0` y la frescura de un
            // dato de hoy caía al último caso de la ternaria: decía «hace 2 días»
            // teniendo la foto recién llegada.
            'dias' => $fecha === null ? null : (int) $fecha->startOfDay()->diffInDays(now()->startOfDay()),
        ];
    }

    /**
     * Cómo de fresco es el dato, dicho como se lee.
     *
     * Es lo primero que hay que ver: unas cifras de hace un mes presentadas como
     * actuales son peores que no tener cifras.
     *
     * @return array{texto: string, nota: string, color: string, punto: string, viejo: bool}
     */
    public function frescura(): array
    {
        ['fecha' => $fecha, 'dias' => $dias] = $this->ultimaFoto();

        if ($this->environment->trashed()) {
            return [
                'texto' => $fecha ? $fecha->format('d/m/Y') : 'nunca',
                'nota' => 'histórico congelado en la baja',
                'color' => 'var(--text-muted)',
                'punto' => 'var(--neutral-500)',
                'viejo' => false,
            ];
        }

        if ($fecha === null) {
            return [
                'texto' => 'nunca',
                'nota' => 'este Moodle no ha enviado datos de uso',
                'color' => 'var(--danger-500)',
                'punto' => 'var(--danger-500)',
                'viejo' => false,
            ];
        }

        // **Dos días de margen, no uno.** La foto lleva la fecha del Moodle, no la
        // nuestra: un entorno en otro huso puede enviar la de «ayer» siendo puntual, y
        // marcarlo en ámbar sería un falso positivo diario.
        if ($dias <= 2) {
            return [
                'texto' => $dias === 0 ? 'hoy' : ($dias === 1 ? 'ayer' : 'hace 2 días'),
                'nota' => 'la foto diaria llega con normalidad',
                'color' => 'var(--text-strong)',
                'punto' => 'var(--success-500)',
                'viejo' => false,
            ];
        }

        return [
            'texto' => 'hace ' . $dias . ' días',
            'nota' => 'último envío: ' . $fecha->format('d/m/Y'),
            'color' => 'var(--warning-500)',
            'punto' => 'var(--warning-500)',
            'viejo' => true,
        ];
    }

    /**
     * Las tres familias de cifras, con su delta.
     *
     * **Agrupadas y sin mezclar**, que era el problema del Manager antiguo: allí la
     * tarjeta de «Cursos» llevaba abajo «predicciones generales» y «acciones
     * predicciones», que son de analítica y no tienen nada que ver con un curso.
     *
     * El **delta va a la vista y no en un `title`**. En el Manager antiguo el dato que
     * importaba —cuánto ha subido y contra qué fecha— estaba en el tooltip: invisible en
     * móvil, e invisible en la captura que se le manda al cliente.
     *
     * @return array<int, array<string, mixed>>
     */
    public function familias(): array
    {
        $foto = $this->ultimaFoto()['foto'];

        if ($foto === null) {
            return [];
        }

        return [
            [
                'nombre' => 'Usuarios',
                'valor' => $foto->users,
                'label' => 'usuarios totales',
                'delta' => $this->delta('users', $foto->users),
                'secundarias' => [
                    [
                        'valor' => $this->numero($foto->activeusers),
                        'label' => 'activos',
                        // Se dice de quién es el criterio: cuando el cliente pregunta por
                        // qué su número no cuadra, la respuesta es esta.
                        'tip' => 'Según el criterio de «activo reciente» de Moodle, no el nuestro',
                    ],
                    [
                        'valor' => $this->decimal($foto->participantnumberaverage),
                        'label' => 'particip. / curso',
                        'tip' => 'Media de participantes por curso',
                    ],
                    [
                        'valor' => $this->decimal($foto->activeparticipantnumberaverage),
                        'label' => 'activos / curso',
                        'tip' => 'Media de participantes activos por curso',
                    ],
                ],
            ],
            [
                'nombre' => 'Contenido',
                'valor' => $foto->courses,
                'label' => 'cursos totales',
                'delta' => $this->delta('courses', $foto->courses),
                'secundarias' => [
                    ['valor' => $this->numero($foto->resources), 'label' => 'recursos', 'tip' => 'Recursos del sitio'],
                    ['valor' => $this->numero($foto->questions), 'label' => 'preguntas', 'tip' => 'Del banco de preguntas'],
                    ['valor' => $this->decimal($foto->modulenumberaverage), 'label' => 'módulos / curso', 'tip' => 'Media de módulos por curso'],
                ],
            ],
            [
                'nombre' => 'Actividad',
                'valor' => $foto->enrolments,
                'label' => 'matrículas totales',
                'delta' => $this->delta('enrolments', $foto->enrolments),
                'secundarias' => [
                    [
                        'valor' => $foto->courses > 0
                            ? $this->decimal($foto->enrolments / $foto->courses)
                            : '—',
                        'label' => 'matr. / curso',
                        'tip' => 'Matrículas dividido entre cursos',
                    ],
                    ['valor' => $this->numero($foto->posts), 'label' => 'mensajes', 'tip' => 'Mensajes en foros'],
                    ['valor' => $this->numero($foto->issuedbadges), 'label' => 'insignias', 'tip' => 'Insignias entregadas'],
                ],
            ],
        ];
    }

    /**
     * Cuánto ha cambiado una métrica en el rango elegido.
     *
     * **«Sin cambios» se dice con palabras y en gris.** En el Manager antiguo un delta de
     * 0 se pintaba en verde con dos iconos vacíos al lado: sin cambio no es una buena
     * noticia, es ninguna noticia.
     *
     * Y si no hay dato con el que comparar, se dice: no se finge un 0.
     *
     * @return array{texto: string, fg: string, bg: string}
     */
    private function delta(string $metrica, ?int $ahora): array
    {
        $etiqueta = self::RANGOS[$this->rango];

        if ($ahora === null) {
            return ['texto' => 'sin dato', 'fg' => 'var(--text-subtle)', 'bg' => 'var(--surface-sunken)'];
        }

        $antes = $this->valorDeHace($metrica, $this->rango);

        if ($antes === null) {
            return [
                'texto' => 'sin histórico de ' . $etiqueta,
                'fg' => 'var(--text-subtle)',
                'bg' => 'var(--surface-sunken)',
            ];
        }

        $diferencia = $ahora - $antes;

        if ($diferencia === 0) {
            return [
                'texto' => 'sin cambios · ' . $etiqueta,
                'fg' => 'var(--neutral-600)',
                'bg' => 'var(--surface-sunken)',
            ];
        }

        return [
            'texto' => ($diferencia > 0 ? '+' : '−')
                . number_format(abs($diferencia), 0, ',', '.') . ' en ' . $etiqueta,
            'fg' => $diferencia > 0 ? 'var(--success-500)' : 'var(--danger-500)',
            'bg' => $diferencia > 0 ? 'var(--success-50)' : 'var(--danger-50)',
        ];
    }

    /**
     * El valor de una métrica hace N días.
     *
     * **La foto más cercana hacia atrás, y no cualquiera.** Si el Moodle no envió justo
     * ese día, comparar contra `null` daría «sin histórico» teniendo datos de un día
     * antes; y coger la más cercana en cualquier dirección podría usar una posterior al
     * corte y dar un delta invertido.
     *
     * **Pero con tolerancia**, porque si no el número miente. Con fotos de hace 200 días
     * y de hace 20, un delta «de 1 mes» calculado contra la de hace 200 anunciaba «+150
     * en 1 mes» cuando ese crecimiento era de más de medio año. Así que la referencia
     * tiene que caer dentro de la ventana `[N, 2N]` días: para comparar con «1 mes», una
     * foto de entre 30 y 60 días atrás. Fuera de ahí no hay comparación honesta y se dice
     * que no la hay.
     */
    private function valorDeHace(string $metrica, int $dias): ?int
    {
        $fila = EnvironmentStat::where('environment_id', $this->environment->id)
            ->whereDate('date', '<=', now()->subDays($dias)->format('Y-m-d'))
            ->whereDate('date', '>=', now()->subDays($dias * 2)->format('Y-m-d'))
            ->orderByDesc('date')
            ->first();

        $valor = $fila?->data[$metrica] ?? null;

        return is_numeric($valor) ? (int) $valor : null;
    }

    /**
     * Las cuatro series del diseño, cada una con sus opciones de ECharts.
     *
     * **Con ECharts y no con SVG a mano** porque cada gráfica lleva tooltip con el valor
     * de ese día y su fecha, y rango navegable. El diseño dibuja sparklines, pero un
     * sparkline sin tooltip obliga a adivinar de qué día es el pico —y esa es la pregunta
     * que se hace mirando una evolución—.
     *
     * @return array<int, array<string, mixed>>
     */
    public function series(): array
    {
        $fotos = $this->fotosDelRango();

        // Con una sola foto no hay evolución que dibujar: dos puntos son el mínimo para
        // que exista una línea. El estado «la serie empieza hoy» lo cuenta.
        if ($fotos->count() < 2) {
            return [];
        }

        return array_map(
            fn (array $serie) => $this->prepararSerie($serie, $fotos),
            self::METRICAS
        );
    }

    /** Las cuatro métricas que se dibujan, con su color del design system. */
    public const METRICAS = [
        ['metrica' => 'users', 'nombre' => 'Usuarios', 'color' => 'var(--teal-500)'],
        ['metrica' => 'courses', 'nombre' => 'Cursos', 'color' => 'var(--orange-500)'],
        ['metrica' => 'enrolments', 'nombre' => 'Matrículas', 'color' => 'var(--info-500)'],
        ['metrica' => 'resources', 'nombre' => 'Recursos', 'color' => 'var(--success-500)'],
    ];

    /**
     * Las fotos del rango, indexadas por día.
     *
     * Una sola consulta para las cuatro series: cada fila trae el payload completo, así
     * que leer la tabla cuatro veces sería decodificar cuatro veces el mismo JSON.
     */
    private function fotosDelRango()
    {
        return EnvironmentStat::where('environment_id', $this->environment->id)
            ->whereDate('date', '>=', now()->subDays($this->rango)->format('Y-m-d'))
            ->orderBy('date')
            ->get()
            ->keyBy(fn (EnvironmentStat $fila) => $fila->date->format('Y-m-d'));
    }

    /**
     * Una serie: el valor de cada día del rango, y sus opciones de gráfica.
     *
     * **Los días sin foto van como `null`, no como 0.** Se recorre el calendario y no las
     * filas, así que los días que faltan existen en el eje con valor nulo. Eso importa
     * aunque la línea se una por encima: un 0 se leería como «ese día no había
     * usuarios» y hundiría la gráfica, mientras que un `null` hace que el tooltip de
     * ese día diga «sin dato» y no altere la escala.
     *
     * La línea **sí se une** —`connectNulls`—: partirla dejaba la evolución en trozos y
     * la tendencia era lo que se venía a ver. Cuántos días faltan se dice debajo de la
     * gráfica, con su número.
     */
    private function prepararSerie(array $serie, $fotos): array
    {
        $dias = [];
        $valores = [];

        for ($d = $this->rango; $d >= 0; $d--) {
            $dia = now()->subDays($d);
            $valor = $fotos[$dia->format('Y-m-d')]->data[$serie['metrica']] ?? null;

            // La etiqueta del eje lleva el día y el mes; con un año, ECharts decide
            // cuántas enseñar. El año no se pone: cabe en el pie de la tarjeta.
            $dias[] = $dia->format('d/m');
            $valores[] = is_numeric($valor) ? (int) $valor : null;
        }

        $conValor = array_values(array_filter($valores, fn ($v) => $v !== null));

        if ($conValor === []) {
            return $serie + ['vacia' => true, 'opciones' => null];
        }

        $ultimo = end($conValor);

        return $serie + [
            'vacia' => false,
            'valorActual' => $this->numero($ultimo),
            'delta' => $this->delta($serie['metrica'], $ultimo),
            // Cuántos días del rango no tienen foto: es lo que dice si la línea se puede
            // creer o está hecha de cuatro puntos sueltos.
            'huecos' => count($valores) - count($conValor),
            'total' => count($valores),
            'desde' => now()->subDays($this->rango)->format('d/m/Y'),
            'hasta' => now()->format('d/m/Y'),
            'opciones' => $this->opcionesDe($serie, $dias, $valores, $this->escalaDe($conValor)),
        ];
    }

    /**
     * El suelo y el techo del eje vertical.
     *
     * **El eje arranca en 0, siempre.** Se probó ajustarlo al rango de los datos para que
     * la curva se notara —1.601 a 1.648 sobre un eje de 0 a 1.650 es casi una línea
     * recta— y se descartó: **un eje recortado distorsiona**. Ese mismo 3 % de crecimiento
     * dibujado entre 1.575 y 1.675 parece que el cliente ha duplicado usuarios, y de esa
     * gráfica se hacen capturas que van a reuniones.
     *
     * El crecimiento no se pierde por eso: **al lado del nombre de cada serie va el delta
     * con su número y su periodo**, que es el dato que no se puede exagerar, y el tooltip
     * da el valor exacto de cualquier día.
     *
     * El techo se sube al múltiplo redondo siguiente para que el eje diga «1.750» y no
     * «1.652», que es un número en el que nadie piensa.
     *
     * @return array{min: int, max: int}
     */
    private function escalaDe(array $conValor): array
    {
        $max = max($conValor);

        // Todo a cero —un Moodle recién montado— dejaría el eje sin altura y ECharts
        // dibujaría la línea sobre el borde.
        if ($max <= 0) {
            return ['min' => 0, 'max' => 1];
        }

        // Un margen del 10 % por arriba para que la línea no toque el techo de la caja,
        // y luego al múltiplo redondo siguiente.
        $paso = $this->pasoLegible((int) ceil($max * 1.1));

        return [
            'min' => 0,
            'max' => (int) (ceil($max * 1.1 / $paso) * $paso),
        ];
    }

    /**
     * El paso de la escala: 1, 2, 5, 10, 20, 50, 100…
     *
     * La serie de 1-2-5 por década es la que usan las reglas y los ejes de siempre,
     * porque son los múltiplos que la cabeza divide sin pensar.
     */
    private function pasoLegible(int $rango): int
    {
        if ($rango <= 0) {
            return 1;
        }

        // Se busca un paso que parta el rango en dos o tres tramos: más marcas no hacen
        // falta —el detalle está en el tooltip— y menos no daría escala.
        $decada = 10 ** (int) floor(log10(max($rango, 1)));

        foreach ([1, 2, 5, 10] as $factor) {
            $paso = (int) ($decada * $factor / 2);

            if ($paso >= 1 && $rango / $paso <= 4) {
                return $paso;
            }
        }

        return max((int) $decada, 1);
    }

    /**
     * Las opciones de ECharts de una serie.
     *
     * Lo que no está aquí —tooltip, ejes, rejilla, tipografía— lo pone `baseDeSerie()` en
     * `charts.js`, para que **todas las gráficas del panel se comporten igual**. Aquí solo
     * va lo que cambia de una a otra.
     *
     * **El color se resuelve en el cliente**: el canvas no entiende `var(--teal-500)` y
     * pintaría negro sin dar error, así que se pasa el nombre del token y `charts.js` lo
     * traduce con `getComputedStyle`.
     */
    private function opcionesDe(array $serie, array $dias, array $valores, array $escala): array
    {
        // **Tres marcas en el eje: principio, medio y final.** El detalle lo da el
        // tooltip, así que treinta fechas apretadas solo tapan la línea. El `interval`
        // se calcula aquí porque `charts.js` no sabe cuántos puntos tiene la serie: es
        // el número de categorías que ECharts se salta entre etiqueta y etiqueta.
        $saltoDelEje = max((int) floor((count($dias) - 1) / 2), 1);

        return [
            'xAxis' => [
                'data' => $dias,
                'axisLabel' => [
                    'interval' => $saltoDelEje,
                    // La última fecha siempre, aunque no caiga en el salto: es «hasta
                    // cuándo llegan los datos» y es la que se busca.
                    'showMaxLabel' => true,
                ],
            ],
            // El eje no arranca en 0: ver `escalaDe()`.
            'yAxis' => [
                'min' => $escala['min'],
                'max' => $escala['max'],
            ],
            'series' => [[
                'name' => $serie['nombre'],
                'type' => 'line',
                'data' => $valores,
                'smooth' => false,
                // Sin puntos, salvo al pasar por encima: con 365 días, 365 círculos
                // tapan la línea.
                'showSymbol' => false,
                'symbolSize' => 6,
                'lineStyle' => ['width' => 2.25],
                'areaStyle' => ['opacity' => 0.09],
                // **La línea se une por encima de los huecos.** Cortarla distinguía
                // mejor «no cambió» de «no hay dato», pero partía la evolución en
                // trozos y la tendencia —que es lo que se viene a ver— dejaba de
                // leerse. El dato no se pierde: el valor sigue siendo `null`, así que
                // el tooltip de ese día dice «sin dato», y debajo de la gráfica se
                // dice cuántos días del rango faltan.
                'connectNulls' => true,
                'tokenDeColor' => $serie['color'],
            ]],
            // **Sin `dataZoom`.** La barra de recorte debajo de la gráfica se descartó:
            // los cuatro botones de rango ya hacen ese trabajo, el tooltip da el valor
            // exacto de cualquier día, y el zoom con la rueda se disparaba al hacer
            // scroll por la página. Cuatro gráficas con una barra cada una era ruido
            // que estorbaba más de lo que servía.
            'dataZoom' => [],
        ];
    }

    /** Un entero como se lee en español, o un guion si no hay dato. */
    private function numero(?int $valor): string
    {
        return $valor === null ? '—' : number_format($valor, 0, ',', '.');
    }

    /** Un decimal con una cifra, o un guion. */
    private function decimal($valor): string
    {
        return $valor === null ? '—' : number_format((float) $valor, 1, ',', '.');
    }

    public function render()
    {
        ['foto' => $foto, 'fecha' => $fecha] = $this->ultimaFoto();

        return view('livewire.environments.uso', [
            'foto' => $foto,
            'fechaDeLaFoto' => $fecha,
            'frescura' => $this->frescura(),
            'familias' => $this->familias(),
            'series' => $this->series(),
            // Tres estados distintos, y ninguno es «todo lleno». Ver el docblock.
            'nuncaHaEnviado' => $foto === null,
            'sinSerie' => $foto !== null && $this->series() === [],
        ])->layout('layouts.app');
    }
}
