import { INestApplication } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import { getDataSourceToken } from "@nestjs/typeorm";
import * as fs from "fs";
import * as mysql from "mysql2/promise";
import * as path from "path";
import { DataSource } from "typeorm";
import { CONTROL_PLANE_CONNECTION } from "../src/control-plane/control-plane.module";
import { CredentialsCryptoService } from "../src/control-plane/credentials-crypto.service";
import { TenantConnectionRegistry } from "../src/tenancy/tenant-connection-registry.service";
import {
  DiscoveredRoute,
  announceIsolationTestTargets,
  assertDatabaseAllowlisted,
  assertFixtureDatabaseAllowlisted,
  attachQueryCounter,
  attachRegistryQueryCounter,
  bootTestAppWithSentinelDatabases,
  concretePath,
  forceReadOnlySessions,
  httpRequest,
  isPublicRoute,
} from "./e2e-helpers";
import { assertSentinelFixturesSeeded } from "./fixtures/seed-sentinel-databases";

type RouteKey = { method: string; path: string };

const positiveControlSnapshot: { routes: RouteKey[] } = JSON.parse(
  fs.readFileSync(
    path.join(__dirname, "tenant-isolation-positive-control-snapshot.json"),
    "utf8",
  ),
);

function routeKey(r: RouteKey): string {
  return `${r.method} ${r.path}`;
}

// Nombres FIJOS — los mismos que ALLOWED_ISOLATION_TEST_DATABASES en
// e2e-helpers.ts. Declarados como const propias (no un string suelto en cada
// INSERT) para que assertFixtureDatabaseAllowlisted() tenga algo concreto que
// verificar antes de crear cada fila — la lista blanca se chequea al CREAR
// la fixture, no solo al arrancar la app.
const TENANT_A_DB_NAME = "test_tenant_a_aislamiento";
const TENANT_B_DB_NAME = "test_tenant_b_aislamiento";

