import { ConfigService } from "@nestjs/config";
import * as fs from "fs";
import * as path from "path";
import * as mysql from "mysql2/promise";
import { Usuario } from "../auth/entities/usuario.entity";
import { CredentialsCryptoService } from "../control-plane/credentials-crypto.service";
import {
  TenantConnectionRegistry,
  TenantNotResolvableError,
  TenantPointsAtControlPlaneError,
  TenantPointsAtSystemSchemaError,
  TenantPoolExhaustedError,
} from "./tenant-connection-registry.service";

// Prueba de INTEGRACION real, no mockeada — contra el mismo servidor que el
// resto de esta obra usa. Inserta filas de tenant reales en mbinvtaller
// (fixture, limpiadas al final — ver el afterAll, que ademas VERIFICA con una
// consulta que no queda ninguna) apuntando a test_default_sentinel, y
// ejercita el registro contra el.
//
// NUNCA la base demo/produccion real (TENANT_CLI_DB_DATABASE): la unique key
// de `tenants` es (db_host, db_port, db_name), no el codigo — desde que el
// tenant real ("autocentro-rodriguez") quedo registrado de verdad en
// mbinvtaller, un fixture que apuntara a esa misma base chocaria con esa fila
// PERMANENTE en cada corrida ("Duplicate entry ... for key
// tenants.UQ_tenants_host_port_dbname"), no solo la primera vez. Encontrado
// corriendo esto de verdad, no leyendo el codigo — asi quedaba antes de que
// hubiera un tenant real.
function loadRootEnv() {
  const envPath = path.join(__dirname, "..", "..", "..", ".env");
  for (const line of fs.readFileSync(envPath, "utf8").split(/\r?\n/)) {
    const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
    if (m && !process.env[m[1]]) process.env[m[1]] = m[2];
  }
}

// Copia minima de ConfigService — solo las claves que el registro y el
// cifrado realmente leen. get()/getOrThrow() con la misma semantica que la
// clase real (getOrThrow lanza si falta la clave), para que el registro no
// note la diferencia.
function makeFakeConfigService(): ConfigService {
  const values: Record<string, string | undefined> = {
    CONTROL_PLANE_CREDENTIALS_ENCRYPTION_KEY: process.env.CONTROL_PLANE_CREDENTIALS_ENCRYPTION_KEY,
    CONTROL_PLANE_DB_HOST: process.env.CONTROL_PLANE_DB_HOST,
    CONTROL_PLANE_DB_PORT: process.env.CONTROL_PLANE_DB_PORT,
    CONTROL_PLANE_DB_DATABASE: process.env.CONTROL_PLANE_DB_DATABASE,
  };
  const get = (key: string, defaultValue?: unknown) => values[key] ?? defaultValue;
  return {
    get,
    getOrThrow: (key: string) => {
      const value = get(key);
      if (value === undefined) {
        throw new Error(`Fake ConfigService: falta ${key} — revisa .env.`);
      }
      return value;
    },
  } as unknown as ConfigService;
}

