import { Injectable, Logger, OnModuleDestroy } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { InjectRepository } from "@nestjs/typeorm";
import * as path from "path";
import { DataSource, EntityTarget, Repository } from "typeorm";
import { CredentialsCryptoService } from "../control-plane/credentials-crypto.service";
import { Tenant, TenantEstado } from "../control-plane/entities/tenant.entity";

type PoolEntry = {
  dataSource: DataSource;
  refCount: number;
  lastUsedAt: number;
  pendingEviction: boolean;
};

export type DataSourceCreatedHook = (
  tenantId: number,
  dataSource: DataSource,
) => void;

// allowOnboarding: unica excepcion permitida a "solo activo, nunca
// onboarding/suspendido" — reservada para control-plane-admin/ inspeccionando
// un tenant que TODAVIA no se activo (verificar/reconciliar esquema ANTES de
// activar es justamente el flujo de alta; sin esta excepcion, seria
// imposible verificar nada antes del primer click de "Activar"). Nunca
// habilita 'suspendido': un tenant pausado a proposito sigue bloqueado
// siempre, con o sin esta opcion — pausar significa "nadie se conecta",
// tambien el panel.
export type AcquireOptions = {
  allowOnboarding?: boolean;
};

// Fail-closed real: quedarse sin conexion tiene que fallar RAPIDO con un
// error que un operador entienda, no colgar el request. La causa raiz
// verificada del cuelgue de 90s que se investigo antes de construir esto:
// mysql2 sin queueLimit espera INDEFINIDAMENTE por una conexion libre. Este
// registro pone: connectionLimit chico por tenant, queueLimit como tope
// duro de cuantos pueden esperar, y un acquireTimeout propio (Promise.race)
// como tope duro de CUANTO pueden esperar — verificado con timeouts
// forzados repetidos que el "perdedor" de la carrera se libera siempre, sin
// acumular conexiones.
const DEFAULT_CONNECTION_LIMIT_PER_TENANT = 5;
const DEFAULT_QUEUE_LIMIT_PER_TENANT = 20;
const DEFAULT_ACQUIRE_TIMEOUT_MS = 5000;
const DEFAULT_MAX_CACHED_TENANTS = 30;
const DEFAULT_IDLE_EVICTION_MS = 15 * 60 * 1000;
const IDLE_SWEEP_INTERVAL_MS = 60 * 1000;

export class TenantPoolExhaustedError extends Error {
  constructor(
    public readonly tenantId: number,
    motivo: string,
  ) {
    super(`Pool agotado para tenant ${tenantId}: ${motivo}`);
  }
}

export class TenantNotResolvableError extends Error {
  constructor(
    public readonly tenantId: number,
    motivo: string,
  ) {
    super(`Tenant ${tenantId} no resoluble: ${motivo}`);
  }
}

// Invariante de seguridad, no un caso mas de "no resoluble": una fila de
// `tenants` que apunte a la base del PLANO DE CONTROL dejaria a ese tenant
// conectandose (con SUS credenciales, las que sean) a la base que contiene
// las credenciales cifradas de TODOS los clientes. Se compara por VALOR
// (host, port, database) contra la conexion real de mbinvtaller — nunca por
// nombre de tenant ni por convencion.
export class TenantPointsAtControlPlaneError extends Error {
  constructor(public readonly tenantId: number) {
    super(
      `Tenant ${tenantId} resuelve a la MISMA base que el plano de control — rechazado. ` +
        "Una fila de tenants nunca puede apuntar a mbinvtaller.",
    );
  }
}

// Independiente del chequeo anterior: estas cuatro son esquemas de SISTEMA
// que MySQL crea en todo servidor, sin importar host/puerto — no son "la
// base de un tenant real" bajo ningun escenario. mysql.user expone la tabla
// de cuentas del SERVIDOR completo; information_schema/performance_schema
// dan metadatos de TODAS las bases visibles para la credencial, no solo la
// del tenant. Se compara solo por NOMBRE (minuscula), a proposito: no
// importa en que host/puerto viva, un esquema de sistema nunca es un
// tenant.
const FORBIDDEN_SYSTEM_SCHEMAS = new Set([
  "mysql",
  "information_schema",
  "performance_schema",
  "sys",
]);

