// Playwright – pruebas e2e y de control de acceso para NARA
// Ajustado a nara-web y nara-api, rama fix/trl-1-4 (reporte TRL 2026-10-10 y sus correcciones).
// Lo que falle se registra, no se oculta.
//
// Instalación (una vez):
//   npm init -y && npm i -D @playwright/test && npx playwright install
// Variables de entorno (una cuenta de prueba por rol):
//   BASE_URL            http://localhost:3102 (laboratorio) o https://www.proyectonara.com (producción, hace de staging)
//   ADMIN_EMAIL / ADMIN_PASS, EXPERTO_EMAIL / EXPERTO_PASS, CLINICO_EMAIL / CLINICO_PASS,
//   PACIENTE_EMAIL / PACIENTE_PASS, OBSERVADOR_EMAIL / OBSERVADOR_PASS
//   El experto y el clínico deben ser del mismo territorio (el flujo 3.2 lo necesita).
// Opcionales:
//   ENTORNO=produccion  no cierra sesiones compartidas (T-03), no edita fichas que ya existían (4.6) y no
//                       genera alertas de crisis sin E2E_ALERTAS=1 (C-03 y el paso 5 del 3.2)
//   API_URL             la API directa (https://nara-api-wyvw.onrender.com) para las llamadas sin navegador;
//                       evita el desafío de Vercel (403 X-Vercel-Mitigated) que no afecta al navegador
//   E2E_ALERTAS=1       en producción, corre los casos que crean una alerta de crisis (avisar antes a los admins)
//   E2E_LOGOUT=1        en producción, corre T-03 (el logout revoca todas las sesiones de la cuenta)
//   TRL_PREFIX          prefijo de los datos que crea la suite (por defecto TRL-QA-E2E-<fecha>-)
//   EVID_DIR            carpeta donde se guardan las respuestas de TEO (3.3) y el resumen de limpieza
//   AUTH_SECRET_TEST    el AUTH_SECRET del entorno probado, solo para probar un token vencido
//   RUN_LOCKOUT=1 con LOCK_EMAIL y LOCK_PASS: prueba de bloqueo por intentos (BLOQUEA esa cuenta)
// Ejecución:
//   npx playwright test e2e.spec.js --reporter=html
// El reporte HTML, los videos y las trazas quedan en playwright-report/ y test-results/.
// Los datos creados (personas, territorio, cuentas) llevan TRL_PREFIX y se archivan o desactivan al final.

const crypto = require('crypto');
const { test, expect, request: pwRequest } = require('@playwright/test');

const BASE_URL = process.env.BASE_URL;
if (!BASE_URL) throw new Error('Falta BASE_URL, por ejemplo https://staging.ejemplo.com');
// Llamadas sin navegador: a la API directa si se definió (en la web, /api/* es un rewrite a esa misma API).
const API_BASE = (process.env.API_URL || BASE_URL).replace(/\/$/, '');
const PROD = process.env.ENTORNO === 'produccion';
const ALERTAS = !PROD || process.env.E2E_ALERTAS === '1';

test.use({ baseURL: BASE_URL, video: 'on', trace: 'on', screenshot: 'on' });

// Si Vercel está desafiando a los clientes que no son navegador, la suite daría fallos falsos: se detiene.
test.beforeAll(async () => {
  const ctx = await pwRequest.newContext({ baseURL: BASE_URL });
  const res = await ctx.get('/ingreso');
  const mitigado = res.headers()['x-vercel-mitigated'];
  await ctx.dispose();
  if (res.status() === 403 && mitigado) {
    throw new Error(`La web responde 403 con X-Vercel-Mitigated: ${mitigado}. Revisar el firewall de Vercel antes de correr la suite.`);
  }
});

// Pantalla a la que lleva cada rol después de entrar (src/lib/db/roles.ts)
const ROLES = {
  admin:      { href: '/inicio',     email: process.env.ADMIN_EMAIL,      pass: process.env.ADMIN_PASS },
  experto:    { href: '/experto',    email: process.env.EXPERTO_EMAIL,    pass: process.env.EXPERTO_PASS },
  clinico:    { href: '/clinico',    email: process.env.CLINICO_EMAIL,    pass: process.env.CLINICO_PASS },
  paciente:   { href: '/paciente',   email: process.env.PACIENTE_EMAIL,   pass: process.env.PACIENTE_PASS },
  observador: { href: '/observador', email: process.env.OBSERVADOR_EMAIL, pass: process.env.OBSERVADOR_PASS },
};
const NOMBRES = Object.keys(ROLES);
const conCredenciales = (rol) => Boolean(ROLES[rol].email && ROLES[rol].pass);
const primerRol = () => NOMBRES.find(conCredenciales);

// GET → roles que deben poder leer la ruta (según requireUser en el código, commit 5ad3924).
// Un rol permitido no debe recibir 401 ni 403; un rol no permitido debe recibir 403.
const TODOS = ['admin', 'experto', 'clinico', 'paciente', 'observador'];
const RUTAS = [
  ['/api/auth/me',       TODOS],
  ['/api/app-state',     TODOS],
  ['/api/accounts',      ['admin', 'clinico']], // el clínico recibe solo id y último ingreso de sus pacientes (H-002)
  ['/api/patients',      ['admin', 'experto', 'clinico']],
  ['/api/patients/me',   ['admin', 'clinico', 'paciente']],
  ['/api/people',        ['admin', 'experto', 'clinico', 'observador']],
  ['/api/territories',   ['admin', 'experto', 'clinico', 'observador']],
  ['/api/experts',       ['admin', 'experto', 'clinico', 'observador']],
  ['/api/flags',         ['admin', 'experto', 'clinico']],
  ['/api/worklists',     ['admin', 'experto', 'clinico']],
  ['/api/assets',        ['admin']],
  ['/api/health/db',     ['admin']],
];

// Elemento estable del panel de cada rol: el test espera verlo y comprueba que siga ahí 3 s después
// (H-002 del reporte 2026-10-10: antes bastaba con que la URL cambiara, y el clínico era expulsado después).
const PANEL = {
  admin: (page) => page.getByRole('link', { name: /Operaciones de la semana/ }),
  experto: (page) => page.getByRole('button', { name: '+ Nueva persona' }),
  // H-010: el botón dice «Cola de crisis» o «Crisis · atender ahora» según haya crisis pendientes;
  // el enlace «Mis pacientes» de la navegación está siempre.
  clinico: (page) => page.getByRole('navigation').getByRole('link', { name: 'Mis pacientes' }),
  paciente: (page) => page.locator('[data-screen-label="Paciente"], [data-testid="paciente-consentimiento"]').first(),
  observador: (page) => page.getByTestId('observador-tablero'),
};

async function loginUI(page, cred, rol) {
  await page.goto('/ingreso');
  await page.getByLabel('Correo').fill(cred.email);
  await page.getByLabel('Contraseña').fill(cred.pass);
  await page.getByRole('button', { name: 'Ingresar' }).click();
  await page.waitForURL((u) => !u.pathname.startsWith('/ingreso'), { timeout: 15000 });
  if (rol && PANEL[rol]) {
    await expect(PANEL[rol](page), `panel de ${rol} visible`).toBeVisible({ timeout: 20000 });
    const url = page.url();
    await page.waitForTimeout(3000);
    expect(page.url(), `${rol} sigue en su panel 3 s después`).toBe(url);
    await expect(PANEL[rol](page)).toBeVisible();
  }
}

/** Primer elemento visible con ese texto (la tabla de usuarios tiene una versión móvil oculta). */
const visible = (loc) => loc.filter({ visible: true }).first();

const HOY = new Date().toISOString().slice(0, 10).replace(/-/g, '');
const PREFIJO = process.env.TRL_PREFIX || `TRL-QA-E2E-${HOY}-`;
const SUFIJO = String(Date.now()).slice(-6);
const creados = { personas: [], territorios: [], cuentas: [] };

