import { INestApplication, ValidationPipe } from "@nestjs/common";
import { Test } from "@nestjs/testing";
import * as http from "http";
import { DataSource } from "typeorm";
import { AppModule } from "../src/app.module";
import { TenantConnectionRegistry } from "../src/tenancy/tenant-connection-registry.service";

// Rutas publicas por diseno — lista corta, sacada del codigo real (no de
// memoria), la misma en las dos pruebas e2e que la necesitan. Cualquier ruta
// publica NUEVA requiere aprobacion explicita: agregarla aca es un diff
// visible, nunca un ajuste silencioso para que una prueba pase.
export const PUBLIC_ROUTES: Array<{ method: string; path: string }> = [
  { method: "GET", path: "/api/v1" }, // health check
  { method: "POST", path: "/api/v1/auth/login" }, // asi se ENTRA al sistema
  { method: "POST", path: "/api/v1/auth/refresh" }, // el refresh token no es un JWT de acceso
  { method: "GET", path: "/api/v1/public/ots/:token/status" }, // seguridad via token firmado en la URL, no JWT
  { method: "GET", path: "/api/v1/public/cotizaciones/:token" }, // idem
  { method: "POST", path: "/api/v1/public/cotizaciones/:token/decision" }, // idem
];

export function isPublicRoute(method: string, routePath: string): boolean {
  return PUBLIC_ROUTES.some((r) => r.method === method && r.path === routePath);
}

// "1" y no un string arbitrario: varias rutas usan ParseIntPipe (ej.
// catalogos/tipos-dano/:id) y un placeholder no numerico llega a producir
// NaN en una consulta SQL real (bug real que encontre corriendo esto por
// primera vez) — ruido que no tiene nada que ver con lo que se quiere medir.
export function concretePath(routePath: string): string {
  return routePath.replace(/:([A-Za-z0-9_]+)/g, "1");
}

export type DiscoveredRoute = { method: string; path: string };

export function discoverRoutes(app: INestApplication): DiscoveredRoute[] {
  const expressApp: any = app.getHttpAdapter().getInstance();
  // Express 5 expone el router en `.router`, no en `._router` (Express 4).
  // Con setGlobalPrefix ya aplicado (se llama antes de app.init()), Nest
  // hornea el prefijo DENTRO de layer.route.path — no hay que agregarlo de
  // nuevo (verificado con un script standalone).
  const stack: any[] = expressApp?.router?.stack || [];
  const routes: DiscoveredRoute[] = [];

  for (const layer of stack) {
    if (!layer.route) continue;
    const routePath = layer.route.path;
    if (typeof routePath !== "string") continue;

    const methods = Object.keys(layer.route.methods || {}).filter(
      (m) => m !== "_all",
    );
    for (const method of methods) {
      routes.push({ method: method.toUpperCase(), path: routePath });
    }
  }

  return routes;
}

export async function bootTestApp(): Promise<{
  app: INestApplication;
  port: number;
  routes: DiscoveredRoute[];
}> {
  const moduleRef = await Test.createTestingModule({
    imports: [AppModule],
  }).compile();

  const app = moduleRef.createNestApplication();
  app.setGlobalPrefix("api/v1");
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
      transform: true,
    }),
  );
  await app.init();
  await app.listen(0);

  const address = app.getHttpServer().address();
  const port = typeof address === "object" && address ? address.port : 0;

  return { app, port, routes: discoverRoutes(app) };
}

// Nunca rechaza — un timeout o error de red resuelve con status -1. Un solo
// request colgado no puede tirar abajo un batch de 87 en paralelo; el -1
// queda visible en la distribucion de status en vez de perderse en un
// unhandled rejection.
export function httpRequest(
  port: number,
  method: string,
  path: string,
  headers?: Record<string, string>,
  body?: string,
): Promise<{ status: number; body: string }> {
  return new Promise((resolve) => {
    const req = http.request(
      {
        host: "127.0.0.1",
        port,
        method,
        path,
        timeout: 30000,
        headers: {
          ...(body ? { "Content-Type": "application/json" } : {}),
          ...headers,
        },
      },
      (res) => {
        const chunks: Buffer[] = [];
        res.on("data", (chunk) => chunks.push(chunk));
        res.on("end", () =>
          resolve({
            status: res.statusCode || 0,
            body: Buffer.concat(chunks).toString("utf8"),
          }),
        );
      },
    );
    req.on("error", () => resolve({ status: -1, body: "" }));
    req.on("timeout", () => {
      req.destroy();
      resolve({ status: -1, body: "" });
    });
    if (body) req.write(body);
    req.end();
  });
}

// ── Instrumentacion de consultas para pruebas de aislamiento ──
//
// El criterio de fuga correcto no es el status HTTP (un 404 puede significar
// "la consulta corrio contra la conexion equivocada y no encontro la fila" —
// eso SI es la fuga). El criterio correcto es "ejecuto una consulta contra
// datos de tenant", medido con un logger real de TypeORM.
export type QueryVerbCounts = { select: number; write: number; other: number };