// CAMBIO DE DECISION (explicito, no accidental): NO se van a crear usuarios
// de solo lectura en el entorno de pruebas. La suite corre con 'christian'
// (TENANT_CLI_DB_USERNAME/PASSWORD), que tiene permisos de escritura reales
// sobre TODO el servidor — 100+ bases de clientes incluidas. El objetivo de
// proteccion CAMBIA: ya no es "que la cuenta no pueda escribir" (no se puede
// exigir eso), es "que la cuenta solo pueda ALCANZAR bases descartables".
//
// Dos capas, en orden de fuerza real:
//   1. LISTA BLANCA FIJA en el codigo (ALLOWED_ISOLATION_TEST_DATABASES,
//      e2e-helpers.ts) — chequeada con SELECT DATABASE() al arrancar Y otra
//      vez antes de crear cada fixture que vaya a conectarse de verdad. Fija
//      en el codigo, nunca en una variable de entorno: si fuera
//      configurable, el mismo tipeo que este diseno existe para hacer
//      imposible tambien la desactivaria.
//   2. SET SESSION TRANSACTION READ ONLY (forceReadOnlySessions) — cinturon
//      ADICIONAL, ya no el tirante. Reduce ruido (algunas rutas fallan un
//      paso antes), pero se pisa con un simple START TRANSACTION READ WRITE.
//      Documentado como lo que es: lo unico que queda del lado de la
//      escritura, no una proteccion real.
//
// Consecuencia directa: el contador de escrituras por verbo SQL
// (attachQueryCounter) pasa de ser una "cota inferior bajo bloqueo" a ser la
// SEÑAL PRINCIPAL de que una ruta escribe — MySQL ya no impide nada, asi que
// si una ruta escribe, ESCRIBE DE VERDAD en la base descartable. Las
// Variantes 1-3 y 6 (control negativo) siguen exigiendo wroteData=[] — antes
// era cinturon y tirante a la vez, ahora es la unica cosa que puede fallar esa
// aserción.
//
// Las TRES bases (creadas a mano por el usuario, misma receta: 13
// migraciones taller_* + filas centinela sinteticas, GRANT agregado para
// christian) — ninguna en uso real:
//   - test_default_sentinel     -> conexion por defecto (modulos sin convertir)
//   - test_tenant_a_aislamiento -> tenant A
//   - test_tenant_b_aislamiento -> tenant B
// La demo real (TENANT_CLI_DB_DATABASE en .env, mbinvautocentrorodriguez) NO
// se toca mas en este archivo — tiene actividad humana real corriendo en
// paralelo (confirmado con timestamps: 5 OT nuevas, 6 vehiculos, un lock de
// cotizacion abierto por un usuario real mientras corrian pruebas
// anteriores).
//
// El bloqueo cubre la base de datos, NO efectos de lado por fuera de ella:
// whatsapp.service.ts llama a la API real de Meta (graph.facebook.com) con
// las credenciales reales de WHATSAPP_ACCESS_TOKEN de .env. Hoy esas
// llamadas fallan temprano (el cliente de prueba no tiene telefono valido),
// pero eso es incidental, no proteccion — no confiar en ello. Bloquear la
// red saliente en esta prueba (stub o intercept de fetch) queda pendiente.
//
// TRAMPA A FUTURO — escrita ahora para no encontrarla en produccion: el
// diseño dice que la auditoria se escribe ANTES del acceso a datos, y que un
// fallo al auditar ABORTA la operacion. El plano de control (mbinvtaller,
// REAL, no un sentinel) va a poder escribir sin problema con christian — el
// dia que exista auditoria real, esta prueba SI podria escribir registros de
// auditoria de verdad (a diferencia de la ronda con usuario de solo lectura,
// donde esa escritura hubiera quedado bloqueada). Revisar entonces si eso
// contamina algo.
//
// QUE MIDE Y COMO — reescrito varias veces sobre versiones que resultaron no
// ser confiables:
//   - v1 media por status HTTP: un 404 con el placeholder "1" no prueba que
//     no hubo fuga. El numero real de fuga (87/87) resulto el TRIPLE del que
//     sugeria el status (27-32/87).
//   - v2 media con concurrencia: la varianza resulto ser del servidor remoto
//     compartido, no del codigo. v3 corre secuencial y es deterministico.
//   - v3 agrego CONTROL POSITIVO pero con un UMBRAL (>=40) — demasiado
//     flojo. v4 compara contra un SNAPSHOT EXACTO — igual que el trinquete
//     de ofensas, se puede achicar solo con aprobacion explicita.
//   - v4 usaba un usuario de MySQL sin privilegio de escritura como tirante
//     principal. v5 abandona esa via — decision explicita del usuario, no un
//     hallazgo mio — y protege con lista blanca de bases descartables en su
//     lugar.
//   - v5 confundio dos cosas bajo el mismo nombre "control negativo":
//     VARIANTE 3 (tenant activo sin membresia) apuntaba a un tenant FALSO
//     (db_password_encrypted='x', nunca pensado para conectarse de verdad).
//     Cuando la conversion de auth empezo a requerir ADQUIRIR la conexion del
//     tenant para autenticar en JwtStrategy, VARIANTE 1-3 bajaron a 0 — pero
//     por un fallo de RESOLUCION (el tenant no existe / esta suspendido / el
//     blob cifrado es invalido), nunca por una verificacion de MEMBRESIA, que
//     no existe en ningun lado todavia. El instrumento se habia saturado: iba
//     a seguir en 0 sin importar que tan mal se convirtiera un modulo, porque
//     nunca llegaba a ejecutar ninguna ruta.
//   - v6 (esta version) hace DOS cambios, no uno:
//     (1) JwtStrategy deja de tocar la base por completo (ver
//     auth/jwt.strategy.ts) — valida solo la firma. Sin eso, VARIANTE 1-3
//     dejan de rechazar TODO por igual.
//     (2) VARIANTE 3 pasa a apuntar a un tenant REAL Y ALCANZABLE (tenant B,
//     no un fixture falso) con una identidad sin ninguna membresia. Y se
//     agrega VARIANTE 6: identidad CON membresia (a tenant A), token para
//     tenant B (real, alcanzable) al que esa identidad NO tiene acceso.
//
//     Resultado medido (no supuesto): VARIANTE 1-2 (tenant que NO resuelve)
//     dan 49/87 — 5 rutas menos que las demas variantes, exactamente las
//     rutas de gestion propias de auth (me/tiendas/users), que SI rechazan
//     porque AuthService intenta adquirir la conexion del tenant y esa
//     adquisicion falla (el tenant no existe/esta suspendido) — enforcement
//     real, no un artefacto. VARIANTE 3 y VARIANTE 6 (tenant SI resuelve, sin
//     membresia) dan 54/87 — IGUAL que VARIANTE 4 (control positivo): nada
//     verifica membresia todavia, ni siquiera las rutas de auth (su bypass
//     solo chequea que el tenant exista y este activo). Ese 54 (no 87) tiene
//     su propia explicacion, sin relacion con auth — ver el comentario junto
//     al snapshot de control positivo.
//
//     Osea: VARIANTE 1-2 miden RESOLUCION (util, va a seguir en 49 hasta que
//     el TenantGuard rechace tenants invalidos ANTES de que auth intente
//     nada). VARIANTE 3 y 6 miden MEMBRESIA — ESE es el numero de progreso
//     real, y hoy es igual al positivo porque nadie la chequea aun.
describe("Aislamiento real: token valido + tenant no resoluble (e2e)", () => {
  let app: INestApplication;
  let port: number;
  let routes: DiscoveredRoute[];
  let jwtService: JwtService;
  let dataSource: DataSource;
  let controlPlaneDataSource: DataSource;
  // Dos contadores INDEPENDIENTES a proposito. El futuro TenantGuard va a
  // consultar mbinvtaller (plano de control) para validar la membresia en
  // CADA request — esa consulta es legitima y esperada, incluso con un
  // tenant invalido (para poder rechazarlo hay que consultar el plano de
  // control primero). El control negativo se mide SOLO con el contador de
  // tenant: si se contaran juntos, el guard mismo haria que el control
  // negativo nunca pueda llegar a 0.
  let counter: ReturnType<typeof attachQueryCounter>;
  let controlPlaneCounter: ReturnType<typeof attachQueryCounter>;
  // Enganchado a la FABRICA del registro (onDataSourceCreated), no a una
  // instancia: cubre, sin cambiar esta prueba, cualquier DataSource de
  // tenant que el registro cree de aca en adelante (el dia que auth o el
  // primer modulo convertido empiecen a pedir conexiones ahi). Suma al
  // MISMO counts que la conexion por defecto — ver comentario en
  // attachRegistryQueryCounter sobre por que es "tenant", nunca "plano de
  // control".
  let registryCounter: ReturnType<typeof attachRegistryQueryCounter>;

  let conn: mysql.Connection;
  // dbA/dbB: conexiones RAW y directas (no via la app) a tenant A y tenant B
  // — ground truth, siempre las bases sentinela fijas (nunca la demo real).
  let dbA: mysql.Connection;
  let dbB: mysql.Connection;
  const FIXTURE_HOST = "fixture-tenant-isolation-test";
  let suspendedTenantId: number;
  let identidadSinMembresiaId: number;
  let validTenantId: number;
  let identidadValidaId: number;
  let tenantBId: number;
  let identidadBId: number;

  beforeAll(async () => {
    // Precondicion, ANTES de arrancar la app entera: si falta alguna fila
    // centinela, aborta con un mensaje que dice exactamente que correr, en
    // vez de fallar mas adelante con un error generico (o peor, con un
    // 500 críptico a mitad de una de las 87 rutas).
    await assertSentinelFixturesSeeded();

    const booted = await bootTestAppWithSentinelDatabases();
    app = booted.app;
    port = booted.port;
    routes = booted.routes.filter((r) => !isPublicRoute(r.method, r.path));
    jwtService = app.get(JwtService);

    dataSource = app.get<DataSource>(getDataSourceToken());
    controlPlaneDataSource = app.get<DataSource>(
      getDataSourceToken(CONTROL_PLANE_CONNECTION),
    );
    const cryptoService = app.get<CredentialsCryptoService>(CredentialsCryptoService);
    const registry = app.get<TenantConnectionRegistry>(TenantConnectionRegistry);

    announceIsolationTestTargets({
      "conexion por defecto": "test_default_sentinel",
      "tenant A": TENANT_A_DB_NAME,
      "tenant B": TENANT_B_DB_NAME,
    });

    // Chequeo OBLIGATORIO antes de correr nada mas: a DONDE apunta la
    // conexion por defecto, leido con SELECT DATABASE() (la fuente de verdad
    // real), nunca solo confiando en la variable de entorno que acabamos de
    // setear.
    await assertDatabaseAllowlisted(dataSource, "conexion por defecto");

    await forceReadOnlySessions(dataSource); // cinturon adicional, no el tirante
    await forceReadOnlySessions(controlPlaneDataSource);
    counter = attachQueryCounter(dataSource);
    controlPlaneCounter = attachQueryCounter(controlPlaneDataSource);
    registryCounter = attachRegistryQueryCounter(registry, counter.counts);

    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,
    });

    const [suspended] = await conn.query<mysql.ResultSetHeader[]>(
      `INSERT INTO tenants (codigo, nombre, estado, entorno, db_host, db_port, db_name, db_user, db_password_encrypted)
       VALUES (?, ?, 'suspendido', 'demo', ?, 1, 'fixture-db-a', 'x', 'x')`,
      ["fixture_suspendido", "Fixture suspendido", FIXTURE_HOST],
    );
    suspendedTenantId = (suspended as any).insertId;

    // Identidad SIN NINGUNA membresia (a ningun tenant) — el target de
    // VARIANTE 3 ya no es un tenant falso (ver v6 en el historial de arriba):
    // apunta a tenantBId, REAL y alcanzable, creado mas abajo. No hace falta
    // un tenant fixture propio para esto.
    const [identidad] = await conn.query<mysql.ResultSetHeader[]>(
      `INSERT INTO identidades (email, nombre, password_hash, estado, rol_plataforma)
       VALUES (?, 'Fixture sin membresia', 'x', 'activa', 'ninguno')`,
      [`fixture-tenant-isolation-sin-membresia-${Date.now()}@example.invalid`],
    );
    identidadSinMembresiaId = (identidad as any).insertId;

    // Tenant A — apunta a test_tenant_a_aislamiento, NUNCA a la demo real.
    // db_user/db_password_encrypted son las credenciales REALES de escritura
    // de 'christian' (TENANT_CLI_DB_USERNAME/PASSWORD) — ya no hay
    // credencial de solo lectura que cifrar, esa via se abandono. La unica
    // proteccion real es que el nombre de esta base este en la lista blanca
    // (chequeado dos lineas mas abajo, ANTES del INSERT).
    assertFixtureDatabaseAllowlisted(TENANT_A_DB_NAME, "tenant A fixture");
    const [validTenant] = await conn.query<mysql.ResultSetHeader[]>(
      `INSERT INTO tenants (codigo, nombre, estado, entorno, db_host, db_port, db_name, db_user, db_password_encrypted)
       VALUES (?, ?, 'activo', 'demo', ?, ?, ?, ?, ?)`,
      [
        "fixture_control_positivo",
        "Fixture control positivo",
        process.env.TENANT_CLI_DB_HOST,
        Number(process.env.TENANT_CLI_DB_PORT),
        TENANT_A_DB_NAME,
        process.env.TENANT_CLI_DB_USERNAME,
        cryptoService.encrypt(process.env.TENANT_CLI_DB_PASSWORD || ""),
      ],
    );
    validTenantId = (validTenant as any).insertId;

    const [identidadValida] = await conn.query<mysql.ResultSetHeader[]>(
      `INSERT INTO identidades (email, nombre, password_hash, estado, rol_plataforma)
       VALUES (?, 'Fixture control positivo', 'x', 'activa', 'ninguno')`,
      [`fixture-tenant-isolation-valida-${Date.now()}@example.invalid`],
    );
    identidadValidaId = (identidadValida as any).insertId;

    // dbA: ground truth de tenant A, leido DIRECTO — nunca via `dataSource`
    // (que apunta a la base sentinela por defecto, no a tenant A).
    dbA = await mysql.createConnection({
      host: process.env.TENANT_CLI_DB_HOST,
      port: Number(process.env.TENANT_CLI_DB_PORT),
      user: process.env.TENANT_CLI_DB_USERNAME,
      password: process.env.TENANT_CLI_DB_PASSWORD,
      database: TENANT_A_DB_NAME,
    });

    // Sondeo via REGISTRO: la conexion que el registro crea para esta
    // fixture, no solo la estatica de arriba. Ya no verifica que no pueda
    // escribir (puede — christian escribe de verdad); verifica que apunte a
    // la base correcta.
    const registryDataSourceA = await registry.acquire(validTenantId);
    try {
      await assertDatabaseAllowlisted(registryDataSourceA, "tenant A (via registro)");
    } finally {
      registry.release(validTenantId);
    }

    // Usuario local real, id LEIDO de la base — nunca hardcodeado.
    const [usuariosA] = await dbA.query<mysql.RowDataPacket[]>(
      "SELECT id FROM taller_usuarios ORDER BY id LIMIT 1",
    );
    if (usuariosA.length === 0) {
      throw new Error(
        `${TENANT_A_DB_NAME} no tiene ningun usuario en taller_usuarios — ` +
          "la membresia de control positivo necesita un usuario local real.",
      );
    }
    const usuarioLocalIdA = (usuariosA[0] as any).id;

    await conn.query(
      `INSERT INTO tenant_membership (identidad_id, tenant_id, usuario_local_id, estado, otorgado_por_identidad_id)
       VALUES (?, ?, ?, 'activa', ?)`,
      [identidadValidaId, validTenantId, usuarioLocalIdA, identidadValidaId],
    );

    // Tenant B — misma receta que A, apuntando a test_tenant_b_aislamiento.
    assertFixtureDatabaseAllowlisted(TENANT_B_DB_NAME, "tenant B fixture");
    const [tenantB] = await conn.query<mysql.ResultSetHeader[]>(
      `INSERT INTO tenants (codigo, nombre, estado, entorno, db_host, db_port, db_name, db_user, db_password_encrypted)
       VALUES (?, ?, 'activo', 'demo', ?, ?, ?, ?, ?)`,
      [
        "fixture_tenant_b",
        "Fixture tenant B",
        process.env.TENANT_CLI_DB_HOST,
        Number(process.env.TENANT_CLI_DB_PORT),
        TENANT_B_DB_NAME,
        process.env.TENANT_CLI_DB_USERNAME,
        cryptoService.encrypt(process.env.TENANT_CLI_DB_PASSWORD || ""),
      ],
    );
    tenantBId = (tenantB as any).insertId;

    dbB = await mysql.createConnection({
      host: process.env.TENANT_CLI_DB_HOST,
      port: Number(process.env.TENANT_CLI_DB_PORT),
      user: process.env.TENANT_CLI_DB_USERNAME,
      password: process.env.TENANT_CLI_DB_PASSWORD,
      database: TENANT_B_DB_NAME,
    });

    const registryDataSourceB = await registry.acquire(tenantBId);
    try {
      await assertDatabaseAllowlisted(registryDataSourceB, "tenant B (via registro)");
    } finally {
      registry.release(tenantBId);
    }

    const [usuariosB] = await dbB.query<mysql.RowDataPacket[]>(
      "SELECT id FROM taller_usuarios ORDER BY id LIMIT 1",
    );
    if (usuariosB.length === 0) {
      throw new Error(
        `${TENANT_B_DB_NAME} no tiene ningun usuario en taller_usuarios — ` +
          "la Variante 5 necesita un usuario local real para armar la membresia.",
      );
    }
    const usuarioLocalIdB = (usuariosB[0] as any).id;

    const [identidadB] = await conn.query<mysql.ResultSetHeader[]>(
      `INSERT INTO identidades (email, nombre, password_hash, estado, rol_plataforma)
       VALUES (?, 'Fixture tenant B', 'x', 'activa', 'ninguno')`,
      [`fixture-tenant-isolation-b-${Date.now()}@example.invalid`],
    );
    identidadBId = (identidadB as any).insertId;

    await conn.query(
      `INSERT INTO tenant_membership (identidad_id, tenant_id, usuario_local_id, estado, otorgado_por_identidad_id)
       VALUES (?, ?, ?, 'activa', ?)`,
      [identidadBId, tenantBId, usuarioLocalIdB, identidadBId],
    );
  }, 60000);

  afterAll(async () => {
    counter?.restore();
    controlPlaneCounter?.restore();
    registryCounter?.restore();
    if (conn) {
      await conn.query("DELETE FROM tenant_membership WHERE tenant_id IN (?)", [
        [validTenantId, tenantBId],
      ]);
      await conn.query("DELETE FROM identidades WHERE id IN (?)", [
        [identidadSinMembresiaId, identidadValidaId, identidadBId],
      ]);
      await conn.query("DELETE FROM tenants WHERE db_host = ?", [FIXTURE_HOST]);
      await conn.query("DELETE FROM tenants WHERE id IN (?)", [
        [validTenantId, tenantBId],
      ]);
      await conn.end();
    }
    await dbA?.end();
    await dbB?.end();
    await app?.close();
  }, 60000);

  function mintToken(tenantId: number, identidadId?: number): string {
    return jwtService.sign({
      sub: 1,
      username: "admin",
      rol: "admin",
      tiendaId: 1,
      permissions: {},
      tenantId,
      identidadId: identidadId ?? 1,
    });
  }

  function totalQueries(c: ReturnType<typeof attachQueryCounter>): number {
    return c.counts.select + c.counts.write + c.counts.other;
  }

  async function requestWithRetry(
    method: string,
    path: string,
    token: string,
  ): Promise<{ status: number; queriedTenant: boolean; queriedControlPlane: boolean }> {
    const ATTEMPTS = 3;
    for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
      const tenantBefore = totalQueries(counter);
      const controlPlaneBefore = totalQueries(controlPlaneCounter);

      const { status } = await httpRequest(port, method, path, {
        Authorization: `Bearer ${token}`,
      });

      const queriedTenant = totalQueries(counter) > tenantBefore;
      const queriedControlPlane =
        totalQueries(controlPlaneCounter) > controlPlaneBefore;

      if (status !== -1 || queriedTenant || queriedControlPlane) {
        return { status, queriedTenant, queriedControlPlane };
      }
    }
    return { status: -1, queriedTenant: false, queriedControlPlane: false };
  }

  // wroteData YA NO es una cota inferior bajo bloqueo — es la SEÑAL
  // PRINCIPAL de escritura real. MySQL no bloquea nada con 'christian': si
  // una ruta ejecuta un INSERT/UPDATE/DELETE contra el tenant, ESE VERBO
  // corrio de verdad contra la base descartable. Sigue siendo una cota
  // inferior en el sentido de "rutas detectadas", no "inventario completo"
  // (una ruta que aborta antes por otra razon no aparece aunque en otro
  // escenario si escribiria) — el inventario real de que rutas ESCRIBEN se
  // saca leyendo el codigo, no de una corrida.
  async function hitAllRoutesWithToken(token: string) {
    const executedQuery: Array<{ method: string; path: string; status: number }> =
      [];
    const executedControlPlaneQuery: Array<{ method: string; path: string }> =
      [];
    const wroteData: Array<{ method: string; path: string; status: number }> =
      [];
    const unknown: Array<{ method: string; path: string }> = [];
    // DIAGNOSTICO, no una asercion nueva (punto 1d): status de CADA una de
    // las 87, sin importar si ejecuto consulta o no. Sirve para una pregunta
    // distinta de "fuga de datos" — "¿la conversion de auth rompio alguna
    // ruta?" — un salto a 500 en una ruta que antes daba 200/400/404 es
    // exactamente ese fallo, y el criterio de fuga (executedQuery) no lo
    // puede ver por si solo.
    const allStatuses: Array<{ method: string; path: string; status: number }> =
      [];

    for (const route of routes) {
      const writesBefore = counter.counts.write;
      const { status, queriedTenant, queriedControlPlane } =
        await requestWithRetry(route.method, concretePath(route.path), token);

      allStatuses.push({ method: route.method, path: route.path, status });

      if (status === -1 && !queriedTenant && !queriedControlPlane) {
        unknown.push({ method: route.method, path: route.path });
        continue;
      }

      // El criterio de fuga es SOLO consultas al TENANT — consultar el plano
      // de control (ej. para validar la membresia) es esperado incluso
      // cuando el tenant termina rechazado.
      if (queriedTenant) {
        executedQuery.push({ ...route, status });
      }
      if (queriedControlPlane) {
        executedControlPlaneQuery.push({ method: route.method, path: route.path });
      }
      if (counter.counts.write > writesBefore) {
        wroteData.push({ ...route, status });
      }
    }

    return {
      checked: routes.length,
      executedQuery,
      executedControlPlaneQuery,
      wroteData,
      unknown,
      allStatuses,
    };
  }

  function reportStatusDistribution(
    label: string,
    allStatuses: Array<{ method: string; path: string; status: number }>,
  ) {
    const histogram: Record<number, number> = {};
    for (const r of allStatuses) {
      histogram[r.status] = (histogram[r.status] || 0) + 1;
    }
    // eslint-disable-next-line no-console
    console.log(
      `[tenant-isolation][${label}] distribucion de status:`,
      JSON.stringify(histogram),
    );

    const errores500 = allStatuses.filter((r) => r.status >= 500);
    if (errores500.length > 0) {
      // eslint-disable-next-line no-console
      console.log(
        `[tenant-isolation][${label}] RUTAS CON 5xx (${errores500.length}):`,
        JSON.stringify(errores500.map((r) => `${r.method} ${r.path} -> ${r.status}`)),
      );
    }
  }

  function report(
    label: string,
    result: Awaited<ReturnType<typeof hitAllRoutesWithToken>>,
  ) {
    reportStatusDistribution(label, result.allStatuses);
    // eslint-disable-next-line no-console
    console.log(
      `[tenant-isolation][${label}] checked=${result.checked} ` +
        `ejecutaron_consulta_tenant=${result.executedQuery.length} ` +
        `ejecutaron_consulta_plano_de_control=${result.executedControlPlaneQuery.length} ` +
        `escribieron_DE_VERDAD=${result.wroteData.length} ` +
        `desconocidas=${result.unknown.length}`,
    );
    if (result.wroteData.length > 0) {
      // eslint-disable-next-line no-console
      console.log(
        `[tenant-isolation][${label}] rutas que ESCRIBIERON de verdad (christian no lo bloquea):`,
        JSON.stringify(result.wroteData),
      );
    }
  }

  it(
    "VARIANTE 1 (control negativo) — tenantId que no existe: ninguna ruta ejecuta consulta ni escribe",
    async () => {
      counter.reset();
      const result = await hitAllRoutesWithToken(mintToken(999999999));
      report("tenant inexistente", result);
      expect(result.unknown).toEqual([]);
      // Con christian, esta es la UNICA cosa que puede detectar una fuga de
      // escritura — MySQL ya no bloquea nada.
      expect(result.wroteData).toEqual([]);
      expect(result.executedQuery).toEqual([]);
    },
    10 * 60 * 1000,
  );

  it(
    "VARIANTE 2 (control negativo) — tenant suspendido: ninguna ruta ejecuta consulta ni escribe",
    async () => {
      counter.reset();
      const result = await hitAllRoutesWithToken(mintToken(suspendedTenantId));
      report("tenant suspendido", result);
      expect(result.unknown).toEqual([]);
      expect(result.wroteData).toEqual([]);
      expect(result.executedQuery).toEqual([]);
    },
    10 * 60 * 1000,
  );

  it(
    // Target REAL Y ALCANZABLE (tenantBId, no un tenant falso — ver v6 en el
    // historial de arriba): esta variante mide RESOLUCION (identidad sin
    // ninguna membresia, en ningun lado), nunca membresia especifica a este
    // tenant — para eso esta VARIANTE 6.
    "VARIANTE 3 (control negativo, resolucion) — identidad sin ninguna membresia, tenant real: ninguna ruta ejecuta consulta ni escribe",
    async () => {
      counter.reset();
      const result = await hitAllRoutesWithToken(
        mintToken(tenantBId, identidadSinMembresiaId),
      );
      report("sin membresia", result);
      expect(result.unknown).toEqual([]);
      expect(result.wroteData).toEqual([]);
      expect(result.executedQuery).toEqual([]);
    },
    10 * 60 * 1000,
  );

  // VARIANTE 6 — la pregunta que de verdad importa, separada de VARIANTE 1-3
  // (que solo miden si el tenant RESUELVE). Identidad CON membresia activa
  // (a tenant A), token para tenant B — real, alcanzable, con usuario local
  // real — al que esa MISMA identidad NO tiene ninguna membresia. Con
  // JwtStrategy sin acceso a datos (ver jwt.strategy.ts), nada bloquea la
  // request antes de llegar a las rutas — asi que esto SI mide si algo, en
  // algun lado, verifica membresia antes de dejar pasar una consulta.
  //
  // HOY tiene que dar 54/87 (no 87 — ver el comentario junto al snapshot de
  // control positivo mas abajo, que explica por que el numero real de rutas
  // que tocan una base con este arnes es 54, no 87) — no hay TenantGuard
  // todavia, ni un solo modulo consulta la membresia. Eso es correcto, no un
  // fallo del instrumento: es el numero de progreso real para lo que viene
  // (TenantGuard + conversion de cada modulo), no algo que se espere en
  // verde ahora. No se debilita la asercion para que pase antes de tiempo.
  it(
    "VARIANTE 6 (control negativo, membresia) — identidad con membresia a OTRO tenant, token para un tenant real sin membresia: ninguna ruta deberia ejecutar consulta ni escribir",
    async () => {
      counter.reset();
      const result = await hitAllRoutesWithToken(
        mintToken(tenantBId, identidadValidaId),
      );
      report("membresia a otro tenant", result);
      expect(result.unknown).toEqual([]);
      expect(result.wroteData).toEqual([]);
      expect(result.executedQuery).toEqual([]);
    },
    10 * 60 * 1000,
  );

  // LO QUE ESTA VARIANTE MIDE HOY, EXPLICITO PARA QUE NADIE LO LEA COMO
  // "el aislamiento de tenant A ya funciona" — no es eso:
  //
  // Con 0 modulos convertidos a tenant-aware (ver ofensas en
  // tenant-aware-baseline.json), NINGUNA de las 87 rutas usa el tenantId del
  // JWT para elegir conexion — todas caen a la conexion por defecto
  // (test_default_sentinel), sin importar que token se use. Este control
  // "positivo" hoy mide "la conexion por defecto responde y el logger la ve"
  // — no mide tenant A en absoluto.
  //
  // EL SNAPSHOT (87 rutas) ESTA DESACTUALIZADO — encontrado midiendo, no
  // supuesto. El numero real hoy es 54, no 87. Causa raiz: el snapshot se
  // construyo en una version anterior en la que JwtStrategy ADQUIRIA la
  // conexion del tenant en CADA request para validar el usuario local (ver
  // auth/jwt.strategy.ts) — esa adquisicion en si misma se contaba como
  // "esta ruta consulto al tenant", sin importar si la ruta misma tocaba
  // algo. Al sacar ese acceso de JwtStrategy (correccion real, no relacionada
  // con este snapshot), quedaron expuestas 33 rutas que NUNCA tocan una base
  // de datos con la forma en que este arnes las llama (sin body, sin query
  // params, con ":id" reemplazado por el literal "1"):
  //   - la mayoria son POST/PUT con un DTO que exige campos (@IsNotEmpty) —
  //     ValidationPipe las rechaza con 400 ANTES de llegar al controller.
  //     Confirmado leyendo create-cliente.dto.ts (nombre/direccion
  //     requeridos) como caso representativo.
  //   - las de busqueda (clientes/historial/productos/vehiculos /search)
  //     dependen de un query param ?q= que este arnes nunca manda —
  //     confirmado en ClientesService.search(): "if (!query) return []",
  //     cero consultas.
  //   - PATCH auth/users/:id/activo depende de ?value= (ParseBoolPipe) —
  //     falta, 400 antes del handler.
  //   - POST uploads/:type y catalogos/vehicular/importar-excel exigen un
  //     archivo adjunto — sin el, 400 antes de la base.
  //   - POST auth/logout y GET catalogos/plantillas/ficha-vehicular NUNCA
  //     tocan una base bajo ninguna circunstancia (logout no persiste nada;
  //     la plantilla es un archivo estatico via res.download()) — la prueba
  //     mas clara de que el 87 viejo contaba el efecto de JwtStrategy, no el
  //     de la ruta.
  // Nada de esto es sobre tenant A ni sobre la conversion — es una limitacion
  // del arnes de pruebas (no manda cuerpos/query params realistas) que
  // siempre existio, pero quedaba tapada por el efecto colateral de
  // JwtStrategy. Verificado corriendo VARIANTE 1 (tenant que no resuelve) por
  // separado: da 49, no 54 — la diferencia son exactamente las 5 rutas
  // propias de auth (me/tiendas/users), que SI rechazan para un tenant
  // invalido porque AuthService intenta adquirir esa conexion y falla —
  // enforcement real de auth, no relacionado con este snapshot.
  //
  // NO se actualiza el snapshot desde este archivo — se muestra como diff
  // para aprobacion explicita en la respuesta a el usuario. Cuando se
  // apruebe, el nuevo snapshot son las mismas rutas actuales MENOS las 33
  // listadas arriba.
  //
  // QUE ESPERAR CUANDO EMPIECE LA CONVERSION (TenantGuard + modulos): el
  // numero (una vez corregido a 54) se mantiene ahi mientras haya CUALQUIER
  // ofensa sin convertir, y solo se mueve cuando las ofensas lleguen a cero
  // — ahi es cuando esta variante empieza a medir tenant A de verdad por
  // primera vez. En ese momento el conjunto PUEDE achicarse legitimamente
  // otra vez (una ruta que antes encontraba una fila en la demo llena y
  // ahora, contra datos centinela minimos, ni siquiera ejecuta la consulta)
  // — se muestra como diff otra vez, nunca en silencio.
  it(
    "VARIANTE 4 (CONTROL POSITIVO) — tenant activo + membresia activa: el CONJUNTO exacto de rutas del snapshot debe seguir ejecutando consulta",
    async () => {
      counter.reset();
      const result = await hitAllRoutesWithToken(
        mintToken(validTenantId, identidadValidaId),
      );
      report("control positivo", result);

      const actualSet = new Set(result.executedQuery.map(routeKey));
      const expectedSet = new Set(positiveControlSnapshot.routes.map(routeKey));

      const desaparecidas = [...expectedSet].filter((k) => !actualSet.has(k));
      const nuevas = [...actualSet].filter((k) => !expectedSet.has(k));

      // eslint-disable-next-line no-console
      console.log(
        `[tenant-isolation][control positivo] snapshot=${expectedSet.size} ` +
          `actual=${actualSet.size} desaparecidas=${desaparecidas.length} nuevas=${nuevas.length}`,
      );

      // Tenant A paso de la demo real (llena de datos, mbinvautocentrorodriguez)
      // a test_tenant_a_aislamiento (filas centinela sinteticas) — algunas
      // rutas pueden pasar de 200 a 404 (ej. una ruta que antes encontraba
      // una fila especifica por id y ahora no). Eso NO es una desaparicion
      // del conjunto: una ruta que ejecuto la consulta y recibio 404 sigue
      // contando como "ejecuto" (ver executedQuery, que no filtra por
      // status). Solo una ruta que DEJA DE CONSULTAR es una desaparicion
      // real — reportado, nunca corregido en silencio (nunca se actualiza
      // el snapshot desde este archivo).
      if (desaparecidas.length > 0) {
        throw new Error(
          "EL CONJUNTO DE RUTAS QUE CONSULTAN BAJO TENANT VALIDO CAMBIO — " +
            `${desaparecidas.length} ruta(s) que SI consultaban ya no lo hacen: ` +
            `${JSON.stringify(desaparecidas)}. Puede ser el instrumento roto, o puede ser ` +
            "un efecto real del cambio de base de tenant A (demo real -> sentinela sintetica). " +
            "Revisar CADA ruta antes de decidir si el snapshot se actualiza — nunca bajarlo en silencio.",
        );
      }

      if (nuevas.length > 0) {
        // eslint-disable-next-line no-console
        console.log(
          "[tenant-isolation][control positivo] rutas NUEVAS que ahora consultan (no es falla, se reporta):",
          JSON.stringify([...actualSet].filter((k) => !expectedSet.has(k))),
        );
      }
    },
    10 * 60 * 1000,
  );

  // VARIANTE 5 — la unica que mide el requisito real: que el tenant A vea
  // SOLO datos de A y el tenant B SOLO datos de B. Las variantes 1-4 son
  // necesarias pero NO suficientes — miden "se rechaza un tenant
  // irresoluble" y "el instrumento esta vivo", nunca "los datos de dos
  // tenants validos no se cruzan".
  //
  // Ya NO esta gateada por configuracion opcional: las tres bases sentinela
  // son un REQUISITO del archivo completo (si no existen, beforeAll ya
  // fallo antes de llegar aca) — asi que las DOS direcciones corren siempre.
  it(
    "VARIANTE 5 — dos tenants validos: el token de A solo debe ver datos de A, y el de B solo datos de B",
    async () => {
      // ORDER BY tarjeta, no "id": taller_clientes tiene tarjeta como PRIMARY
      // KEY (varchar), nunca tuvo una columna id — bug real encontrado
      // corriendo esto por primera vez contra datos reales (con las tres
      // bases vacias, esta consulta nunca se habia ejecutado de verdad).
      const [clientesA] = await dbA.query<mysql.RowDataPacket[]>(
        "SELECT tarjeta, nombre FROM taller_clientes ORDER BY tarjeta LIMIT 1",
      );
      const [clientesB] = await dbB.query<mysql.RowDataPacket[]>(
        "SELECT tarjeta, nombre FROM taller_clientes ORDER BY tarjeta LIMIT 1",
      );
      if (clientesA.length === 0 || clientesB.length === 0) {
        throw new Error(
          "Tenant A o tenant B no tienen ninguna fila en taller_clientes — " +
            "no hay ground truth con el que comparar. Ver las filas centinela requeridas.",
        );
      }
      const clienteA = clientesA[0] as any;
      const clienteB = clientesB[0] as any;

      if (clienteA.tarjeta === clienteB.tarjeta) {
        throw new Error(
          "El ground truth de A y B tiene la MISMA tarjeta — las dos bases no son " +
            "distinguibles entre si, esta variante no puede medir nada asi.",
        );
      }

      const tokenA = mintToken(validTenantId, identidadValidaId);
      const tokenB = mintToken(tenantBId, identidadBId);

      const respA = await httpRequest(port, "GET", "/api/v1/clientes", {
        Authorization: `Bearer ${tokenA}`,
      });
      // eslint-disable-next-line no-console
      console.log(
        `[tenant-isolation][VARIANTE 5][direccion A] status=${respA.status} ` +
          `clienteA.tarjeta=${clienteA.tarjeta} clienteB.tarjeta=${clienteB.tarjeta}`,
      );
      expect(respA.status).toBe(200);
      expect(respA.body).not.toContain(clienteB.tarjeta);
      expect(respA.body).toContain(clienteA.tarjeta);

      const respB = await httpRequest(port, "GET", "/api/v1/clientes", {
        Authorization: `Bearer ${tokenB}`,
      });
      // eslint-disable-next-line no-console
      console.log(
        `[tenant-isolation][VARIANTE 5][direccion B] status=${respB.status} ` +
          `clienteA.tarjeta=${clienteA.tarjeta} clienteB.tarjeta=${clienteB.tarjeta}`,
      );
      expect(respB.status).toBe(200);
      expect(respB.body).not.toContain(clienteA.tarjeta);
      expect(respB.body).toContain(clienteB.tarjeta);
    },
    60000,
  );
});