async function sesionAPI(rol) {
  const ctx = await pwRequest.newContext({ baseURL: API_BASE });
  const res = await ctx.post('/api/auth/login', { data: { email: ROLES[rol].email, password: ROLES[rol].pass } });
  expect(res.ok(), `login por API del rol ${rol}`).toBeTruthy();
  // En http (laboratorio) la cookie Secure no se reenvía sola: se pasa como cabecera.
  const setCookie = res.headers()['set-cookie'] || '';
  const sid = setCookie.match(/nara_sid=([^;]+)/);
  if (sid && !API_BASE.startsWith('https')) {
    const c = await contextoConCookie(sid[1]);
    c.__setCookie = setCookie;
    return c;
  }
  ctx.__setCookie = setCookie;
  return ctx;
}

async function contextoConCookie(valor) {
  return pwRequest.newContext({ baseURL: API_BASE, extraHTTPHeaders: { Cookie: `nara_sid=${valor}` } });
}

async function cookieDeSesion(ctx) {
  const estado = await ctx.storageState();
  const guardada = estado.cookies.find((c) => c.name === 'nara_sid');
  if (guardada) return guardada;
  // En http, Playwright no guarda la cookie Secure: se lee de la cabecera Set-Cookie del login.
  const h = ctx.__setCookie || '';
  const m = h.match(/nara_sid=([^;]+)/);
  if (!m) return undefined;
  const ss = (h.match(/SameSite=(\w+)/i) || [])[1];
  return { name: 'nara_sid', value: m[1], httpOnly: /HttpOnly/i.test(h), secure: /;\s*Secure/i.test(h), sameSite: ss ? ss[0].toUpperCase() + ss.slice(1).toLowerCase() : undefined };
}

/* ---------------- Casos transversales (01_casos_funcionales_uat.md) ---------------- */

for (const rol of NOMBRES) {
  test(`T-01 login válido (${rol}) lleva a ${ROLES[rol].href}`, async ({ page }) => {
    test.skip(!conCredenciales(rol), `Define ${rol.toUpperCase()}_EMAIL y ${rol.toUpperCase()}_PASS`);
    await loginUI(page, ROLES[rol], rol);
    await expect(page).toHaveURL(new RegExp(ROLES[rol].href + '(/|\\?|$)'));
  });
}

test('T-02 login inválido muestra un mensaje genérico y no entra', async ({ page }) => {
  await page.goto('/ingreso');
  await page.getByLabel('Correo').fill(`trl-qa-${HOY}-t02-noexiste@trl-qa.invalid`);
  await page.getByLabel('Contraseña').fill('incorrecta');
  await page.getByRole('button', { name: 'Ingresar' }).click();
  await expect(page.getByText('Correo o contraseña incorrectos.')).toBeVisible();
  await expect(page).toHaveURL(/\/ingreso/);
  const cuerpo = (await page.locator('body').innerText()).toLowerCase();
  expect(cuerpo).not.toContain('no existe');
});

test('T-03 tras cerrar sesión la API ya no reconoce al usuario', async () => {
  const rol = primerRol();
  test.skip(!rol, 'Define al menos una cuenta de prueba');
  // El logout revoca todas las sesiones de la cuenta: en producción sacaría a quien use la misma cuenta prueba.*.
  test.skip(PROD && process.env.E2E_LOGOUT !== '1', 'En producción solo con E2E_LOGOUT=1 (cierra todas las sesiones de la cuenta)');
  const ctx = await sesionAPI(rol);
  expect((await ctx.get('/api/auth/me')).status()).toBe(200);
  await ctx.post('/api/auth/logout');
  expect((await ctx.get('/api/auth/me')).status()).toBe(401);
  await ctx.dispose();
});

/* ---------------- S-1: la sesión no se puede falsificar ---------------- */

test('S-1 una cookie con el id de una cuenta (sin firma) no inicia sesión', async () => {
  for (const valor of ['admin', 'admin.9999999999.firmafalsa']) {
    const ctx = await contextoConCookie(valor);
    expect.soft((await ctx.get('/api/auth/me')).status(), `cookie nara_sid=${valor}`).toBe(401);
    await ctx.dispose();
  }
});

test('S-1 una firma alterada no inicia sesión', async () => {
  const rol = primerRol();
  test.skip(!rol, 'Define al menos una cuenta de prueba');
  const ctx = await sesionAPI(rol);
  const cookie = await cookieDeSesion(ctx);
  expect(cookie, 'la sesión debe entregar la cookie nara_sid').toBeTruthy();
  const ultimo = cookie.value.slice(-1);
  const alterado = cookie.value.slice(0, -1) + (ultimo === 'A' ? 'B' : 'A');
  const falso = await contextoConCookie(alterado);
  expect((await falso.get('/api/auth/me')).status()).toBe(401);
  await falso.dispose();
  await ctx.dispose();
});

test('S-1 un token vencido con firma válida no inicia sesión', async () => {
  const secreto = process.env.AUTH_SECRET_TEST;
  const rol = primerRol();
  test.skip(!secreto || !rol, 'Define AUTH_SECRET_TEST (el secreto del staging) y una cuenta de prueba');
  const ctx = await sesionAPI(rol);
  const yo = await (await ctx.get('/api/auth/me')).json();
  const exp = Math.floor(Date.now() / 1000) - 60;
  const carga = `${yo.user.id}.${exp}`;
  const firma = crypto.createHmac('sha256', secreto).update(carga).digest('base64url');
  const vencido = await contextoConCookie(`${carga}.${firma}`);
  expect((await vencido.get('/api/auth/me')).status()).toBe(401);
  await vencido.dispose();
  await ctx.dispose();
});

test('S-1 la cookie de sesión tiene HttpOnly, SameSite=Lax y Secure (en https)', async () => {
  const rol = primerRol();
  test.skip(!rol, 'Define al menos una cuenta de prueba');
  const ctx = await sesionAPI(rol);
  const cookie = await cookieDeSesion(ctx);
  expect(cookie).toBeTruthy();
  expect(cookie.httpOnly).toBe(true);
  expect(cookie.sameSite).toBe('Lax');
  if (API_BASE.startsWith('https://')) expect(cookie.secure).toBe(true);
  await ctx.dispose();
});

/* ---------------- S-2: rutas protegidas por sesión y rol ---------------- */

test('S-2 sin sesión las rutas responden 401 (y /auth/me dice «sin sesión» sin error)', async () => {
  const ctx = await pwRequest.newContext({ baseURL: API_BASE });
  for (const [ruta] of RUTAS) {
    const res = await ctx.get(ruta);
    if (ruta === '/api/auth/me') {
      // H-015: sin cookie no hay nada que validar → 200 con user null (no deja errores en la consola de /ingreso).
      expect.soft(res.status(), 'sin sesión → /api/auth/me').toBe(200);
      expect.soft((await res.json()).user, '/api/auth/me sin sesión').toBeNull();
    } else {
      expect.soft(res.status(), `sin sesión → ${ruta}`).toBe(401);
    }
  }
  // H-002: la ruta nueva de alertas tampoco acepta escrituras sin sesión.
  expect.soft((await ctx.post('/api/alerts', { data: { pid: 'x', sev: 'crisis' } })).status(), 'sin sesión → POST /api/alerts').toBe(401);
  await ctx.dispose();
});

for (const rol of NOMBRES) {
  test(`S-2 matriz de rutas con el rol ${rol}`, async () => {
    test.skip(!conCredenciales(rol), `Define ${rol.toUpperCase()}_EMAIL y ${rol.toUpperCase()}_PASS`);
    const ctx = await sesionAPI(rol);
    for (const [ruta, permitidos] of RUTAS) {
      const estado = (await ctx.get(ruta)).status();
      if (permitidos.includes(rol)) {
        expect.soft([401, 403], `${rol} → ${ruta} (permitido, recibió ${estado})`).not.toContain(estado);
      } else {
        expect.soft(estado, `${rol} → ${ruta} (no permitido)`).toBe(403);
      }
    }
    await ctx.dispose();
  });
}

/* ---------------- S-3: solo el administrador crea cuentas ---------------- */

// Con prefijo y dominio .invalid: si una prueba negativa fallara y creara la cuenta, la limpieza la desactiva.
const CUENTA_PRUEBA = () => ({
  name: `${PREFIJO}S3`,
  email: `trl-qa+s3${Date.now()}@trl-qa.invalid`,
  role: 'Administrador',
  password: 'ClavePrueba123',
});