describe("TenantConnectionRegistry (integracion real)", () => {
  let conn: mysql.Connection;
  let crypto: CredentialsCryptoService;
  let configService: ConfigService;
  let registry: TenantConnectionRegistry;
  let tenantId: number;
  let tenantRepository: any;
  const fixtureIds: number[] = [];

  async function insertFixtureTenant(overrides: {
    codigo: string;
    estado?: string;
    dbHost: string;
    dbPort: number;
    dbName: string;
    dbUser: string;
    dbPasswordEncrypted: string;
  }): Promise<number> {
    const [result] = await conn.query<mysql.ResultSetHeader[]>(
      `INSERT INTO tenants (codigo, nombre, estado, entorno, db_host, db_port, db_name, db_user, db_password_encrypted)
       VALUES (?, ?, ?, 'demo', ?, ?, ?, ?, ?)`,
      [
        overrides.codigo,
        overrides.codigo,
        overrides.estado ?? "activo",
        overrides.dbHost,
        overrides.dbPort,
        overrides.dbName,
        overrides.dbUser,
        overrides.dbPasswordEncrypted,
      ],
    );
    const id = (result as any).insertId;
    fixtureIds.push(id);
    return id;
  }

  beforeAll(async () => {
    loadRootEnv();

    conn = await mysql.createConnection({
      host: process.env.CONTROL_PLANE_DB_HOST,
      port: Number(process.env.CONTROL_PLANE_DB_PORT),
      user: process.env.CONTROL_PLANE_DB_USERNAME,
      password: process.env.CONTROL_PLANE_DB_PASSWORD,
      database: process.env.CONTROL_PLANE_DB_DATABASE,
    });

    configService = makeFakeConfigService();
    crypto = new CredentialsCryptoService(configService);
    crypto.onModuleInit();

    tenantId = await insertFixtureTenant({
      codigo: `fixture_registry_${Date.now()}`,
      dbHost: process.env.TENANT_CLI_DB_HOST!,
      dbPort: Number(process.env.TENANT_CLI_DB_PORT),
      dbName: "test_default_sentinel",
      dbUser: process.env.TENANT_CLI_DB_USERNAME!,
      dbPasswordEncrypted: crypto.encrypt(process.env.TENANT_CLI_DB_PASSWORD || ""),
    });

    // Repositorio minimo, sin bootear todo Nest — el registro solo necesita
    // .findOne({where: {id}}).
    tenantRepository = {
      findOne: async ({ where: { id } }: any) => {
        const [rows] = await conn.query(
          "SELECT id, estado, db_host, db_port, db_name, db_user, db_password_encrypted FROM tenants WHERE id = ?",
          [id],
        );
        const row = (rows as any[])[0];
        if (!row) return null;
        return {
          id: row.id,
          estado: row.estado,
          dbHost: row.db_host,
          dbPort: row.db_port,
          dbName: row.db_name,
          dbUser: row.db_user,
          dbPasswordEncrypted: row.db_password_encrypted,
        };
      },
    };

    registry = new TenantConnectionRegistry(tenantRepository, crypto, configService);
  }, 30000);

  afterAll(async () => {
    await registry.onModuleDestroy();
    if (fixtureIds.length > 0) {
      await conn.query("DELETE FROM tenants WHERE id IN (?)", [fixtureIds]);
    }

    // No confiar en que cada test limpio lo suyo — confirmarlo con una
    // consulta real. Si algo quedo, esto tiene que fallar la suite, no
    // pasar en silencio.
    const [remaining] = await conn.query<mysql.RowDataPacket[]>(
      "SELECT id, codigo FROM tenants WHERE codigo LIKE 'fixture_%'",
    );
    await conn.end();
    expect(remaining).toEqual([]);
  }, 30000);

  it("acquire() crea una DataSource real y corre una consulta real", async () => {
    const dataSource = await registry.acquire(tenantId);
    const rows = await dataSource.query("SELECT 1 as ok");
    // mysql2 devuelve literales enteros como BIGINT (string) por defecto,
    // no como Number — de ahi el cast explicito.
    expect(Number(rows[0].ok)).toBe(1);
    registry.release(tenantId);
  }, 15000);

  it("acquire() reusa la misma DataSource para el mismo tenant (no crea una nueva cada vez)", async () => {
    const first = await registry.acquire(tenantId);
    const second = await registry.acquire(tenantId);
    expect(first).toBe(second);
    registry.release(tenantId);
    registry.release(tenantId);
  }, 15000);

  it("refCount sube con cada acquire() y baja con cada release()", async () => {
    await registry.acquire(tenantId);
    await registry.acquire(tenantId);
    const mid = registry.debugSnapshot().find((e) => e.tenantId === tenantId);
    expect(mid?.refCount).toBeGreaterThanOrEqual(2);

    registry.release(tenantId);
    registry.release(tenantId);
    const after = registry.debugSnapshot().find((e) => e.tenantId === tenantId);
    expect(after?.refCount).toBe(0);
  }, 15000);

  it("un tenantId que no existe en el plano de control lanza TenantNotResolvableError", async () => {
    await expect(registry.acquire(999999999)).rejects.toThrow(
      TenantNotResolvableError,
    );
  }, 15000);

  it("un tenant con estado != 'activo' lanza TenantNotResolvableError", async () => {
    const suspendedId = await insertFixtureTenant({
      codigo: `fixture_suspendido_registry_${Date.now()}`,
      estado: "suspendido",
      dbHost: "fixture-host-nunca-conectado",
      dbPort: 999,
      dbName: "x",
      dbUser: "x",
      dbPasswordEncrypted: "x",
    });
    await expect(registry.acquire(suspendedId)).rejects.toThrow(
      TenantNotResolvableError,
    );
  }, 15000);

  // allowOnboarding: la unica excepcion permitida al gate de arriba —
  // reservada para control-plane-admin/ verificando el esquema de un tenant
  // ANTES de activarlo (sin esto, seria imposible correr "Verificar
  // compatibilidad" antes del primer click de "Activar" — bug real,
  // encontrado corriendo el flujo completo del panel de administracion, no
  // razonando sobre el codigo).
  it("un tenant con estado='onboarding' lanza TenantNotResolvableError SIN allowOnboarding", async () => {
    // dbName distinto ("x-onb-sin-opcion"): mismo motivo que en la prueba de
    // 'suspendido' mas abajo — la unique key es (host, port, db_name) y
    // "fixture-host-nunca-conectado"+999+"x" ya esta en uso por la prueba de
    // 'estado != activo'.
    const onboardingId = await insertFixtureTenant({
      codigo: `fixture_onboarding_sin_opcion_${Date.now()}`,
      estado: "onboarding",
      dbHost: "fixture-host-nunca-conectado",
      dbPort: 999,
      dbName: "x-onb-sin-opcion",
      dbUser: "x",
      dbPasswordEncrypted: "x",
    });
    await expect(registry.acquire(onboardingId)).rejects.toThrow(
      TenantNotResolvableError,
    );
  }, 15000);

  it("un tenant con estado='onboarding' SI conecta con allowOnboarding:true", async () => {
    // test_tenant_a_aislamiento, no test_default_sentinel: esta ultima ya
    // tiene una fila fixture puesta en el beforeAll de este archivo — la
    // unique key es (host, port, db_name), asi que reusar el mismo db_name
    // aca chocaria con esa fila todavia viva durante la corrida.
    const onboardingId = await insertFixtureTenant({
      codigo: `fixture_onboarding_con_opcion_${Date.now()}`,
      estado: "onboarding",
      dbHost: process.env.TENANT_CLI_DB_HOST!,
      dbPort: Number(process.env.TENANT_CLI_DB_PORT),
      dbName: "test_tenant_a_aislamiento",
      dbUser: process.env.TENANT_CLI_DB_USERNAME!,
      dbPasswordEncrypted: crypto.encrypt(process.env.TENANT_CLI_DB_PASSWORD || ""),
    });
    const dataSource = await registry.acquire(onboardingId, { allowOnboarding: true });
    expect(dataSource.isInitialized).toBe(true);
    registry.release(onboardingId);
  }, 15000);

  it("un tenant con estado='suspendido' sigue rechazado AUNQUE se pida allowOnboarding:true", async () => {
    // dbName distinto ("x-susp-opcion", no "x"): la unique key es
    // (host, port, db_name) — "fixture-host-nunca-conectado"+999+"x" ya lo
    // usa la prueba de 'estado != activo' de mas arriba, todavia sin
    // limpiar durante esta misma corrida.
    const suspendedId = await insertFixtureTenant({
      codigo: `fixture_suspendido_con_opcion_${Date.now()}`,
      estado: "suspendido",
      dbHost: "fixture-host-nunca-conectado",
      dbPort: 999,
      dbName: "x-susp-opcion",
      dbUser: "x",
      dbPasswordEncrypted: "x",
    });
    await expect(
      registry.acquire(suspendedId, { allowOnboarding: true }),
    ).rejects.toThrow(TenantNotResolvableError);
  }, 15000);

  // Invariante de seguridad, no un caso mas de "no resoluble": si una fila de
  // `tenants` pudiera resolver a la base del PLANO DE CONTROL, ese "tenant"
  // terminaria conectado (con SUS credenciales) a la base que contiene las
  // credenciales cifradas de TODOS los clientes. Se verifica con la terna
  // REAL de mbinvtaller, no con un valor inventado.
  it("el registro RECHAZA crear una conexion de tenant hacia la base del plano de control", async () => {
    const rogueId = await insertFixtureTenant({
      codigo: `fixture_apunta_al_plano_de_control_${Date.now()}`,
      dbHost: process.env.CONTROL_PLANE_DB_HOST!,
      dbPort: Number(process.env.CONTROL_PLANE_DB_PORT),
      dbName: process.env.CONTROL_PLANE_DB_DATABASE!,
      // Nunca se llega a usar: el chequeo de invariante corre ANTES de
      // descifrar o conectar.
      dbUser: "x",
      dbPasswordEncrypted: "x",
    });
    await expect(registry.acquire(rogueId)).rejects.toThrow(
      TenantPointsAtControlPlaneError,
    );
  }, 15000);

  it("withTenantRepositoryExplicit() ejecuta la funcion con un repositorio funcional y NO libera hasta que la funcion termina", async () => {
    const baseline =
      registry.debugSnapshot().find((e) => e.tenantId === tenantId)?.refCount ?? 0;

    let refCountDuringFn: number | undefined;

    const count = await registry.withTenantRepositoryExplicit(
      tenantId,
      Usuario,
      async (repo) => {
        refCountDuringFn = registry
          .debugSnapshot()
          .find((e) => e.tenantId === tenantId)?.refCount;
        return repo.count();
      },
    );

    expect(count).toBeGreaterThan(0);
    // Mientras `fn` corria, el refCount tenia que estar ARRIBA del baseline
    // — la prueba real de que la ventana de desalojo esta cerrada durante
    // el uso, no solo durante el acquire.
    expect(refCountDuringFn).toBeGreaterThan(baseline);

    const after = registry.debugSnapshot().find((e) => e.tenantId === tenantId);
    expect(after?.refCount).toBe(baseline);
  }, 15000);

  it("withTenantRepositoryExplicit() libera igual si la funcion lanza (finally, no solo el camino feliz)", async () => {
    const baseline =
      registry.debugSnapshot().find((e) => e.tenantId === tenantId)?.refCount ?? 0;

    await expect(
      registry.withTenantRepositoryExplicit(tenantId, Usuario, async () => {
        throw new Error("fixture: fn lanza a proposito");
      }),
    ).rejects.toThrow("fixture: fn lanza a proposito");

    const after = registry.debugSnapshot().find((e) => e.tenantId === tenantId);
    expect(after?.refCount).toBe(baseline);
  }, 15000);

  // La forma general: una sola llamada, la DataSource completa, mas de un
  // repositorio SIN anidar acquires. Decidido asi porque auth va a necesitar
  // Usuario y potencialmente otra entidad sin relacion TypeORM en la misma
  // operacion — anidar withTenantRepositoryExplicit() infla el conteo de
  // bypasses sin necesidad (ver comentario en el metodo).
  it("withTenantDataSourceExplicit() permite mas de un repositorio en UN solo acquire, y libera solo al final", async () => {
    const baseline =
      registry.debugSnapshot().find((e) => e.tenantId === tenantId)?.refCount ?? 0;
    let refCountDuringFn: number | undefined;

    const result = await registry.withTenantDataSourceExplicit(
      tenantId,
      async (dataSource) => {
        const usuarioRepo = dataSource.getRepository(Usuario);
        const otraQuery = await dataSource.query("SELECT 1 as ok");
        refCountDuringFn = registry
          .debugSnapshot()
          .find((e) => e.tenantId === tenantId)?.refCount;
        return { usuarios: await usuarioRepo.count(), ok: Number(otraQuery[0].ok) };
      },
    );

    expect(result.usuarios).toBeGreaterThan(0);
    expect(result.ok).toBe(1);
    expect(refCountDuringFn).toBeGreaterThan(baseline);

    const after = registry.debugSnapshot().find((e) => e.tenantId === tenantId);
    expect(after?.refCount).toBe(baseline);
  }, 15000);

  // Estos cuatro son esquemas de SISTEMA de MySQL, no bases de un tenant, en
  // CUALQUIER servidor — se rechazan solo por nombre, sin importar host o
  // puerto. Sin esto, un tenant mal configurado (o un ataque) podria resolver
  // a mysql.user (cuentas del servidor completo) o information_schema
  // (metadatos de TODAS las bases visibles para la credencial).
  it.each(["mysql", "information_schema", "performance_schema", "sys"])(
    "el registro RECHAZA crear una conexion de tenant hacia el esquema de sistema '%s'",
    async (schemaName) => {
      const rogueId = await insertFixtureTenant({
        codigo: `fixture_esquema_sistema_${schemaName}_${Date.now()}`,
        dbHost: process.env.TENANT_CLI_DB_HOST!,
        dbPort: Number(process.env.TENANT_CLI_DB_PORT),
        dbName: schemaName,
        // Nunca se llega a usar: el chequeo corre antes de descifrar o conectar.
        dbUser: "x",
        dbPasswordEncrypted: "x",
      });
      await expect(registry.acquire(rogueId)).rejects.toThrow(
        TenantPointsAtSystemSchemaError,
      );
    },
    15000,
  );

  // El hook se prueba contra una base de tenant REAL y CONECTABLE distinta de
  // la principal — test_tenant_b_aislamiento, la misma base dedicada que usa
  // la Variante 5 del e2e de aislamiento (mismo servidor, misma credencial
  // 'christian' — TENANT_CLI_DB_HOST/PORT/USERNAME/PASSWORD, ya NO una
  // credencial de solo lectura separada: esa via se abandono). No puede ser
  // el plano de control ni un esquema de sistema: las dos pruebas de arriba
  // exigen exactamente que esos casos se RECHACEN.
  //
  // Sin skip condicional: las tres bases dedicadas (default/A/B) son un
  // REQUISITO de esta linea de trabajo, no una mejora opcional — si
  // test_tenant_b_aislamiento no existe o falta el GRANT de christian
  // todavia, esta prueba tiene que FALLAR con un error de conexion claro, no
  // saltarse en silencio.
  it("onDataSourceCreated() dispara para cada tenant NUEVO que el registro resuelve, no para reusos", async () => {
    const created: number[] = [];
    registry.onDataSourceCreated((id) => created.push(id));

    // El tenant ya existe en el registro (de pruebas anteriores) — reusar
    // NO deberia disparar el hook otra vez.
    await registry.acquire(tenantId);
    registry.release(tenantId);
    expect(created).toEqual([]);

    const freshId = await insertFixtureTenant({
      codigo: `fixture_hook_${Date.now()}`,
      dbHost: process.env.TENANT_CLI_DB_HOST!,
      dbPort: Number(process.env.TENANT_CLI_DB_PORT),
      dbName: "test_tenant_b_aislamiento",
      dbUser: process.env.TENANT_CLI_DB_USERNAME!,
      dbPasswordEncrypted: crypto.encrypt(process.env.TENANT_CLI_DB_PASSWORD || ""),
    });

    await registry.acquire(freshId);
    expect(created).toEqual([freshId]);
    registry.release(freshId);
  }, 15000);

  // Verificado contando conexiones DEL POOL (pool._allConnections /
  // pool._freeConnections, expuestas por mysql2 — ver base/pool.js), nunca
  // Threads_connected: ese es un contador GLOBAL del servidor de MySQL,
  // compartido con 100+ bases de otros clientes — puede subir o bajar por
  // actividad ajena a esta prueba, no es una medicion confiable de ESTE
  // pool. Real, con tiempo real (DEFAULT_ACQUIRE_TIMEOUT_MS=5000ms) — no
  // hay forma de forzar el agotamiento sin esperarlo de verdad.
  it("el acquireTimeout agota el pool y libera siempre a la conexion que llega tarde (perdedor), medido con el pool real", async () => {
    const dataSource = await registry.acquire(tenantId);
    const pool: any = (dataSource.driver as any).pool;

    const held: any[] = [];
    for (let i = 0; i < 5; i++) {
      held.push(
        await new Promise((resolve, reject) => {
          pool.getConnection((err: any, c: any) => (err ? reject(err) : resolve(c)));
        }),
      );
    }
    const allConnectionsAtCapacity = pool._allConnections.length;

    const forced: { err: any; conn: any } = await new Promise((resolve) => {
      pool.getConnection((err: any, c: any) => resolve({ err, conn: c }));
    });
    expect(forced.err).toBeInstanceOf(TenantPoolExhaustedError);
    expect(forced.conn).toBeUndefined();

    // Libera los 5 sostenidos: esto es lo que finalmente le entrega una
    // conexion real al pedido que ya perdio la carrera contra el timer.
    for (const c of held) c.release();

    // Le da tiempo al "perdedor" tardio a llegar y auto-liberarse.
    await new Promise((resolve) => setTimeout(resolve, 300));

    expect(pool._allConnections.length).toBeLessThanOrEqual(allConnectionsAtCapacity);
    expect(pool._freeConnections.length).toBe(pool._allConnections.length);

    registry.release(tenantId);
  }, 15000);
});