export function classifyQueryVerb(sql: string): "select" | "write" | "other" {
  const verb = sql.trim().split(/\s+/)[0]?.toUpperCase() || "";
  if (verb === "SELECT" || verb === "SHOW") return "select";
  if (verb === "INSERT" || verb === "UPDATE" || verb === "DELETE") return "write";
  return "other";
}

// Engancha dataSource.logger y devuelve un contador vivo por verbo SQL.
// Acepta un `counts` compartido opcional para que esta misma funcion sirva
// de unidad tanto para UNA instancia (la conexion por defecto de
// app.module.ts, hoy) como para cada DataSource que cree el registro de
// conexiones por tenant (ver attachRegistryQueryCounter) — todas suman al
// mismo acumulador "tenant", sin duplicar la logica de clasificacion.
export function attachQueryCounter(
  dataSource: DataSource,
  counts: QueryVerbCounts = { select: 0, write: 0, other: 0 },
): {
  counts: QueryVerbCounts;
  reset: () => void;
  restore: () => void;
} {
  const originalLogger = dataSource.logger;

  (dataSource as any).logger = {
    logQuery: (query: string) => {
      counts[classifyQueryVerb(query)]++;
    },
    logQueryError: (_error: string, query: string) => {
      counts[classifyQueryVerb(query)]++;
    },
    logQuerySlow: () => {},
    logSchemaBuild: () => {},
    logMigration: () => {},
    log: () => {},
  };

  return {
    counts,
    reset: () => {
      counts.select = 0;
      counts.write = 0;
      counts.other = 0;
    },
    restore: () => {
      (dataSource as any).logger = originalLogger;
    },
  };
}

// Enganche a la FABRICA, no a una instancia: el registro nunca crea
// conexiones de plano de control (esas son estaticas, ver
// control-plane.module.ts), asi que toda DataSource que salga de aca es, por
// definicion, de un tenant — suma al MISMO acumulador de "counts" que la
// conexion por defecto, nunca al de plano de control. Cubre, sin tocar esta
// prueba otra vez, el dia en que auth (withTenantDataSourceExplicit) o el
// primer modulo convertido empiecen a pedir conexiones aca.
export function attachRegistryQueryCounter(
  registry: TenantConnectionRegistry,
  counts: QueryVerbCounts,
): { restore: () => void } {
  const restores: Array<() => void> = [];
  registry.onDataSourceCreated((_tenantId, dataSource) => {
    restores.push(attachQueryCounter(dataSource, counts).restore);
  });
  return {
    restore: () => restores.forEach((restore) => restore()),
  };
}

// CINTURON ADICIONAL — ya NO es una proteccion real (decision explicita: no
// se van a crear usuarios de solo lectura en el entorno de pruebas; la suite
// corre con 'christian', que tiene permisos de escritura reales sobre TODO
// el servidor). Un ajuste de sesion se pisa con un simple
// START TRANSACTION READ WRITE — se deja puesto solo porque reduce ruido
// (algunas rutas fallan un paso antes), no porque bloquee nada de verdad. La
// proteccion real es la lista blanca de bases mas abajo
// (ALLOWED_ISOLATION_TEST_DATABASES) mas el hecho de que las tres bases son
// descartables: si algo escribe, escribe en una base que no le importa a
// nadie, nunca en una base real de cliente.
//
// Para cubrir conexiones YA EXISTENTES (ej. la que abre
// seedUsersIfTableExists() al arrancar la app, antes de que este hook se
// instale) hace falta un burst de queries CONCURRENTES mayor al tamaño del
// pool: en secuencial, el pool reutiliza la misma conexion libre una y otra
// vez y nunca crea otras nuevas, asi que el evento 'connection' nunca
// dispara para ellas. BURST_SIZE=20 cubre el default de mysql2
// (connectionLimit=10) con margen.
export async function forceReadOnlySessions(
  dataSource: DataSource,
): Promise<void> {
  const pool = (dataSource.driver as any).pool;
  if (pool && typeof pool.on === "function") {
    pool.on("connection", (conn: any) => {
      // Handler de evento: no se puede await. .catch() evita una promesa
      // rechazada sin manejar si la conexion se cierra antes de que
      // responda — igual queda protegida por el GRANT del usuario.
      conn
        .promise()
        .query("SET SESSION TRANSACTION READ ONLY")
        .catch(() => {});
    });
  }

  const BURST_SIZE = 20;
  await Promise.all(
    Array.from({ length: BURST_SIZE }, () =>
      dataSource.query("SET SESSION TRANSACTION READ ONLY"),
    ),
  );
}