test('S-3 sin sesión no se puede crear una cuenta', async () => {
  const ctx = await pwRequest.newContext({ baseURL: API_BASE });
  expect((await ctx.post('/api/accounts', { data: CUENTA_PRUEBA() })).status()).toBe(401);
  await ctx.dispose();
});

for (const rol of NOMBRES.filter((r) => r !== 'admin')) {
  test(`S-3 el rol ${rol} no puede crear cuentas`, async () => {
    test.skip(!conCredenciales(rol), `Define ${rol.toUpperCase()}_EMAIL y ${rol.toUpperCase()}_PASS`);
    const ctx = await sesionAPI(rol);
    expect((await ctx.post('/api/accounts', { data: CUENTA_PRUEBA() })).status()).toBe(403);
    await ctx.dispose();
  });
}

/* ---------------- S-5 y A03 ---------------- */

test('S-5 los intentos repetidos se frenan (BLOQUEA la cuenta LOCK_EMAIL)', async () => {
  test.skip(process.env.RUN_LOCKOUT !== '1' || !process.env.LOCK_EMAIL || !process.env.LOCK_PASS,
    'Define RUN_LOCKOUT=1, LOCK_EMAIL y LOCK_PASS (una cuenta que se pueda bloquear)');
  const ctx = await pwRequest.newContext({ baseURL: API_BASE });
  for (let i = 0; i < 5; i++) {
    await ctx.post('/api/auth/login', { data: { email: process.env.LOCK_EMAIL, password: 'incorrecta' + i } });
  }
  const sexto = await ctx.post('/api/auth/login', { data: { email: process.env.LOCK_EMAIL, password: process.env.LOCK_PASS } });
  expect(sexto.status(), 'tras 5 intentos fallidos, ni la clave correcta debe entrar').not.toBe(200);
  await ctx.dispose();
});

// Sin bloquear ninguna cuenta real: un correo inexistente con prefijo (el contador vence solo a los 15 min).
// SPEC-03 (FR-03.2): el 5.º fallo ya bloquea y responde 429, igual que con una cuenta que existe (ficha 5.10 del reporte -7).
test('S-5 un correo inexistente se frena en el quinto intento con el mismo mensaje que una cuenta real', async () => {
  const ctx = await pwRequest.newContext({ baseURL: API_BASE });
  const correo = `trl-qa-${HOY}-s5-${SUFIJO}@trl-qa.invalid`;
  for (let i = 1; i <= 4; i++) {
    const r = await ctx.post('/api/auth/login', { data: { email: correo, password: 'incorrecta' + i } });
    expect.soft(r.status(), `intento ${i}`).toBe(401);
    expect.soft((await r.json().catch(() => ({}))).error, `intento ${i}: mensaje genérico`).toBe('Correo o contraseña incorrectos.');
  }
  for (const i of [5, 6]) {
    const r = await ctx.post('/api/auth/login', { data: { email: correo, password: 'incorrecta' + i } });
    expect.soft(r.status(), `intento ${i}: frenado`).toBe(429);
    expect.soft((await r.json().catch(() => ({}))).error, `intento ${i}: mismo mensaje que el bloqueo de una cuenta real`).toBe('Demasiados intentos. Espere unos minutos e intente de nuevo.');
  }
  await ctx.dispose();
});

test('A03 un objeto en lugar de texto en el login se rechaza con 400 (inyección en MongoDB)', async () => {
  const ctx = await pwRequest.newContext({ baseURL: API_BASE });
  const res = await ctx.post('/api/auth/login', { data: { email: { $ne: '' }, password: { $ne: '' } } });
  expect(res.status(), 'H-007: petición inválida, no error del servidor').toBe(400);
  await ctx.dispose();
});

/* ---------------- A05: cabeceras de seguridad ---------------- */

test('A05 cabeceras de seguridad en la pantalla de ingreso', async () => {
  const ctx = await pwRequest.newContext({ baseURL: BASE_URL });
  const res = await ctx.get('/ingreso');
  const h = res.headers();
  test.info().annotations.push({ type: 'cabeceras', description: JSON.stringify(h, null, 2) });
  if (BASE_URL.startsWith('https://')) expect.soft(h['strict-transport-security'], 'Strict-Transport-Security').toBeTruthy();
  expect.soft(h['x-content-type-options'], 'X-Content-Type-Options').toBe('nosniff');
  expect.soft(h['x-frame-options'] || h['content-security-policy'], 'X-Frame-Options o Content-Security-Policy').toBeTruthy();
  expect.soft(h['content-security-policy'], 'Content-Security-Policy').toBeTruthy();
  expect.soft(h['x-powered-by'], 'sin X-Powered-By en la web').toBeUndefined();
  const api = (await ctx.get('/api/health')).headers();
  expect.soft(api['x-powered-by'], 'sin X-Powered-By en la API').toBeUndefined();
  expect.soft(api['x-content-type-options'], 'nosniff en la API').toBe('nosniff');
  await ctx.dispose();
});

test('H-014 /api/health identifica la versión desplegada', async () => {
  const ctx = await pwRequest.newContext({ baseURL: BASE_URL });
  const h = await (await ctx.get('/api/health')).json();
  test.info().annotations.push({ type: 'commit', description: String(h.commit) });
  expect(h.commit, 'commit desplegado').toBeTruthy();
  await ctx.dispose();
});

/* ---------------- Salud de la base (criterio 4.2) ---------------- */

test('4.2 /api/health/db responde ok con todas las colecciones (administrador)', async () => {
  test.skip(!conCredenciales('admin'), 'Define ADMIN_EMAIL y ADMIN_PASS');
  const ctx = await sesionAPI('admin');
  const res = await ctx.get('/api/health/db');
  expect(res.status()).toBe(200);
  const cuerpo = await res.json();
  expect(cuerpo.ok).toBe(true);
  expect(cuerpo.missing).toEqual([]);
  await ctx.dispose();
});

/* ---------------- Ayudas de los flujos por rol ---------------- */

const RIESGO = (perfil) => {
  const n = parseInt(String(perfil || '').replace(/^P/, ''), 10);
  return Number.isFinite(n) && n >= 1 && n <= 15 ? Math.floor((n - 1) / 3) : -1;
};

/** Cierra el aviso modal de la app («Entendido») si apareció. */
async function entendido(page) {
  const b = page.getByRole('button', { name: 'Entendido' });
  // El aviso entra con una animación: se espera hasta 3 s a que aparezca.
  const aparecio = await b.waitFor({ state: 'visible', timeout: 3000 }).then(() => true, () => false);
  if (aparecio) await b.click();
  await expect(b).toBeHidden();
}

async function erroresDeConsola(page) {
  const errores = [];
  page.on('console', (m) => { if (m.type() === 'error') errores.push(m.text().slice(0, 200)); });
  page.on('response', (r) => { if (r.url().includes('/api/') && r.status() >= 400) errores.push(`HTTP ${r.status()} ${r.request().method()} ${new URL(r.url()).pathname}`); });
  return errores;
}

/** F-01, F-02 y F-04: el experto registra una persona, firma el consentimiento, responde el cuestionario y guarda la visita.
 *  Respuestas fijas: PHQ-9 = 8 (Leve) y capacidad digital = 11 (Alta) → perfil P06. Devuelve la persona guardada. */
