import { Page, expect } from "@playwright/test";

/**
 * Cierra toasts de Element que bloquean clicks.
 * Duplicado aqui para evitar imports circulares con persistent-context.ts.
 */
async function dismissToasts(page: Page) {
  await page.waitForTimeout(1_000);
  const buttons = [
    page.getByRole("button", { name: "Dismiss" }),
    page.locator("button").filter({ hasText: /^OK$/ }),
    page.getByRole("button", { name: "Close" }),
    page.getByRole("button", { name: "Not now" }),
    page.getByRole("button", { name: "Got it" }),
  ];
  for (const btn of buttons) {
    try {
      if (await btn.first().isVisible()) {
        await btn.first().click({ force: true });
        await page.waitForTimeout(300);
      }
    } catch { /* ignore */ }
  }
}

export interface WaitForReplyOptions {
  /** Timeout en ms para esperar la respuesta (default: 30s) */
  timeout?: number;
  /** Filtrar por sender display name si se especifica */
  sender?: string;
}

/**
 * Navega a un room por nombre.
 * Primero intenta click directo en el sidebar, luego usa el buscador.
 */
export async function goToRoom(page: Page, roomName: string) {
  console.log(`[goToRoom] Buscando room: "${roomName}"`);

  // Estrategia 1: Click directo en el room del sidebar (mas robusto)
  const sidebarRoom = page.locator(
    `.mx_RoomTile, [role="treeitem"]`
  ).filter({ hasText: new RegExp(roomName, "i") }).first();

  const directMatch = await sidebarRoom
    .waitFor({ state: "visible", timeout: 5_000 })
    .then(() => true)
    .catch(() => false);

  if (directMatch) {
    console.log(`[goToRoom] Room "${roomName}" encontrado en sidebar, click directo`);
    await sidebarRoom.click();
    await waitForRoomLoaded(page, roomName);
    return;
  }

  // Estrategia 2: Usar busqueda (Ctrl+K es mas confiable que click en Search)
  console.log("[goToRoom] Room no visible en sidebar, usando busqueda...");
  await page.keyboard.press("Control+k");

  // Esperar a que aparezca el dialog de busqueda
  const searchInput = page.locator(
    '[role="searchbox"], input[type="search"], .mx_SpotlightDialog input'
  ).first();
  const hasSearch = await searchInput
    .waitFor({ state: "visible", timeout: 5_000 })
    .then(() => true)
    .catch(() => false);

  if (!hasSearch) {
    // Fallback: click en el boton de Search con force para evitar toasts
    console.log("[goToRoom] Ctrl+K no abrio busqueda, intentando click en Search...");
    const searchButton = page.locator('[aria-label="Search"]').first();
    await searchButton.click({ force: true });
    await searchInput.waitFor({ state: "visible", timeout: 5_000 });
  }

  await searchInput.fill(roomName);
  console.log(`[goToRoom] Texto ingresado: "${roomName}"`);

  // Seleccionar el room de los resultados
  const roomResult = page.locator(
    '[role="option"], .mx_SpotlightDialog_result'
  ).filter({ hasText: new RegExp(roomName, "i") }).first();

  const hasResult = await roomResult
    .waitFor({ state: "visible", timeout: 10_000 })
    .then(() => true)
    .catch(() => false);

  if (!hasResult) {
    console.error(`[goToRoom] No se encontro room "${roomName}" en resultados`);
    await page.screenshot({
      path: `test-results/ERROR-goToRoom-no-result-${Date.now()}.png`,
      fullPage: true,
    });
    // Cerrar el dialog de busqueda
    await page.keyboard.press("Escape");
    throw new Error(`Room "${roomName}" no encontrado en busqueda`);
  }

  console.log(`[goToRoom] Seleccionando room "${roomName}"`);
  await roomResult.click();

  await waitForRoomLoaded(page, roomName);
}

/**
 * Espera a que un room termine de cargar (header visible + composer listo).
 */