export class TenantPointsAtSystemSchemaError extends Error {
  constructor(
    public readonly tenantId: number,
    public readonly schemaName: string,
  ) {
    super(
      `Tenant ${tenantId} resuelve a '${schemaName}', un esquema de SISTEMA de MySQL — rechazado. ` +
        "Ningun tenant real vive ahi; conectarse le daria a la app lectura sobre metadatos o " +
        "cuentas del SERVIDOR completo, no de un cliente.",
    );
  }
}

// El nombre de una base NO identifica un tenant por si solo (la misma base
// "mbinvautocentrorodriguez" existe en pruebas y en produccion, en
// servidores distintos) — por eso este registro cachea por tenantId
// (la clave real, id numerico de la fila en `tenants`), nunca por db_name.
@Injectable()
export class TenantConnectionRegistry implements OnModuleDestroy {
  private readonly logger = new Logger(TenantConnectionRegistry.name);
  private readonly pools = new Map<number, PoolEntry>();
  private readonly dataSourceCreatedHooks: DataSourceCreatedHook[] = [];
  private readonly sweepInterval: NodeJS.Timeout;

  constructor(
    @InjectRepository(Tenant, "controlPlane")
    private readonly tenantRepository: Repository<Tenant>,
    private readonly crypto: CredentialsCryptoService,
    private readonly configService: ConfigService,
  ) {
    // .unref() para que este timer nunca sea la razon de que el proceso no
    // cierre — un timer sin unref() es exactamente la clase de handle
    // colgado que se investigo (sin reproducir) en el cuelgue de 90s.
    this.sweepInterval = setInterval(
      () => this.sweepIdleConnections(),
      IDLE_SWEEP_INTERVAL_MS,
    );
    this.sweepInterval.unref();
  }

  async onModuleDestroy() {
    clearInterval(this.sweepInterval);
    await Promise.all(
      [...this.pools.values()].map((entry) =>
        entry.dataSource.isInitialized ? entry.dataSource.destroy() : Promise.resolve(),
      ),
    );
    this.pools.clear();
  }

  // Se llama UNA vez por cada DataSource que este registro cree — nunca por
  // instancia existente. Asi, cualquier instrumentacion (contador de
  // consultas de una prueba, metricas de produccion) cubre TODO tenant que
  // se resuelva de aca en adelante, no solo el que existia cuando se
  // enganchó. Ver test/e2e-helpers.ts, que antes se enganchaba a una
  // instancia — anotado como pendiente, resuelto aca.
  onDataSourceCreated(hook: DataSourceCreatedHook): void {
    this.dataSourceCreatedHooks.push(hook);
  }

  /**
   * Bypass EXPLICITO del contexto ambiental. Uso reservado: auth, que
   * RESUELVE el tenant (login/switch) y por lo tanto no puede depender de un
   * contexto de tenant que el mismo todavia no produjo — no hay ALS que leer
   * en ese momento. Cualquier otro modulo tiene que usar getRepository(),
   * que lee el tenantId de TenantContextStorage. El escaner mecanico
   * (tenancy/scan-tenant-unaware-access.ts) cuenta cada llamada a este
   * metodo como un "bypass" contra una lista blanca aprobada — nunca queda
   * invisible, ni siquiera en un uso legitimo.
   *
   * FORMA "with", no "get": si esto devolviera el Repository y liberara de
   * inmediato (como la version anterior), el refCount volveria a 0 ANTES de
   * que quien llama termine de usar el repositorio — evictIfOverCapacity()
   * podria desalojar y destruir ese DataSource mientras una consulta todavia
   * esta en vuelo. Sosteniendo el acquire durante TODA la ejecucion de `fn`,
   * el refCount se mantiene >=1 (evictIfOverCapacity() solo desaloja
   * entradas con refCount===0) hasta que la funcion termina — exito o error.
   *
   * UN solo repositorio por llamada: si quien llama necesita mas de una
   * entidad en la misma operacion (ej. auth leyendo Usuario y necesitando
   * tambien Tienda o alguna otra tabla sin relacion TypeORM), NO anides una
   * segunda llamada a este metodo — usa withTenantDataSourceExplicit(), que
   * entrega la DataSource completa y sostiene UN solo acquire para todas las
   * consultas que hagan falta. Anidar tecnicamente no rompe nada (acquire()
   * sobre un tenant ya cacheado solo suma refCount), pero infla el conteo de
   * bypasses por cada llamada anidada sin necesidad, y es menos legible.
   */
  async withTenantRepositoryExplicit<T extends object, R>(
    tenantId: number,
    entity: EntityTarget<T>,
    fn: (repository: Repository<T>) => Promise<R>,
    options?: AcquireOptions,
  ): Promise<R> {
    return this.withTenantDataSourceExplicit(
      tenantId,
      (dataSource) => fn(dataSource.getRepository(entity)),
      options,
    );
  }