async function registrarHastaEvaluacion(page, nombre, correo) {
  await page.getByRole('button', { name: '+ Nueva persona' }).click();
  const d = page.getByRole('dialog', { name: 'Nueva persona' });
  await d.getByLabel('Nombre', { exact: true }).fill(nombre);
  await d.getByLabel('Apellido').fill('Prueba E2E');
  await d.getByLabel('Fecha de nacimiento').fill('1980-05-01');
  await d.getByLabel('Género').selectOption({ index: 1 });
  await d.getByLabel('Estado civil').selectOption({ index: 1 });
  await d.getByPlaceholder('3xx xxx xxxx').fill('[dato real omitido]');
  await d.getByLabel('Correo', { exact: true }).fill(correo);
  await d.getByLabel('Vereda o barrio').fill('Vereda Prueba');
  await d.getByLabel('Estrato').selectOption({ index: 2 });
  await d.getByRole('button', { name: 'Agregar y empezar visita' }).click();
  await page.waitForURL(/\/consentimiento$/);
  // Consentimiento: los tres puntos obligatorios y la firma
  for (const t of ['Participar en la evaluación y en el seguimiento', 'Tratamiento de sus datos personales', 'El equipo clínico le contactará']) {
    await page.getByText(t).first().click();
  }
  await page.locator('canvas').first().scrollIntoViewIfNeeded();
  const box = await page.locator('canvas').first().boundingBox();
  await page.mouse.move(box.x + 20, box.y + box.height / 2);
  await page.mouse.down();
  for (let k = 1; k <= 10; k++) await page.mouse.move(box.x + 20 + (k * (box.width - 40)) / 10, box.y + box.height / 2 + (k % 2 ? -15 : 15));
  await page.mouse.up();
  // H-008: la evidencia no muestra un GPS fijo ni afirma que está dentro del territorio.
  await expect(page.getByText(/4,6378° N|4,5339° N/), 'H-008: sin GPS fijo').toHaveCount(0);
  await expect(page.getByText('Dentro del territorio asignado'), 'H-008: sin afirmación fija').toHaveCount(0);
  await page.getByRole('button', { name: 'Continuar a la evaluación' }).click();
  await page.waitForURL(/\/evaluacion$/);
}

async function registrarYEvaluar(page, nombre, correo) {
  await registrarHastaEvaluacion(page, nombre, correo);
  // F-02: PHQ-9 (8 × «Varios días» y la pregunta 9 en «Nunca»), capacidad digital al máximo, contexto del sismo
  for (let i = 0; i < 8; i++) await page.getByRole('button', { name: '1 · Varios días' }).nth(i).click();
  await page.getByRole('button', { name: '0 · Nunca' }).nth(8).click();
  await page.getByRole('button', { name: 'Confirmar las revisadas' }).click();
  await page.getByRole('button', { name: /^Capacidad digital/ }).click();
  for (const n of ['Smartphone propio', 'Estable', 'Apps y videollamadas', 'Cómoda']) await page.getByRole('button', { name: n, exact: true }).click();
  await page.getByRole('button', { name: 'Sí', exact: true }).nth(0).click();
  await page.getByRole('button', { name: 'Sí', exact: true }).nth(1).click();
  await page.getByRole('button', { name: 'Confirmar las revisadas' }).click();
  await page.getByRole('button', { name: /^Contexto del sismo/ }).click();
  await page.getByRole('button', { name: 'Ninguno', exact: true }).click();
  await page.getByRole('button', { name: 'No', exact: true }).click();
  await page.getByRole('button', { name: 'Confirmar las revisadas' }).click();
  await expect(page.getByText('15 de 15 preguntas confirmadas')).toBeVisible();
  await page.getByRole('button', { name: 'Calcular resultado' }).click();
  await page.waitForURL(/\/resultado$/);
  // F-02: se calculan riesgo y capacidad digital; F-04: se asigna 1 de 15 perfiles y su ruta
  await expect(page.getByText('8 de 27'), 'F-02: PHQ-9 = 8').toBeVisible();
  await expect(page.getByText('11 de 11'), 'F-02: capacidad digital = 11').toBeVisible();
  await expect(page.getByText(/P06 · Leve × digital alta/), 'F-04: perfil P06').toBeVisible();
  await expect(page.getByText('Ruta asignada').first(), 'F-04: ruta').toBeVisible();
  await page.getByRole('button', { name: 'Guardar visita' }).first().click();
  await page.waitForURL((u) => u.pathname === '/experto', { timeout: 20000 });
  const api = await sesionAPI('experto');
  let persona;
  await expect.poll(async () => {
    const r = await (await api.get('/api/people?q=' + encodeURIComponent(nombre))).json();
    persona = (r.people || []).find((x) => x.name.startsWith(nombre));
    return persona && persona.profile;
  }, { message: 'la evaluación quedó guardada en la API', timeout: 15000 }).toBe('P06');
  expect(persona.evalPhq, 'PHQ-9 guardado').toBe(8);
  expect(persona.evalDig, 'digital guardado').toBe(11);
  // H-004 (SPEC-002 FR-004): «Guardar visita» solo vuelve a la lista con el consentimiento ya en el servidor.
  const cons = ((await (await api.get('/api/app-state')).json()).slices?.consents || {})[persona.id];
  expect(cons?.visitaAt && cons?.firmado, 'H-004: consentimiento firmado guardado al volver a la lista').toBeTruthy();
  creados.personas.push(persona.id);
  await api.dispose();
  return persona;
}

/* ---------------- Flujos por rol (criterios 4.4 a 4.8) ---------------- */
// Crean datos solo con TRL_PREFIX y en el territorio propio; al final se archivan (ver afterAll).

test('4.4 Flujo Paciente: consentimiento, su ruta y su resumen; solo sus datos (T-01, P-01, P-02, P-03, P-06)', async ({ page }) => {
  test.skip(!conCredenciales('paciente'), 'Faltan credenciales del paciente');
  const errores = await erroresDeConsola(page);
  await loginUI(page, ROLES.paciente, 'paciente');
  const api = await sesionAPI('paciente');
  const yo = (await (await api.get('/api/auth/me')).json()).user;
  // P-01: en el primer ingreso se presenta el consentimiento antes de registrar nada
  const gate = page.getByTestId('paciente-consentimiento');
  if (await gate.isVisible()) {
    await expect(gate.getByText('Antes de empezar')).toBeVisible();
    await gate.getByRole('button', { name: 'Acepto y continúo' }).click();
  }
  await expect(gate).toBeHidden();
  await expect.poll(async () => {
    const st = await (await api.get('/api/app-state')).json();
    return st.slices?.consents?.[yo.patientId]?.appAt || null;
  }, { message: 'P-01: el consentimiento quedó registrado con fecha', timeout: 15000 }).toBeTruthy();
  // P-02: ve los servicios de su ruta
  const herramientas = page.getByRole('region', { name: 'Herramientas' });
  await expect(herramientas, 'P-02: su ruta').toBeVisible();
  expect(await herramientas.getByRole('button').count(), 'P-02: al menos un servicio').toBeGreaterThan(0);
  // P-03: resumen en lenguaje simple
  await page.goto('/paciente/resumen');
  await expect(page).toHaveURL(/\/paciente\/resumen/);
  await expect(page.locator('body')).not.toContainText('Sin módulos activos');
  // P-06: solo sus datos
  expect((await api.get('/api/patients/me')).status()).toBe(200);
  expect((await api.get('/api/patients')).status(), 'P-06: el paciente no lista pacientes').toBe(403);
  const st = await (await api.get('/api/app-state')).json();
  for (const k of ['notes', 'referrals', 'accessLog']) expect(Object.keys(st.slices || {}), `app-state sin ${k}`).not.toContain(k);
  expect(Object.keys(st.slices.consents || {}).every((k) => k === yo.patientId), 'P-06: solo su consentimiento').toBeTruthy();
  // H-003 (SPEC-003 FR-001): de recursos.people solo su propia entrada.
  expect(Object.keys(st.slices.recursos?.people || {}).every((k) => [yo.id, yo.patientId].includes(k)), 'H-003: solo su entrada de recursos.people').toBeTruthy();
  expect(Object.keys(st.slices.notifs || {}).every((k) => k === yo.id), 'H-003: solo sus avisos').toBeTruthy();
  expect(errores, 'sin errores en la consola ni respuestas de error').toEqual([]);
  await api.dispose();
});

