<?php

namespace App\Models\Environments;

use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;

use App\Models\Clients\Client;
use App\Models\Monitoring\Notice;
use App\Models\Monitoring\Report;
use App\Models\Products\LicenseToken;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use App\Models\Support\EnvironmentSupport;
use App\Models\Support\Support;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\DB;

class Environment extends Model
{
    use HasFactory, SoftDeletes;

    protected $table = 'environments';

    /**
     * Los usos del entorno: `[shortname => nombre]`.
     *
     * **Era la constante `ENVIRONMENTS`** con los cuatro valores escritos a mano. Ahora es
     * el catálogo `envs`, que se gestiona desde Configuración › Catálogos: se pueden crear,
     * editar, borrar y decidir cuáles salen marcados de entrada en el filtro del listado.
     *
     * Mantiene la forma de la constante —`$key => $label`— porque es la que consumen los
     * cuatro formularios, el asistente y el selector de licencias. Cacheado: ver
     * {@see Env::catalogo()}.
     *
     * @return array<string, string>
     */
    public static function envs(): array
    {
        return Env::etiquetas();
    }

    /**
     * El color con el que se pinta un uso: `[texto, fondo]`.
     *
     * **Está aquí y no en las vistas porque estaba en seis sitios y no coincidían.** La
     * fila del listado tenía una paleta, la ficha del entorno tenía la suya —`pro` en
     * naranja— bajo un comentario que decía «el mismo código de color que el listado», y la
     * barra de filtros no pintaba nada: los cuatro botones salían en gris. Con la etiqueta
     * de un color y el botón que la filtra de otro, el único código de color de la pantalla
     * dejaba de ser un código.
     *
     * @return array{string, string}
     */
    public static function colorDeEnv(?string $env): array
    {
        return Env::colorDe($env);
    }

    const JIRA_PROJECT_URL = 'https://tresipunt.atlassian.net/browse/';

    protected $fillable = [
        'name',
        'domain',
        'description',
        'version',
        'versiondb',
        'env',
        'active',
        'client_id',
        'type_id',
        'environment_support_id',
        'has_support',
        'key',
        'lastversion',
        'lastminor',
        'moodletoken',
        'refresh_at',
        'license_token_id',
        'deactivated_at',
        'deactivated_by',
        'deactivation_reason',
        'review_note',
        'review_level',
        'review_flagged_at',
        'review_flagged_by',
        'login_url',
    ];

    /**
     * El uso que está **exento del índice único del dominio**, por una columna generada.
     *
     * `environments.domain_unique` es una columna generada STORED definida como
     * `CASE WHEN env = 'local' THEN NULL ELSE domain END`, y el índice único del dominio
     * cuelga de ella: los sitios locales están exentos porque varios desarrolladores
     * comparten `moodle.test` (fixes/PRODSECU-192).
     *
     * Eso convierte el valor de `env` en algo más que una etiqueta: **`'Local'` o `'LOCAL'`
     * no son `'local'` para MySQL**, así que un entorno con el valor mal escrito entra en el
     * índice único y pierde la exención, sin que nada avise. Dos de los cinco formularios
     * validaban `env` como `required|string` a secas y dejaban pasar cualquier cadena
     * (MGR-010).
     *
     * **Y por esto el catálogo de usos protege esta fila**: su `shortname` no se puede
     * cambiar ni la fila borrar, porque la comparación de la columna generada es contra
     * esta cadena literal y una migración no se entera de que alguien renombró un catálogo.
     * Ver {@see Env::esExentoDelIndice()} y `BorrarUsoModal`.
     */
    public const USO_EXENTO_DEL_INDICE = 'local';

    /**
     * Los `env` que existen ahora mismo, en el orden del catálogo.
     *
     * **Era la constante `ENTORNOS`.** Ahora es catálogo —`Configuración › Catálogos`—, así
     * que la lista puede crecer sin desplegar. Lo que no cambia es que el sitio donde hay
     * que mirar si un uso nuevo afecta al índice del dominio es {@see USO_EXENTO_DEL_INDICE}.
     *
     * @return list<string>
     */
    public static function entornos(): array
    {
        return Env::todos();
    }

