#!/usr/bin/env php
<?php

declare(strict_types=1);

/**
 * Registra en el catálogo los objetos que hay en el repositorio.
 *
 *   bin/importar-objetos /ruta/al/repositorio/public
 *
 * Detecta qué protocolo habla cada uno leyendo su código, para saber cuáles emiten
 * telemetría y cuáles hay que adaptar. En el prototipo esa información no existía: eran
 * archivos sueltos referenciados por nombre.
 */

use App\Core\Kernel;
use App\Modules\Objetos\Models\Objeto;
use App\Modules\Objetos\Services\RepositorioDeObjetos;
use Illuminate\Database\Capsule\Manager as DB;

require dirname(__DIR__) . '/vendor/autoload.php';
$base = dirname(__DIR__);
Dotenv\Dotenv::createImmutable($base)->safeLoad();
(new Kernel($base))->boot();

// El primer argumento que no sea una bandera es el directorio.
$sueltos    = array_values(array_filter(array_slice($argv, 1), static fn (string $a): bool => !str_starts_with($a, '--')));
$directorio = $sueltos[0] ?? '/home/diegosant/Lerd/lms-objetos/public';

if (!is_dir($directorio)) {
    fwrite(STDERR, "No existe el directorio: $directorio\n");
    exit(1);
}

/**
 * ¿Este texto se guardó con la codificación estropeada?
 *
 * El caso típico es UTF-8 leído como latin-1: «ó» se convierte en «Ã³», «é» en «Ã©». La
 * marca es la Ã seguida de un carácter de control o de símbolo, que en español no aparece
 * nunca de forma legítima.
 */
function estaRoto(?string $texto): bool
{
    return $texto !== null && preg_match('~[ÃÂ][\x{0080}-\x{00BF}]~u', $texto) === 1;
}

/**
 * El título que declara el objeto.
 *
 * Devuelve null si no hay uno utilizable, para que quien llama decida el respaldo. Las
 * entidades se decodifican —«Diagn&oacute;stico» es un título, no un texto con código
 * dentro— y se normaliza el espacio en blanco, que en estos archivos viene con saltos de
 * línea dentro de la etiqueta.
 */
function tituloDe(string $codigo): ?string
{
    if (preg_match('~<title[^>]*>(.*?)</title>~is', $codigo, $m) !== 1) {
        return null;
    }

    $titulo = html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
    $titulo = trim(preg_replace('~\s+~u', ' ', $titulo) ?? '');

    return $titulo === '' ? null : mb_substr($titulo, 0, 200);
}

/**
 * De qué va el objeto, a partir de lo que se lee al abrirlo.
 *
 * Es una heurística: se toma el texto visible que sigue al título, que en estos objetos
 * suele traer el módulo, el tipo de actividad y su duración —«Módulo 2 · Actividad
 * formativa · 90 minutos»—. No siempre acertará, y por eso se puede reescribir a mano y la
 * reimportación respeta lo escrito. Vale la pena igual: la alternativa es un desplegable
 * donde elegir significa abrir los cincuenta y cinco.
 */
function descripcionDe(string $codigo, string $titulo): ?string
{
    // Fuera lo que no se lee en pantalla, o la descripción acaba siendo CSS.
    $cuerpo = preg_replace('~(?is)<(script|style|head|noscript).*?</\1>~', ' ', $codigo) ?? '';
    $texto  = strip_tags($cuerpo);
    $texto  = html_entity_decode($texto, ENT_QUOTES | ENT_HTML5, 'UTF-8');

    // Los emoji decoran los encabezados de estos objetos y no describen nada.
    $texto = preg_replace('~[\x{1F300}-\x{1FAFF}\x{2600}-\x{27BF}\x{FE0F}]~u', ' ', $texto) ?? $texto;
    $texto = trim(preg_replace('~\s+~u', ' ', $texto) ?? '');

    // El título ya se muestra al lado; repetirlo en la descripción gasta la línea entera.
    if ($titulo !== '' && str_contains($texto, $titulo)) {
        $texto = trim(substr($texto, strpos($texto, $titulo) + strlen($titulo)));
    }

    $texto = trim($texto, " ·-—|");

    return $texto === '' ? null : mb_substr($texto, 0, 160);
}

$nuevos = $actualizados = $sinProtocolo = 0;
$duplicados = [];