test('4.5 Flujo Experto de campo: registra, evalúa y asigna perfil en su territorio (T-01, F-01, F-02, F-04, F-06)', async ({ page }) => {
  test.skip(!conCredenciales('experto'), 'Faltan credenciales del experto');
  const errores = await erroresDeConsola(page);
  await loginUI(page, ROLES.experto, 'experto');
  const persona = await registrarYEvaluar(page, `${PREFIJO}Exp-${SUFIJO}`, `trl-qa+exp${SUFIJO}@trl-qa.invalid`);
  const api = await sesionAPI('experto');
  const yo = (await (await api.get('/api/auth/me')).json()).user;
  expect(persona.terr, 'F-01: asociada a su territorio').toBe(yo.terr);
  // H-009: el clínico de la ficha es la cuenta Clínico del territorio, no un nombre fijo.
  const cliYo = await sesionAPI('clinico');
  const clinico = (await (await cliYo.get('/api/auth/me')).json()).user;
  await cliYo.dispose();
  expect(persona.clin, 'H-009: sin clínico fijo por nombre').not.toBe('Dra. Lucía [dato real omitido]');
  if (clinico?.terr === yo.terr) expect(persona.clin, 'H-009: clínico del territorio').toBe(clinico.name);
  // La lista se refresca desde el servidor después de «Guardar visita»; con la suite en paralelo tarda más de 5 s.
  await expect(page.getByText(`${PREFIJO}Exp-${SUFIJO}`).first(), 'aparece en su lista').toBeVisible({ timeout: 15000 });
  const pac = await (await api.get('/api/patients')).json();
  expect(pac.patients.every((p) => p.terr === yo.terr), 'F-06: todos de su territorio').toBeTruthy();
  const ajena = await api.post('/api/people', { data: { name: `${PREFIJO}ajena-${SUFIJO}`, terr: yo.terr === 'Cali' ? 'Bogotá' : 'Cali' } });
  expect(ajena.status(), 'F-06: no puede registrar en otro territorio').toBe(403);
  expect(errores, 'sin errores en la consola ni respuestas de error').toEqual([]);
  await api.dispose();
});

test('4.6 Flujo Clínico: lista por riesgo, ficha completa, nota y ajuste de ruta con autor y fecha (T-01, C-01, C-02, C-04)', async ({ page, browser }) => {
  test.skip(!conCredenciales('clinico'), 'Faltan credenciales del clínico');
  test.skip(PROD && !conCredenciales('experto'), 'En producción la nota se escribe en una ficha creada por la suite: faltan credenciales del experto');
  // En producción no se editan fichas que ya existían: el experto registra antes una persona con prefijo.
  let propia;
  if (PROD) {
    test.setTimeout(180000);
    const pe = await (await browser.newContext({ baseURL: BASE_URL })).newPage();
    await loginUI(pe, ROLES.experto, 'experto');
    propia = await registrarYEvaluar(pe, `${PREFIJO}Cli-${SUFIJO}`, `trl-qa+cli${SUFIJO}@trl-qa.invalid`);
    await pe.context().close();
  }
  const errores = await erroresDeConsola(page);
  await loginUI(page, ROLES.clinico, 'clinico');
  const api = await sesionAPI('clinico');
  const yo = (await (await api.get('/api/auth/me')).json()).user;
  const pac = await (await api.get('/api/patients')).json();
  expect(pac.patients.every((p) => p.terr === yo.terr), 'C-01: solo su territorio').toBeTruthy();
  // H-003 (SPEC-003 FR-003): el clínico solo recibe sus avisos (los suyos y los del rol).
  const stC = (await (await api.get('/api/app-state')).json()).slices || {};
  expect(Object.keys(stC.notifs || {}).every((k) => [yo.id, yo.name, 'clin', 'clinico'].includes(k)), 'H-003: solo sus avisos').toBeTruthy();
  const cuentas = await (await api.get('/api/accounts')).json();
  expect(cuentas.accounts.every((a) => !a.email && !a.name), 'H-002: cuentas sin correo ni nombre').toBeTruthy();
  // C-01: ordenados por riesgo
  await page.getByRole('link', { name: 'Mis pacientes' }).click();
  await page.waitForURL(/\/clinico\/pacientes$/);
  const perfiles = await page.getByRole('button', { name: /^(P\d{2}|Sin perfil)$/ }).allInnerTexts();
  expect(perfiles.length, 'hay pacientes en la lista').toBeGreaterThan(0);
  // C-01: primero las crisis (el contador «en crisis N» de la cabecera) y luego de mayor a menor riesgo.
  // H-010: sin esto la prueba solo pasaba si el laboratorio no tenía pacientes en crisis.
  const enCrisis = Number(((await page.getByRole('button', { name: /^en crisis \d+$/ }).first().innerText().catch(() => '')).match(/\d+/) || [0])[0]);
  const riesgos = perfiles.slice(enCrisis).map(RIESGO);
  expect(riesgos, 'C-01: de mayor a menor riesgo después de las crisis').toEqual([...riesgos].sort((a, b) => b - a));
  // C-02: ficha con perfil, ruta, historial y alertas (en producción, la ficha creada por la suite)
  if (propia) {
    await page.goto('/clinico/pacientes/' + encodeURIComponent(propia.id));
  } else {
    await page.getByRole('button', { name: /años$/ }).first().click();
  }
  await page.waitForURL(/\/clinico\/pacientes\/[^/]+$/);
  for (const t of ['Ruta asignada', 'Historial de crisis', 'Línea de tiempo']) await expect(page.getByText(t).first(), `C-02: ${t}`).toBeVisible();
  // C-04: nota y ajuste de ruta, guardados con quién y cuándo
  const nota = `${PREFIJO}nota-${SUFIJO}`;
  const bloque = page.getByTestId('clinico-nota-ruta');
  await bloque.getByLabel('Nota clínica').fill(nota);
  await bloque.getByRole('button', { name: 'Guardar nota' }).click();
  await entendido(page);
  await bloque.getByRole('button', { name: 'Subir a sesiones semanales' }).click();
  await entendido(page);
  await expect(page.getByText(nota), 'C-04: la nota aparece en la línea de tiempo').toBeVisible();
  await expect(page.getByText(`Nota clínica · ${yo.name}`).first(), 'C-04: con el autor').toBeVisible();
  await expect(page.getByText(`Cambio de ruta · ${yo.name}`).first(), 'C-04: ajuste con el autor').toBeVisible();
  // Persistencia: tras recargar, la nota sigue (se guardó en el servidor)
  await page.waitForTimeout(1500);
  await page.reload();
  await expect(page.getByText(nota), 'C-04: la nota quedó guardada').toBeVisible({ timeout: 20000 });
  expect(errores, 'sin errores en la consola ni respuestas de error').toEqual([]);
  await api.dispose();
});