    /**
     * La regla de validación de `env`, para las cinco puertas que crean o editan entornos.
     *
     * Se devuelve la regla entera y no solo la lista para que ninguna puerta pueda quedarse
     * a medias —con el `in:` pero sin el `required`, por ejemplo—. Ver `entornos()`.
     */
    public static function reglaDeEnv(): string
    {
        return 'required|string|in:' . implode(',', self::entornos());
    }

    protected $casts = [
        'active'       => 'boolean',
        'has_support'  => 'boolean',
        'refresh_at'   => 'datetime',
        'deactivated_at' => 'datetime',
        'review_flagged_at' => 'datetime',
        // Credencial de administración del Moodle del CLIENTE, no nuestra: se guarda
        // cifrada. Llega en `site.token` dentro del `sync` y se usa para el botón
        // "Sincronizar ahora". Ver known-issues MGR-038.
        //
        // Consecuencia de tenerlo cifrado: no se puede buscar por este campo
        // (`where('moodletoken', $x)` no casa nunca, porque cada cifrado da un valor
        // distinto). Hoy no hace falta y no debería hacer falta nunca.
        //
        // **`SafeEncrypted` y no `encrypted`**: el pipeline de pre ejecuta
        // `php artisan key:generate` en cada despliegue, y con una clave nueva el cast
        // estándar lanza `DecryptException` al leer. Este campo se lee en el render de
        // CADA tarjeta del listado de entornos, así que eso sería un error 500 en la
        // pantalla principal en vez de un token que hay que volver a pegar. Ver
        // `deploy-y-cicd.md`.
        'moodletoken'  => \App\Casts\SafeEncrypted::class,
    ];

    /**
     * Campos que nunca deben salir en un `toArray()` ni en un JSON.
     *
     * `moodletoken` está cifrado en la base, pero el modelo lo descifra al leerlo: sin
     * esto, cualquier `return $environment` de un endpoint o un `dd()` en una vista lo
     * imprimiría en claro.
     */
    protected $hidden = [
        'moodletoken',
    ];

    /**
     * Normaliza el dominio al guardarlo: quita espacios al inicio/final y
     * trailing slashes. Evita duplicados que difieren solo en estos detalles
     * y que de otro modo provocarían colisiones en HostNormalizer. Ver
     * fixes/PRODSECU-192.
     */
    public function setDomainAttribute($value): void
    {
        $this->attributes['domain'] = $value === null
            ? null
            : rtrim(trim($value), '/');
    }

    /**
     * **Vacío a propósito.** Aquí estaban los ocho atributos de soporte, y estar en
     * `$appends` significa calcularlos en CADA serialización del modelo, los pida
     * alguien o no. No los pinta ninguna pantalla —eran los accessors muertos donde
     * se escondían dos bugs de Carbon (MGR-043 y MGR-044)—, así que se pagaban 16
     * consultas por entorno para nada.
     *
     * Siguen existiendo como accessors: quien los pida, los tiene. Ver MGR-015.
     */
    protected $appends = [];

    /** Memoria del soporte vigente por instancia. Ver `get_current_support()`. */
    private array $cacheDeSoporte = [];

    // -------------------------
    // Relaciones principales
    // -------------------------

    public function client()
    {
        return $this->belongsTo(Client::class);
    }

    public function type()
    {
        return $this->belongsTo(Type::class);
    }

    /**
     * Token de licencia asignado a este entorno
     */
    public function licenseToken()
    {
        return $this->belongsTo(LicenseToken::class);
    }

    public function plugins()
    {
        return $this->hasMany(Plugin::class);
    }

    public function data()
    {
        return $this->hasOne(Data::class);
    }

    public function environmentStats()
    {
        return $this->hasMany(EnvironmentStat::class);
    }

    public function reports()
    {
        return $this->hasMany(Report::class);
    }

    public function updates()
    {
        return $this->hasMany(Update::class);
    }

    public function notices()
    {
        return $this->hasMany(Notice::class);
    }

    public function supports()
    {
        return $this->belongsToMany(Support::class, 'environment_support')
            ->withPivot(['comment', 'start_at', 'end_at'])
            ->withTimestamps();
    }

    // -------------------------
    // Lógica de soporte
    // -------------------------