// LISTA BLANCA FIJA — nunca configurable por variable de entorno. Si fuera
// configurable, el mismo tipeo en .env que este diseno existe para hacer
// imposible ('christian' alcanza 100+ bases reales de clientes en este
// servidor) tambien podria desactivar la lista blanca — un solo error
// tipografico apuntando esta suite a una base real seria irreversible. Las
// tres son descartables, creadas a mano por el usuario, con la MISMA receta
// (13 migraciones taller_* + filas centinela sinteticas).
export const ALLOWED_ISOLATION_TEST_DATABASES = [
  "test_default_sentinel",
  "test_tenant_a_aislamiento",
  "test_tenant_b_aislamiento",
] as const;

// Sondeo obligatorio: YA NO verifica que la cuenta no pueda escribir (no
// puede — 'christian' escribe de verdad). Verifica a DONDE apunta la
// conexion. Con SELECT DATABASE(), nunca con la variable de entorno — si el
// proceso tiene un valor viejo cacheado o algo lo pisa a mitad de camino, lo
// que importa es adonde MySQL dice que estamos conectados de verdad.
export async function assertDatabaseAllowlisted(
  dataSource: DataSource,
  label: string,
): Promise<string> {
  const rows = await dataSource.query("SELECT DATABASE() as db");
  const actual = rows[0]?.db;

  if (!ALLOWED_ISOLATION_TEST_DATABASES.includes(actual)) {
    throw new Error(
      `[${label}] Esta conexion apunta a '${actual}', que NO esta en la lista blanca fija ` +
        `(${ALLOWED_ISOLATION_TEST_DATABASES.join(", ")}). ABORTA de inmediato: la credencial ` +
        "de esta prueba (christian) alcanza 100+ bases reales de clientes en este servidor — " +
        "seguir de largo arriesgaria escribir ahi. Revisa TENANT_CLI_DB_DATABASE y las filas " +
        "de tenant fixture antes de volver a correr esto.",
    );
  }

  return actual;
}

// Misma lista blanca, chequeada ANTES de insertar una fila de tenant fixture
// que vaya a ser resuelta de verdad (no las fixtures 'suspendido'/'sin
// membresia', que nunca llegan a conectarse) — no alcanza con chequear al
// arrancar la app si despues una fixture nueva apunta a otro lado.
export function assertFixtureDatabaseAllowlisted(dbName: string, label: string): void {
  if (!ALLOWED_ISOLATION_TEST_DATABASES.includes(dbName as any)) {
    throw new Error(
      `[${label}] La fixture intenta apuntar a '${dbName}', fuera de la lista blanca fija ` +
        `(${ALLOWED_ISOLATION_TEST_DATABASES.join(", ")}). No se crea esta fila.`,
    );
  }
}

// Nunca en silencio: antes de tocar nada, deja constancia BIEN VISIBLE de
// contra que bases va a correr esta suite y de que puede escribir en ellas
// de verdad — la proteccion es que son descartables, no que esten
// bloqueadas.
export function announceIsolationTestTargets(targets: Record<string, string>): void {
  const lines = Object.entries(targets).map(([label, db]) => `    ${label} -> ${db}`);
  // eslint-disable-next-line no-console
  console.log(
    "\n================================================================\n" +
      "  AISLAMIENTO E2E — bases reales contra las que va a correr esto.\n" +
      "  La credencial (christian) PUEDE ESCRIBIR de verdad en las tres.\n" +
      "  Protegidas por ser DESCARTABLES, no por ningun permiso de MySQL.\n" +
      lines.join("\n") +
      "\n================================================================\n",
  );
}

// Arranca la app ENTERA con la conexion por defecto apuntando a la base
// SENTINELA (test_default_sentinel), nunca a la demo real
// (TENANT_CLI_DB_DATABASE en .env, que el equipo usa de verdad — 5 OT
// nuevas, 6 vehiculos, un lock de cotizacion abierto por un usuario real
// mientras corrian pruebas anteriores). Host/puerto/credencial NO se tocan
// (las tres bases viven en el mismo servidor, con la misma cuenta) — solo el
// NOMBRE de la base cambia, y solo durante la prueba (restaurado en el
// finally).
//
// Sin esto, cualquier modulo TODAVIA SIN CONVERTIR (que sigue leyendo de la
// conexion por defecto) encontraria los datos reales de la demo por
// COINCIDENCIA de configuracion al correr esta suite — no es aislamiento,
// es que TENANT_CLI_DB_DATABASE hoy apunta ahi. El dia que se elimine la
// conexion por defecto (ver TenancyModule), esta funcion deja de hacer falta.
export async function bootTestAppWithSentinelDatabases(): Promise<{
  app: INestApplication;
  port: number;
  routes: DiscoveredRoute[];
}> {
  const original = {
    tenantDatabase: process.env.TENANT_CLI_DB_DATABASE,
  };

  process.env.TENANT_CLI_DB_DATABASE = "test_default_sentinel";

  try {
    return await bootTestApp();
  } finally {
    process.env.TENANT_CLI_DB_DATABASE = original.tenantDatabase;
  }
}