test('4.7 Flujo Administrador: crea, edita y desactiva un territorio; crea y desactiva un usuario (T-01, A-01, A-02)', async ({ page }) => {
  test.skip(!conCredenciales('admin'), 'Faltan credenciales del administrador');
  const errores = await erroresDeConsola(page);
  await loginUI(page, ROLES.admin, 'admin');
  const admin = await sesionAPI('admin');
  expect((await (await admin.get('/api/health/db')).json()).missing).toEqual([]);
  // A-01: crear
  const terr = `${PREFIJO}Terr-${SUFIJO}`;
  await page.goto('/territorios');
  await page.getByRole('button', { name: '+ Crear territorio' }).click();
  const d = page.getByRole('dialog', { name: 'Crear territorio' });
  await d.getByLabel('Municipio').fill(terr);
  await d.getByLabel('Meta de captación').fill('100');
  await d.getByRole('button', { name: 'Guardar territorio' }).click();
  await expect(page.getByText(`Territorio ${terr} creado`)).toBeVisible();
  await entendido(page);
  creados.territorios.push(terr);
  // A-01: editar (línea de crisis) y desactivar
  await page.goto('/admin/territorio?t=' + encodeURIComponent(terr));
  await page.getByLabel('Línea de crisis local').fill('018000123456');
  await page.getByRole('button', { name: 'Guardar línea de crisis' }).click();
  await expect(page.getByText('Línea de crisis guardada.')).toBeVisible();
  await entendido(page);
  await page.getByRole('button', { name: 'Desactivar territorio' }).click();
  await expect(page.getByText(/Territorio desactivado/)).toBeVisible();
  await entendido(page);
  const enAdmin = (await (await admin.get('/api/territories')).json()).territories.find((t) => t.name === terr);
  expect(enAdmin?.crisisLine, 'A-01: la edición quedó guardada').toBe('018000123456');
  expect(enAdmin?.active, 'A-01: quedó desactivado').toBe(false);
  if (conCredenciales('experto')) {
    const exp = await sesionAPI('experto');
    const lista = (await (await exp.get('/api/territories')).json()).territories.map((t) => t.name);
    expect(lista, 'A-01: el experto ya no lo ve').not.toContain(terr);
    await exp.dispose();
  }
  // A-02: crear un usuario observador y desactivarlo
  const correo = `trl-qa+obs${SUFIJO}@trl-qa.invalid`;
  const org = `${PREFIJO}Org-${SUFIJO}`;
  const clave = `Clave-${SUFIJO}-Prueba`;
  await page.goto('/usuarios');
  await page.getByRole('button', { name: '+ Crear usuario' }).click();
  const u = page.getByRole('dialog', { name: 'Nuevo usuario' });
  await u.getByLabel('Rol').selectOption('Observador');
  await u.getByLabel('Nombre', { exact: true }).fill(`${PREFIJO}Observador ${SUFIJO}`);
  await u.getByLabel('Correo', { exact: true }).fill(correo);
  await u.getByPlaceholder('Mínimo 8 caracteres').fill(clave);
  await u.getByLabel('Organización').fill(org);
  await u.getByRole('button', { name: 'Crear' }).click();
  await expect(u).toBeHidden();
  await entendido(page);
  const anon = await pwRequest.newContext({ baseURL: BASE_URL });
  expect((await anon.post('/api/auth/login', { data: { email: correo, password: clave } })).status(), 'A-02: el usuario nuevo entra').toBe(200);
  const cuenta = (await (await admin.get('/api/accounts')).json()).accounts.find((a) => a.email === correo);
  expect(cuenta?.roleId, 'A-02: rol correcto').toBe('observador');
  creados.cuentas.push(cuenta.id);
  await page.getByRole('searchbox', { name: 'Buscar' }).fill(org);
  await visible(page.getByText(org, { exact: true })).click();
  await page.getByRole('button', { name: 'Desactivar usuario' }).click();
  await entendido(page);
  await expect.poll(async () => (await (await admin.get('/api/accounts')).json()).accounts.find((a) => a.id === cuenta.id)?.status,
    { message: 'A-02: quedó inactivo', timeout: 15000 }).toBe('Inactivo');
  expect((await anon.post('/api/auth/login', { data: { email: correo, password: clave } })).status(), 'A-02: el usuario desactivado no entra').not.toBe(200);
  expect(errores, 'sin errores en la consola ni respuestas de error').toEqual([]);
  await anon.dispose();
  await admin.dispose();
});

test('4.8 Flujo Observador: tablero agregado, sin datos personales y de solo lectura (T-01, O-01, O-02)', async ({ page }) => {
  test.skip(!conCredenciales('observador'), 'Faltan credenciales del observador');
  const errores = await erroresDeConsola(page);
  await loginUI(page, ROLES.observador, 'observador');
  await expect(page.getByText(/personas en el programa/)).toBeVisible();
  const api = await sesionAPI('observador');
  const gente = await (await api.get('/api/people')).json();
  expect(gente.people, 'O-02: ningún registro por persona').toBeUndefined();
  expect(gente.summary?.total, 'O-02: solo el resumen agregado').toBeGreaterThan(0);
  const expertos = (await (await api.get('/api/experts')).json()).experts;
  expect(expertos.every((e) => !e.name && !e.phone), 'O-02: expertos sin nombre ni teléfono').toBeTruthy();
  // H-003 (SPEC-003 FR-002): sin avance por persona en recursos.
  const stO = (await (await api.get('/api/app-state')).json()).slices || {};
  expect(Object.keys(stO.recursos?.people || {}), 'H-003: observador sin recursos.people').toEqual([]);
  expect(stO.recursos?.assigned, 'H-003: observador sin recursos.assigned').toBeUndefined();
  const put = await api.put('/api/app-state', { data: { slices: { rules: {} } } });
  expect(put.status(), 'O-01: no escribe').toBe(403);
  expect(errores, 'sin errores en la consola ni respuestas de error').toEqual([]);
  await api.dispose();
});

/* ---------------- T-05, informe y fechas (H-005, H-009, H-010 del reporte 2026-10-10) ---------------- */

test('T-05 con sesión de otro rol, abrir otra pantalla devuelve a su panel con acceso denegado', async ({ page }) => {
  test.skip(!conCredenciales('admin'), 'Faltan credenciales del administrador');
  await loginUI(page, ROLES.admin, 'admin');
  for (const ruta of ['/paciente', '/clinico', '/experto']) {
    await page.goto(ruta);
    await page.waitForURL(/acceso=denegado/, { timeout: 15000 });
    expect(new URL(page.url()).pathname, ruta + ' → panel del admin').toBe('/inicio');
  }
});

test('A-05 el informe semanal del admin sale de los datos (sin cifras fijas) y con la fecha de hoy', async ({ page }) => {
  test.skip(!conCredenciales('admin'), 'Faltan credenciales del administrador');
  await loginUI(page, ROLES.admin, 'admin');
  const hoyCorto = new Date().toLocaleDateString('es-CO', { day: 'numeric', month: 'short', timeZone: 'America/Bogota' }).replace('.', '');
  await expect(page.getByText(/^Generado el /), 'H-009: fecha real en /inicio').toContainText(hoyCorto.split(' ')[0]);
  await page.goto('/informe?t=admin-weekly');
  const cuerpo = page.locator('body');
  await expect(cuerpo).toContainText('Operaciones de la semana');
  for (const fija of ['1.148', '712', '1.036']) await expect(cuerpo, 'H-005: sin la cifra fija ' + fija).not.toContainText(fija);
  const admin = await sesionAPI('admin');
  const total = (await (await admin.get('/api/people')).json()).total;
  await expect(cuerpo, 'H-005: el total de personas coincide con la API').toContainText(total.toLocaleString('es-CO'));
  await admin.dispose();
});

test('H-009 el experto ve la fecha de hoy en «Visitas de hoy»', async ({ page }) => {
  test.skip(!conCredenciales('experto'), 'Faltan credenciales del experto');
  await loginUI(page, ROLES.experto, 'experto');
  const d = new Date(new Date().toLocaleString('en-US', { timeZone: 'America/Bogota' }));
  const meses = ['enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio', 'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre'];
  await expect(page.getByText(new RegExp(d.getDate() + ' de ' + meses[d.getMonth()] + ' · Solo cuentan'))).toBeVisible();
});

/* ---------------- 3.2 Punta a punta y 3.3 IA ---------------- */