    /**
     * El contrato de soporte vigente, como relación precargable.
     *
     * `environment_support_id` apunta a una fila de `environment_support`, y hasta ahora
     * se leía con `DB::table()->where('id', …)->first()` suelto: eso no se puede
     * precargar, así que cada entorno de un listado hacía su propia consulta. Con la
     * relación, un `with('soporteVigente.support')` resuelve todo el listado de una vez.
     *
     * Ver MGR-015.
     */
    public function soporteVigente(): BelongsTo
    {
        return $this->belongsTo(EnvironmentSupport::class, 'environment_support_id');
    }

    /**
     * Datos del soporte vigente, calculados **una sola vez por instancia**.
     *
     * **El problema que resuelve (MGR-015).** Los ocho accessors de soporte llamaban a
     * este método y este hacía dos consultas sin guardar el resultado: **16 consultas
     * para serializar un entorno**. Medido antes de esto: 16 con soporte, 50 para los 21
     * entornos de la base local —y solo salían 50 en vez de 336 porque el puntero estaba
     * roto en 19 de ellos, así que el coste real aparecía justo al arreglarlo—.
     *
     * Ahora: memoizado, y por la relación, así que precargando son **cero** consultas
     * extra.
     *
     * El `array_key_exists` en lugar de `?? null` es a propósito: «no tiene soporte» es
     * un resultado válido y cacheable, y con `??` se recalcularía en cada acceso.
     */
    protected function get_current_support()
    {
        if (array_key_exists('_soporte', $this->cacheDeSoporte)) {
            return $this->cacheDeSoporte['_soporte'];
        }

        return $this->cacheDeSoporte['_soporte'] = $this->calcularSoporteVigente();
    }

    private function calcularSoporteVigente(): ?array
    {
        if (!$this->environment_support_id) {
            return null;
        }

        $contrato = $this->soporteVigente;
        $soporte = $contrato?->support;

        if (!$contrato || !$soporte) {
            return null;
        }

        // `Carbon::parse` y no `createFromFormat('Y-m-d', …)`: el segundo lanza excepción
        // si el valor es null o trae hora, y estas columnas son `date` en MariaDB y TEXT
        // en sqlite. Parte de MGR-015.
        $start = $contrato->start_at?->copy();
        $end = $contrato->end_at?->copy();

        if (!$start || !$end) {
            return null;
        }

        return [
            'type' => $soporte->id,
            'name' => $soporte->name,
            // Días que quedan, negativo si venció. Por días de calendario y con la
            // fecha pasada como sujeto: en Carbon 3 `diffInDays` va con signo y esto
            // devolvía "hace -89 días". Ver MGR-030.
            'days' => $contrato->diasRestantes(),
            'total' => (int) $start->startOfDay()->diffInDays($end->copy()->startOfDay()),
            'start' => $contrato->start_at->format('Y-m-d'),
            'end' => $contrato->end_at->format('Y-m-d'),
            'comment' => $contrato->comment,
            'status' => $contrato->estado(),
        ];
    }

    // -------------------------
    // Getters automáticos
    // -------------------------

    public function getSupportnameAttribute()
    {
        return $this->get_current_support()['name'] ?? '';
    }

    public function getSupporttypeAttribute()
    {
        return $this->get_current_support()['type'] ?? '';
    }

    public function getSupportdaysAttribute()
    {
        return $this->get_current_support()['days'] ?? '';
    }

    public function getSupporttotalAttribute()
    {
        return $this->get_current_support()['total'] ?? '';
    }

    public function getSupportstartAttribute()
    {
        return $this->get_current_support()['start'] ?? '';
    }

    public function getSupportendAttribute()
    {
        return $this->get_current_support()['end'] ?? '';
    }

    public function getSupportcommentAttribute()
    {
        return $this->get_current_support()['comment'] ?? '';
    }

    public function getSupportstatusAttribute()
    {
        if (!$this->has_support) {
            return 0;
        }
        return $this->get_current_support()['status'] ?? 0;
    }
    /* ==========================================================
       Apagar y encender un entorno
       ========================================================== */

    /** Quién lo apagó. */
    public function deactivatedBy(): BelongsTo
    {
        return $this->belongsTo(\App\Models\Auth\User::class, 'deactivated_by');
    }