  /**
   * La forma general del mismo bypass: entrega la DataSource completa en vez
   * de un Repository de una sola entidad. Uso: cuando una operacion de auth
   * necesita mas de una entidad, una consulta cruda, o una transaccion — todo
   * dentro de UN solo acquire/release. withTenantRepositoryExplicit() es un
   * atajo sobre esto para el caso de una sola entidad.
   */
  async withTenantDataSourceExplicit<R>(
    tenantId: number,
    fn: (dataSource: DataSource) => Promise<R>,
    options?: AcquireOptions,
  ): Promise<R> {
    const dataSource = await this.acquire(tenantId, options);
    try {
      return await fn(dataSource);
    } finally {
      this.release(tenantId);
    }
  }

  // Quien recibe una DataSource cruda de withTenantDataSourceExplicit()
  // necesita poder pedirle un Repository -- ese .getRepository() tiene que
  // vivir ACA (tenancy/, en la lista blanca del escaner), no en el archivo
  // de quien llama, o scan-tenant-unaware-access.ts (textual, no sabe que
  // esta DataSource ya paso por el bypass) lo marca como ofensa nueva, igual
  // que si fuera una inyeccion directa sin contexto de tenant.
  repositoryForDataSource<T extends object>(
    dataSource: DataSource,
    entity: EntityTarget<T>,
  ): Repository<T> {
    return dataSource.getRepository(entity);
  }

  async acquire(tenantId: number, options?: AcquireOptions): Promise<DataSource> {
    const existing = this.pools.get(tenantId);
    if (existing) {
      existing.refCount++;
      existing.lastUsedAt = Date.now();
      return existing.dataSource;
    }

    const dataSource = await this.createDataSource(tenantId, options);
    this.evictIfOverCapacity();
    this.pools.set(tenantId, {
      dataSource,
      refCount: 1,
      lastUsedAt: Date.now(),
      pendingEviction: false,
    });

    for (const hook of this.dataSourceCreatedHooks) {
      hook(tenantId, dataSource);
    }

    return dataSource;
  }

  release(tenantId: number): void {
    const entry = this.pools.get(tenantId);
    if (!entry) return;

    entry.refCount = Math.max(0, entry.refCount - 1);
    entry.lastUsedAt = Date.now();

    if (entry.pendingEviction && entry.refCount === 0) {
      this.pools.delete(tenantId);
      entry.dataSource
        .destroy()
        .catch((error) =>
          this.logger.warn(
            `Error cerrando DataSource diferido del tenant ${tenantId}: ${error?.message}`,
          ),
        );
    }
  }