async function waitForRoomLoaded(page: Page, roomName: string) {
  // Esperar header del room o composer — ambos indican que el room cargo
  const roomHeader = page.locator(
    `[data-testid="room-header-name"], .mx_RoomHeader_heading, h2`
  ).filter({ hasText: new RegExp(roomName, "i") }).first();

  const headerVisible = await roomHeader
    .waitFor({ state: "visible", timeout: 10_000 })
    .then(() => true)
    .catch(() => false);

  if (!headerVisible) {
    // Fallback: verificar que al menos el composer esta visible
    const composer = page.getByRole("textbox", { name: /message/i });
    await composer.waitFor({ state: "visible", timeout: 10_000 });
  }

  console.log(`[goToRoom] Estamos en room "${roomName}"`);
}

/**
 * Envia un mensaje de texto en el room actual.
 */
export async function sendMessage(page: Page, text: string) {
  console.log(`[sendMessage] Enviando: "${text}"`);

  const composer = page.getByRole("textbox", { name: /message/i });
  const hasComposer = await composer
    .waitFor({ state: "visible", timeout: 10_000 })
    .then(() => true)
    .catch(() => false);

  if (!hasComposer) {
    console.error("[sendMessage] Composer no encontrado");
    await page.screenshot({
      path: `test-results/ERROR-sendMessage-no-composer-${Date.now()}.png`,
      fullPage: true,
    });
    throw new Error("Composer de mensajes no encontrado");
  }

  await composer.fill(text);
  await composer.press("Enter");
  console.log("[sendMessage] Enter presionado");

  // Esperar a que el mensaje aparezca en el timeline
  await expect(
    page.locator(".mx_EventTile_body, .mx_MTextBody").filter({ hasText: text }).last()
  ).toBeVisible({ timeout: 10_000 });
  console.log(`[sendMessage] Mensaje visible en timeline: "${text}"`);
}

/**
 * Cierra el thread panel de Element Web si esta abierto.
 */
export async function closeThreadPanel(page: Page) {
  const closeBtn = page.locator(
    ".mx_BaseCard_close, [data-testid='base-card-close-button'], .mx_RightPanel_closeButton"
  ).first();
  try {
    if (await closeBtn.isVisible({ timeout: 1_000 })) {
      await closeBtn.click({ force: true });
      console.log("[closeThreadPanel] Thread panel cerrado");
      await page.waitForTimeout(300);
    }
  } catch { /* panel ya estaba cerrado */ }
}

/**
 * Obtiene el room ID del room actual desde la URL + SDK.
 * Resuelve aliases si la URL contiene uno.
 */
async function getCurrentRoomId(page: Page): Promise<string | null> {
  return page.evaluate(async () => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const client = (window as any).mxMatrixClientPeg?.get?.();
    if (!client) return null;
    const hash = window.location.hash;
    const match = hash.match(/#\/room\/([^?/]+)/);
    if (!match) return null;
    const roomIdOrAlias = decodeURIComponent(match[1]);
    if (roomIdOrAlias.startsWith("!")) return roomIdOrAlias;
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const resolved = await (client as any).getRoomIdForAlias(roomIdOrAlias);
    return resolved?.room_id || null;
  });
}

/**
 * Espera una respuesta del bot en el main timeline usando el Matrix SDK.
 * Rastrea el ultimo event ID ANTES de llamar esta funcion para solo detectar
 * mensajes NUEVOS, evitando falsos positivos de mensajes historicos.
 *
 * Usa SDK en lugar de locators DOM porque los thread summaries en la main
 * timeline inyectan sender elements adicionales que confunden la deteccion.
 */