    /**
     * Qué deja de funcionar si se apaga este entorno, para decirlo ANTES.
     *
     * **Lo que pasa de verdad.** `ProductToken` comprueba `active` antes de repartir por
     * acción, así que en la siguiente petición ese Moodle recibe `403 Entorno inactivo`
     * en **todas** las acciones: licencia, setup, SCSS, JS, funcionalidades, tutoriales
     * y recursos. No es «deja de aparecer en el listado»: es que el sitio del cliente
     * deja de recibir lo que tiene contratado.
     *
     * Se cuenta lo que hay para que el aviso diga a qué afecta, no solo que afecta.
     *
     * @return array{productos: int, licencia: ?string, soporteVigente: bool, plugins: int, ultimaSenal: ?string}
     */
    public function impactoDeApagarlo(): array
    {
        $licencia = $this->licenseToken;

        return [
            // Los productos que este sitio está recibiendo hoy y dejaría de recibir.
            'productos' => $licencia?->products()->count() ?? 0,
            'licencia' => $licencia?->name,
            // Apagar un entorno con soporte en vigor es casi siempre un error: se está
            // cobrando por algo que se acaba de cortar.
            'soporteVigente' => $this->soporteVigente !== null
                && $this->soporteVigente->end_at !== null
                && $this->soporteVigente->end_at->isFuture(),
            'plugins' => $this->plugins()->count(),
            // Si lleva meses sin dar señal, apagarlo no rompe nada: es un dato que
            // cambia la decisión, así que va en el aviso.
            'ultimaSenal' => $this->refresh_at?->diffForHumans(),
        ];
    }

    /**
     * Por dónde se entra a este Moodle.
     *
     * **El caso que lo justifica**: un sitio con SSO —SAML, CAS, OAuth2— manda a todo el
     * mundo al proveedor de identidad en cuanto se pulsa «Entrar», y nosotros no tenemos
     * cuenta ahí. Para entrar con la cuenta manual de soporte hace falta una URL distinta, y
     * **cada sitio la tiene a su manera**: `?saml=off`, `?authCAS=NOCAS`, otro dominio.
     *
     * Cuando no hay nada puesto se construye la de siempre, `{dominio}/login/index.php`, que
     * es la de la inmensa mayoría. **Rellenar el campo en los sitios normales sería copiar
     * veinte veces el mismo dato** y tener veinte sitios donde se desincronice el día que
     * alguien cambie un dominio.
     *
     * Devuelve `null` sin dominio y sin enlace: no hay nada que ofrecer, y un enlace a
     * `/login/index.php` a secas llevaría al propio panel.
     */
    public function enlaceDeLogin(): ?string
    {
        if ($this->login_url) {
            return $this->login_url;
        }

        if (! $this->domain) {
            return null;
        }

        return rtrim($this->domain, '/') . '/login/index.php';
    }

    /** ¿El enlace de login es uno puesto a mano, o el de siempre? */
    public function tieneLoginPropio(): bool
    {
        return (bool) $this->login_url;
    }

    /* ==========================================================
       Pendiente de revisar
       ========================================================== */

    /** Quién lo marcó. */
    public function reviewFlaggedBy(): BelongsTo
    {
        return $this->belongsTo(\App\Models\Auth\User::class, 'review_flagged_by');
    }

    /**
     * ¿Hay algo anotado que revisar?
     *
     * **La fecha es el estado**, no hay un booleano aparte: dos columnas que dicen lo mismo
     * acaban diciendo cosas distintas el día que alguien actualiza una y no la otra.
     */
    public function estaPendienteDeRevision(): bool
    {
        return $this->review_flagged_at !== null;
    }

    /**
     * Anota que hay algo que revisar, con su nivel y quién lo vio.
     *
     * **La fecha solo se pone la primera vez.** Editar el texto de una observación de hace
     * tres semanas no la convierte en nueva: si se refrescara, un pendiente antiguo se
     * rejuvenecería cada vez que alguien le añade una coma, y la antigüedad es justo el dato
     * con el que se decide qué se atiende. Quien la marcó tampoco cambia.
     */
    public function marcarParaRevisar(string $nota, string $nivel, ?int $usuarioId): void
    {
        $this->update([
            'review_note' => $nota,
            'review_level' => $nivel,
            'review_flagged_at' => $this->review_flagged_at ?? now(),
            'review_flagged_by' => $this->review_flagged_by ?? $usuarioId,
        ]);
    }