  private async createDataSource(tenantId: number, options?: AcquireOptions): Promise<DataSource> {
    const tenant = await this.tenantRepository.findOne({
      where: { id: tenantId },
    });

    if (!tenant) {
      throw new TenantNotResolvableError(tenantId, "no existe en el plano de control");
    }
    const estadosPermitidos: TenantEstado[] = options?.allowOnboarding
      ? [TenantEstado.ACTIVO, TenantEstado.ONBOARDING]
      : [TenantEstado.ACTIVO];
    if (!estadosPermitidos.includes(tenant.estado)) {
      throw new TenantNotResolvableError(
        tenantId,
        `estado='${tenant.estado}', se esperaba ${estadosPermitidos.join(" o ")}`,
      );
    }
    if (FORBIDDEN_SYSTEM_SCHEMAS.has(tenant.dbName.toLowerCase())) {
      throw new TenantPointsAtSystemSchemaError(tenantId, tenant.dbName);
    }
    if (this.pointsAtControlPlane(tenant)) {
      throw new TenantPointsAtControlPlaneError(tenantId);
    }

    const password = this.crypto.decrypt(tenant.dbPasswordEncrypted);

    const dataSource = new DataSource({
      type: "mysql",
      host: tenant.dbHost,
      port: tenant.dbPort,
      username: tenant.dbUser,
      password,
      database: tenant.dbName,
      // Excluye control-plane/, control-plane-admin/ y tenancy/ — las
      // entidades de tenant viven en todos los demas modulos de features.
      // "control-plane-admin" necesita su PROPIA entrada en el patron
      // porque "!(control-plane|tenancy)" matchea por nombre exacto, no por
      // prefijo — "control-plane-admin" no quedaba cubierto por
      // "control-plane" solo (no tiene entidades propias hoy, pero evita la
      // misma trampa silenciosa si el dia de mañana agrega alguna).
      entities: [
        path.join(
          __dirname,
          "..",
          "!(control-plane|control-plane-admin|tenancy)",
          "**",
          "*.entity{.ts,.js}",
        ),
      ],
      synchronize: false,
      extra: {
        connectionLimit: DEFAULT_CONNECTION_LIMIT_PER_TENANT,
        queueLimit: DEFAULT_QUEUE_LIMIT_PER_TENANT,
      },
    });

    await dataSource.initialize();
    try {
      this.wrapWithAcquireTimeout(dataSource, tenantId);
    } catch (error) {
      // El wrapper es la unica defensa contra la espera indefinida (la causa
      // raiz verificada del cuelgue de 90s) — si no se pudo instalar, esta
      // DataSource ya conectada NO puede quedar viva y sin rastrear.
      await dataSource.destroy().catch(() => {});
      throw error;
    }

    return dataSource;
  }

  // Comparacion por VALOR contra la conexion real de mbinvtaller — nunca por
  // nombre/codigo de tenant. CONTROL_PLANE_DB_PORT llega de ConfigService
  // como el string crudo de .env salvo que algo mas lo parsee antes; se
  // normaliza con Number() para que la comparacion contra tenant.dbPort
  // (entero real, columna `int`) no falle por tipo.
  private pointsAtControlPlane(tenant: Tenant): boolean {
    const controlPlaneHost = this.configService.getOrThrow<string>("CONTROL_PLANE_DB_HOST");
    const controlPlanePort = Number(
      this.configService.get("CONTROL_PLANE_DB_PORT", 3306),
    );
    const controlPlaneDatabase = this.configService.getOrThrow<string>(
      "CONTROL_PLANE_DB_DATABASE",
    );

    return (
      tenant.dbHost === controlPlaneHost &&
      tenant.dbPort === controlPlanePort &&
      tenant.dbName === controlPlaneDatabase
    );
  }