export async function waitForBotReply(
  page: Page,
  options?: WaitForReplyOptions
): Promise<string> {
  const timeout = options?.timeout ?? 30_000;
  const senderFilter = options?.sender;
  console.log(
    `[waitForBotReply] Esperando respuesta (timeout: ${timeout}ms, sender: ${senderFilter || "any"})...`
  );

  // Capturar el ultimo event ID ANTES de que el bot pueda responder.
  // Solo detectaremos mensajes que lleguen DESPUES de este punto.
  const startEventId = await page.evaluate(async () => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const client = (window as any).mxMatrixClientPeg?.get?.();
    if (!client) return null;
    const hash = window.location.hash;
    const match = hash.match(/#\/room\/([^?/]+)/);
    if (!match) return null;
    const roomIdOrAlias = decodeURIComponent(match[1]);
    let roomId = roomIdOrAlias;
    if (!roomId.startsWith("!")) {
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      const resolved = await (client as any).getRoomIdForAlias(roomIdOrAlias);
      roomId = resolved?.room_id;
    }
    if (!roomId) return null;
    const room = client.getRoom(roomId);
    if (!room) return null;
    const events = room.getLiveTimeline().getEvents();
    return events.length > 0
      ? (events[events.length - 1] as { getId: () => string }).getId()
      : null;
  });

  const startTime = Date.now();
  while (Date.now() - startTime < timeout) {
    // Detectar E2EE errors en el timeline visible
    const undecryptable = page.locator(
      '.mx_DecryptionFailureBody, [class*="UnableToDecrypt"]'
    );
    if ((await undecryptable.count()) > 0) {
      console.error("[waitForBotReply] E2EE ERROR: mensajes sin descifrar");
      await page.screenshot({
        path: `test-results/ERROR-e2ee-${Date.now()}.png`,
        fullPage: true,
      });
      throw new Error(
        "E2EE error: se detectaron mensajes 'Unable to decrypt'. " +
          "Verificar que cross-signing esta configurado correctamente " +
          "y que la recovery key es valida."
      );
    }

    const reply = await page.evaluate(({ senderFilter, startEventId }) => {
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      const client = (window as any).mxMatrixClientPeg?.get?.();
      if (!client) return null;
      const hash = window.location.hash;
      const match = hash.match(/#\/room\/([^?/]+)/);
      if (!match) return null;
      const roomIdOrAlias = decodeURIComponent(match[1]);
      // Only synchronous room lookup here — getRoomIdForAlias is async but evaluate
      // can be async too. Using direct room lookup for speed.
      const rooms = client.getRooms();
      const room = roomIdOrAlias.startsWith("!")
        ? client.getRoom(roomIdOrAlias)
        : rooms.find((r: { roomId: string; getCanonicalAlias: () => string | null }) =>
            r.getCanonicalAlias() === roomIdOrAlias ||
            r.roomId === roomIdOrAlias
          );
      if (!room) return null;

      const events: Array<{
        getId: () => string;
        getType: () => string;
        getSender: () => string;
        getContent: () => Record<string, unknown>;
      }> = room.getLiveTimeline().getEvents();

      // Encontrar la posicion del startEventId
      const startIdx = startEventId
        ? events.findIndex((e) => e.getId() === startEventId)
        : -1;

      // Solo eventos NUEVOS (posteriores al startEventId)
      const newEvents = events.slice(startIdx + 1);

      for (let i = newEvents.length - 1; i >= 0; i--) {
        const evt = newEvents[i];
        if (evt.getType() !== "m.room.message") continue;
        const content = evt.getContent() as {
          body?: string;
          "m.relates_to"?: { rel_type?: string };
          msgtype?: string;
        };
        // Ignorar thread replies — solo mensajes de la main timeline
        if (content["m.relates_to"]?.rel_type === "m.thread") continue;

        const sender = evt.getSender();
        if (senderFilter) {
          // eslint-disable-next-line @typescript-eslint/no-explicit-any
          const member = (room as any).getMember(sender);
          const displayName = member?.name || sender;
          if (!displayName.includes(senderFilter)) continue;
        }
        return content.body || "";
      }
      return null;
    }, { senderFilter: senderFilter ?? null, startEventId });

    if (reply) {
      console.log(
        `[waitForBotReply] Respuesta recibida (${Date.now() - startTime}ms): "${reply.substring(0, 60)}..."`
      );
      return reply;
    }

    await page.waitForTimeout(500);
  }

  console.error(`[waitForBotReply] TIMEOUT despues de ${timeout}ms`);
  await page.screenshot({
    path: `test-results/ERROR-timeout-waitForBotReply-${Date.now()}.png`,
    fullPage: true,
  });
  throw new Error(
    `Timeout (${timeout}ms): no se recibio respuesta del bot` +
      (senderFilter ? ` (sender esperado: ${senderFilter})` : "")
  );
}

/**
 * Obtiene el texto del ultimo mensaje visible en el timeline.
 */
export async function getLastMessage(page: Page): Promise<string | null> {
  const messages = page.locator(".mx_EventTile_body, .mx_MTextBody");
  const count = await messages.count();
  if (count === 0) return null;
  return messages.last().textContent();
}

/**
 * Obtiene el display name del sender del ultimo mensaje.
 */
async function getLastMessageSender(page: Page): Promise<string | null> {
  const senders = page.locator(
    ".mx_DisambiguatedProfile_displayName, .mx_SenderProfile_name"
  );
  const count = await senders.count();
  if (count === 0) return null;
  return senders.last().textContent();
}

/**
 * Inicia un thread sobre el ultimo mensaje del timeline via UI.
 *
 * Flujo:
 *   1. Right-click en el ultimo EventTile del main timeline
 *   2. Click en "Reply in Thread" del context menu
 *   3. Esperar a que aparezca el thread panel (panel derecho)
 *   4. Escribir un mensaje en el composer del thread panel
 *   5. Enviar con Enter
 *
 * Fallback SDK: si el context menu no aparece (headless), envia via SDK.
 */
export async function startThreadOnLastMessage(page: Page) {
  console.log("[startThread] Dismissing toasts...");
  await dismissToasts(page);

  // Localizar el ultimo EventTile en el main timeline (no en thread panel)
  const mainTimeline = page.locator(".mx_RoomView_body");
  const eventTiles = mainTimeline.locator(".mx_EventTile").filter({
    has: page.locator(".mx_EventTile_body, .mx_MTextBody"),
  });

  const tileCount = await eventTiles.count();
  if (tileCount === 0) throw new Error("[startThread] No hay EventTiles en el timeline");

  const lastTile = eventTiles.last();
  console.log(`[startThread] Right-click en ultimo EventTile (${tileCount} tiles)`);

  // Scroll hasta el ultimo tile para asegurarnos de que es visible
  await lastTile.scrollIntoViewIfNeeded();
  await page.waitForTimeout(300);

  // Right-click para abrir el context menu
  await lastTile.click({ button: "right", force: true });
  console.log("[startThread] Context menu abierto via right-click");

  // Esperar a que aparezca el context menu
  const contextMenu = page.locator(
    ".mx_ContextualMenu, .mx_IconizedContextMenu, [role='menu']"
  );
  const menuVisible = await contextMenu
    .waitFor({ state: "visible", timeout: 5_000 })
    .then(() => true)
    .catch(() => false);

  if (menuVisible) {
    console.log("[startThread] Context menu visible, buscando 'Reply in Thread'...");

    // Buscar la opcion "Reply in Thread" (puede variar por idioma)
    const threadOption = page.locator(
      "[role='menuitem'], .mx_IconizedContextMenu_option, .mx_ContextualMenu_item"
    ).filter({
      hasText: /reply in thread|thread|responder en hilo/i,
    }).first();

    const optionVisible = await threadOption
      .waitFor({ state: "visible", timeout: 3_000 })
      .then(() => true)
      .catch(() => false);

    if (optionVisible) {
      await threadOption.click({ force: true });
      console.log("[startThread] Click en 'Reply in Thread'");

      // Esperar a que aparezca el thread panel en el lado derecho
      const threadPanel = page.locator(
        ".mx_ThreadView, .mx_ThreadPanel, .mx_RightPanel .mx_BaseCard"
      );
      await threadPanel.waitFor({ state: "visible", timeout: 10_000 });
      console.log("[startThread] Thread panel visible");

      // Escribir en el composer del thread panel
      const threadComposer = threadPanel.getByRole("textbox", { name: /message/i });
      await threadComposer.waitFor({ state: "visible", timeout: 5_000 });
      await threadComposer.fill("Hola desde el thread, respondeme aqui por favor");
      await threadComposer.press("Enter");
      console.log("[startThread] Mensaje enviado via UI en el thread panel");
      return;
    }

    // Context menu abierto pero sin opcion de thread — cerrar y usar fallback
    console.warn("[startThread] Opcion 'Reply in Thread' no encontrada en context menu");
    await page.keyboard.press("Escape");
    await page.waitForTimeout(300);
  } else {
    console.warn("[startThread] Context menu no aparecio, usando fallback SDK");
  }

  // --- Fallback SDK (si la UI no funciono en headless) ---
  console.log("[startThread] Fallback: enviando mensaje threaded via SDK");

  const threadInfo = await page.evaluate(async () => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const client = (window as any).mxMatrixClientPeg?.get?.();
    if (!client) throw new Error("Matrix client no disponible en window");

    const hash = window.location.hash;
    const match = hash.match(/#\/room\/([^?/]+)/);
    if (!match) throw new Error(`No se pudo obtener room ID de la URL: ${hash}`);
    const roomIdOrAlias = decodeURIComponent(match[1]);

    let roomId: string;
    if (roomIdOrAlias.startsWith("!")) {
      roomId = roomIdOrAlias;
    } else {
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      const resolved = await (client as any).getRoomIdForAlias(roomIdOrAlias);
      if (!resolved?.room_id) throw new Error(`No se pudo resolver alias: ${roomIdOrAlias}`);
      roomId = resolved.room_id;
    }

    const room = client.getRoom(roomId);
    if (!room) throw new Error("Room no encontrado");

    const timeline = room.getLiveTimeline().getEvents();
    const lastMsgEvent = [...timeline].reverse().find(
      (e: { getType: () => string }) => e.getType() === "m.room.message"
    );
    if (!lastMsgEvent) throw new Error("No hay mensajes en el timeline");

    return { roomId, eventId: lastMsgEvent.getId() };
  });

  await page.evaluate(async ({ roomId, eventId }) => {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const client = (window as any).mxMatrixClientPeg.get();
    await client.sendMessage(roomId, {
      msgtype: "m.text",
      body: "Hola desde el thread, respondeme aqui por favor",
      "m.relates_to": {
        rel_type: "m.thread",
        event_id: eventId,
        is_falling_back: true,
        "m.in_reply_to": { event_id: eventId },
      },
    });
  }, threadInfo);

  console.log("[startThread] Mensaje threaded enviado via SDK (fallback)");
}

/**
 * Envia un mensaje en el panel de thread abierto.
 */
export async function sendThreadMessage(page: Page, text: string) {
  console.log(`[sendThreadMessage] Enviando en thread: "${text}"`);

  // El composer del thread esta dentro del panel derecho
  const threadPanel = page.locator(
    ".mx_ThreadView, .mx_ThreadPanel, .mx_RightPanel .mx_BaseCard"
  );
  const composer = threadPanel.getByRole("textbox", { name: /message/i });
  await composer.fill(text);
  await composer.press("Enter");

  // Esperar a que el mensaje aparezca dentro del thread
  await expect(
    threadPanel
      .locator(".mx_EventTile_body, .mx_MTextBody")
      .filter({ hasText: text })
      .last()
  ).toBeVisible({ timeout: 10_000 });
  console.log("[sendThreadMessage] Mensaje visible en thread");
}

/**
 * Espera la respuesta de un bot dentro del panel de thread.
 * Similar a waitForBotReply pero busca solo dentro del thread panel.
 */
export async function waitForThreadReply(
  page: Page,
  options?: WaitForReplyOptions
): Promise<string> {
  const timeout = options?.timeout ?? 30_000;
  const startTime = Date.now();
  console.log(
    `[waitForThreadReply] Esperando respuesta en thread (timeout: ${timeout}ms)...`
  );

  const threadPanel = page.locator(
    ".mx_ThreadView, .mx_ThreadPanel, .mx_RightPanel .mx_BaseCard"
  );

  while (Date.now() - startTime < timeout) {
    // Detectar errores de E2EE dentro del thread
    const undecryptable = threadPanel.locator(
      '.mx_DecryptionFailureBody, [class*="UnableToDecrypt"]'
    );
    if ((await undecryptable.count()) > 0) {
      console.error("[waitForThreadReply] E2EE error en thread");
      await page.screenshot({
        path: `test-results/ERROR-e2ee-thread-${Date.now()}.png`,
        fullPage: true,
      });
      throw new Error(
        "E2EE error en thread: se detectaron mensajes 'Unable to decrypt'."
      );
    }

    // Buscar mensajes en el thread panel
    const messages = threadPanel.locator(
      ".mx_EventTile_body, .mx_MTextBody"
    );
    const count = await messages.count();

    // Necesitamos al menos 2 mensajes (el del usuario + la respuesta del bot)
    if (count >= 2) {
      const lastMsg = await messages.last().textContent();
      if (lastMsg && options?.sender) {
        const senders = threadPanel.locator(
          ".mx_DisambiguatedProfile_displayName, .mx_SenderProfile_name"
        );
        const senderCount = await senders.count();
        if (senderCount > 0) {
          const lastSender = await senders.last().textContent();
          if (lastSender?.includes(options.sender)) {
            console.log(
              `[waitForThreadReply] Respuesta de "${options.sender}" en thread (${Date.now() - startTime}ms)`
            );
            return lastMsg;
          }
        }
      } else if (lastMsg) {
        console.log(
          `[waitForThreadReply] Respuesta en thread (${Date.now() - startTime}ms)`
        );
        return lastMsg;
      }
    }

    await page.waitForTimeout(500);
  }

  console.error(
    `[waitForThreadReply] TIMEOUT despues de ${timeout}ms`
  );
  await page.screenshot({
    path: `test-results/ERROR-timeout-thread-${Date.now()}.png`,
    fullPage: true,
  });

  throw new Error(
    `Timeout (${timeout}ms): el bot no respondio dentro del thread` +
      (options?.sender ? ` (sender esperado: ${options.sender})` : "")
  );
}

/**
 * Verifica que el bot NO respondio en el timeline principal tras enviar un thread.
 * Busca mensajes del bot en el timeline principal que no deberian estar ahi.
 * Retorna true si el timeline principal NO tiene respuesta del bot (correcto).
 */
export async function assertBotDidNotReplyInMainTimeline(
  page: Page,
  botName: string,
  afterText: string,
  checkDurationMs: number = 5_000
): Promise<void> {
  // Esperar un poco para dar tiempo al bot a responder (incorrectamente) en main
  await page.waitForTimeout(checkDurationMs);

  // Buscar mensajes en el timeline principal (fuera del thread panel)
  const mainTimeline = page.locator(".mx_RoomView_body");
  const botMessages = mainTimeline.locator(
    ".mx_EventTile_body, .mx_MTextBody"
  );
  const senders = mainTimeline.locator(
    ".mx_DisambiguatedProfile_displayName, .mx_SenderProfile_name"
  );

  const msgCount = await botMessages.count();
  const senderCount = await senders.count();

  // Verificar que el ultimo mensaje del timeline principal no es del bot
  // (despues de nuestro mensaje de thread)
  if (msgCount > 0 && senderCount > 0) {
    const lastSender = await senders.last().textContent();
    if (lastSender?.includes(botName)) {
      const lastMsg = await botMessages.last().textContent();
      // Si el ultimo mensaje del main timeline es del bot y es posterior
      // a nuestro mensaje original, el bot respondio fuera del thread
      if (lastMsg && lastMsg !== afterText) {
        throw new Error(
          `El bot respondio en el timeline principal en vez de en el thread. ` +
            `Ultimo mensaje del bot: "${lastMsg}"`
        );
      }
    }
  }
}

/**
 * Espera la respuesta del bot en un thread usando el Matrix SDK de Element.
 * No depende del panel de thread UI — consulta el timeline directamente.
 */
export async function waitForThreadReplyViaSdk(
  page: Page,
  options?: WaitForReplyOptions
): Promise<string> {
  const timeout = options?.timeout ?? 30_000;
  const startTime = Date.now();
  const senderFilter = options?.sender;

  console.log(
    `[waitForThreadReplyViaSdk] Esperando respuesta en thread (timeout: ${timeout}ms, sender: ${senderFilter || "any"})...`
  );

  while (Date.now() - startTime < timeout) {
    const reply = await page.evaluate(({ senderFilter }) => {
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      const client = (window as any).mxMatrixClientPeg?.get?.();
      if (!client) return null;

      // Scoped to current room only (via URL) to avoid false positives
      const hash = window.location.hash;
      const match = hash.match(/#\/room\/([^?/]+)/);
      const roomIdOrAlias = match ? decodeURIComponent(match[1]) : null;

      const rooms = client.getRooms().filter(
        (r: { getMyMembership: () => string; roomId: string }) => {
          if (r.getMyMembership() !== "join") return false;
          if (roomIdOrAlias) {
            return r.roomId === roomIdOrAlias ||
              r.roomId === roomIdOrAlias; // alias resolution handled below
          }
          return true;
        }
      );

      for (const room of rooms) {
        // Skip rooms that don't match the current URL room
        if (roomIdOrAlias && !roomIdOrAlias.startsWith("!")) {
          // For aliases, check if the room has this alias
          const aliases = room.getAltAliases?.() || [];
          const canonicalAlias = room.getCanonicalAlias?.();
          if (canonicalAlias !== roomIdOrAlias && !aliases.includes(roomIdOrAlias)) {
            continue;
          }
        }

        const timeline = room.getLiveTimeline().getEvents();
        // Buscar eventos que sean respuestas de thread (m.relates_to.rel_type === "m.thread")
        const threadReplies = timeline.filter((e: {
          getType: () => string;
          getContent: () => { "m.relates_to"?: { rel_type?: string } };
          getSender: () => string;
        }) => {
          if (e.getType() !== "m.room.message") return false;
          const content = e.getContent();
          const relatesTo = content["m.relates_to"];
          if (!relatesTo || relatesTo.rel_type !== "m.thread") return false;
          // Filtrar por sender si se especifico
          if (senderFilter) {
            const sender = e.getSender();
            // Verificar por display name
            const member = room.getMember(sender);
            const displayName = member?.name || sender;
            if (!displayName.includes(senderFilter)) return false;
          }
          return true;
        });

        if (threadReplies.length > 0) {
          const lastReply = threadReplies[threadReplies.length - 1];
          const content = lastReply.getContent();
          return content.body || content.formatted_body || "";
        }
      }
      return null;
    }, { senderFilter });

    if (reply) {
      console.log(
        `[waitForThreadReplyViaSdk] Respuesta encontrada (${Date.now() - startTime}ms): "${reply.substring(0, 80)}..."`
      );
      return reply;
    }

    await page.waitForTimeout(1_000);
  }

  throw new Error(
    `Timeout (${timeout}ms): el bot no respondio en el thread` +
      (senderFilter ? ` (sender: ${senderFilter})` : "")
  );
}

/**
 * Verifica que no hay mensajes "Unable to decrypt" en el timeline visible.
 * Lanza error descriptivo si los encuentra.
 */
export async function assertNoDecryptionErrors(page: Page) {
  const undecryptable = page.locator(
    '.mx_DecryptionFailureBody, [class*="UnableToDecrypt"]'
  );
  const texts = await undecryptable.allTextContents();
  if (texts.length > 0) {
    throw new Error(
      `E2EE error: ${texts.length} mensaje(s) no pudieron descifrarse. ` +
        "Verificar cross-signing y recovery key."
    );
  }
}