test('3.2 Punta a punta: el experto registra y evalúa, el clínico aprueba, el admin crea la cuenta y el paciente ve su ruta', async ({ browser }) => {
  test.skip(!['experto', 'clinico', 'admin'].every(conCredenciales), 'Faltan credenciales de experto, clínico o admin');
  test.setTimeout(240000);
  const nombre = `${PREFIJO}Ruta-${SUFIJO}`;
  const correo = `trl-qa+ruta${SUFIJO}@trl-qa.invalid`;
  const clave = `Clave-${SUFIJO}-Ruta`;
  const nueva = async () => (await browser.newContext({ baseURL: BASE_URL, recordVideo: { dir: test.info().outputPath('video') } })).newPage();
  // 1–3. Experto: registro, cuestionario, perfil y ruta
  const pe = await nueva();
  await loginUI(pe, ROLES.experto, 'experto');
  const persona = await registrarYEvaluar(pe, nombre, correo);
  await pe.context().close();
  // El perfil pasa por la aprobación del clínico del territorio
  const pc = await nueva();
  await loginUI(pc, ROLES.clinico, 'clinico');
  await pc.goto('/clinico/aprobaciones');
  await pc.getByRole('searchbox', { name: 'Buscar' }).fill(nombre);
  const tarjeta = pc.getByRole('article').filter({ hasText: nombre });
  await tarjeta.getByRole('button', { name: 'Aceptar evaluación' }).click();
  await entendido(pc);
  await expect(tarjeta).toBeHidden({ timeout: 15000 });
  await pc.context().close();
  // El admin crea la cuenta de la persona registrada en campo
  const pa = await nueva();
  await loginUI(pa, ROLES.admin, 'admin');
  await pa.goto('/usuarios');
  await pa.getByRole('button', { name: '+ Crear usuario' }).click();
  const u = pa.getByRole('dialog', { name: 'Nuevo usuario' });
  await u.getByLabel('Rol').selectOption('Paciente');
  const opcion = u.getByLabel('Persona').locator('option', { hasText: nombre });
  await u.getByLabel('Persona').selectOption(await opcion.getAttribute('value'));
  // El correo viene de la ficha registrada en campo (campo de solo lectura).
  await expect(u.getByLabel('Correo', { exact: true })).toHaveValue(correo);
  await u.getByPlaceholder('Mínimo 8 caracteres').fill(clave);
  await u.getByRole('button', { name: 'Crear' }).click();
  await expect(u).toBeHidden();
  await entendido(pa);
  await pa.context().close();
  const admin = await sesionAPI('admin');
  const cuenta = (await (await admin.get('/api/accounts')).json()).accounts.find((a) => a.email === correo);
  expect(cuenta, 'la cuenta del paciente existe').toBeTruthy();
  creados.cuentas.push(cuenta.id);
  await admin.dispose();
  // 4. El paciente entra, acepta el consentimiento y ve su ruta
  const pp = await nueva();
  await loginUI(pp, { email: correo, pass: clave }, 'paciente');
  await pp.getByTestId('paciente-consentimiento').getByRole('button', { name: 'Acepto y continúo' }).click();
  const herramientas = pp.getByRole('region', { name: 'Herramientas' });
  await expect(herramientas, 'el paciente ve su ruta').toBeVisible({ timeout: 20000 });
  expect(await herramientas.getByRole('button').count(), 'con al menos un servicio').toBeGreaterThan(0);
  // 5. P-04 (H-001, SPEC-001): el paciente escribe una señal de riesgo a TEO y el servidor la escala.
  // En producción crea una alerta que ven los admins reales: solo con E2E_ALERTAS=1.
  if (!ALERTAS) {
    test.info().annotations.push({ type: 'omitido', description: 'Paso 5 (alerta de crisis por TEO) omitido en producción: falta E2E_ALERTAS=1' });
    await pp.context().close();
    return;
  }
  const riesgo = 'Últimamente pienso que todos estarían mejor sin mí y he pensado en hacerme daño.';
  // Con la cookie de la sesión del navegador, directo a la API (sin el desafío de Vercel a clientes sin navegador).
  const sidPaciente = (await pp.context().cookies()).find((c) => c.name === 'nara_sid');
  const ctxPaciente = await contextoConCookie(sidPaciente ? sidPaciente.value : '');
  const teo = await ctxPaciente.post('/api/teo/chat', { data: { message: riesgo } });
  await ctxPaciente.dispose();
  const tj = await teo.json();
  expect(tj.crisis && tj.escalada, 'H-001: TEO responde crisis escalada').toBeTruthy();
  expect(tj.text, 'H-001: con las líneas 123 y 192, sin «CRISIS.»').toMatch(/123[\s\S]*192/);
  await pp.context().close();
  const cli = await sesionAPI('clinico');
  const t0 = Date.now();
  await expect.poll(async () => ((await (await cli.get('/api/app-state')).json()).slices?.alerts || [])
    .some((a) => a.id === `a-${persona.id}` && a.sev === 'crisis' && a.pid === persona.id), { timeout: 5000, message: 'H-001: la alerta llega al clínico en 5 s o menos' }).toBeTruthy();
  const msAlerta = Date.now() - t0;
  const ficha = ((await (await cli.get('/api/patients')).json()).patients || []).find((p) => p.id === persona.id);
  expect(ficha?.status, 'H-001: la persona queda en Crisis').toBe('Crisis');
  expect((ficha?.phqDates || []).every((d) => /^\d{4}-\d{2}-\d{2}/.test(String(d))), 'H-013: phqDates con fecha ISO').toBeTruthy();
  await cli.dispose();
  const pc2 = await nueva();
  await loginUI(pc2, ROLES.clinico, 'clinico');
  await pc2.goto('/clinico/crisis');
  await expect(pc2.getByText(nombre).first(), 'H-001: aparece en la cola de crisis').toBeVisible({ timeout: 15000 });
  await pc2.context().close();
  test.info().annotations.push({ type: 'persona', description: `${persona.id} · ${persona.profile} · alerta de TEO en ${msAlerta} ms` });
});

test('C-03 (H-002) la pregunta 9 mayor que 0 avisa al clínico en 5 s, sin guardar la visita', async ({ browser }) => {
  test.skip(!['experto', 'clinico'].every(conCredenciales), 'Faltan credenciales de experto o clínico');
  test.skip(!ALERTAS, 'En producción crea una alerta de crisis que ven los admins reales: define E2E_ALERTAS=1 después de avisarles');
  test.setTimeout(120000);
  const nombre = `${PREFIJO}P9-${SUFIJO}`;
  const pe = await (await browser.newContext({ baseURL: BASE_URL, recordVideo: { dir: test.info().outputPath('video') } })).newPage();
  await loginUI(pe, ROLES.experto, 'experto');
  await registrarHastaEvaluacion(pe, nombre, `trl-qa+p9${SUFIJO}@trl-qa.invalid`);
  const exp = await sesionAPI('experto');
  const persona = ((await (await exp.get('/api/people?q=' + encodeURIComponent(nombre))).json()).people || []).find((x) => x.name.startsWith(nombre));
  expect(persona, 'la persona nueva existe').toBeTruthy();
  creados.personas.push(persona.id);
  await exp.dispose();
  await pe.getByRole('button', { name: '1 · Varios días' }).nth(8).click();
  const cli = await sesionAPI('clinico');
  const t0 = Date.now();
  await expect.poll(async () => ((await (await cli.get('/api/app-state')).json()).slices?.alerts || [])
    .some((a) => a.id === `a-${persona.id}` && a.sev === 'crisis' && a.status === 'new'), { timeout: 5000, message: 'H-002: la alerta llega al clínico en 5 s o menos' }).toBeTruthy();
  const ms = Date.now() - t0;
  // Se cierra la app sin guardar la visita: la alerta sigue.
  await pe.context().close();
  const log = (await (await cli.get('/api/app-state')).json()).slices?.crisisLog || [];
  expect(log.some((e) => e.alertId === `a-${persona.id}` && e.type === 'created'), 'H-002: evento en el historial de crisis').toBeTruthy();
  await cli.dispose();
  const pc = await (await browser.newContext({ baseURL: BASE_URL })).newPage();
  await loginUI(pc, ROLES.clinico, 'clinico');
  await pc.goto('/clinico/crisis');
  await expect(pc.getByText(nombre).first(), 'H-002: en «Por tomar»').toBeVisible({ timeout: 15000 });
  await pc.context().close();
  test.info().annotations.push({ type: 'alerta', description: `${persona.id} · ${ms} ms` });
});