    /**
     * Da la observación por revisada y la borra.
     *
     * Se borra a propósito, igual que al encender un entorno: dejar la nota de algo ya
     * resuelto haría que el listado siguiera enseñando «pendiente» en un sitio que no lo
     * está. **El rastro queda en el log del panel** —con el texto, el nivel y quién la
     * cerró—, que es donde se consulta el histórico de todo lo demás.
     */
    public function darPorRevisado(): void
    {
        $this->update([
            'review_note' => null,
            'review_level' => null,
            'review_flagged_at' => null,
            'review_flagged_by' => null,
        ]);
    }

    /**
     * Apaga el entorno dejando dicho quién y por qué.
     *
     * El motivo no es decoración: es lo que se lee cuando un cliente llama seis meses
     * después preguntando por qué su Moodle no recibe nada.
     */
    public function apagar(string $motivo, ?int $usuarioId): void
    {
        $this->update([
            'active' => false,
            'deactivated_at' => now(),
            'deactivated_by' => $usuarioId,
            'deactivation_reason' => $motivo,
        ]);
    }

    /**
     * Vuelve a encenderlo y borra el registro de la baja.
     *
     * Se borra a propósito: dejar la fecha y el motivo de una baja ya deshecha haría que
     * el listado enseñara «de baja desde…» en un entorno encendido. El rastro de que
     * pasó queda en el log del panel, que es donde se consulta el histórico.
     */
    public function encender(?int $usuarioId): void
    {
        $this->update([
            'active' => true,
            'deactivated_at' => null,
            'deactivated_by' => null,
            'deactivation_reason' => null,
        ]);
    }

    /**
     * El dashboard se olvida de lo que sabía del parque cuando un entorno cambia.
     *
     * **Por qué.** «Estado del parque» cuenta solo los entornos activos, pero el bloque
     * entero se cachea cinco minutos y nadie invalidaba esa caché. Así que desactivar un
     * sitio y volver al dashboard lo seguía enseñando, con su versión y todo, hasta cinco
     * minutos después.
     *
     * Eso no es un desfase inofensivo: es la pantalla de portada diciendo que existe algo
     * que acabas de apagar. Un dashboard al que hay que preguntarle dos veces es un
     * dashboard que se deja de mirar —el mismo razonamiento que el del porcentaje de error
     * permanente en Monitorización—.
     *
     * Solo `dashboard:parque`: `dashboard:actividad` mira el log de la API y
     * `dashboard:contenido` los tipos de contenido de los productos, y ninguno de los dos
     * cambia porque se toque un entorno.
     */
    protected static function booted(): void
    {
        static::saved(fn () => self::olvidarElParque());
        static::deleted(fn () => self::olvidarElParque());

        // **El estado y su rastro no pueden ir por caminos distintos.** `active` se
        // cambia desde tres sitios: la modal —que pide motivo—, la casilla «Activo»
        // del formulario de edición y `Create`. Si el rastro lo escribiera solo la
        // modal, encender un entorno desde la casilla dejaría puesta la fecha y el
        // motivo de una baja ya deshecha, y el listado enseñaría «apagado desde el
        // 4/9 «migración»» en un entorno encendido: peor que no tener el dato.
        //
        // Así que el rastro se mantiene aquí, que es por donde pasan todos los
        // caminos. La modal sigue siendo la única que escribe un motivo; lo que se
        // apaga desde la casilla queda con fecha y responsable, y la pantalla dice
        // «sin motivo anotado», que es la verdad.
        static::saving(function (self $entorno) {
            if (! $entorno->isDirty('active')) {
                return;
            }

            if ($entorno->active) {
                $entorno->deactivated_at = null;
                $entorno->deactivated_by = null;
                $entorno->deactivation_reason = null;

                return;
            }

            // Si ya trae fecha, la ha puesto `apagar()` con su motivo: no se toca.
            if ($entorno->deactivated_at === null) {
                $entorno->deactivated_at = now();
                // Null cuando lo apaga la API o un comando: no hay usuario que
                // anotar, y eso también es información.
                $entorno->deactivated_by = Auth::id();
            }
        });
    }

    public static function olvidarElParque(): void
    {
        Cache::forget('dashboard:parque');
    }

}