// Prueba de la prueba: si el enganche del logger se rompe (por ejemplo,
// porque quedo apuntando a una DataSource vieja tras construir el registro
// de conexiones), el control positivo tiene que detectarlo comparando contra
// el snapshot — no reportar exito. Sintetico, no necesita la app real.
describe("el control positivo detecta un instrumento roto (comparando contra el snapshot)", () => {
  function checkAgainstSnapshot(actualRoutes: RouteKey[]): string[] {
    const actualSet = new Set(actualRoutes.map(routeKey));
    const expectedSet = new Set(positiveControlSnapshot.routes.map(routeKey));
    return [...expectedSet].filter((k) => !actualSet.has(k));
  }

  it("con un logger que nunca cuenta nada (0 rutas), TODAS las del snapshot aparecen como desaparecidas", () => {
    const desaparecidas = checkAgainstSnapshot([]);
    expect(desaparecidas.length).toBe(positiveControlSnapshot.routes.length);
  });

  it("con un logger que cuenta la MITAD (instrumento medio muerto), la mitad aparece como desaparecida — un umbral fijo no atrapaba esto", () => {
    const mitad = positiveControlSnapshot.routes.slice(
      0,
      Math.floor(positiveControlSnapshot.routes.length / 2),
    );
    const desaparecidas = checkAgainstSnapshot(mitad);
    expect(desaparecidas.length).toBeGreaterThan(0);
    // El punto no es un numero fijo (eso fue lo que fallaba antes: con el
    // umbral viejo ">=40" y el snapshot en 87 rutas, la mitad (~43) HUBIERA
    // PASADO igual). El punto es que la mitad, SEA CUAL SEA el tamaño actual
    // del snapshot, tiene que aparecer como desaparecida — eso es lo que un
    // umbral fijo no puede garantizar y una comparacion contra el conjunto
    // EXACTO si garantiza.
    expect(mitad.length).toBeGreaterThan(0);
    expect(mitad.length).toBeLessThan(positiveControlSnapshot.routes.length);
  });

  it("con el conjunto completo, no hay desaparecidas", () => {
    const desaparecidas = checkAgainstSnapshot(positiveControlSnapshot.routes);
    expect(desaparecidas).toEqual([]);
  });
});