test('H-007 el alta rechaza una fecha de nacimiento futura y un teléfono con letras (formulario y API)', async ({ page }) => {
  test.skip(!conCredenciales('experto'), 'Faltan credenciales del experto');
  await loginUI(page, ROLES.experto, 'experto');
  const nombre = `${PREFIJO}Val-${SUFIJO}`;
  await page.getByRole('button', { name: '+ Nueva persona' }).click();
  const d = page.getByRole('dialog', { name: 'Nueva persona' });
  await d.getByLabel('Nombre', { exact: true }).fill(nombre);
  await d.getByLabel('Apellido').fill('Prueba E2E');
  await d.getByLabel('Fecha de nacimiento').fill('2099-01-01');
  await d.getByLabel('Vereda o barrio').fill('Vereda Prueba');
  await d.getByRole('button', { name: 'Agregar y empezar visita' }).click();
  await expect(page.getByText('La fecha de nacimiento no puede ser futura.'), 'H-007: fecha futura').toBeVisible();
  await entendido(page);
  await d.getByLabel('Fecha de nacimiento').fill('1980-05-01');
  await d.getByPlaceholder('3xx xxx xxxx').fill('abc123');
  // El campo de teléfono no deja escribir letras (queda vacío): eso ya cumple. Si las aceptara, el alta las rechaza.
  const tel = await d.getByPlaceholder('3xx xxx xxxx').inputValue();
  if (/[a-z]/i.test(tel)) {
    await d.getByRole('button', { name: 'Agregar y empezar visita' }).click();
    await expect(page.getByText('El teléfono debe tener 10 dígitos y empezar por 3.'), 'H-007: teléfono').toBeVisible();
    await entendido(page);
  }
  await d.getByPlaceholder('3xx xxx xxxx').fill('12345');
  await d.getByRole('button', { name: 'Agregar y empezar visita' }).click();
  await expect(page.getByText('El teléfono debe tener 10 dígitos y empezar por 3.'), 'H-007: teléfono incompleto').toBeVisible();
  await entendido(page);
  const api = await sesionAPI('experto');
  const yo = (await (await api.get('/api/auth/me')).json()).user;
  const r1 = await api.post('/api/people', { data: { name: `${nombre} API`, terr: yo.terr, birthDate: '2099-01-01' } });
  expect(r1.status(), 'H-007: la API rechaza la fecha futura').toBe(400);
  const r2 = await api.post('/api/people', { data: { name: `${nombre} API`, terr: yo.terr, birthDate: '1980-05-01', phone: 'abc123' } });
  expect(r2.status(), 'H-007: la API rechaza el teléfono con letras').toBe(400);
  await api.dispose();
});

test('H-011 el foco del teclado se ve en /ingreso', async ({ page }) => {
  await page.goto('/ingreso');
  await page.getByLabel('Correo').focus();
  await page.keyboard.press('Tab');
  await page.keyboard.press('Shift+Tab');
  const foco = await page.evaluate(() => { const e = document.activeElement; const c = getComputedStyle(e); return { tag: e.tagName, outline: c.outlineStyle, ancho: c.outlineWidth, sombra: c.boxShadow }; });
  expect(foco.tag).toBe('INPUT');
  expect(foco.outline !== 'none' && foco.ancho !== '0px', 'H-011: indicador de foco visible').toBeTruthy();
});

test('H-014 el admin archiva una persona desde su ficha, con motivo', async ({ page }) => {
  test.skip(!['experto', 'admin'].every(conCredenciales), 'Faltan credenciales de experto o admin');
  const exp = await sesionAPI('experto');
  const yo = (await (await exp.get('/api/auth/me')).json()).user;
  const nombre = `${PREFIJO}Arch-${SUFIJO}`;
  const alta = await (await exp.post('/api/people', { data: { name: nombre, terr: yo.terr, birthDate: '1980-05-01', place: 'Vereda Prueba' } })).json();
  const id = alta.person?.id;
  expect(id, 'persona creada para archivar').toBeTruthy();
  creados.personas.push(id);
  await exp.dispose();
  await loginUI(page, ROLES.admin, 'admin');
  await page.goto('/admin/persona?c=' + encodeURIComponent(alta.person.code || id));
  await page.getByRole('button', { name: 'Archivar', exact: true }).click();
  await page.getByLabel('Motivo').fill('Prueba e2e de archivo (H-014)');
  await page.getByRole('button', { name: 'Confirmar archivo' }).click();
  await page.waitForURL(/\/personas$/);
  const admin = await sesionAPI('admin');
  const lista = (await (await admin.get('/api/people?q=' + encodeURIComponent(nombre))).json()).people || [];
  expect(lista.some((p) => p.id === id), 'H-014: ya no aparece en las listas').toBeFalsy();
  await admin.dispose();
});

test('3.3 TEO responde 3 entradas de ejemplo con proveedor y modelo (la coherencia la revisa una persona)', async () => {
  test.skip(!conCredenciales('paciente') || !conCredenciales('clinico'), 'Faltan credenciales de paciente o clínico');
  test.setTimeout(120000);
  const pac = await sesionAPI('paciente');
  const cli = await sesionAPI('clinico');
  const entradas = [
    ['paciente', pac, '/api/teo/chat', { message: 'Hola TEO, esta semana he dormido mal y me despierto con miedo cuando pasa un camión.' }],
    ['clinico', cli, '/api/teo/ask', { question: '¿Cuántos pacientes de mi territorio están en estado Activo y cuántos sin evaluación?' }],
    ['clinico', cli, '/api/teo/ask', { question: '¿Qué perfiles son los más frecuentes entre mis pacientes?' }],
  ];
  const salida = [];
  for (const [rol, ctx, ruta, cuerpo] of entradas) {
    const t0 = Date.now();
    const res = await ctx.post(ruta, { data: cuerpo });
    const j = await res.json().catch(() => ({}));
    salida.push({ rol, ruta, entrada: cuerpo, status: res.status(), ms: Date.now() - t0, provider: j.provider || null, model: j.model || null, respuesta: j.text || j.error || '' });
  }
  if (process.env.EVID_DIR) require('fs').writeFileSync(require('path').join(process.env.EVID_DIR, 'teo_3.3.json'), JSON.stringify(salida, null, 2));
  await test.info().attach('teo_3.3.json', { body: JSON.stringify(salida, null, 2), contentType: 'application/json' });
  // H-010: aserciones duras (antes eran blandas).
  for (const r of salida) {
    expect(r.status, `${r.ruta} responde 200`).toBe(200);
    expect(r.respuesta.length, `${r.ruta} con texto`).toBeGreaterThan(10);
    expect(r.provider && r.model, `${r.ruta} con proveedor y modelo`).toBeTruthy();
  }
  await pac.dispose();
  await cli.dispose();
});

/* ---------------- Limpieza (H-012): archivar y desactivar lo creado ---------------- */

// Se busca por prefijo y no solo por la lista en memoria: si un test falla, Playwright reinicia el
// proceso y esa lista se pierde.
test.afterAll(async () => {
  if (!conCredenciales('admin')) return;
  const admin = await sesionAPI('admin');
  const personas = ((await (await admin.get('/api/people?q=' + encodeURIComponent(PREFIJO))).json()).people || []).map((p) => p.id);
  for (const id of new Set([...creados.personas, ...personas])) {
    await admin.post(`/api/people/${encodeURIComponent(id)}/archive`, { data: { reason: 'Limpieza de la suite e2e TRL' } });
  }
  const terrs = ((await (await admin.get('/api/territories')).json()).territories || []).filter((t) => t.name.startsWith(PREFIJO) && t.active !== false);
  for (const t of terrs) await admin.post('/api/territories', { data: { name: t.name, active: false } });
  const cuentas = ((await (await admin.get('/api/accounts')).json()).accounts || [])
    .filter((c) => /^trl-qa\+(obs|ruta|exp|cli|s3)\d+@trl-qa\.invalid$/.test(c.email || '') && c.status !== 'Inactivo');
  for (const c of cuentas) await admin.post('/api/accounts', { data: { ...c, status: 'Inactivo' } });
  // Las alertas de crisis de personas de la suite no se cierran por API (sería reescribir app-state, que es
  // compartido en producción): se listan para que el clínico de prueba las cierre en /clinico/crisis.
  const ids = new Set([...creados.personas, ...personas]);
  const alertas = (((await (await admin.get('/api/app-state')).json()).slices || {}).alerts || [])
    .filter((a) => ids.has(a.pid) && !['closed', 'resolved', 'cerrada'].includes(String(a.status)))
    .map((a) => ({ id: a.id, pid: a.pid, sev: a.sev, status: a.status }));
  const resumen = { personas: ids.size, territorios: terrs.length, cuentas: cuentas.length, alertasAbiertas: alertas };
  console.log('Limpieza e2e:', JSON.stringify(resumen));
  if (process.env.EVID_DIR) require('fs').writeFileSync(require('path').join(process.env.EVID_DIR, 'limpieza.json'), JSON.stringify(resumen, null, 2));
  await admin.dispose();
});