foreach (glob(rtrim($directorio, '/') . '/*.html') ?: [] as $ruta) {
    $archivo = basename($ruta);

    // Los que empiezan por _ son utilidades del repositorio, no contenido.
    if (str_starts_with($archivo, '_') || $archivo === 'index.html') {
        continue;
    }

    $codigo = file_get_contents($ruta) ?: '';

    // La misma pregunta que se hace el alta por pantalla, y con el mismo código: tenerla
    // escrita dos veces hacía que un objeto quedara marcado de una manera u otra según por
    // dónde hubiera entrado. `emite_progreso` significa «puede completar la actividad por
    // sí solo», y para eso `sc_complete` basta —el aula lo acepta igual que `sc_progress`—.
    $emiteProgreso = RepositorioDeObjetos::emiteProgreso($codigo);
    $emiteNota     = RepositorioDeObjetos::emiteNota($codigo);

    // Aparte, y solo para la cuenta del resumen: un objeto que ni avisa de que terminó.
    $emiteCompleto = $emiteProgreso;

    if (!$emiteProgreso && !$emiteCompleto) {
        $sinProtocolo++;
    }

    $slug = pathinfo($archivo, PATHINFO_FILENAME);

    // El título sale del <title> del propio objeto, no del nombre del archivo. Deducirlo
    // del archivo producía entradas como «Actividad3 DefinicionesAlgoritmos  1 », y un
    // desplegable de cincuenta y cinco de esas no se puede usar para elegir nada.
    $delArchivo = ucfirst(str_replace(['_', '-'], ' ', $slug));
    $titulo     = tituloDe($codigo) ?? $delArchivo;
    $descripcion = descripcionDe($codigo, $titulo);

    // Se busca por ARCHIVO y no por slug: un archivo es un objeto, y en el catálogo
    // heredado hay registros distintos apuntando al mismo HTML porque se importaron con
    // slugs diferentes. El que está en uso era justo uno de esos, y por buscar por slug
    // se quedaba sin el título real y sin descripción mientras su gemelo sí los tenía.
    $gemelos = Objeto::query()->where('archivo', $archivo)->where('version', 1)->get();

    if ($gemelos->count() > 1) {
        $duplicados[] = sprintf('%s (%d registros)', $archivo, $gemelos->count());
    }

    $existente = $gemelos->first();

    $avisoAutomatico = 'No emite telemetría: hay que añadirle el SDK para poder medir avance.';

    // Una nota escrita a mano no se pisa: puede decir que un archivo es documentación y no
    // contenido, y esa clasificación la hizo una persona mirándolo.
    $notaPrevia   = $existente->notas ?? null;
    $notaEsPropia = $notaPrevia !== null && $notaPrevia !== $avisoAutomatico;

    $datos = [
        // Ni el título ni la descripción se pisan si ya están escritos: pueden haberse
        // corregido a mano, y una persona mirando el objeto sabe más que esta heurística.
        //
        // Con dos excepciones, que no son ediciones de nadie: un título con la codificación
        // estropeada, y uno que coincide letra por letra con lo que se deduce del nombre
        // del archivo —«SimPolitica Simulador»—, que es lo que ponía este mismo script
        // antes de aprender a leer el <title>.
        'titulo'         => $existente === null || estaRoto($existente->titulo) || $existente->titulo === $delArchivo
            ? $titulo
            : $existente->titulo,
        'descripcion'    => ($existente?->descripcion) ?? $descripcion,
        'archivo'        => $archivo,
        'protocolo'      => 1,
        'emite_progreso' => $emiteProgreso,
        'emite_nota'     => $emiteNota,
        'checksum'       => substr(hash('sha256', $codigo), 0, 64),
        'notas'          => $notaEsPropia
            ? $notaPrevia
            : ((!$emiteProgreso && !$emiteCompleto) ? $avisoAutomatico : null),
    ];

    if ($existente !== null) {
        // Todos los registros del mismo archivo, no solo el primero: si no, el duplicado
        // que alguien está usando se queda con los datos viejos.
        foreach ($gemelos as $gemelo) {
            $gemelo->fill($datos)->save();
            $actualizados++;
        }
    } else {
        Objeto::query()->create(['slug' => $slug, 'version' => 1] + $datos);
        $nuevos++;
    }
}

printf("Objetos nuevos:       %d\n", $nuevos);
printf("Objetos actualizados: %d\n", $actualizados);
printf("Sin telemetría:       %d  (necesitan el SDK)\n", $sinProtocolo);

if ($duplicados !== []) {
    // Se avisa y no se fusiona: dos registros del mismo archivo pueden estar usados por
    // cursos distintos, y unirlos cambiaría el avance de gente que ya cursó.
    printf("\nMismo archivo en varios registros:\n");
    foreach (array_unique($duplicados) as $aviso) {
        printf("  · %s\n", $aviso);
    }
}

// ── Archivado ───────────────────────────────────────────────────────────────
//
// Archivar saca un objeto del desplegable sin borrarlo. Es una decisión y por eso va tras
// una bandera: quien importa material nuevo no espera que la lista encoja de paso.
//
// Se archivan dos cosas: lo que ya no existe en el repositorio —quedó de una importación
// vieja— y lo que nadie usa en ningún curso. De cincuenta y cinco objetos había seis en
// uso, y buscar entre los otros cuarenta y nueve es el motivo de que la lista no se
// entienda. Vuelven con --desarchivar.

if (in_array('--archivar-sin-uso', $argv, true)) {
    $enDisco = array_map(
        static fn (string $r): string => basename($r),
        glob(rtrim($directorio, '/') . '/*.html') ?: [],
    );

    $usados = DB::table('actividades')->whereNotNull('objeto_id')->pluck('objeto_id')->all();
    $archivados = 0;

    foreach (Objeto::query()->where('archivado', false)->get() as $objeto) {
        $existeElArchivo = in_array($objeto->archivo, $enDisco, true);
        $alguienLoUsa    = in_array($objeto->id, $usados, true);

        if ($existeElArchivo && $alguienLoUsa) {
            continue;
        }

        $objeto->archivado = true;
        $objeto->save();
        $archivados++;
    }

    printf("Archivados:           %d  (fuera del desplegable, recuperables)\n", $archivados);
}

if (in_array('--desarchivar', $argv, true)) {
    $vueltos = Objeto::query()->where('archivado', true)->update(['archivado' => false]);
    printf("Desarchivados:        %d\n", $vueltos);
}