  // Verificado con timeouts forzados repetidos (10 seguidos): el "perdedor"
  // de la carrera entre el timer y la conexion real SIEMPRE se libera —
  // contando conexiones del POOL (pool._allConnections), no
  // Threads_connected (contador GLOBAL del servidor, compartido con 100+
  // bases de otros clientes — ni sube ni baja por esta prueba de forma
  // confiable). mysql2 no tiene un acquireTimeout nativo que acote el
  // tiempo de espera (queueLimit solo acota CUANTOS esperan, no CUANTO
  // esperan) — por eso el wrapper. Ver
  // tenant-connection-registry.service.spec.ts.
  //
  // package.json fija mysql2 en un patch exacto (no rango ^) precisamente
  // porque este wrapper depende de una superficie NO PUBLICA (driver.pool,
  // pool.getConnection) que una version nueva de mysql2 puede cambiar sin
  // que sea un breaking change de su API publica. Fail LOUD, no silencioso:
  // degradar en silencio a "sin acquireTimeout" reintroduciria la espera
  // indefinida (la causa raiz verificada del cuelgue de 90s).
  private wrapWithAcquireTimeout(dataSource: DataSource, tenantId: number): void {
    const pool: any = (dataSource.driver as any).pool;
    if (!pool || typeof pool.getConnection !== "function") {
      throw new Error(
        `No se pudo instalar el acquireTimeout para el tenant ${tenantId}: ` +
          "(dataSource.driver as any).pool no tiene la forma esperada de un Pool de mysql2 " +
          "(pool.getConnection como funcion). Una version nueva de mysql2, o un cambio en " +
          "como TypeORM expone el pool, rompio un supuesto de superficie no publica. " +
          "Sin este wrapper, un pool agotado esperaria INDEFINIDAMENTE en vez de fallar " +
          "con 503 — fallar aca, ruidoso, es mejor que degradar en silencio a eso.",
      );
    }

    const originalGetConnection = pool.getConnection.bind(pool);

    pool.getConnection = (callback: (err: any, conn: any) => void) => {
      let settled = false;
      const timer = setTimeout(() => {
        if (settled) return;
        settled = true;
        callback(
          new TenantPoolExhaustedError(
            tenantId,
            `no se pudo adquirir una conexion en ${DEFAULT_ACQUIRE_TIMEOUT_MS}ms`,
          ),
          undefined,
        );
      }, DEFAULT_ACQUIRE_TIMEOUT_MS);
      timer.unref?.();

      originalGetConnection((err: any, conn: any) => {
        if (settled) {
          // Llego tarde — el timeout ya gano la carrera. Liberar SIEMPRE,
          // o esta conexion se pierde del pool para siempre.
          if (!err && conn) conn.release();
          return;
        }
        settled = true;
        clearTimeout(timer);
        callback(err, conn);
      });
    };
  }

  private evictIfOverCapacity(): void {
    if (this.pools.size < DEFAULT_MAX_CACHED_TENANTS) return;

    const sorted = [...this.pools.entries()].sort(
      (a, b) => a[1].lastUsedAt - b[1].lastUsedAt,
    );

    for (const [tenantId, entry] of sorted) {
      if (entry.refCount === 0) {
        this.pools.delete(tenantId);
        entry.dataSource
          .destroy()
          .catch((error) =>
            this.logger.warn(
              `Error cerrando DataSource evacuado del tenant ${tenantId}: ${error?.message}`,
            ),
          );
        return;
      }
    }

    // Todos los tenants cacheados estan en uso — nunca se bloquea el alta de
    // uno nuevo esperando a que alguno se libere (eso reintroduciria la
    // espera indefinida). Se excede el tope temporalmente y se marca el LRU
    // para cerrarse en cuanto termine de usarse.
    const [lruTenantId, lruEntry] = sorted[0];
    lruEntry.pendingEviction = true;
    this.logger.warn(
      `Tope de ${DEFAULT_MAX_CACHED_TENANTS} tenants cacheados excedido temporalmente — ` +
        `todos en uso. Tenant ${lruTenantId} (LRU) se cerrara en cuanto se libere.`,
    );
  }

  private sweepIdleConnections(): void {
    const now = Date.now();
    for (const [tenantId, entry] of this.pools.entries()) {
      if (entry.refCount === 0 && now - entry.lastUsedAt > DEFAULT_IDLE_EVICTION_MS) {
        this.pools.delete(tenantId);
        entry.dataSource
          .destroy()
          .catch((error) =>
            this.logger.warn(
              `Error cerrando DataSource inactivo del tenant ${tenantId}: ${error?.message}`,
            ),
          );
      }
    }
  }

  /** Solo para pruebas/diagnostico — nunca para decidir logica de negocio. */
  debugSnapshot(): Array<{ tenantId: number; refCount: number; pendingEviction: boolean }> {
    return [...this.pools.entries()].map(([tenantId, entry]) => ({
      tenantId,
      refCount: entry.refCount,
      pendingEviction: entry.pendingEviction,
    }));
  }
}
