/**
 * Bridge REST API — exposes Chat SDK fetch capabilities to the Python backend.
 *
 * The bot service is the single gateway for all platform communication.
 * These endpoints let the Python backend fetch messages, channels, and threads
 * without needing platform-specific SDKs.
 */

import type { IncomingMessage, ServerResponse } from "node:http";
import { Buffer } from "node:buffer";
import { timingSafeEqual } from "node:crypto";
import { lookup as dnsLookup } from "node:dns/promises";
import { isIP } from "node:net";
import type { SlackAdapter } from "@chat-adapter/slack";
import type { TeamsAdapter } from "@chat-adapter/teams";
import type { Message as ChatSDKMessage } from "chat";
import { cleanSlackMrkdwn } from "./slack-mrkdwn.js";
import type { ChatManager } from "./chat-manager.js";
export type { PlatformErrorShape } from "./bridge/platformError.js";
export { classifyPlatformError } from "./bridge/platformError.js";
import { classifyPlatformError } from "./bridge/platformError.js";
import {
  jsonResponse,
  readBody,
  BodyTooLargeError,
  messageForCode,
  safeErrorMessage,
} from "./http-utils.js";
export { jsonResponse, safeErrorMessage, messageForCode } from "./http-utils.js";
import { logger } from "./logger.js";

// ── Types ───────────────────────────────────────────────────────────────────

export interface NormalizedMessage {
  content: string;
  author: string;
  author_name: string;
  author_image: string | null;
  platform: string;
  channel_id: string;
  channel_name: string;
  message_id: string;
  timestamp: string;
  thread_id: string | null;
  attachments: Array<{ type: string; url?: string; name?: string }>;
  reactions: Array<{ name: string; count: number }>;
  reply_count: number;
  is_bot: boolean;
  subtype: string | null;
  links: Array<{ url: string; title?: string; description?: string; imageUrl?: string; siteName?: string }>;
}

export interface NormalizedChannel {
  channel_id: string;
  name: string;
  platform: string;
  is_member: boolean;
  member_count: number | null;
  topic: string | null;
  purpose: string | null;
}

// ── Platform Bridge interface ────────────────────────────────────────────────

interface GetMessagesOpts {
  limit: number;
  since?: string;
  before?: string;
  order?: string;
}

interface PlatformBridge {
  listChannels(): Promise<NormalizedChannel[]>;
  getChannel(id: string): Promise<NormalizedChannel>;
  getMessages(id: string, opts: GetMessagesOpts): Promise<NormalizedMessage[]>;
  getMessageCount(channelId: string): Promise<number>;
  getThreadMessages(channelId: string, threadId: string): Promise<NormalizedMessage[]>;
  proxyFile(url: string): Promise<{ contentType: string; buffer: Buffer }>;
  resolveUser(userId: string): Promise<{ name: string; image: string | null }>;
}

// ── Auth ────────────────────────────────────────────────────────────────────
//
// Security finding M1 mitigation: BRIDGE_API_KEY is required unconditionally.
// The legacy "no key configured = no auth" dev-mode bypass keyed off
// BEEVER_ENV/NODE_ENV is gone — a staging environment that forgot to set
// BEEVER_ENV=production used to land wide open.
//
// For genuine local development, set BRIDGE_ALLOW_UNAUTH="true" (strict
// string match) AND leave BRIDGE_API_KEY unset. Any other combination
// enforces the bearer.

function constantTimeEqual(a: string, b: string): boolean {
  const aBuf = Buffer.from(a);
  const bBuf = Buffer.from(b);
  if (aBuf.length !== bBuf.length) return false;
  return timingSafeEqual(aBuf, bBuf);
}

function unauthorized(res: ServerResponse): false {
  res.writeHead(401, { "Content-Type": "application/json" });
  res.end(JSON.stringify({ error: "Unauthorized", code: "AUTH_FAILED" }));
  return false;
}

export function checkAuth(
  req: IncomingMessage,
  res: ServerResponse,
): boolean {
  // Read env per-call so tests can mutate process.env between requests
  // and operators can swap keys without restarting (cost is negligible —
  // process.env lookup is a hash hit).
  const bridgeKey = process.env.BRIDGE_API_KEY || "";
  // Issue #34 — BRIDGE_ALLOW_UNAUTH is a no-op outside dev. Previously any
  // non-production environment honored the flag, which let operators
  // accidentally run the bridge wide-open on staging. The flag now requires
  // BEEVER_ENV === "development" (the explicit dev marker), not "anything
  // but production".
  const isDev = process.env.BEEVER_ENV === "development";
  const allowUnauth = isDev && process.env.BRIDGE_ALLOW_UNAUTH === "true";
  const hmacDual = process.env.BEEVER_BRIDGE_HMAC_DUAL === "true";

  if (!bridgeKey) {
    if (allowUnauth) return true; // explicit local-dev opt-in (BEEVER_ENV=development gate)
    return unauthorized(res);
  }

  const authHeader = req.headers.authorization || "";
  const expected = `Bearer ${bridgeKey}`;
  if (!constantTimeEqual(authHeader, expected)) {
    // Issue #49 — keep the comparison constant-time on every path.
    // Previously this used `authHeader === expected`, which leaks
    // string-comparison timing information about the bearer token
    // (only exploitable when BEEVER_BRIDGE_HMAC_DUAL=true is set; the
    // path is opt-in and slated for removal next release per the
    // existing deprecation note). The branch is logically equivalent
    // to the outer check (same inputs → same equality outcome), so
    // it can never accept where the outer rejected — but using
    // `constantTimeEqual` here makes the contract explicit and
    // survives any future change to `expected` (e.g. multi-key
    // rotation) that would make the branch reachable.
    if (hmacDual && constantTimeEqual(authHeader, expected)) {
      console.warn(
        "Bridge auth: accepted via legacy == path (BEEVER_BRIDGE_HMAC_DUAL). Retire flag next release.",
      );
      return true;
    }
    return unauthorized(res);
  }
  return true;
}

/**
 * Fail-fast guard for production misconfiguration. Called from
 * `registerBridgeRoutes` the first time the bridge is wired up, so
 * importing this module (e.g. from a test) is a pure side-effect-free
 * operation.
 */
export function assertBridgeAuthReady(): void {
  const bridgeKey = process.env.BRIDGE_API_KEY || "";
  // Issue #34 — match the new dev-only gate from `checkAuth`. The startup
  // warning only fires for the *effective* unauth state, not the literal
  // env value; setting BRIDGE_ALLOW_UNAUTH=true outside dev is silently
  // ignored at request time (the bridge stays locked).
  const isDev = process.env.BEEVER_ENV === "development";
  const allowUnauth = isDev && process.env.BRIDGE_ALLOW_UNAUTH === "true";
  const isProd =
    process.env.BEEVER_ENV === "production" ||
    process.env.NODE_ENV === "production";

  if (!bridgeKey && isProd) {
    console.error(
      "FATAL: BRIDGE_API_KEY is required in production (BEEVER_ENV/NODE_ENV=production)",
    );
    process.exit(1);
  }

  if (!bridgeKey && allowUnauth) {
    console.warn(
      "⚠️  BRIDGE_ALLOW_UNAUTH=true (with BEEVER_ENV=development) — running without bridge authentication. Do NOT use in staging or production.",
    );
  } else if (
    !bridgeKey &&
    process.env.BRIDGE_ALLOW_UNAUTH === "true" &&
    !isDev
  ) {
    // Operator set the flag but BEEVER_ENV is not 'development' — surface
    // a loud warning so they know the flag is being ignored and the bridge
    // will return 401 on every call.
    console.warn(
      "⚠️  BRIDGE_ALLOW_UNAUTH=true is IGNORED unless BEEVER_ENV=development. The bridge will return 401 until BRIDGE_API_KEY is set or BEEVER_ENV is explicitly 'development'.",
    );
  }
}

// ── SSRF guard for proxyFile: block RFC1918, loopback, link-local, cloud metadata ──

const PRIVATE_V4_RANGES: Array<[number, number]> = [
  [0x0a000000, 0xff000000], // 10/8
  [0xac100000, 0xfff00000], // 172.16/12
  [0xc0a80000, 0xffff0000], // 192.168/16
  [0x7f000000, 0xff000000], // 127/8
  [0xa9fe0000, 0xffff0000], // 169.254/16 (incl. 169.254.169.254)
  [0x64400000, 0xffc00000], // 100.64/10 CGNAT
  [0x00000000, 0xff000000], // 0/8
];

function ipv4ToInt(ip: string): number {
  const parts = ip.split(".").map((p) => Number(p));
  if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) {
    return -1;
  }
  return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
}

function isPrivateIP(ip: string): boolean {
  const family = isIP(ip);
  if (family === 4) {
    const n = ipv4ToInt(ip);
    if (n < 0) return true;
    for (const [base, mask] of PRIVATE_V4_RANGES) {
      if ((n & mask) >>> 0 === (base & mask) >>> 0) return true;
    }
    return false;
  }
  if (family === 6) {
    const lc = ip.toLowerCase();
    if (lc === "::1" || lc === "::") return true;
    if (lc.startsWith("fe80") || lc.startsWith("fc") || lc.startsWith("fd")) return true;
    if (lc.includes(".")) {
      const tail = lc.slice(lc.lastIndexOf(":") + 1);
      if (isIP(tail) === 4 && isPrivateIP(tail)) return true;
    }
    return false;
  }
  return true;
}

export async function assertPublicUrl(rawUrl: string): Promise<void> {
  let parsed: URL;
  try {
    parsed = new URL(rawUrl);
  } catch {
    throw new Error("invalid URL");
  }
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
    throw new Error(`unsupported scheme: ${parsed.protocol}`);
  }
  const host = parsed.hostname;
  if (!host) throw new Error("missing host");
  if (isIP(host)) {
    if (isPrivateIP(host)) throw new Error(`blocked private IP literal: ${host}`);
    return;
  }
  const { address } = await dnsLookup(host);
  if (isPrivateIP(address)) {
    throw new Error(`host ${host} resolved to private IP ${address}`);
  }
}

/**
 * Pure host-string matcher. Matches by EXACT lowercase hostname — no
 * substring, no suffix-by-default. Pass an entry prefixed with "." (e.g.
 * `.sharepoint.com`) to allow that suffix as a proper subdomain match
 * (`a.sharepoint.com` matches, `sharepoint.com` does NOT).
 *
 * Exported so per-platform allowlist semantics can be unit-tested without
 * touching DNS.
 */
export function isHostAllowed(host: string, allowedHosts: ReadonlyArray<string>): boolean {
  const lower = host.toLowerCase();
  for (const entry of allowedHosts) {
    const normalized = entry.toLowerCase();
    if (normalized.startsWith(".")) {
      if (lower.endsWith(normalized) && lower.length > normalized.length) return true;
    } else if (lower === normalized) {
      return true;
    }
  }
  return false;
}

/**
 * Composes a strict host allowlist with `assertPublicUrl`.
 *
 * `assertPublicUrl` alone only blocks RFC1918/loopback/link-local/cloud-metadata
 * targets — it accepts ANY public host. For token-bearing fetches (Slack/
 * Mattermost bot tokens, Discord bot tokens, Telegram bot tokens) that's
 * insufficient: an attacker who can route any URL through the proxy could
 * exfiltrate the token to a public host they control.
 *
 * Order matters: the allowlist check runs BEFORE the DNS resolution in
 * `assertPublicUrl`. Non-allowlisted hosts are rejected immediately with no
 * DNS lookup, so attacker URLs do not leak to our resolver. Allowlisted hosts
 * still pass through `assertPublicUrl` for the private-IP guard (defense
 * against DNS poisoning where a trusted host resolves to a private IP).
 *
 * Returns the parsed URL on success so callers don't have to parse twice.
 */
export async function assertAllowedFetchUrl(
  rawUrl: string,
  allowedHosts: ReadonlyArray<string>,
): Promise<URL> {
  let parsed: URL;
  try {
    parsed = new URL(rawUrl);
  } catch {
    throw new Error("invalid URL");
  }
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
    throw new Error(`unsupported scheme: ${parsed.protocol}`);
  }
  if (!parsed.hostname) throw new Error("missing host");
  if (!isHostAllowed(parsed.hostname, allowedHosts)) {
    throw new Error(`host not in allowlist: ${parsed.hostname.toLowerCase()}`);
  }
  await assertPublicUrl(rawUrl);
  return parsed;
}

// ── Helpers ─────────────────────────────────────────────────────────────────

// jsonResponse is imported from ./http-utils.js above and re-exported.

/** Default and maximum message fetch limits for history routes. */
export const DEFAULT_MESSAGE_LIMIT = 100;
export const MAX_MESSAGE_LIMIT = 500;

function parseQuery(url: string): URLSearchParams {
  const idx = url.indexOf("?");
  return new URLSearchParams(idx >= 0 ? url.slice(idx + 1) : "");
}

// classifyPlatformError is imported from ./bridge/platformError.js above and re-exported.

// readBody is imported from ./http-utils.js (with MAX_BODY_SIZE cap and BodyTooLargeError).

// ── User profile cache (module-level, persists across requests) ─────────────

const userProfileCache = new Map<string, { name: string; image: string | null }>();
const USER_LOOKUP_CONCURRENCY = 8;

/** RES-286 — let callers (notably ChatManager's scheduled adapter recycle)
 *  drop this cross-platform user-profile cache. Entries here are name+avatar
 *  lookups against Slack/Discord/Teams/etc. — re-fetching is cheap and the
 *  Map is otherwise unbounded over the bot's lifetime. */
export function clearUserProfileCache(): void {
  userProfileCache.clear();
}

// ── SlackBridge ──────────────────────────────────────────────────────────────

/** Hosts allowed for token-bearing Slack file fetches (CodeQL alert #27).
 *  Must be EXACT host matches — no substring, no wildcard suffix. */
const SLACK_FILE_HOSTS: readonly string[] = ["files.slack.com", "slack-files.com"];

/** Hosts allowed for Discord CDN / attachment fetches (CodeQL alert #29). */
const DISCORD_FILE_HOSTS: readonly string[] = ["cdn.discordapp.com", "media.discordapp.net"];

/** Host allowed for the Discord REST API (CodeQL alert #28). */
const DISCORD_API_HOST = "discord.com";

/** Exact-match hosts for Teams attachment fetches (CodeQL alert #30).
 *  Restricted to `graph.microsoft.com` because that's the only host the
 *  CodeQL `HostnameSanitizerGuard` can verify via a literal-prefix
 *  `startsWith` — tenant SharePoint subdomains can't be enumerated at
 *  compile time. Production Teams adapters route file content through
 *  the Graph `/sites/.../drive/items/.../content` endpoint, so SharePoint
 *  direct links are not the common case. */
const TEAMS_EXACT_HOSTS: readonly string[] = ["graph.microsoft.com"];

/** Host allowed for Telegram bot API + file fetches (CodeQL alert #31). */
const TELEGRAM_FILE_HOSTS: readonly string[] = ["api.telegram.org"];

/**
 * Assert host is in the allowlist + scheme is http(s) + the underlying
 * IP is not private/loopback/cloud-metadata. Returns `void` so the
 * tainted URL value never flows back through this helper into a `fetch`
 * argument — every call site builds its own `safeUrl` literal in scope.
 *
 * CodeQL `js/request-forgery` recognises only a narrow set of
 * sanitizers (per `RequestForgeryCustomizations.qll` /
 * `RequestForgeryQuery.qll`):
 *   - `Sanitizer` instances — primarily `UriEncodingSanitizer`
 *     (`encodeURIComponent`, with `encodesPathSeparators()` true).
 *   - `sanitizingPrefixEdge` (NOT `hostnameSanitizingPrefixEdge`!) —
 *     a string-concat operand preceding the tainted one must contain
 *     `?` or `#` to qualify.
 *
 * The previous-attempt `HostnameSanitizerGuard` (`startsWith` with a
 * literal `https://host/` prefix) is wired into the URL-redirect
 * queries (`ServerSideUrlRedirectConfig`) but NOT into the request-
 * forgery configuration. So `startsWith`-based guards on the same SSA
 * variable do nothing for `js/request-forgery`. The only practical
 * sanitizer for path-shaped tainted URL data is `encodeURIComponent`
 * — see `safeBuildUrl` below.
 */
async function assertHostAllowedAndPublic(
  parsed: URL,
  allowedHosts: readonly string[],
): Promise<void> {
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
    throw new Error(`unsupported scheme: ${parsed.protocol}`);
  }
  if (!allowedHosts.includes(parsed.hostname.toLowerCase())) {
    throw new Error(`host not in allowlist: ${parsed.hostname.toLowerCase()}`);
  }
  await assertPublicUrl(parsed.href);
}

/**
 * Per-segment `encodeURIComponent` sanitization for a URL pathname.
 *
 * `encodeURIComponent` is the only `Sanitizer` recognised by CodeQL's
 * `js/request-forgery` configuration (`UriEncodingSanitizer` with
 * `encodesPathSeparators()` true — see `Xss.qll`). Splitting on `/`
 * and re-joining with literal `/` keeps the URL path structure intact
 * at runtime, while ensuring every tainted segment passes through a
 * sanitizer barrier before reaching the concatenation that produces
 * the fetch URL.
 *
 * `decodeURIComponent` is run first so that already-encoded inputs
 * (e.g. Slack pre-signed URLs that contain `%xx` sequences) round-trip
 * exactly — the encode-then-decode is a no-op for valid URL components.
 */
function encodeUrlPathSegments(pathname: string): string {
  // Split on `/` so that path separators are preserved as literals.
  // Each non-empty segment is round-tripped through encode/decode to
  // ensure CodeQL sees a `UriEncodingSanitizer` barrier on every
  // tainted operand of the eventual URL concatenation.
  return pathname
    .split("/")
    .map((seg) => {
      if (seg.length === 0) return "";
      let decoded: string;
      try {
        decoded = decodeURIComponent(seg);
      } catch {
        // Malformed `%xx` — fall back to the raw segment, which
        // `encodeURIComponent` will then percent-encode safely.
        decoded = seg;
      }
      return encodeURIComponent(decoded);
    })
    .join("/");
}

/**
 * Per-pair `encodeURIComponent` sanitization for a URL search string
 * (e.g. `"?a=1&b=two"`). Returns the empty string if the search is
 * empty or doesn't begin with `?`.
 */
function encodeUrlSearch(search: string): string {
  if (!search || !search.startsWith("?")) return "";
  const body = search.slice(1);
  if (body.length === 0) return "?";
  const safePairs = body.split("&").map((pair) => {
    const eq = pair.indexOf("=");
    const safeDecode = (s: string) => {
      try {
        return decodeURIComponent(s);
      } catch {
        return s;
      }
    };
    if (eq < 0) return encodeURIComponent(safeDecode(pair));
    const k = encodeURIComponent(safeDecode(pair.slice(0, eq)));
    const v = encodeURIComponent(safeDecode(pair.slice(eq + 1)));
    return `${k}=${v}`;
  });
  return `?${safePairs.join("&")}`;
}

/**
 * Decode a single percent-encoded channel-id or thread-id route segment.
 *
 * Connection routes capture the channel id with `([^/]+)` and pass it through
 * verbatim. Teams channel ids contain `:` and `@` (e.g.
 * `19:abc@thread.tacv2`), which an HTTP client may percent-encode to
 * `19%3Aabc%40thread.tacv2`. Without decoding, the id no longer matches the
 * `19:…@thread` channel shape and downstream Graph reads misfire. This is a
 * no-op for ids without `%` sequences (Slack `C0…`, Discord/Telegram numeric,
 * Mattermost alphanumeric), so it is safe for every platform. Malformed `%xx`
 * falls back to the raw segment.
 *
 * Applied at ALL route callsites that capture a channel-id or thread-id
 * segment: connection-scoped, platform-prefixed, and legacy routes (H1).
 */
function decodeChannelSegment(segment: string): string {
  try {
    return decodeURIComponent(segment);
  } catch {
    return segment;
  }
}

class SlackBridge implements PlatformBridge {
  private adapter: SlackAdapter;
  private _token?: string;

  constructor(adapter: SlackAdapter) {
    this.adapter = adapter;
  }

  /**
   * Resolve the connection's bot token. @chat-adapter/slack@4.28.x no longer
   * authenticates the raw WebClient — it resolves the token per request via
   * `defaultBotTokenProvider()`. Calling `client.<method>()` without a token
   * therefore fails with `not_authed` (→ 403). Every Web API call below passes
   * this token explicitly. Cached: one connection has exactly one bot token.
   */
  private async token(): Promise<string | undefined> {
    if (this._token) return this._token;
    const provider = (this.adapter as any).defaultBotTokenProvider;
    this._token = typeof provider === "function"
      ? await provider()
      : (this.adapter as any).botToken;
    return this._token;
  }

  async resolveUser(userId: string): Promise<{ name: string; image: string | null }> {
    if (userProfileCache.has(userId)) return userProfileCache.get(userId)!;
    try {
      const result = await (this.adapter as any).client.users.info({ user: userId, token: await this.token() });
      const profile = result.user?.profile;
      const resolved = {
        name: result.user?.real_name || result.user?.name || userId,
        image: profile?.image_48 || profile?.image_72 || null,
      };
      userProfileCache.set(userId, resolved);
      return resolved;
    } catch {
      const fallback = { name: userId, image: null };
      userProfileCache.set(userId, fallback);
      return fallback;
    }
  }

  async listChannels(): Promise<NormalizedChannel[]> {
    const channels: NormalizedChannel[] = [];
    let cursor: string | undefined;

    do {
      const result = await (this.adapter as any).client.conversations.list({
        types: "public_channel,private_channel",
        limit: 200,
        exclude_archived: true,
        token: await this.token(),
        ...(cursor ? { cursor } : {}),
      });

      for (const ch of result.channels || []) {
        channels.push({
          channel_id: ch.id,
          name: ch.name || "",
          platform: "slack",
          is_member: ch.is_member ?? false,
          member_count: ch.num_members ?? null,
          topic: ch.topic?.value ?? null,
          purpose: ch.purpose?.value ?? null,
        });
      }

      cursor = result.response_metadata?.next_cursor;
    } while (cursor);

    return channels;
  }

  async getChannel(id: string): Promise<NormalizedChannel> {
    const result = await (this.adapter as any).client.conversations.info({ channel: id, token: await this.token() });
    const ch = result.channel;
    return {
      channel_id: id,
      name: ch?.name || "",
      platform: "slack",
      is_member: ch?.is_member ?? false,
      member_count: ch?.num_members ?? null,
      topic: ch?.topic?.value ?? null,
      purpose: ch?.purpose?.value ?? null,
    };
  }

  async getMessages(channelId: string, opts: GetMessagesOpts): Promise<NormalizedMessage[]> {
    const historyParams: Record<string, unknown> = {
      channel: channelId,
      limit: opts.limit,
      token: await this.token(),
    };
    if (opts.since) {
      const sinceEpoch = new Date(opts.since).getTime() / 1000;
      historyParams.oldest = String(sinceEpoch);
    }
    if (opts.before) {
      historyParams.latest = opts.before;
    }

    const result = await (this.adapter as any).client.conversations.history(historyParams);
    const rawMessages = result.messages || [];

    // Get channel name
    let channelName = "";
    try {
      const chInfo = await (this.adapter as any).client.conversations.info({ channel: channelId, token: await this.token() });
      channelName = chInfo.channel?.name || "";
    } catch { /* ignore */ }

    // Resolve user profiles
    const userIds: string[] = [...new Set<string>(
      rawMessages
        .filter((m: any) => m.user && !m.bot_id)
        .map((m: any) => m.user as string),
    )];
    const userMap = new Map<string, { name: string; image: string | null }>();
    for (let i = 0; i < userIds.length; i += USER_LOOKUP_CONCURRENCY) {
      const chunk = userIds.slice(i, i + USER_LOOKUP_CONCURRENCY);
      const resolved = await Promise.all(
        chunk.map(async (uid: string) => [uid, await this.resolveUser(uid)] as const),
      );
      for (const [uid, profile] of resolved) {
        userMap.set(uid, profile);
      }
    }

    const messages: NormalizedMessage[] = rawMessages.map((msg: any) => {
      const authorId: string = msg.user || msg.bot_id || "unknown";
      const userInfo = userMap.get(authorId);
      const subtype: string | undefined = msg.subtype;
      const detectedBot = !!msg.bot_id || subtype === "bot_message";
      const rawText: string = msg.text || "";
      const threadTs: string | undefined = msg.thread_ts;

      return {
        content: cleanSlackMrkdwn(rawText, userMap),
        author: authorId,
        author_name: msg.username || userInfo?.name || authorId,
        author_image: userInfo?.image || null,
        platform: "slack",
        channel_id: channelId,
        channel_name: channelName ? `#${channelName}` : "",
        message_id: msg.ts || "",
        timestamp: new Date(Number.parseFloat(msg.ts || "0") * 1000).toISOString(),
        thread_id: threadTs && threadTs !== msg.ts ? threadTs : null,
        attachments: [
          ...(msg.files || []).map((f: any) => ({
            type: f.mimetype?.startsWith("image/") ? "image"
                : f.mimetype?.startsWith("video/") ? "video"
                : "file",
            url: f.url_private_download || f.url_private || f.permalink,
            name: f.name || f.title,
            mimetype: f.mimetype || "",
          })),
          ...(msg.attachments || [])
            .filter((a: any) => a.image_url && !a.from_url && !a.original_url)
            .map((a: any) => ({
              type: "image" as const,
              url: a.image_url,
              name: a.title || a.fallback || "Image",
              mimetype: "image/png",
            })),
        ],
        reactions: (msg.reactions || []).map((r: any) => ({
          name: r.name,
          count: r.count,
        })),
        reply_count: msg.reply_count || 0,
        is_bot: detectedBot,
        subtype: subtype || null,
        links: (msg.attachments || [])
          .filter((a: any) => a.from_url || a.original_url)
          .map((a: any) => ({
            url: a.from_url || a.original_url,
            title: a.title,
            description: a.text || a.fallback,
            imageUrl: a.image_url || a.thumb_url,
            siteName: a.service_name,
          })),
      };
    });

    if (opts.order === "asc") {
      messages.reverse();
    }
    return messages;
  }

  async getMessageCount(channelId: string): Promise<number> {
    let count = 0;
    let cursor: string | undefined;
    do {
      const params: Record<string, unknown> = { channel: channelId, limit: 200, token: await this.token() };
      if (cursor) params.cursor = cursor;
      const result = await (this.adapter as any).client.conversations.history(params);
      count += (result.messages || []).length;
      cursor = result.response_metadata?.next_cursor || undefined;
    } while (cursor);
    return count;
  }

  async getThreadMessages(channelId: string, threadId: string): Promise<NormalizedMessage[]> {
    const result = await (this.adapter as any).client.conversations.replies({
      channel: channelId,
      ts: threadId,
      limit: 200,
      token: await this.token(),
    });

    const rawReplies = result.messages || [];

    const userIds: string[] = [...new Set<string>(
      rawReplies
        .filter((m: any) => m.user && !m.bot_id)
        .map((m: any) => m.user as string),
    )];
    const userMap = new Map<string, { name: string; image: string | null }>();
    for (let i = 0; i < userIds.length; i += USER_LOOKUP_CONCURRENCY) {
      const chunk = userIds.slice(i, i + USER_LOOKUP_CONCURRENCY);
      const resolved = await Promise.all(
        chunk.map(async (uid: string) => [uid, await this.resolveUser(uid)] as const),
      );
      for (const [uid, profile] of resolved) {
        userMap.set(uid, profile);
      }
    }

    return rawReplies.map((msg: any) => {
      const authorId: string = msg.user || msg.bot_id || "unknown";
      const userInfo = userMap.get(authorId);
      const subtype: string | undefined = msg.subtype;
      const detectedBot = !!msg.bot_id || subtype === "bot_message";
      const rawText: string = msg.text || "";

      return {
        content: cleanSlackMrkdwn(rawText, userMap),
        author: authorId,
        author_name: msg.username || userInfo?.name || authorId,
        author_image: userInfo?.image || null,
        platform: "slack",
        channel_id: channelId,
        channel_name: "",
        message_id: msg.ts || "",
        timestamp: new Date(Number.parseFloat(msg.ts || "0") * 1000).toISOString(),
        thread_id: threadId,
        attachments: (msg.files || []).map((f: any) => ({
          type: f.mimetype?.startsWith("image/") ? "image" : "file",
          url: f.url_private,
          name: f.name,
          mimetype: f.mimetype || "",
        })),
        reactions: (msg.reactions || []).map((r: any) => ({
          name: r.name,
          count: r.count,
        })),
        reply_count: 0,
        is_bot: detectedBot,
        subtype: subtype || null,
        links: [],
      };
    });
  }

  // ── Request queue: serializes Slack file API calls to avoid rate-limit bursts ──
  private fileRequestQueue: Promise<void> = Promise.resolve();
  private static readonly FILE_REQUEST_SPACING_MS = 200; // minimum ms between file requests

  async proxyFile(fileUrl: string): Promise<{ contentType: string; buffer: Buffer }> {
    // Serialize file requests to avoid hitting Slack rate limits
    return new Promise((resolve, reject) => {
      this.fileRequestQueue = this.fileRequestQueue
        .then(() => this._proxyFileInner(fileUrl).then(resolve, reject))
        .then(() => new Promise<void>((r) => setTimeout(r, SlackBridge.FILE_REQUEST_SPACING_MS)));
    });
  }

  private async _proxyFileInner(
    fileUrl: string,
    retries = 3,
  ): Promise<{ contentType: string; buffer: Buffer }> {
    const decodedUrl = decodeURIComponent(fileUrl);
    // CodeQL js/request-forgery (alerts #52/#56): inline URL parsing,
    // host allowlist + private-IP guard, then per-segment
    // `encodeURIComponent` rebuild. `encodeURIComponent` is the only
    // sanitizer wired into `RequestForgeryConfig` for path-shaped data
    // (`UriEncodingSanitizer`, see `Xss.qll`). `startsWith`-based
    // `HostnameSanitizerGuard` is NOT used by the request-forgery query
    // — it only barriers the URL-redirect queries.
    let parsedSlack: URL;
    try {
      parsedSlack = new URL(decodedUrl);
    } catch {
      throw new Error("invalid Slack file URL");
    }
    await assertHostAllowedAndPublic(parsedSlack, SLACK_FILE_HOSTS);
    const safeSlackPath = encodeUrlPathSegments(parsedSlack.pathname);
    const safeSlackSearch = encodeUrlSearch(parsedSlack.search);
    let slackSafeUrl: string;
    if (parsedSlack.hostname.toLowerCase() === "files.slack.com") {
      slackSafeUrl = `https://files.slack.com${safeSlackPath}${safeSlackSearch}`;
    } else if (parsedSlack.hostname.toLowerCase() === "slack-files.com") {
      slackSafeUrl = `https://slack-files.com${safeSlackPath}${safeSlackSearch}`;
    } else {
      throw new Error("Slack file URL did not match expected host");
    }
    const token = (this.adapter as any).defaultBotToken || (this.adapter as any).getToken();

    let response = await fetch(slackSafeUrl, {
      headers: { Authorization: `Bearer ${token}` },
    });

    // Retry on 429 with exponential backoff
    if (response.status === 429 && retries > 0) {
      const retryAfter = parseInt(response.headers.get("retry-after") || "2", 10);
      const waitMs = retryAfter * 1000;
      logger.debug(`Bridge: Slack rate limited (429), retrying after ${retryAfter}s (${retries} retries left)`);
      await new Promise((r) => setTimeout(r, waitMs));
      return this._proxyFileInner(fileUrl, retries - 1);
    }

    const contentType = response.headers.get("content-type") || "";
    if (contentType.includes("text/html") && decodedUrl.includes("files-pri")) {
      logger.debug("Bridge: fileProxy got HTML, trying files.sharedPublicURL fallback");
      const match = decodedUrl.match(/files-pri\/[^/]+-(F[^/]+)\//);
      if (match) {
        const fileId = match[1];
        try {
          const fileInfo = await (this.adapter as any).client.files.info({ file: fileId });
          const downloadUrl = fileInfo.file?.url_private_download || fileInfo.file?.url_private;
          if (downloadUrl) {
            // Defense-in-depth: even though downloadUrl came from Slack's
            // files.info API, validate before sending the bot token.
            // Same encodeURIComponent-per-segment pattern as primary fetch.
            let parsedFallback: URL;
            try {
              parsedFallback = new URL(downloadUrl);
            } catch {
              throw new Error("invalid Slack fallback URL");
            }
            await assertHostAllowedAndPublic(parsedFallback, SLACK_FILE_HOSTS);
            const safeFallbackPath = encodeUrlPathSegments(parsedFallback.pathname);
            const safeFallbackSearch = encodeUrlSearch(parsedFallback.search);
            let fallbackSafeUrl: string;
            if (parsedFallback.hostname.toLowerCase() === "files.slack.com") {
              fallbackSafeUrl = `https://files.slack.com${safeFallbackPath}${safeFallbackSearch}`;
            } else if (parsedFallback.hostname.toLowerCase() === "slack-files.com") {
              fallbackSafeUrl = `https://slack-files.com${safeFallbackPath}${safeFallbackSearch}`;
            } else {
              throw new Error("Slack fallback URL did not match expected host");
            }
            response = await fetch(fallbackSafeUrl, {
              headers: { Authorization: `Bearer ${token}` },
            });
            // Retry on 429 for fallback URL too
            if (response.status === 429 && retries > 0) {
              const retryAfter = parseInt(response.headers.get("retry-after") || "2", 10);
              logger.debug(`Bridge: Slack rate limited on fallback (429), retrying after ${retryAfter}s`);
              await new Promise((r) => setTimeout(r, retryAfter * 1000));
              return this._proxyFileInner(fileUrl, retries - 1);
            }
          }
        } catch (e) {
          console.log("Bridge: files.info fallback failed:", e);
        }
      }
    }

    if (!response.ok) {
      throw new Error(`Failed to fetch file: ${response.status}`);
    }

    const finalContentType = response.headers.get("content-type") || "application/octet-stream";
    if (finalContentType.includes("text/html")) {
      throw new Error("File proxy returned HTML instead of file content — file may be deleted or inaccessible");
    }
    const buffer = Buffer.from(await response.arrayBuffer());
    return { contentType: finalContentType, buffer };
  }
}

// ── DiscordBridge ─────────────────────────────────────────────────────────────

class DiscordBridge implements PlatformBridge {
  private adapter: unknown;
  private botToken: string;

  // ── Request queue: serializes Discord API calls to avoid bursts ──
  private requestQueue: Promise<void> = Promise.resolve();
  private static readonly REQUEST_SPACING_MS = 100; // minimum ms between requests

  // ── Caches ──
  private channelCache: { data: NormalizedChannel[]; expiresAt: number } | null = null;
  private static readonly CHANNEL_CACHE_TTL_MS = 300_000; // 5 minutes — reduces Discord API rate limit pressure
  private channelFetchInFlight: Promise<NormalizedChannel[]> | null = null; // dedup concurrent calls

  constructor(adapter: unknown) {
    this.adapter = adapter;
    this.botToken = (adapter as any).botToken;
  }

  /**
   * Convenience wrapper for Discord REST API calls.
   * Requests are serialized through a queue with spacing to prevent bursts,
   * and rate-limit 429 responses are retried with the server-provided delay.
   */
  private discordApi(path: string, retries = 3): Promise<any> {
    return new Promise((resolve, reject) => {
      this.requestQueue = this.requestQueue
        .then(() => this.executeDiscordRequest(path, retries))
        .then(resolve, reject);
    });
  }

  private async executeDiscordRequest(path: string, retries: number): Promise<any> {
    // CodeQL js/request-forgery (alert #28): regex-validate `path` to
    // the relative-path shape Discord's REST API uses. Combined with
    // the literal-host concatenation below + the inline startsWith
    // sanitizer guard, this gives the data-flow analysis a complete
    // chain of recognised barriers.
    if (!/^\/[A-Za-z0-9_\-./?&=,@%:]*$/.test(path)) {
      throw new Error("invalid Discord API path");
    }
    // Defense in depth: the regex above permits `.` so `..` would slip
    // through as a literal character class match. Reject any path that
    // contains `..` or `//` to prevent traversal / authority injection
    // even though the literal-host concat already prevents host change.
    if (path.includes("..") || path.includes("//")) {
      throw new Error("invalid Discord API path");
    }
    const apiUrl = `https://${DISCORD_API_HOST}/api/v10${path}`;
    // CodeQL HostnameSanitizerGuard — inline startsWith on the
    // concatenated URL with a literal `https://<host>/` prefix.
    if (!apiUrl.startsWith("https://discord.com/")) {
      throw new Error("Discord API URL did not match expected prefix");
    }
    const res = await fetch(apiUrl, {
      headers: { Authorization: `Bot ${this.botToken}` },
    });

    if (res.status === 429 && retries > 0) {
      const rawRetryAfter = parseFloat(res.headers.get("retry-after") || "2") * 1000;
      // Cap wait at 5s — longer waits block the request pipeline and cause frontend timeouts
      const retryAfter = Math.min(rawRetryAfter, 5000);
      console.warn(`DiscordBridge: rate limited on ${path}, retrying in ${retryAfter}ms (server asked ${rawRetryAfter}ms)`);
      await new Promise((r) => setTimeout(r, retryAfter));
      return this.executeDiscordRequest(path, retries - 1);
    }

    if (!res.ok) {
      throw new Error(`Discord API ${path}: ${res.status} ${res.statusText}`);
    }

    // Space out requests to stay under Discord's rate limits
    await new Promise((r) => setTimeout(r, DiscordBridge.REQUEST_SPACING_MS));

    return res.json();
  }

  async resolveUser(userId: string): Promise<{ name: string; image: string | null }> {
    if (userProfileCache.has(userId)) return userProfileCache.get(userId)!;
    try {
      const user = await this.discordApi(`/users/${userId}`);
      const avatarUrl = user.avatar
        ? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png`
        : null;
      const resolved = {
        name: user.global_name || user.username || userId,
        image: avatarUrl,
      };
      userProfileCache.set(userId, resolved);
      return resolved;
    } catch {
      const fallback = { name: userId, image: null };
      userProfileCache.set(userId, fallback);
      return fallback;
    }
  }

  async listChannels(): Promise<NormalizedChannel[]> {
    // Return cached result if still fresh
    if (this.channelCache && Date.now() < this.channelCache.expiresAt) {
      return this.channelCache.data;
    }

    // Deduplicate: if a fetch is already in flight, share the same promise
    if (this.channelFetchInFlight) {
      return this.channelFetchInFlight;
    }

    this.channelFetchInFlight = this._fetchChannelsFromDiscord();
    try {
      return await this.channelFetchInFlight;
    } finally {
      this.channelFetchInFlight = null;
    }
  }

  private async _fetchChannelsFromDiscord(): Promise<NormalizedChannel[]> {
    try {
      const guilds: any[] = await this.discordApi("/users/@me/guilds");
      const channels: NormalizedChannel[] = [];

      for (const guild of guilds) {
        try {
          const guildChannels: any[] = await this.discordApi(`/guilds/${guild.id}/channels`);
          const textTypes = new Set([0, 5, 15]);
          for (const ch of guildChannels) {
            if (textTypes.has(ch.type)) {
              channels.push({
                channel_id: ch.id,
                name: ch.name || "",
                platform: "discord",
                is_member: true,
                member_count: ch.member_count ?? null,
                topic: ch.topic ?? null,
                purpose: null,
              });
            }
          }
        } catch (err) {
          console.warn(`DiscordBridge: failed to list channels for guild ${guild.id}:`, safeErrorMessage(err));
        }
      }

      this.channelCache = {
        data: channels,
        expiresAt: Date.now() + DiscordBridge.CHANNEL_CACHE_TTL_MS,
      };
      return channels;
    } catch (err) {
      // If we have stale cached data, return it instead of empty
      if (this.channelCache) {
        console.warn("DiscordBridge: listChannels failed, returning stale cache:", safeErrorMessage(err));
        return this.channelCache.data;
      }
      console.error("DiscordBridge: listChannels error (no cache):", safeErrorMessage(err));
      return [];
    }
  }

  async getChannel(id: string): Promise<NormalizedChannel> {
    const ch = await this.discordApi(`/channels/${id}`);
    return {
      channel_id: id,
      name: ch.name || "",
      platform: "discord",
      is_member: true,
      member_count: ch.member_count ?? null,
      topic: ch.topic ?? null,
      purpose: null,
    };
  }

  async getMessages(channelId: string, opts: GetMessagesOpts): Promise<NormalizedMessage[]> {
    const limit = Math.min(opts.limit, 100);
    const ch = await this.discordApi(`/channels/${channelId}`);
    let messagesUrl = `/channels/${channelId}/messages?limit=${limit}`;
    if (opts.before) {
      messagesUrl += `&before=${opts.before}`;
    }
    const rawMessages: any[] = await this.discordApi(messagesUrl);
    const messages: NormalizedMessage[] = [];

    for (const m of rawMessages) {
      const avatarUrl = m.author?.avatar
        ? `https://cdn.discordapp.com/avatars/${m.author.id}/${m.author.avatar}.png`
        : null;
      messages.push({
        content: m.content || "",
        author: m.author?.id || "unknown",
        author_name: m.author?.global_name || m.author?.username || "unknown",
        author_image: avatarUrl,
        platform: "discord",
        channel_id: channelId,
        channel_name: ch.name || "",
        message_id: m.id,
        timestamp: m.timestamp || new Date().toISOString(),
        thread_id: m.message_reference?.message_id ?? null,
        attachments: (m.attachments ?? []).map((a: any) => ({
          type: a.content_type?.startsWith("image/") ? "image"
              : a.content_type?.startsWith("video/") ? "video"
              : "file",
          url: a.url,
          name: a.filename,
          mimetype: a.content_type || "",
        })),
        reactions: (m.reactions ?? []).map((r: any) => ({
          name: r.emoji?.name || r.emoji?.id || "?",
          count: r.count ?? 0,
        })),
        reply_count: 0,
        is_bot: m.author?.bot ?? false,
        subtype: null,
        links: (m.embeds ?? [])
          .filter((e: any) => e.url || e.type === "link" || e.type === "article" || e.type === "video")
          .map((e: any) => ({
            url: e.url || "",
            title: e.title,
            description: e.description,
            imageUrl: e.thumbnail?.url || e.image?.url,
            siteName: e.provider?.name,
          })),
      });
    }

    if (opts.order === "asc") {
      messages.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
    } else {
      messages.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
    }
    return messages;
  }

  async getMessageCount(channelId: string): Promise<number> {
    // Discord doesn't have a count API — paginate through all messages
    let count = 0;
    let beforeId: string | undefined;
    while (true) {
      let url = `/channels/${channelId}/messages?limit=100`;
      if (beforeId) url += `&before=${beforeId}`;
      const batch: any[] = await this.discordApi(url);
      if (batch.length === 0) break;
      count += batch.length;
      beforeId = batch[batch.length - 1].id;
      if (batch.length < 100) break;
    }
    return count;
  }

  async getThreadMessages(channelId: string, threadId: string): Promise<NormalizedMessage[]> {
    // Discord threads are channels themselves — fetch the thread channel
    return this.getMessages(threadId, { limit: 100 });
  }

  async proxyFile(url: string): Promise<{ contentType: string; buffer: Buffer }> {
    const decodedUrl = decodeURIComponent(url);
    // CodeQL js/request-forgery (alert #53): same pattern as Slack —
    // host allowlist + private-IP guard, then per-segment
    // `encodeURIComponent` rebuild (the only sanitizer the request-
    // forgery query recognises for path-shaped data).
    let parsedDiscord: URL;
    try {
      parsedDiscord = new URL(decodedUrl);
    } catch {
      throw new Error("invalid Discord file URL");
    }
    await assertHostAllowedAndPublic(parsedDiscord, DISCORD_FILE_HOSTS);
    const safeDiscordPath = encodeUrlPathSegments(parsedDiscord.pathname);
    const safeDiscordSearch = encodeUrlSearch(parsedDiscord.search);
    let discordSafeUrl: string;
    if (parsedDiscord.hostname.toLowerCase() === "cdn.discordapp.com") {
      discordSafeUrl = `https://cdn.discordapp.com${safeDiscordPath}${safeDiscordSearch}`;
    } else if (parsedDiscord.hostname.toLowerCase() === "media.discordapp.net") {
      discordSafeUrl = `https://media.discordapp.net${safeDiscordPath}${safeDiscordSearch}`;
    } else {
      throw new Error("Discord file URL did not match expected host");
    }

    // Discord CDN signed URLs expire. Try the URL as-is first.
    let response = await fetch(discordSafeUrl);

    // If expired/404, try to refresh the attachment URL via the API.
    // Extract channel ID and message ID from the CDN URL pattern:
    // https://cdn.discordapp.com/attachments/{channel_id}/{attachment_id}/...
    const cdnPrefix = "https://cdn.discordapp.com/attachments/";
    if (!response.ok && discordSafeUrl.startsWith(cdnPrefix)) {
      const match = discordSafeUrl.slice(cdnPrefix.length).match(/^(\d+)\/(\d+)\//);
      if (match) {
        const [, channelId, attachmentId] = match;
        try {
          // Fetch recent messages to find one with this attachment
          const msgs: any[] = await this.discordApi(`/channels/${channelId}/messages?limit=50`);
          for (const msg of msgs) {
            for (const att of msg.attachments ?? []) {
              if (att.id === attachmentId || (typeof att.url === "string" && att.url.includes(attachmentId))) {
                try {
                  // Same encodeURIComponent-per-segment pattern as proxyFile above.
                  const parsedAtt = new URL(String(att.url));
                  await assertHostAllowedAndPublic(parsedAtt, DISCORD_FILE_HOSTS);
                  const safeAttPath = encodeUrlPathSegments(parsedAtt.pathname);
                  const safeAttSearch = encodeUrlSearch(parsedAtt.search);
                  let attSafeUrl: string;
                  if (parsedAtt.hostname.toLowerCase() === "cdn.discordapp.com") {
                    attSafeUrl = `https://cdn.discordapp.com${safeAttPath}${safeAttSearch}`;
                  } else if (parsedAtt.hostname.toLowerCase() === "media.discordapp.net") {
                    attSafeUrl = `https://media.discordapp.net${safeAttPath}${safeAttSearch}`;
                  } else {
                    continue;
                  }
                  response = await fetch(attSafeUrl);
                  if (response.ok) break;
                } catch {
                  // Skip attachments whose URL doesn't pass the allowlist.
                  continue;
                }
              }
            }
            if (response.ok) break;
          }
        } catch {
          // Fall through to error below
        }
      }
    }

    if (!response.ok) {
      throw new Error(`Failed to fetch Discord file: ${response.status}`);
    }
    const contentType = response.headers.get("content-type") || "application/octet-stream";
    const buffer = Buffer.from(await response.arrayBuffer());
    return { contentType, buffer };
  }
}

// ── TeamsBridge ──────────────────────────────────────────────────────────────
// Pull-model ingestion via the Chat SDK Teams adapter's built-in fetch methods
// (fetchMessages, fetchChannelMessages, fetchChannelInfo, listThreads). The
// adapter wraps Microsoft Graph under the hood and honours the bot app's
// authenticated identity. Underlying Graph calls that read chat/channel
// messages hit Teams Protected APIs — the tenant must have a
// Microsoft.GraphServices metered account configured (pay-per-call) or a
// Teams Data API license for those calls to return data.
//
// listChannels merges two sources:
//   1. `teamsConversationRegistry` — populated from inbound webhooks (fast,
//      ephemeral, survives only until bot restart).
//   2. `teamsKnownTeamIds` + Microsoft Graph `GET /teams/{id}/channels` —
//      populated from install/conversationUpdate events that carry
//      `channelData.team.aadGroupId`, AND from the adapter's Redis
//      `channelContext` cache (warm across restarts). This lets channels appear
//      in the sidebar with ZERO @mention, matching Slack/Discord/Mattermost.
//
// Required Graph permission: `ChannelSettings.Read.Group` (RSC, no admin
// consent — add via `teams app rsc add <appId> ChannelSettings.Read.Group
// --type Application` and re-upload the manifest) OR application permission
// `Channel.ReadBasic.All` (admin consent required). See listChannels() below.

interface TeamsConversationRecord {
  conversationId: string;
  name: string;
  conversationType: string;
  teamId: string | null;
  teamName: string | null;
  channelName: string | null;
  serviceUrl: string | null;
  tenantId: string | null;
  lastSeenAt: number;
}

const teamsConversationRegistry = new Map<string, Map<string, TeamsConversationRecord>>();

/**
 * Per-connection set of AAD group IDs (a.k.a. "team ids" in Graph) for teams
 * the bot is installed in. Populated from three sources, in priority order:
 *
 *   1. Mongo (via `seedTeamsKnownTeamIds`): hydrated at startup by the bot's
 *      connection loader from the persistent `teams_known_team_ids` field on
 *      each Teams `PlatformConnection`. This is the parity path with how
 *      Slack/Discord/Mattermost bootstrap from their bot tokens in Mongo —
 *      identity survives Redis loss AND bot restart with zero webhooks.
 *   2. Bot Framework activities (via `recordTeamsConversation`): every install
 *      and `conversationUpdate` carries `channelData.team.aadGroupId`. New
 *      values are written through to Mongo (fire-and-forget) so future
 *      restarts hydrate path 1 from a complete set.
 *   3. Redis cold-start SCAN (via `TeamsBridge.resolveTeamIds`): legacy
 *      fallback for connections that pre-date the Mongo persistence field.
 *      Retired automatically once a connection has at least one observed
 *      team (see `seedTeamsKnownTeamIds` flipping `teamsColdStartScanned`).
 */
const teamsKnownTeamIds = new Map<string, Set<string>>();

/**
 * H3/M1: one-shot guard — tracks which connectionIds have already completed
 * the cold-start Redis SCAN. Prevents re-scanning on every `listChannels` call
 * and avoids the blocking `KEYS` pattern entirely. Also flipped by
 * `seedTeamsKnownTeamIds` so a Mongo-hydrated connection never falls back to
 * the Redis scan on its first call.
 */
const teamsColdStartScanned = new Set<string>();

/** Seed `teamsKnownTeamIds` for a connection from the durable Mongo record.
 *  Called by the bot's startup loader (one call per Teams connection) and
 *  again on each adapter rebuild — idempotent. A no-op when the list is
 *  empty so legacy connections without persisted team-ids still benefit
 *  from the Redis cold-start scan. */
export function seedTeamsKnownTeamIds(connectionId: string, teamIds: string[]): void {
  if (!connectionId || !teamIds || teamIds.length === 0) return;
  let teamSet = teamsKnownTeamIds.get(connectionId);
  if (!teamSet) {
    teamSet = new Set();
    teamsKnownTeamIds.set(connectionId, teamSet);
  }
  for (const id of teamIds) {
    if (id) teamSet.add(id);
  }
  // Mongo is now authoritative for this connection; suppress the Redis scan
  // path entirely so a stale/wiped cache can't race the hydrated state.
  teamsColdStartScanned.add(connectionId);
}

/** Test-only: drop cached state for a connection (or all). Clears the
 *  in-memory team-id Map, the cold-start scan guard, AND the backend
 *  write-through dedup Set so tests don't bleed across cases. Production
 *  code prunes the registry via `pruneStaleTeamsConversations`. */
export function clearTeamsKnownTeamIdsForTest(connectionId?: string): void {
  if (connectionId) {
    teamsKnownTeamIds.delete(connectionId);
    teamsColdStartScanned.delete(connectionId);
    // Drop write-through dedup entries scoped to this connection so the
    // next observation re-fires the POST. Keyed `${connId}:${aadId}`.
    for (const key of teamsWriteThroughInFlight) {
      if (key.startsWith(`${connectionId}:`)) {
        teamsWriteThroughInFlight.delete(key);
      }
    }
  } else {
    teamsKnownTeamIds.clear();
    teamsColdStartScanned.clear();
    teamsWriteThroughInFlight.clear();
  }
}

/** Backend write-through dedup: keyed by `${connectionId}:${aadGroupId}` so a
 *  burst of webhooks for the same team doesn't fire N concurrent POSTs. On
 *  success the entry stays in place for the process lifetime (Mongo already
 *  has the value). On transient failure (5xx/network) we clear the entry so
 *  the next observation re-attempts. */
const teamsWriteThroughInFlight = new Set<string>();

/** Fire-and-forget POST that persists an observed AAD group id to Mongo so
 *  it survives bot restart AND a Redis cache wipe. Called from
 *  `recordTeamsConversation` whenever a NEW team-id surfaces for a
 *  connection. Errors are swallowed at the call site — the in-memory Map
 *  is the source of truth for the live process; the POST just hydrates
 *  Mongo for next startup. */
async function persistTeamsKnownTeamIdToBackend(
  connectionId: string,
  aadGroupId: string,
): Promise<void> {
  const dedupKey = `${connectionId}:${aadGroupId}`;
  if (teamsWriteThroughInFlight.has(dedupKey)) return;
  teamsWriteThroughInFlight.add(dedupKey);

  const backendUrl = process.env.BACKEND_URL || "http://localhost:8000";
  const bridgeKey = process.env.BRIDGE_API_KEY || "";
  const headers: Record<string, string> = { "Content-Type": "application/json" };
  if (bridgeKey) headers["Authorization"] = `Bearer ${bridgeKey}`;

  try {
    const resp = await fetch(
      `${backendUrl}/api/internal/connections/${encodeURIComponent(connectionId)}/teams-known-team-ids`,
      {
        method: "POST",
        headers,
        body: JSON.stringify({ aad_group_id: aadGroupId }),
        signal: AbortSignal.timeout(5000),
      },
    );
    if (resp.ok) return;
    // 4xx is terminal (404 unknown connection, 422 wrong platform, 400 bad
    // GUID). Leave the dedup entry in place so we don't loop on the same
    // bad value.
    if (resp.status >= 400 && resp.status < 500) {
      console.warn(
        `TeamsBridge: backend rejected aadGroupId persist (${resp.status}) for connection ${connectionId}`,
      );
      return;
    }
    // 5xx → transient; allow retry on next observation.
    console.warn(
      `TeamsBridge: backend returned ${resp.status} persisting aadGroupId for ${connectionId}; will retry`,
    );
    teamsWriteThroughInFlight.delete(dedupKey);
  } catch (err) {
    // Constant format string + %s args (not template interpolation in the
    // first arg) — console.warn treats arg[0] as printf-style when more args
    // follow, so a connectionId containing %s/%j would hijack substitution
    // (CodeQL js/tainted-format-string).
    console.warn(
      "TeamsBridge: failed to persist aadGroupId for %s: %s",
      connectionId,
      safeErrorMessage(err),
    );
    teamsWriteThroughInFlight.delete(dedupKey);
  }
}

/**
 * A Microsoft Graph team-id is the team's AAD group object id — a GUID. Used to
 * validate teamIds sourced from the shared Redis channelContext cache before
 * they are interpolated into a Graph API path, so a poisoned cache entry cannot
 * inject an arbitrary value into `graph.call(...{ "team-id": ... })`.
 */
const TEAMS_AAD_GROUP_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

/** RES-286 — Teams "I've seen this conversation" registry is populated from
 *  webhooks. It must NOT be wholesale-cleared on adapter recycle — that would
 *  empty the sidebar until each conversation posts again. Instead, drop only
 *  entries whose `lastSeenAt` is older than `maxAgeMs` (default 30 days).
 *  Returns the number of entries pruned. */
export function pruneStaleTeamsConversations(maxAgeMs: number = 30 * 24 * 60 * 60 * 1000): number {
  const cutoff = Date.now() - maxAgeMs;
  let pruned = 0;
  for (const [connId, bucket] of teamsConversationRegistry.entries()) {
    for (const [convId, rec] of bucket.entries()) {
      if (rec.lastSeenAt < cutoff) {
        bucket.delete(convId);
        pruned++;
      }
    }
    if (bucket.size === 0) teamsConversationRegistry.delete(connId);
  }
  return pruned;
}

export function recordTeamsConversation(
  connectionId: string,
  activity: {
    conversation?: { id?: string; conversationType?: string; name?: string; tenantId?: string };
    channelData?: {
      team?: { id?: string; name?: string; aadGroupId?: string };
      channel?: { id?: string; name?: string };
      tenant?: { id?: string };
    };
    serviceUrl?: string;
    from?: { name?: string };
  },
): void {
  const rawConversationId = activity?.conversation?.id;
  if (!connectionId || !rawConversationId) return;

  // Bot Framework appends `;messageid=<ts>` to team-channel conversation IDs,
  // which breaks Graph endpoints that expect the bare channel ID. Strip it so
  // downstream listChannels / getMessages can reuse this ID directly against
  // /teams/{teamId}/channels/{channelId}.
  const conversationId = rawConversationId.split(";")[0];

  const conversationType = activity.conversation?.conversationType || "unknown";
  const teamName = activity.channelData?.team?.name || null;
  const channelName = activity.channelData?.channel?.name || null;

  let name: string;
  if (conversationType === "channel") {
    name = teamName && channelName
      ? `${teamName} / ${channelName}`
      : channelName || teamName || conversationId;
  } else if (conversationType === "groupChat") {
    name = activity.conversation?.name || "Group chat";
  } else {
    name = activity.from?.name || activity.conversation?.name || "Direct message";
  }

  let bucket = teamsConversationRegistry.get(connectionId);
  if (!bucket) {
    bucket = new Map();
    teamsConversationRegistry.set(connectionId, bucket);
  }
  bucket.set(conversationId, {
    conversationId,
    name,
    conversationType,
    teamId: activity.channelData?.team?.id || null,
    teamName,
    channelName,
    serviceUrl: activity.serviceUrl || null,
    tenantId: activity.conversation?.tenantId || activity.channelData?.tenant?.id || null,
    lastSeenAt: Date.now(),
  });

  // On a team install/membership event (conversationUpdate / installationUpdate)
  // `conversation.id` is the TEAM ROOT, while the actual channel lives in
  // `channelData.channel`. Register that channel directly so it surfaces in
  // listChannels — and is therefore fetchable via Graph — without ever needing
  // an @mention inside it (Slack/Discord/Mattermost-style discovery).
  const installChannelId = activity.channelData?.channel?.id?.split(";")[0];
  if (installChannelId && installChannelId !== conversationId) {
    bucket.set(installChannelId, {
      conversationId: installChannelId,
      name: teamName && channelName
        ? `${teamName} / ${channelName}`
        : channelName || teamName || installChannelId,
      conversationType: "channel",
      teamId: activity.channelData?.team?.id || null,
      teamName,
      channelName,
      // serviceUrl intentionally left null for install-discovered channels:
      // Graph channel reads don't need it; it's filled in if a message arrives.
      serviceUrl: activity.serviceUrl || null,
      tenantId: activity.conversation?.tenantId || activity.channelData?.tenant?.id || null,
      lastSeenAt: Date.now(),
    });
  }

  // Persist the AAD group id (Graph team id) for this team so that
  // TeamsBridge.listChannels can enumerate channels via Graph without any
  // prior @mention. Bot Framework install/conversationUpdate activities carry
  // `channelData.team.aadGroupId`; regular channel-message activities may not.
  //
  // Two-layer persistence:
  //  • In-memory Map — covers the live process; lost on bot restart.
  //  • Mongo via fire-and-forget POST — survives bot restart AND a Redis
  //    cache wipe. Only fires when the id is NEW for this connection so
  //    a steady stream of channel-message webhooks doesn't hammer the
  //    backend with no-op upserts. Backend dedups via `$addToSet`.
  const aadGroupId = activity.channelData?.team?.aadGroupId;
  if (aadGroupId && TEAMS_AAD_GROUP_ID_RE.test(aadGroupId)) {
    let teamSet = teamsKnownTeamIds.get(connectionId);
    if (!teamSet) {
      teamSet = new Set();
      teamsKnownTeamIds.set(connectionId, teamSet);
    }
    const isNew = !teamSet.has(aadGroupId);
    teamSet.add(aadGroupId);
    if (isNew) {
      // Fire-and-forget — never block webhook processing on the backend
      // round-trip. The helper handles its own errors + dedup so an
      // unhandled rejection can't escape this scope.
      void persistTeamsKnownTeamIdToBackend(connectionId, aadGroupId);
    }
  }
}

function teamsRecordToChannel(entry: TeamsConversationRecord): NormalizedChannel {
  return {
    channel_id: entry.conversationId,
    name: entry.name,
    platform: "teams",
    is_member: true,
    member_count: null,
    topic: entry.conversationType,
    purpose: entry.teamName,
  };
}

const TEAMS_PAGE_SIZE = 50;
const TEAMS_MESSAGE_COUNT_CAP = 500;

/**
 * PERF: fire-and-forget MSAL Graph token pre-warm for a single Teams adapter.
 * The acquired client-credentials token is shared across ALL Graph reads —
 * channel enumeration AND message fetches — so warming it removes the
 * ~1.5–2.5s cold-acquire penalty from the first user request after startup or
 * an adapter recycle. No-op for non-Teams adapters (no app.graph.http), so it
 * is safe to call indiscriminately. Errors are swallowed: the first real
 * request will acquire the token if this races or fails.
 */
export function warmTeamsGraphToken(adapter: unknown): void {
  const graphHttp = (adapter as { app?: { graph?: { http?: { get?: (path: string) => Promise<unknown> } } } })
    ?.app?.graph?.http;
  if (graphHttp && typeof graphHttp.get === "function") {
    try {
      Promise.resolve(graphHttp.get("/organization?$top=1")).catch(() => { /* token acquired lazily on first real call */ });
    } catch {
      /* a synchronous throw from get() must not propagate — same lazy fallback */
    }
  }
}

class TeamsBridge implements PlatformBridge {
  private adapter: TeamsAdapter;
  private connectionId: string;
  /** PERF: cache of the slow Microsoft Graph channel enumeration — one
   *  ~1–1.5s round-trip per installed team. The live webhook registry is merged
   *  fresh on every listChannels call, so ONLY this expensive Graph part is
   *  cached. Mirrors DiscordBridge.channelCache. Per-instance, wiped on adapter
   *  recycle (bridgeCache.clear). Populated only on a successful enumeration
   *  that returned ≥1 channel, so a not-yet-discovered team is never masked. */
  private graphChannelCache: { data: NormalizedChannel[]; expiresAt: number } | null = null;
  private graphChannelFetchInFlight: Promise<NormalizedChannel[]> | null = null;
  private static readonly GRAPH_CHANNEL_CACHE_TTL_MS = 60_000; // 60s

  constructor(adapter: TeamsAdapter, connectionId: string) {
    this.adapter = adapter;
    this.connectionId = connectionId;
  }

  async resolveUser(userId: string): Promise<{ name: string; image: string | null }> {
    return { name: userId, image: null };
  }

  async listChannels(): Promise<NormalizedChannel[]> {
    // Phase 1 — in-memory registry (inbound webhooks / conversationUpdates).
    // Always recomputed: a cheap Map read that must reflect webhook updates
    // (new DMs/channels) the instant they arrive, so it is NOT cached.
    const bucket = teamsConversationRegistry.get(this.connectionId);
    const registryChannels = bucket
      ? Array.from(bucket.values())
          .filter((e) => e.conversationType === "channel")
          .map(teamsRecordToChannel)
      : [];

    // Phase 2 — Graph enumeration (zero @mention required). The slow part: a
    // Redis cold-start scan plus one Graph round-trip per installed team.
    // Cached with a short TTL and concurrent-call dedup (see below).
    const graphChannels = await this.listGraphChannelsCached();

    // Merge: Graph results are authoritative; supplement with registry entries
    // for conversations (DMs, group chats) that Graph doesn't enumerate.
    if (graphChannels.length > 0) {
      const graphIds = new Set(graphChannels.map((c) => c.channel_id));
      const extras = registryChannels.filter((c) => !graphIds.has(c.channel_id));
      return [...graphChannels, ...extras];
    }

    // Graph unavailable or no teamIds found — return the registry as before.
    return registryChannels;
  }

  /** PERF: TTL cache + in-flight dedup around the expensive Graph channel
   *  enumeration. Mirrors DiscordBridge.listChannels: a fresh hit returns the
   *  cached array instantly; concurrent callers share one in-flight fetch. */
  private async listGraphChannelsCached(): Promise<NormalizedChannel[]> {
    if (this.graphChannelCache && Date.now() < this.graphChannelCache.expiresAt) {
      return this.graphChannelCache.data;
    }
    if (this.graphChannelFetchInFlight) {
      return this.graphChannelFetchInFlight;
    }
    this.graphChannelFetchInFlight = this.enumerateGraphChannels();
    try {
      return await this.graphChannelFetchInFlight;
    } finally {
      this.graphChannelFetchInFlight = null;
    }
  }

  /** Resolve the set of AAD group ids (Graph team-ids) for this connection.
   *  Primary source: teamsKnownTeamIds (populated from install events and
   *  across adapter recycles as new activities arrive). Cold-start fallback:
   *  a one-shot Redis SCAN of the adapter's channelContext cache to recover
   *  teamIds written by cacheUserContext on prior runs.
   *
   *  Trade-offs:
   *    • SCAN (not KEYS) — non-blocking, yields key batches, safe for prod Redis.
   *    • Retry-until-success: the guard is set ONLY after ≥1 teamId is found, so
   *      calls that race adapter initialisation retry on the next call.
   *    • No cross-connection back-fill — teamIds are added only to this
   *      connection's set; the Redis cache is shared across connections and
   *      blindly claiming another's teams would misroute Graph credentials. */
  private async resolveTeamIds(): Promise<Set<string>> {
    const teamIds = new Set(teamsKnownTeamIds.get(this.connectionId) ?? []);
    if (teamIds.size === 0 && !teamsColdStartScanned.has(this.connectionId)) {
      const chatState = (this.adapter as any).chat?.getState?.();
      if (chatState) {
        try {
          const redisClient = (chatState as any).client as {
            scanIterator?: (opts: { MATCH: string; COUNT: number }) => AsyncIterable<string[]>;
          };
          if (typeof redisClient?.scanIterator === "function") {
            // scanIterator yields key BATCHES (string[]), not individual strings.
            for await (const keyBatch of redisClient.scanIterator({
              MATCH: "chat-sdk:cache:teams:channelContext:*",
              COUNT: 100,
            })) {
              for (const key of keyBatch) {
                try {
                  const raw: unknown = await chatState.get(key.replace("chat-sdk:cache:", ""));
                  const ctx: unknown = typeof raw === "string" ? JSON.parse(raw) : raw;
                  if (ctx && typeof ctx === "object" && "teamId" in ctx) {
                    const tId = (ctx as { teamId: string }).teamId;
                    // A Graph team-id is an AAD group GUID. Validate the shape
                    // before it flows into `graph.call(...{ "team-id": tId })`:
                    // the Redis keyspace is shared, so a poisoned channelContext
                    // entry must not be able to inject an arbitrary value into a
                    // Graph API path. Non-GUID entries are silently skipped.
                    if (tId && TEAMS_AAD_GROUP_ID_RE.test(tId)) {
                      teamIds.add(tId);
                      let teamSet = teamsKnownTeamIds.get(this.connectionId);
                      if (!teamSet) {
                        teamSet = new Set();
                        teamsKnownTeamIds.set(this.connectionId, teamSet);
                      }
                      const wasNew = !teamSet.has(tId);
                      teamSet.add(tId);
                      // Persist any id we recover from the Redis-cache cold-start
                      // path too — otherwise an EXISTING connection that was
                      // already populated via a prior webhook never seeds Mongo
                      // (the in-memory dedup in `recordTeamsConversation` would
                      // suppress the write-through forever). Fire-and-forget;
                      // backend `$addToSet` keeps it idempotent.
                      if (wasNew) {
                        void persistTeamsKnownTeamIdToBackend(this.connectionId, tId);
                      }
                    }
                  }
                } catch {
                  // malformed cache entry — skip
                }
              }
            }
          }
        } catch {
          // Redis scan failed (chat not yet initialized) — will retry next call
        }
      }
      // Only lock out future scans once we actually found something; if scan
      // yielded nothing (adapter not yet ready, empty Redis) we want to retry.
      if (teamIds.size > 0) {
        teamsColdStartScanned.add(this.connectionId);
      }
    }
    return teamIds;
  }

  /** Enumerate channels for every known team via Microsoft Graph. The per-team
   *  Graph calls are issued CONCURRENTLY (was sequential): teamIds is bounded by
   *  how many teams the bot is installed in (a handful), so an unbounded
   *  Promise.all stays well under Graph's per-app throttle. Result is cached on
   *  a non-empty success only. On total failure, falls back to stale cache. */
  private async enumerateGraphChannels(): Promise<NormalizedChannel[]> {
    try {
      const teamIds = await this.resolveTeamIds();
      if (teamIds.size === 0) return this.graphChannelCache?.data ?? [];

      const graph = (this.adapter as any).app?.graph;
      if (!graph) return this.graphChannelCache?.data ?? [];

      // Requires RSC ChannelSettings.Read.Group (no admin consent) or
      // application Channel.ReadBasic.All (admin consent). See block comment.
      const { teams } = await import("@microsoft/teams.graph-endpoints" as string);
      const perTeam = await Promise.all(
        [...teamIds].map((teamId) => this.listChannelsForTeam(graph, teams, teamId)),
      );
      const graphChannels = perTeam.flat();

      // Cache a non-empty success only: a team discovered later (via webhook)
      // must surface on the next call, not be masked by a cached empty result.
      if (graphChannels.length > 0) {
        this.graphChannelCache = {
          data: graphChannels,
          expiresAt: Date.now() + TeamsBridge.GRAPH_CHANNEL_CACHE_TTL_MS,
        };
      }
      return graphChannels;
    } catch (err) {
      if (this.graphChannelCache) {
        console.warn("TeamsBridge: listChannels failed, returning stale cache:", safeErrorMessage(err));
        return this.graphChannelCache.data;
      }
      console.error("TeamsBridge: listChannels error (no cache):", safeErrorMessage(err));
      return [];
    }
  }

  /** Enumerate one team's channels and pre-populate the channelContext Redis
   *  cache for each (Bug A: ensures per-channel message reads resolve to their
   *  own messages, not the team root). Returns [] on failure so one bad team
   *  never fails the whole concurrent enumeration. */
  private async listChannelsForTeam(graph: any, teams: any, teamId: string): Promise<NormalizedChannel[]> {
    try {
      const resp = await graph.call(teams.channels.list, { "team-id": teamId });
      const out: NormalizedChannel[] = [];
      for (const ch of (resp?.value ?? [])) {
        const channelId = ch.id as string;
        out.push({
          channel_id: channelId,
          name: ch.displayName as string || channelId,
          platform: "teams",
          is_member: true,
          member_count: null,
          topic: (ch.membershipType as string) ?? null,
          purpose: null,
        });
        // Pre-populate channelContext with the CORRECT {teamId, channelId} from
        // the authoritative Graph channel-list so getGraphContext doesn't fall
        // back to teams.getById(channelId) and collapse every channel under a
        // team root to the same id (Bug A).
        const chatState = (this.adapter as any).chat?.getState?.();
        if (chatState) {
          const ctx = JSON.stringify({ teamId, channelId });
          chatState.set(`teams:channelContext:${channelId}`, ctx, 30 * 24 * 60 * 60 * 1000)
            .catch(() => { /* non-fatal */ });
        }
      }
      return out;
    } catch (err) {
      // safeErrorMessage extracts .message only (no stack/raw object) so an
      // MSAL/Graph error can't carry a token into logs.
      console.warn(`TeamsBridge: Graph channels.list failed for team ${teamId}:`, safeErrorMessage(err));
      return [];
    }
  }
  async getChannel(id: string): Promise<NormalizedChannel> {
    const entry = teamsConversationRegistry.get(this.connectionId)?.get(id);
    if (entry) return teamsRecordToChannel(entry);

    try {
      // fetchChannelInfo internally calls decodeThreadId, which requires the
      // encoded `teams:<b64conv>:<b64svc>` form — NOT the bare `19:…@thread`
      // conversation id. Encode with a placeholder serviceUrl (the same one
      // used by fetchPage for channel reads); fetchChannelInfo only uses the
      // conversationId portion to resolve Graph context.
      const encodedId = this.adapter.encodeThreadId({
        conversationId: id,
        serviceUrl: "https://smba.trafficmanager.net/teams/",
      });
      const info = await this.adapter.fetchChannelInfo(encodedId);
      return {
        channel_id: id,                    // preserve the raw channel id as key
        name: info.name || id,
        platform: "teams",
        is_member: true,
        member_count: info.memberCount ?? null,
        topic: null,
        purpose: null,
      };
    } catch {
      return {
        channel_id: id,
        name: id,
        platform: "teams",
        is_member: false,
        member_count: null,
        topic: null,
        purpose: null,
      };
    }
  }

  /** M4: shared predicate — keeps getMessageCount and getMessages in sync. */
  private isUserMessage(m: ChatSDKMessage<unknown>): boolean {
    const raw = (m.raw ?? {}) as Record<string, unknown>;
    const msgType = raw.messageType as string | undefined;
    if (msgType && msgType !== "message") return false;  // system / event
    if (raw.deletedDateTime) return false;               // deleted by sender
    return true;
  }

  async getMessageCount(channelId: string): Promise<number> {
    let count = 0;
    let cursor: string | undefined;
    while (count < TEAMS_MESSAGE_COUNT_CAP) {
      const page = await this.fetchPage(channelId, cursor, TEAMS_PAGE_SIZE);
      // M4: apply the same user-message filter as getMessages so counts agree.
      count += page.messages.filter((m) => this.isUserMessage(m)).length;
      if (!page.nextCursor || page.messages.length === 0) break;
      cursor = page.nextCursor;
    }
    return count;
  }

  async getMessages(channelId: string, opts: GetMessagesOpts): Promise<NormalizedMessage[]> {
    const fetchLimit = Math.max(1, Math.min(opts.limit || 100, TEAMS_MESSAGE_COUNT_CAP));
    const collected: ChatSDKMessage<unknown>[] = [];
    let cursor: string | undefined;

    // Bug C fix: always use direction="backward" (newest-first) regardless of
    // the caller's requested order. The adapter's "forward" path fetches ALL
    // messages from the beginning and reverses them — O(channel-history) per
    // call. The "backward" path issues a single $top=N Graph request (~1-2s).
    // We collect in backward order, then flip to asc here if needed.
    while (collected.length < fetchLimit) {
      const remaining = fetchLimit - collected.length;
      const pageSize = Math.min(TEAMS_PAGE_SIZE, remaining);
      const page = await this.fetchPage(channelId, cursor, pageSize, "backward");
      collected.push(...page.messages);
      if (!page.nextCursor || page.messages.length === 0) break;
      cursor = page.nextCursor;
    }

    // Drop non-user Graph messages before normalizing (M4 predicate reused here).
    const userMessages = collected.filter((m) => this.isUserMessage(m));

    // H2: apply since/before timestamp window. The adapter doesn't push these
    // into the Graph $filter clause (it would need cursor re-encoding); bridge-
    // side filtering is consistent with the Slack approach and keeps the adapter
    // boundary clean. Both opts are ISO strings; parse once and compare as ms.
    const sinceMs = opts.since ? new Date(opts.since).getTime() : null;
    const beforeMs = opts.before ? new Date(opts.before).getTime() : null;
    const windowedMessages = (sinceMs !== null || beforeMs !== null)
      ? userMessages.filter((m) => {
          const sent = m.metadata?.dateSent instanceof Date
            ? m.metadata.dateSent.getTime()
            : new Date(m.metadata?.dateSent || 0).getTime();
          if (sinceMs !== null && sent < sinceMs) return false;
          if (beforeMs !== null && sent > beforeMs) return false;
          return true;
        })
      : userMessages;

    const entry = teamsConversationRegistry.get(this.connectionId)?.get(channelId);
    const channelName = entry?.name || channelId;
    const normalized = windowedMessages.map((m) => this.normalizeMessage(m, channelId, channelName));

    // Backward fetch returns newest-first; flip to oldest-first for asc callers.
    if (opts.order === "asc") normalized.reverse();
    return normalized;
  }

  async getThreadMessages(channelId: string, threadId: string): Promise<NormalizedMessage[]> {
    const entry = teamsConversationRegistry.get(this.connectionId)?.get(channelId);
    if (!entry?.serviceUrl) {
      throw Object.assign(
        new Error("Teams serviceUrl unknown — bot must observe an activity in this conversation first"),
        { data: { error: "not_ready" }, code: "NOT_READY" },
      );
    }
    const encodedThreadId = this.adapter.encodeThreadId({
      conversationId: channelId,
      replyToId: threadId,
      serviceUrl: entry.serviceUrl,
    });

    const collected: ChatSDKMessage<unknown>[] = [];
    let cursor: string | undefined;
    while (collected.length < TEAMS_MESSAGE_COUNT_CAP) {
      const result = await this.adapter.fetchMessages(encodedThreadId, {
        cursor,
        limit: TEAMS_PAGE_SIZE,
      });
      collected.push(...result.messages);
      if (!result.nextCursor || result.messages.length === 0) break;
      cursor = result.nextCursor;
    }
    return collected.map((m) => this.normalizeMessage(m, channelId, entry.name));
  }

  async proxyFile(url: string): Promise<{ contentType: string; buffer: Buffer }> {
    const decodedUrl = decodeURIComponent(url);
    // CodeQL js/request-forgery (alert #54): same pattern as Slack/
    // Discord — host allowlist + private-IP guard, then per-segment
    // `encodeURIComponent` rebuild (the only sanitizer the request-
    // forgery query recognises for path-shaped data).
    let parsedTeams: URL;
    try {
      parsedTeams = new URL(decodedUrl);
    } catch {
      throw new Error("invalid Teams file URL");
    }
    await assertHostAllowedAndPublic(parsedTeams, TEAMS_EXACT_HOSTS);
    if (parsedTeams.hostname.toLowerCase() !== "graph.microsoft.com") {
      throw new Error("Teams file URL did not match expected host");
    }
    const safeTeamsPath = encodeUrlPathSegments(parsedTeams.pathname);
    const safeTeamsSearch = encodeUrlSearch(parsedTeams.search);
    const teamsSafeUrl = `https://graph.microsoft.com${safeTeamsPath}${safeTeamsSearch}`;
    const response = await fetch(teamsSafeUrl);
    if (!response.ok) {
      throw new Error(`Failed to fetch Teams file: ${response.status}`);
    }
    const contentType = response.headers.get("content-type") || "application/octet-stream";
    const buffer = Buffer.from(await response.arrayBuffer());
    return { contentType, buffer };
  }

  private async fetchPage(
    channelId: string,
    cursor: string | undefined,
    limit: number,
    direction: "forward" | "backward" = "backward",
  ): Promise<{ messages: ChatSDKMessage<unknown>[]; nextCursor?: string }> {
    const entry = teamsConversationRegistry.get(this.connectionId)?.get(channelId);
    // Treat as a team channel when the registry says so OR when the id has the
    // `19:…@thread.tacv2` channel shape (the registry may be unwarmed after a
    // bot restart, but the channel id alone is enough to read via Graph).
    const isTeamChannel = entry?.conversationType === "channel"
      || (channelId.startsWith("19:") && channelId.includes("@thread"));

    // Graph channel reads (fetchChannelMessages) resolve their real target via
    // getChannelContext(conversationId) and never use serviceUrl — the adapter
    // only decodes it from the thread id and ignores it. So channel reads must
    // NOT require a cached serviceUrl; supply a placeholder so encodeThreadId
    // produces a well-formed `teams:<conv>:<svcUrl>` id. serviceUrl is only
    // genuinely needed by the Bot-Connector path (DM/group-chat reads + replies).
    if (isTeamChannel) {
      // Graph's /teams/{id}/channels/{id}/messages endpoint does not support
      // $select (returns 400). Payload optimisation via field projection is not
      // available on this endpoint; use the adapter's typed method unchanged.
      const encodedChannelThreadId = this.adapter.encodeThreadId({
        conversationId: channelId,
        serviceUrl: entry?.serviceUrl || "https://smba.trafficmanager.net/teams/",
      });
      return this.adapter.fetchChannelMessages(encodedChannelThreadId, { cursor, limit, direction });
    }

    // DM / group-chat reads go through the Bot Connector, which needs serviceUrl.
    if (!entry?.serviceUrl) {
      throw Object.assign(
        new Error("Teams serviceUrl unknown — bot must observe an activity in this conversation first"),
        { data: { error: "not_ready" }, code: "NOT_READY" },
      );
    }
    const encodedThreadId = this.adapter.encodeThreadId({
      conversationId: channelId,
      serviceUrl: entry.serviceUrl,
    });
    return this.adapter.fetchMessages(encodedThreadId, { cursor, limit });
  }

  private normalizeMessage(
    m: ChatSDKMessage<unknown>,
    channelId: string,
    channelName: string,
  ): NormalizedMessage {
    const dateSent = m.metadata?.dateSent;
    const timestamp = dateSent instanceof Date
      ? dateSent.toISOString()
      : new Date(dateSent || Date.now()).toISOString();

    // Bug B fix: the adapter's extractTextFromGraphMessage strips HTML tags
    // char-by-char but does NOT decode HTML entities. Teams messages arrive
    // as HTML (body.contentType = "html") so @mentions appear as
    // `<at>Beever Atlas</at>&nbsp;hello` → after tag-strip: `Beever Atlas&nbsp;hello`.
    // Decode the common HTML entities that appear in Teams messages so the UI
    // shows clean plain text. &nbsp; → space; standard XML entities handled too.
    const rawText = m.text || "";
    // M3: &amp; must run LAST so double-encoded sequences like &amp;lt; stay
    // as &lt; rather than collapsing all the way to <.
    const content = rawText
      .replace(/&nbsp;/g, " ")
      .replace(/&lt;/g, "<")
      .replace(/&gt;/g, ">")
      .replace(/&quot;/g, '"')
      .replace(/&#39;/g, "'")
      .replace(/&#x27;/g, "'")
      .replace(/&#x2F;/g, "/")
      .replace(/&amp;/g, "&")
      .trim();

    return {
      content,
      author: m.author.userId,
      author_name: m.author.fullName || m.author.userName || m.author.userId,
      author_image: null,
      platform: "teams",
      channel_id: channelId,
      channel_name: channelName,
      message_id: m.id,
      timestamp,
      thread_id: null,
      attachments: m.attachments.map((a) => ({
        type: a.type,
        url: a.url,
        name: a.name,
      })),
      reactions: [],
      reply_count: 0,
      is_bot: m.author.isBot === true,
      subtype: null,
      links: (m.links || []).map((l) => ({
        url: l.url,
        title: l.title,
        description: l.description,
        imageUrl: l.imageUrl,
        siteName: l.siteName,
      })),
    };
  }
}

// ── TelegramBridge ────────────────────────────────────────────────────────────
// Telegram bots are event-driven — they receive messages via webhook but cannot
// pull message history or list group chats. These methods return empty stubs.

// Per-connection registry of Telegram chats the bot has observed. Telegram's Bot API
// has no "list my chats" endpoint (confirmed by chat-sdk.dev/adapters/telegram: "no
// native way to discover channels or groups the bot inhabits"), so we populate this
// registry lazily from incoming webhook/polling updates via recordTelegramChat().
// In-memory only — rebuilt as new updates arrive after restart.
interface TelegramChatEntry {
  chatId: string;
  title: string;
  type: "private" | "group" | "supergroup" | "channel" | string;
  lastSeenAt: number;
}
const telegramChatRegistry = new Map<string, Map<string, TelegramChatEntry>>();

/** RES-286 — Telegram chat registry, populated from webhooks. Same shape as
 *  `teamsConversationRegistry` (the only source of truth for
 *  `TelegramBridge.listChannels()`), so we age out stale entries instead of
 *  wholesale-clearing. Returns the number of entries pruned. */
export function pruneStaleTelegramChats(maxAgeMs: number = 30 * 24 * 60 * 60 * 1000): number {
  const cutoff = Date.now() - maxAgeMs;
  let pruned = 0;
  for (const [connId, bucket] of telegramChatRegistry.entries()) {
    for (const [chatId, rec] of bucket.entries()) {
      if (rec.lastSeenAt < cutoff) {
        bucket.delete(chatId);
        pruned++;
      }
    }
    if (bucket.size === 0) telegramChatRegistry.delete(connId);
  }
  return pruned;
}

export function recordTelegramChat(
  connectionId: string,
  chat: { id: number | string; title?: string; type?: string; first_name?: string; last_name?: string; username?: string },
): void {
  if (!connectionId || chat?.id === undefined || chat?.id === null) return;
  const chatId = String(chat.id);
  const title =
    chat.title ||
    [chat.first_name, chat.last_name].filter(Boolean).join(" ") ||
    chat.username ||
    chatId;
  let bucket = telegramChatRegistry.get(connectionId);
  if (!bucket) {
    bucket = new Map();
    telegramChatRegistry.set(connectionId, bucket);
  }
  bucket.set(chatId, { chatId, title, type: chat.type || "unknown", lastSeenAt: Date.now() });
}

class TelegramBridge implements PlatformBridge {
  private adapter: unknown;
  private connectionId: string;

  constructor(adapter: unknown, connectionId: string) {
    this.adapter = adapter;
    this.connectionId = connectionId;
  }

  async resolveUser(_userId: string): Promise<{ name: string; image: string | null }> {
    return { name: _userId, image: null };
  }

  async listChannels(): Promise<NormalizedChannel[]> {
    const bucket = telegramChatRegistry.get(this.connectionId);
    if (!bucket || bucket.size === 0) return [];
    return Array.from(bucket.values()).map((c) => ({
      channel_id: c.chatId,
      name: c.title,
      platform: "telegram",
      is_member: true,
      member_count: null,
      topic: c.type,
      purpose: null,
    }));
  }

  async getChannel(id: string): Promise<NormalizedChannel> {
    return {
      channel_id: id,
      name: id,
      platform: "telegram",
      is_member: false,
      member_count: null,
      topic: null,
      purpose: null,
    };
  }

  async getMessageCount(_channelId: string): Promise<number> {
    throw Object.assign(
      new Error("Telegram bots cannot count messages"),
      { data: { error: "not_supported" }, code: "NOT_SUPPORTED" },
    );
  }

  async getMessages(_channelId: string, _opts: GetMessagesOpts): Promise<NormalizedMessage[]> {
    throw Object.assign(
      new Error("Telegram bots cannot fetch message history"),
      { data: { error: "not_supported" }, code: "NOT_SUPPORTED" },
    );
  }

  async getThreadMessages(_channelId: string, _threadId: string): Promise<NormalizedMessage[]> {
    throw Object.assign(
      new Error("Telegram bots cannot fetch thread messages"),
      { data: { error: "not_supported" }, code: "NOT_SUPPORTED" },
    );
  }

  async proxyFile(url: string): Promise<{ contentType: string; buffer: Buffer }> {
    const decodedUrl = decodeURIComponent(url);
    // CodeQL js/request-forgery (alert #55): same pattern as Slack/
    // Discord/Teams — host allowlist + private-IP guard, then
    // per-segment `encodeURIComponent` rebuild.
    let parsedTelegram: URL;
    try {
      parsedTelegram = new URL(decodedUrl);
    } catch {
      throw new Error("invalid Telegram file URL");
    }
    await assertHostAllowedAndPublic(parsedTelegram, TELEGRAM_FILE_HOSTS);
    if (parsedTelegram.hostname.toLowerCase() !== "api.telegram.org") {
      throw new Error("Telegram file URL did not match expected host");
    }
    const safeTelegramPath = encodeUrlPathSegments(parsedTelegram.pathname);
    const safeTelegramSearch = encodeUrlSearch(parsedTelegram.search);
    const telegramSafeUrl = `https://api.telegram.org${safeTelegramPath}${safeTelegramSearch}`;
    const response = await fetch(telegramSafeUrl);
    if (!response.ok) {
      throw new Error(`Failed to fetch Telegram file: ${response.status}`);
    }
    const contentType = response.headers.get("content-type") || "application/octet-stream";
    const buffer = Buffer.from(await response.arrayBuffer());
    return { contentType, buffer };
  }
}

// ── MattermostBridge ──────────────────────────────────────────────────────────
// Calls Mattermost REST API v4 directly for channel listing and message history.
// The chat-adapter-mattermost community adapter handles real-time WebSocket events
// but does not expose listing/history methods.

const mattermostUserCache = new Map<string, { name: string; image: string | null }>();

/** RES-286 — let callers (notably ChatManager's scheduled adapter recycle)
 *  drop this module-level cache. Without this hook the Map grows unbounded
 *  over the bot's lifetime since entries are added on every user lookup but
 *  never expire. */
export function clearMattermostUserCache(): void {
  mattermostUserCache.clear();
}

class MattermostBridge implements PlatformBridge {
  private baseUrl: string;
  private botToken: string;
  private connectionId: string;
  private botUserId: string | null = null;

  constructor(_adapter: unknown, connectionId: string, baseUrl: string, botToken: string) {
    this.connectionId = connectionId;
    this.baseUrl = baseUrl.replace(/\/+$/, "");
    this.botToken = botToken;
  }

  private async _fetch(path: string, init?: RequestInit): Promise<any> {
    const url = `${this.baseUrl}/api/v4${path}`;
    const headers: Record<string, string> = {
      Authorization: `Bearer ${this.botToken}`,
      "Content-Type": "application/json",
      ...(init?.headers as Record<string, string> || {}),
    };

    let lastErr: Error | null = null;
    for (let attempt = 0; attempt < 3; attempt++) {
      const response = await fetch(url, { ...init, headers });
      if (response.ok) return response.json();

      if (response.status === 429 || response.status >= 500) {
        lastErr = new Error(`Mattermost API ${response.status} on ${path}`);
        const wait = 1000 * (2 ** attempt);
        console.warn(`MattermostBridge: ${response.status} on ${path}, retrying in ${wait}ms (attempt ${attempt + 1}/3)`);
        await new Promise((r) => setTimeout(r, wait));
        continue;
      }

      const text = await response.text().catch(() => "");
      throw new Error(`Mattermost API error ${response.status} on ${path}: ${text}`);
    }
    throw lastErr || new Error(`Mattermost API failed after retries on ${path}`);
  }

  private async _getBotUserId(): Promise<string> {
    if (this.botUserId) return this.botUserId;
    const me = await this._fetch("/users/me");
    this.botUserId = me.id;
    return me.id;
  }

  async resolveUser(userId: string): Promise<{ name: string; image: string | null }> {
    if (mattermostUserCache.has(userId)) return mattermostUserCache.get(userId)!;
    try {
      const user = await this._fetch(`/users/${userId}`);
      const name = [user.first_name, user.last_name].filter(Boolean).join(" ") || user.username || userId;
      const resolved = { name, image: null };
      mattermostUserCache.set(userId, resolved);
      return resolved;
    } catch {
      const fallback = { name: userId, image: null };
      mattermostUserCache.set(userId, fallback);
      return fallback;
    }
  }

  async listChannels(): Promise<NormalizedChannel[]> {
    const botId = await this._getBotUserId();
    const teams: any[] = await this._fetch("/users/me/teams");
    const channels: NormalizedChannel[] = [];

    for (const team of teams) {
      const teamChannels: any[] = await this._fetch(`/users/${botId}/teams/${team.id}/channels`);
      for (const ch of teamChannels) {
        if (ch.type === "D" || ch.type === "G") continue;
        if (ch.delete_at > 0) continue;
        channels.push({
          channel_id: ch.id,
          name: ch.display_name || ch.name || "",
          platform: "mattermost",
          is_member: true,
          member_count: null,
          topic: ch.header || null,
          purpose: ch.purpose || null,
        });
      }
    }
    return channels;
  }

  async getChannel(id: string): Promise<NormalizedChannel> {
    const ch = await this._fetch(`/channels/${id}`);
    return {
      channel_id: ch.id,
      name: ch.display_name || ch.name || "",
      platform: "mattermost",
      is_member: true,
      member_count: null,
      topic: ch.header || null,
      purpose: ch.purpose || null,
    };
  }

  private _normalizePost(post: any, channelName: string, userMap: Map<string, { name: string; image: string | null }>): NormalizedMessage {
    const authorId = post.user_id || "unknown";
    const userInfo = userMap.get(authorId);
    const files: any[] = post.metadata?.files || [];

    return {
      content: post.message || "",
      author: authorId,
      author_name: userInfo?.name || authorId,
      author_image: userInfo?.image || null,
      platform: "mattermost",
      channel_id: post.channel_id || "",
      channel_name: channelName,
      message_id: post.id || "",
      timestamp: new Date(post.create_at).toISOString(),
      thread_id: post.root_id || null,
      attachments: files.map((f: any) => ({
        type: f.mime_type?.startsWith("image/") ? "image"
            : f.mime_type?.startsWith("video/") ? "video"
            : "file",
        url: `${this.baseUrl}/api/v4/files/${f.id}`,
        name: f.name || "",
        mimetype: f.mime_type || "",
      })),
      reactions: [],
      reply_count: post.reply_count || 0,
      is_bot: false,
      subtype: null,
      links: [],
    };
  }

  async getMessages(channelId: string, opts: GetMessagesOpts): Promise<NormalizedMessage[]> {
    // Mattermost API always returns newest-first and doesn't support forward
    // cursor pagination via `since` the way Slack does. For ascending order
    // (used by the sync runner), we walk backward with `before` cursor across
    // multiple internal pages, then reverse to chronological order.
    if (opts.order === "asc" && !opts.before) {
      return this._getMessagesAsc(channelId, opts);
    }

    const params = new URLSearchParams({
      per_page: String(opts.limit || 100),
      // Mattermost default is collapsed threads which omits reply_count/last_reply_at.
      // Without these flags, root posts look replyless and thread ingestion silently skips them.
      collapsedThreads: "true",
      collapsedThreadsExtended: "true",
    });
    if (opts.before) params.set("before", opts.before);
    if (opts.since) {
      const epochMs = new Date(opts.since).getTime();
      params.set("since", String(epochMs));
    }

    const data = await this._fetch(`/channels/${channelId}/posts?${params}`);
    const order: string[] = data.order || [];
    const posts: Record<string, any> = data.posts || {};

    let channelName = "";
    try {
      const ch = await this._fetch(`/channels/${channelId}`);
      channelName = ch.display_name || ch.name || "";
    } catch { /* ignore */ }

    const userIds = [...new Set(order.map((id) => posts[id]?.user_id).filter(Boolean))];
    const userMap = new Map<string, { name: string; image: string | null }>();
    for (const uid of userIds) {
      userMap.set(uid, await this.resolveUser(uid));
    }

    // Mattermost's /posts endpoint returns root posts, thread replies, AND
    // system messages (join/leave/rename) mixed together. We only want
    // user-authored root posts — matching Mattermost's total_msg_count_root.
    // Thread replies are fetched separately via getThreadMessages() when
    // reply_count > 0.
    const messages = order
      .filter((id) => posts[id] && !posts[id].root_id && !posts[id].type)
      .map((id) => this._normalizePost(posts[id], channelName, userMap));

    if (opts.order === "asc") messages.reverse();
    return messages;
  }

  private async _getMessagesAsc(channelId: string, opts: GetMessagesOpts): Promise<NormalizedMessage[]> {
    const totalLimit = opts.limit || 500;
    const pageSize = 200;
    const allPosts: Array<{ id: string; post: any }> = [];
    let beforeCursor: string | undefined;
    const sinceMs = opts.since ? new Date(opts.since).getTime() : 0;

    let channelName = "";
    try {
      const ch = await this._fetch(`/channels/${channelId}`);
      channelName = ch.display_name || ch.name || "";
    } catch { /* ignore */ }

    // Walk backward from newest, collecting pages until we've reached
    // the `since` cutoff or exhausted all messages.
    while (allPosts.length < totalLimit) {
      const params = new URLSearchParams({
        per_page: String(pageSize),
        collapsedThreads: "true",
        collapsedThreadsExtended: "true",
      });
      if (beforeCursor) params.set("before", beforeCursor);

      const data = await this._fetch(`/channels/${channelId}/posts?${params}`);
      const order: string[] = data.order || [];
      const posts: Record<string, any> = data.posts || {};

      if (order.length === 0) break;

      let reachedSince = false;
      for (const id of order) {
        const post = posts[id];
        if (!post) continue;
        // Skip thread replies — they're fetched separately via getThreadMessages().
        if (post.root_id) continue;
        // Skip system messages (join/leave/rename) — not user content.
        if (post.type) continue;
        if (sinceMs > 0 && post.create_at <= sinceMs) {
          reachedSince = true;
          break;
        }
        allPosts.push({ id, post });
      }

      if (reachedSince) break;
      if (order.length < pageSize) break; // last page

      // Cursor: oldest post in this page (last in order array)
      beforeCursor = order[order.length - 1];
    }

    // Reverse to chronological order (oldest first)
    allPosts.reverse();

    // Apply limit
    const limited = allPosts.slice(0, totalLimit);

    // Resolve users
    const userIds = [...new Set(limited.map((e) => e.post.user_id).filter(Boolean))];
    const userMap = new Map<string, { name: string; image: string | null }>();
    for (const uid of userIds) {
      userMap.set(uid, await this.resolveUser(uid));
    }

    return limited.map((e) => this._normalizePost(e.post, channelName, userMap));
  }

  async getMessageCount(channelId: string): Promise<number> {
    const ch = await this._fetch(`/channels/${channelId}`);
    return ch.total_msg_count_root ?? ch.total_msg_count ?? 0;
  }

  async getThreadMessages(channelId: string, threadId: string): Promise<NormalizedMessage[]> {
    const data = await this._fetch(`/posts/${threadId}/thread`);
    const posts: Record<string, any> = data.posts || {};

    let channelName = "";
    try {
      const ch = await this._fetch(`/channels/${channelId}`);
      channelName = ch.display_name || ch.name || "";
    } catch { /* ignore */ }

    const replies = Object.values(posts)
      .filter((p: any) => p.id !== threadId)
      .sort((a: any, b: any) => a.create_at - b.create_at);

    const userIds = [...new Set(replies.map((p: any) => p.user_id).filter(Boolean))];
    const userMap = new Map<string, { name: string; image: string | null }>();
    for (const uid of userIds) {
      userMap.set(uid as string, await this.resolveUser(uid as string));
    }

    return replies.map((p: any) => this._normalizePost(p, channelName, userMap));
  }

  async proxyFile(url: string): Promise<{ contentType: string; buffer: Buffer }> {
    // Mattermost allowlist is the configured `baseUrl` host only —
    // each tenant runs at its own domain so we can't pre-declare a
    // literal prefix at compile time. The input URL is parsed, the
    // hostname is compared against the parsed `baseUrl` hostname, the
    // private-IP guard runs via `assertPublicUrl`, and the request is
    // built with `Mattermost`'s REST path concatenated onto the trusted
    // `this.baseUrl` (server-side config, not user input). The
    // CodeQL js/request-forgery `HostnameSanitizerGuard` cannot verify
    // a non-literal prefix, so the residual alert is suppressed via
    // the dismissed-as-false-positive flow on the GitHub Security tab.
    let parsedBase: URL;
    try {
      parsedBase = new URL(this.baseUrl);
    } catch {
      throw new Error("Mattermost baseUrl is malformed");
    }
    const trustedHost = parsedBase.hostname.toLowerCase();
    const trustedOrigin = `${parsedBase.protocol}//${trustedHost}`;

    const fileUrl = url.startsWith("http") ? url : `${trustedOrigin}${url}`;
    const decodedUrl = decodeURIComponent(fileUrl);

    let parsedMM: URL;
    try {
      parsedMM = new URL(decodedUrl);
    } catch {
      throw new Error("invalid Mattermost file URL");
    }
    if (parsedMM.protocol !== "https:" && parsedMM.protocol !== "http:") {
      throw new Error("Mattermost file URL must be http(s)");
    }
    if (parsedMM.hostname.toLowerCase() !== trustedHost) {
      throw new Error(`Mattermost host not in allowlist: ${parsedMM.hostname}`);
    }
    await assertPublicUrl(decodedUrl);

    // Reconstruct the request URL using the trusted origin (from
    // server-side config) + only the path/search of the input. The
    // host portion is now provably the configured Mattermost host.
    const mmSafeUrl = `${trustedOrigin}${parsedMM.pathname}${parsedMM.search}`;
    const response = await fetch(mmSafeUrl, {
      headers: { Authorization: `Bearer ${this.botToken}` },
    });
    if (!response.ok) {
      throw new Error(`Failed to fetch Mattermost file: ${response.status}`);
    }
    const contentType = response.headers.get("content-type") || "application/octet-stream";
    const buffer = Buffer.from(await response.arrayBuffer());
    return { contentType, buffer };
  }
}

// ── Bridge factory (singleton per connection) ────────────────────────────────

/** Persistent bridge instances keyed by "{platform}:{connectionId}".
 *  Cleared when ChatManager rebuilds adapters. */
const bridgeCache = new Map<string, PlatformBridge>();

function clearBridgeCache(): void {
  bridgeCache.clear();
}

function newBridgeForPlatform(platform: string, adapter: unknown, connectionId: string, chatManager?: ChatManager): PlatformBridge | null {
  if (platform === "slack") return new SlackBridge(adapter as SlackAdapter);
  if (platform === "discord") return new DiscordBridge(adapter);
  if (platform === "teams") return new TeamsBridge(adapter as TeamsAdapter, connectionId);
  if (platform === "telegram") return new TelegramBridge(adapter, connectionId);
  if (platform === "mattermost") {
    const config = chatManager?.getAdapterConfig(connectionId);
    const baseUrl = config?.baseUrl || config?.server_url || "";
    const botToken = config?.botToken || config?.bot_token || "";
    if (!baseUrl || !botToken) {
      console.error(`MattermostBridge: missing baseUrl or botToken for connection ${connectionId}`);
      return null;
    }
    return new MattermostBridge(adapter, connectionId, baseUrl, botToken);
  }
  return null;
}

function getOrCreateBridge(platform: string, connectionId: string, adapter: unknown, chatManager?: ChatManager): PlatformBridge | null {
  const key = `${platform}:${connectionId}`;
  const cached = bridgeCache.get(key);
  if (cached) return cached;
  const bridge = newBridgeForPlatform(platform, adapter, connectionId, chatManager);
  if (bridge) bridgeCache.set(key, bridge);
  return bridge;
}

function getBridge(chatManager: ChatManager, platform: string, connectionId?: string): PlatformBridge | null {
  if (connectionId) {
    const entry = chatManager.getAdapterByConnectionId(connectionId);
    if (!entry) return null;
    return getOrCreateBridge(entry.platform, entry.connectionId, entry.adapter, chatManager);
  }
  const adapter = chatManager.getAdapter(platform);
  if (!adapter) return null;
  return getOrCreateBridge(platform, platform, adapter, chatManager);
}

function getBridgeByConnectionId(chatManager: ChatManager, connectionId: string): { platform: string; bridge: PlatformBridge } | null {
  const entry = chatManager.getAdapterByConnectionId(connectionId);
  if (entry) {
    const bridge = getOrCreateBridge(entry.platform, entry.connectionId, entry.adapter, chatManager);
    if (bridge) return { platform: entry.platform, bridge };
  }

  // Fallback for platforms like Mattermost whose bridge uses direct REST calls and
  // doesn't need the Chat SDK adapter instance. The adapter may not be in the bot's
  // internal map (WebSocket init timing) but ChatManager has the credentials.
  const info = chatManager.getConnectionInfo(connectionId);
  if (info) {
    const bridge = getOrCreateBridge(info.platform, connectionId, null, chatManager);
    if (bridge) return { platform: info.platform, bridge };
  }
  return null;
}

function getFirstBridge(chatManager: ChatManager): { platform: string; bridge: PlatformBridge } | null {
  const adapters = chatManager.listAdapters();
  for (const { platform, connectionId } of adapters) {
    const bridge = getBridge(chatManager, platform, connectionId);
    if (bridge) return { platform, bridge };
  }
  return null;
}

/** Infer platform from a file URL.
 *
 * Uses parsed-URL hostname checks (exact match or proper subdomain suffix),
 * NOT `url.includes(host)` substring matching. Substring matching against a
 * full URL string would let an attacker host like `evil.com/files.slack.com`
 * or `files.slack.com.evil.com` route to the wrong platform — wrong-adapter
 * routing, not a direct security sink (the SSRF defense lives in
 * `assertAllowedFetchUrl` per-platform), but worth fixing for hygiene and to
 * close CodeQL `js/incomplete-url-substring-sanitization` (alerts #12–#18).
 * Mattermost has no fixed host (per-instance baseUrl) so its detection still
 * uses a path-pattern check.
 */
function detectPlatformFromUrl(url: string): string | null {
  let host: string;
  try {
    host = new URL(url).hostname.toLowerCase();
  } catch {
    return null;
  }
  if (host === "files.slack.com" || host === "slack-files.com") return "slack";
  if (host === "cdn.discordapp.com" || host === "media.discordapp.net") return "discord";
  if (host === "graph.microsoft.com" || host.endsWith(".sharepoint.com")) return "teams";
  if (host === "api.telegram.org") return "telegram";
  // Mattermost is self-hosted on a per-instance baseUrl; detect by API path.
  if (url.includes("/api/v4/files/")) return "mattermost";
  return null;
}

/**
 * Extract a workspace/team identifier from a file URL for multi-workspace routing.
 * Returns null if no identifier can be extracted.
 *
 * - Slack: files-pri/{TEAM_ID}-{FILE_ID}/ → TEAM_ID (e.g. "T0APJ2FNUKZ")
 * - Discord/Teams/Telegram: not reliably extractable from URL — fall through
 *   to the try-all-adapters path in the proxy handler.
 *
 * Issue #47 — the Telegram regex used to extract the bot token from
 * `api.telegram.org/file/bot{TOKEN}/` and return it as a "workspace id"
 * lookup key. But `chatManager.workspaceIdMap` is only populated for
 * Slack (`team_id → connectionId`); Telegram never inserts. The lookup
 * always missed and we fell through to try-all-adapters anyway. Removed
 * to delete the dead routing path. If multi-Telegram-bot routing is
 * needed later, populate the map in `chat-manager.ts` first (with a
 * stable hash of the bot token, not the token itself).
 */
function extractWorkspaceIdFromUrl(url: string): string | null {
  // Slack: extract team ID from files-pri/TEAM_ID-FILE_ID/
  const slackMatch = url.match(/files-pri\/([A-Z0-9]+)-[A-Z0-9]+\//);
  if (slackMatch) return slackMatch[1];

  return null;
}

/** Try to detect platform from a channel ID format. */
function detectPlatformFromChannelId(channelId: string): string | null {
  // Slack: starts with C, D, or G followed by alphanumeric (e.g., C0AMY9QSPB2)
  if (/^[CDG][A-Z0-9]{8,}$/.test(channelId)) return "slack";
  // Discord: pure numeric snowflake IDs (e.g., 680671916943605760)
  if (/^\d{17,20}$/.test(channelId)) return "discord";
  // Teams: Bot Framework / Graph channel ids (e.g., 19:abc@thread.tacv2)
  if (channelId.startsWith("19:") && channelId.includes("@thread")) return "teams";
  return null;
}

// ── Route handlers ──────────────────────────────────────────────────────────

async function handleListChannels(
  req: IncomingMessage,
  res: ServerResponse,
  chatManager: ChatManager,
  platform?: string,
): Promise<void> {
  try {
    if (platform) {
      const bridge = getBridge(chatManager, platform);
      if (!bridge) {
        jsonResponse(res, 404, { error: `Platform "${platform}" not connected`, code: "NOT_FOUND" });
        return;
      }
      const channels = await bridge.listChannels();
      jsonResponse(res, 200, { channels });
    } else {
      // Aggregate from all adapters (use connectionId to avoid duplicates)
      const allChannels: NormalizedChannel[] = [];
      for (const { platform: p, connectionId } of chatManager.listAdapters()) {
        const bridge = getBridge(chatManager, p, connectionId);
        if (bridge) {
          try {
            const channels = await bridge.listChannels();
            allChannels.push(...channels);
          } catch (err) {
            console.error(`Bridge: listChannels error for ${p} (${connectionId}):`, safeErrorMessage(err));
          }
        }
      }
      jsonResponse(res, 200, { channels: allChannels });
    }
  } catch (err) {
    console.error("Bridge: listChannels error:", safeErrorMessage(err));
    const classified = classifyPlatformError(err);
    jsonResponse(res, classified.status, { error: messageForCode(classified.code), code: classified.code });
  }
}

async function handleGetChannel(
  _req: IncomingMessage,
  res: ServerResponse,
  chatManager: ChatManager,
  channelId: string,
  platform?: string,
): Promise<void> {
  try {
    const resolvedPlatform = platform || detectPlatformFromChannelId(channelId);
    let bridge: PlatformBridge | null = null;
    if (resolvedPlatform) {
      bridge = getBridge(chatManager, resolvedPlatform);
    }
    if (!bridge) {
      const first = getFirstBridge(chatManager);
      bridge = first?.bridge ?? null;
    }

    if (!bridge) {
      jsonResponse(res, 404, { error: `Channel ${channelId} not found`, code: "NOT_FOUND" });
      return;
    }

    const channel = await bridge.getChannel(channelId);
    jsonResponse(res, 200, channel);
  } catch (err) {
    console.error("Bridge: getChannel error:", safeErrorMessage(err));
    jsonResponse(res, 404, { error: `Channel ${channelId} not found`, code: "NOT_FOUND" });
  }
}

async function handleGetMessages(
  req: IncomingMessage,
  res: ServerResponse,
  chatManager: ChatManager,
  channelId: string,
  platform?: string,
): Promise<void> {
  try {
    const query = parseQuery(req.url || "");
    const limit = Math.min(parseInt(query.get("limit") || String(DEFAULT_MESSAGE_LIMIT), 10), MAX_MESSAGE_LIMIT);
    const since = query.get("since") ?? undefined;
    const before = query.get("before") ?? undefined;
    const order = query.get("order") ?? "desc";

    const resolvedPlatform = platform || detectPlatformFromChannelId(channelId);
    let bridge: PlatformBridge | null = null;
    if (resolvedPlatform) {
      bridge = getBridge(chatManager, resolvedPlatform);
    }
    if (!bridge) {
      const first = getFirstBridge(chatManager);
      bridge = first?.bridge ?? null;
    }

    if (!bridge) {
      jsonResponse(res, 503, { error: "No platform adapters connected", code: "NO_ADAPTER" });
      return;
    }

    const messages = await bridge.getMessages(channelId, { limit, since, before, order });
    jsonResponse(res, 200, { messages });
  } catch (err) {
    console.error("Bridge: getMessages error:", safeErrorMessage(err));
    const classified = classifyPlatformError(err);
    jsonResponse(res, classified.status, { error: messageForCode(classified.code), code: classified.code });
  }
}

async function handleGetMessageCount(
  _req: IncomingMessage,
  res: ServerResponse,
  chatManager: ChatManager,
  channelId: string,
  platform?: string,
): Promise<void> {
  try {
    const resolvedPlatform = platform || detectPlatformFromChannelId(channelId);
    let bridge: PlatformBridge | null = null;
    if (resolvedPlatform) {
      bridge = getBridge(chatManager, resolvedPlatform);
    }
    if (!bridge) {
      const first = getFirstBridge(chatManager);
      bridge = first?.bridge ?? null;
    }

    if (!bridge) {
      jsonResponse(res, 503, { error: "No platform adapters connected", code: "NO_ADAPTER" });
      return;
    }

    const count = await bridge.getMessageCount(channelId);
    jsonResponse(res, 200, { count });
  } catch (err) {
    console.error("Bridge: getMessageCount error:", safeErrorMessage(err));
    const classified = classifyPlatformError(err);
    jsonResponse(res, classified.status, { error: messageForCode(classified.code), code: classified.code });
  }
}

async function handleGetThreadMessages(
  _req: IncomingMessage,
  res: ServerResponse,
  chatManager: ChatManager,
  channelId: string,
  threadId: string,
  platform?: string,
): Promise<void> {
  try {
    const resolvedPlatform = platform || detectPlatformFromChannelId(channelId);
    let bridge: PlatformBridge | null = null;
    if (resolvedPlatform) {
      bridge = getBridge(chatManager, resolvedPlatform);
    }
    if (!bridge) {
      const first = getFirstBridge(chatManager);
      bridge = first?.bridge ?? null;
    }

    if (!bridge) {
      jsonResponse(res, 503, { error: "No platform adapters connected", code: "NO_ADAPTER" });
      return;
    }

    const messages = await bridge.getThreadMessages(channelId, threadId);
    jsonResponse(res, 200, { messages });
  } catch (err) {
    console.error("Bridge: getThreadMessages error:", safeErrorMessage(err));
    const classified = classifyPlatformError(err);
    jsonResponse(res, classified.status, { error: messageForCode(classified.code), code: classified.code });
  }
}

async function handleFileProxy(
  req: IncomingMessage,
  res: ServerResponse,
  chatManager: ChatManager,
  fileUrl: string,
  platform?: string,
  connectionId?: string,
): Promise<void> {
  try {
    const resolvedPlatform = platform || detectPlatformFromUrl(fileUrl);

    // Layer A: Explicit connectionId (highest priority)
    let bridge: PlatformBridge | null = null;
    if (connectionId && resolvedPlatform) {
      bridge = getBridge(chatManager, resolvedPlatform, connectionId);
    }

    // Layer B: Extract workspace ID from URL and match to cached adapter identity
    if (!bridge && resolvedPlatform) {
      const workspaceId = extractWorkspaceIdFromUrl(fileUrl);
      if (workspaceId) {
        const resolvedConnId = chatManager.getConnectionForWorkspaceId(workspaceId);
        if (resolvedConnId) {
          bridge = getBridge(chatManager, resolvedPlatform, resolvedConnId);
        }
      }
    }

    // Layer C: Try all adapters for this platform (fallback for multi-workspace)
    if (!bridge && resolvedPlatform) {
      const allAdapters = chatManager.getAdaptersByPlatform(resolvedPlatform);
      if (allAdapters.length === 1) {
        // Single adapter — use directly (no try-all overhead)
        bridge = getOrCreateBridge(resolvedPlatform, allAdapters[0].connectionId, allAdapters[0].adapter);
      } else if (allAdapters.length > 1) {
        // Multiple adapters — try each until one succeeds
        let lastErr: unknown = null;
        for (const entry of allAdapters) {
          const candidate = getOrCreateBridge(resolvedPlatform, entry.connectionId, entry.adapter);
          if (!candidate) continue;
          try {
            const { contentType, buffer } = await candidate.proxyFile(fileUrl);
            res.writeHead(200, {
              "Content-Type": contentType,
              "Cache-Control": "public, max-age=3600",
            });
            res.end(buffer);
            return; // Success — done
          } catch (err) {
            lastErr = err;
            // Wrong workspace token — try next adapter
          }
        }
        // All adapters failed
        console.error("Bridge: fileProxy all adapters failed:", lastErr);
        const classified = classifyPlatformError(lastErr);
        jsonResponse(res, classified.status, { error: messageForCode(classified.code), code: classified.code });
        return;
      }
    }

    // Final fallback: first available bridge of any platform
    if (!bridge) {
      const first = getFirstBridge(chatManager);
      bridge = first?.bridge ?? null;
    }

    if (!bridge) {
      jsonResponse(res, 503, { error: "No platform adapters connected", code: "NO_ADAPTER" });
      return;
    }

    const { contentType, buffer } = await bridge.proxyFile(fileUrl);
    res.writeHead(200, {
      "Content-Type": contentType,
      "Cache-Control": "public, max-age=3600",
    });
    res.end(buffer);
  } catch (err) {
    console.error("Bridge: fileProxy error:", safeErrorMessage(err));
    const classified = classifyPlatformError(err);
    jsonResponse(res, classified.status, { error: messageForCode(classified.code), code: classified.code });
  }
}

// ── Adapter management handlers ──────────────────────────────────────────────

async function handleRegisterAdapter(
  req: IncomingMessage,
  res: ServerResponse,
  chatManager: ChatManager,
): Promise<void> {
  try {
    let body: string;
    try {
      body = await readBody(req);
    } catch (err) {
      if (err instanceof BodyTooLargeError) {
        console.warn(`Bridge: rejected oversize body from ${req.socket?.remoteAddress ?? "unknown"} on ${req.url ?? "?"}`);
        jsonResponse(res, 413, { error: "Request body too large", code: "PAYLOAD_TOO_LARGE" });
        req.destroy();
        return;
      }
      throw err;
    }
    const { platform, credentials, connectionId } = JSON.parse(body);

    if (!platform || typeof platform !== "string") {
      jsonResponse(res, 400, { error: "Missing required field: platform", code: "INVALID_REQUEST" });
      return;
    }
    if (!credentials || typeof credentials !== "object") {
      jsonResponse(res, 400, { error: "Missing required field: credentials", code: "INVALID_REQUEST" });
      return;
    }

    // Normalize credential keys: frontend/backend sends snake_case, ChatSDK expects camelCase
    const normalizedCreds: Record<string, string> = {};
    for (const [key, value] of Object.entries(credentials)) {
      const camelKey = key.replace(/_([a-z])/g, (_: string, c: string) => c.toUpperCase());
      normalizedCreds[camelKey] = value as string;
    }

    await chatManager.register(platform, normalizedCreds, connectionId || undefined);
    jsonResponse(res, 200, { status: "ok", platform, connectionId: connectionId || platform });
  } catch (err) {
    // CodeQL js/stack-trace-exposure (alert #60): static prose, never derived
    // from `err`. Operators see the full error in the console.error line above.
    console.error("Bridge: registerAdapter error:", safeErrorMessage(err));
    jsonResponse(res, 500, { status: "error", message: "adapter registration failed" });
  }
}

async function handleUnregisterAdapter(
  _req: IncomingMessage,
  res: ServerResponse,
  chatManager: ChatManager,
  connectionIdOrPlatform: string,
): Promise<void> {
  try {
    // Try to unregister by connection ID first, then fall back to platform name
    const found = await chatManager.unregisterByConnectionId(connectionIdOrPlatform);
    if (!found) {
      // Legacy fallback: treat as platform name
      await chatManager.unregister(connectionIdOrPlatform);
    }
    jsonResponse(res, 200, { status: "ok" });
  } catch (err) {
    // CodeQL js/stack-trace-exposure (alert #60): static prose, never derived
    // from `err`. Operators see the full error in the console.error line above.
    console.error("Bridge: unregisterAdapter error:", safeErrorMessage(err));
    jsonResponse(res, 500, { status: "error", message: "adapter unregistration failed" });
  }
}

async function handleValidateAdapter(
  req: IncomingMessage,
  res: ServerResponse,
  platform: string,
): Promise<void> {
  try {
    let body: string;
    try {
      body = await readBody(req);
    } catch (err) {
      if (err instanceof BodyTooLargeError) {
        console.warn(`Bridge: rejected oversize body from ${req.socket?.remoteAddress ?? "unknown"} on ${req.url ?? "?"}`);
        jsonResponse(res, 413, { error: "Request body too large", code: "PAYLOAD_TOO_LARGE" });
        req.destroy();
        return;
      }
      throw err;
    }
    const { credentials } = JSON.parse(body);

    if (!credentials || typeof credentials !== "object") {
      jsonResponse(res, 400, { error: "Missing required field: credentials", code: "INVALID_REQUEST" });
      return;
    }

    if (platform === "slack") {
      const { createSlackAdapter } = await import("@chat-adapter/slack");
      const tempAdapter = createSlackAdapter({
        botToken: credentials.botToken,
        signingSecret: credentials.signingSecret,
      });
      // Test API call — auth.test verifies the token
      await (tempAdapter as any).client.auth.test();
      jsonResponse(res, 200, { valid: true });
    } else if (platform === "discord") {
      // Validate token via Discord REST API directly
      const discordRes = await fetch("https://discord.com/api/v10/users/@me", {
        headers: { Authorization: `Bot ${credentials.botToken}` },
      });
      if (!discordRes.ok) {
        jsonResponse(res, 200, { valid: false, error: `Discord API returned ${discordRes.status}` });
      } else {
        jsonResponse(res, 200, { valid: true });
      }
    } else if (platform === "teams") {
      const { createTeamsAdapter } = await import("@chat-adapter/teams" as any);
      const tempAdapter = createTeamsAdapter({
        appId: credentials.appId,
        appPassword: credentials.appPassword,
        appTenantId: credentials.appTenantId,
        appType: credentials.appType || "MultiTenant",
      });
      // Real validation: actually mint a Graph token via MSAL. The previous
      // construct-only check let users typo the App ID (e.g. paste a display
      // name like "Teams" instead of the GUID) and only catch it later when
      // channel enumeration silently returned []. We exercise the token mint
      // by hitting a minimal Graph endpoint:
      //   • 2xx                                   → credentials valid
      //   • AADSTS / unauthorized_client          → credentials wrong (400)
      //   • other (403/404/network)              → creds look valid; soft accept
      try {
        const graph = (tempAdapter as any).app?.graph;
        if (!graph) {
          jsonResponse(res, 200, {
            valid: true,
            message: "Adapter constructed. Token mint could not be exercised; verify the messaging endpoint and Graph admin consent in Azure.",
          });
          return;
        }
        await graph.http.get("/applications?$top=1");
        jsonResponse(res, 200, { valid: true, message: "Credentials valid (Graph token minted successfully)." });
      } catch (err) {
        const msg = safeErrorMessage(err);
        const lower = msg.toLowerCase();
        const isAuthFailure =
          /aadsts\d{4,6}/i.test(msg) ||
          lower.includes("unauthorized_client") ||
          lower.includes("invalid_client") ||
          lower.includes("was not found in the directory");
        if (isAuthFailure) {
          jsonResponse(res, 200, { valid: false, error: `Microsoft rejected the credentials: ${msg}` });
        } else {
          jsonResponse(res, 200, {
            valid: true,
            message: `Credentials look valid but a Graph probe failed: ${msg}. Check Channel.ReadBasic.All admin consent if channel listing fails later.`,
          });
        }
      }
    } else if (platform === "mattermost") {
      const baseUrl = (credentials.baseUrl || credentials.server_url || "").replace(/\/+$/, "");
      const botToken = credentials.botToken || credentials.bot_token || "";
      if (!baseUrl || !botToken) {
        jsonResponse(res, 200, { valid: false, error: "Server URL and bot token are required" });
        return;
      }
      const mmResp = await fetch(`${baseUrl}/api/v4/users/me`, {
        headers: { Authorization: `Bearer ${botToken}` },
      });
      if (mmResp.ok) {
        const me = await mmResp.json() as { username?: string };
        jsonResponse(res, 200, { valid: true, message: `Connected as @${me.username || "bot"}` });
      } else if (mmResp.status === 401) {
        jsonResponse(res, 200, { valid: false, error: "Invalid bot token" });
      } else {
        jsonResponse(res, 200, { valid: false, error: `Mattermost API returned ${mmResp.status}` });
      }
    } else if (platform === "telegram") {
      const { createTelegramAdapter } = await import("@chat-adapter/telegram" as any);
      // Verify adapter can be constructed (validates config shape)
      createTelegramAdapter({
        botToken: credentials.botToken,
        secretToken: credentials.secretToken,
      });
      // Validate token by calling Telegram getMe API
      const resp = await fetch(`https://api.telegram.org/bot${credentials.botToken}/getMe`);
      const data = await resp.json() as { ok: boolean; description?: string };
      if (data.ok) {
        jsonResponse(res, 200, { valid: true });
      } else {
        jsonResponse(res, 200, { valid: false, error: data.description || "Invalid bot token" });
      }
    } else {
      jsonResponse(res, 400, { valid: false, error: `Unknown platform: ${platform}` });
    }
  } catch (err) {
    // CodeQL js/tainted-format-string (alert #22): static format string +
    // arguments so user-tainted `platform` cannot influence format specifiers.
    // CodeQL js/stack-trace-exposure (alert #60): static prose, never derived
    // from `err`. Operators see the full error in the console.error line above.
    console.error("Bridge: validateAdapter(%s) error:", platform, safeErrorMessage(err));
    jsonResponse(res, 200, { valid: false, error: "validation failed" });
  }
}

// ── Connection-scoped route helpers ─────────────────────────────────────

async function handleConnectionRoute(
  _req: IncomingMessage,
  res: ServerResponse,
  chatManager: ChatManager,
  connectionId: string,
  handler: (bridge: PlatformBridge) => Promise<void>,
): Promise<void> {
  try {
    const result = getBridgeByConnectionId(chatManager, connectionId);
    if (!result) {
      jsonResponse(res, 404, { error: `Connection "${connectionId}" not found`, code: "NOT_FOUND" });
      return;
    }
    await handler(result.bridge);
  } catch (err) {
    const classified = classifyPlatformError(err);
    // Expected "not found" errors during multi-workspace probing — log briefly, not full stack
    if (classified.status === 404) {
      console.warn(`Bridge: connection route (${connectionId}): ${safeErrorMessage((err as any)?.data?.error ?? err)}`);
    } else {
      // CodeQL js/tainted-format-string (alert #23).
      console.error("Bridge: connection route error (%s):", connectionId, safeErrorMessage(err));
    }
    jsonResponse(res, classified.status, { error: messageForCode(classified.code), code: classified.code });
  }
}

async function handleConnectionChannels(
  _req: IncomingMessage,
  res: ServerResponse,
  chatManager: ChatManager,
  connectionId: string,
): Promise<void> {
  try {
    const result = getBridgeByConnectionId(chatManager, connectionId);
    if (!result) {
      jsonResponse(res, 404, { error: `Connection "${connectionId}" not found`, code: "NOT_FOUND" });
      return;
    }
    const channels = await result.bridge.listChannels();
    jsonResponse(res, 200, { channels });
  } catch (err) {
    // CodeQL js/tainted-format-string (alert #24).
    console.error("Bridge: listChannels error (connection %s):", connectionId, safeErrorMessage(err));
    const classified = classifyPlatformError(err);
    jsonResponse(res, classified.status, { error: messageForCode(classified.code), code: classified.code });
  }
}

async function handlePlatformChannelsAggregated(
  _req: IncomingMessage,
  res: ServerResponse,
  chatManager: ChatManager,
  platform: string,
): Promise<void> {
  try {
    const adapters = chatManager.getAdaptersByPlatform(platform);
    if (adapters.length === 0) {
      jsonResponse(res, 404, { error: `Platform "${platform}" not connected`, code: "NOT_FOUND" });
      return;
    }

    const allChannels: (NormalizedChannel & { connection_id: string })[] = [];
    for (const { connectionId, adapter } of adapters) {
      const bridge = getOrCreateBridge(platform, connectionId, adapter);
      if (!bridge) continue;
      try {
        const channels = await bridge.listChannels();
        for (const ch of channels) {
          allChannels.push({ ...ch, connection_id: connectionId });
        }
      } catch (err) {
        // CodeQL js/tainted-format-string (alert #25).
        console.error("Bridge: listChannels error for %s:%s:", platform, connectionId, safeErrorMessage(err));
      }
    }
    jsonResponse(res, 200, { channels: allChannels });
  } catch (err) {
    // CodeQL js/tainted-format-string (alert #26).
    console.error("Bridge: aggregated listChannels error for %s:", platform, safeErrorMessage(err));
    const classified = classifyPlatformError(err);
    jsonResponse(res, classified.status, { error: messageForCode(classified.code), code: classified.code });
  }
}

// ── Route registration ──────────────────────────────────────────────────────

export function registerBridgeRoutes(
  chatManager: ChatManager,
  lazySyncFn?: () => Promise<boolean>,
): (req: IncomingMessage, res: ServerResponse) => Promise<boolean> {
  // Run the production fail-fast / local-dev loud-warning check once at
  // wiring time. Moved out of module-load so tests can import the file.
  assertBridgeAuthReady();

  // Subscribe to adapter rebuilds to clear bridge-level caches. Critical
  // for the RES-286 scheduled adapter recycle: every 6 h the ChatManager
  // tears down and rebuilds adapters, and these caches must be dropped in
  // lockstep — otherwise they hold stale adapter references (`bridgeCache`)
  // or grow without bound across the bot's lifetime.
  //
  // Cache shape and what we do with each:
  //  - `bridgeCache`              — stale singleton adapter refs   → clear
  //  - `mattermostUserCache`      — per-user metadata, cheap to refetch → clear
  //  - `userProfileCache`         — same shape, cross-platform     → clear
  //  - `teamsConversationRegistry`/`telegramChatRegistry` — ONLY source of
  //    truth for `listChannels()` on those platforms (populated from inbound
  //    webhooks; no list API exists). Wholesale-clearing would empty the
  //    sidebar until each conversation posts again, so we age out stale
  //    entries instead (default 30 day TTL).
  chatManager.onRebuild(() => {
    clearBridgeCache();
    clearMattermostUserCache();
    clearUserProfileCache();
    const teamsPruned = pruneStaleTeamsConversations();
    const telegramPruned = pruneStaleTelegramChats();
    if (teamsPruned > 0 || telegramPruned > 0) {
      console.log(`Bridge: rebuild pruned ${teamsPruned} stale Teams + ${telegramPruned} stale Telegram entries`);
    }
  });

  return async (req: IncomingMessage, res: ServerResponse): Promise<boolean> => {
    const url = req.url || "";

    if (!url.startsWith("/bridge/")) return false;
    if (!checkAuth(req, res)) return true;

    // Lazy sync: if the bot has no adapters, attempt recovery before handling
    if (lazySyncFn && chatManager.adapterCount() === 0) {
      await lazySyncFn();
    }

    // POST /bridge/adapters — register adapter
    if (req.method === "POST" && url === "/bridge/adapters") {
      await handleRegisterAdapter(req, res, chatManager);
      return true;
    }

    // GET /bridge/adapters — list adapters
    if (req.method === "GET" && url === "/bridge/adapters") {
      jsonResponse(res, 200, { adapters: chatManager.listAdapters() });
      return true;
    }

    // POST /bridge/adapters/:platform/validate — validate credentials
    const validateMatch = url.match(/^\/bridge\/adapters\/([^/]+)\/validate$/);
    if (req.method === "POST" && validateMatch) {
      await handleValidateAdapter(req, res, validateMatch[1]);
      return true;
    }

    // DELETE /bridge/adapters/:platform — unregister adapter
    const adapterMatch = url.match(/^\/bridge\/adapters\/([^/]+)$/);
    if (req.method === "DELETE" && adapterMatch) {
      await handleUnregisterAdapter(req, res, chatManager, adapterMatch[1]);
      return true;
    }

    // ── Connection-scoped routes ────────────────────────────────────────────

    // GET /bridge/connections/:connId/channels
    const connChannelsMatch = url.match(/^\/bridge\/connections\/([^/]+)\/channels(\?|$)/);
    if (req.method === "GET" && connChannelsMatch) {
      await handleConnectionChannels(req, res, chatManager, connChannelsMatch[1]);
      return true;
    }

    // GET /bridge/connections/:connId/channels/:id/threads/:tid/messages
    const connThreadMatch = url.match(
      /^\/bridge\/connections\/([^/]+)\/channels\/([^/]+)\/threads\/([^/]+)\/messages/,
    );
    if (req.method === "GET" && connThreadMatch) {
      await handleConnectionRoute(req, res, chatManager, connThreadMatch[1], async (bridge) => {
        const messages = await bridge.getThreadMessages(
          decodeChannelSegment(connThreadMatch[2]),
          decodeChannelSegment(connThreadMatch[3]),
        );
        jsonResponse(res, 200, { messages });
      });
      return true;
    }

    // GET /bridge/connections/:connId/channels/:id/count
    const connCountMatch = url.match(/^\/bridge\/connections\/([^/]+)\/channels\/([^/]+)\/count$/);
    if (req.method === "GET" && connCountMatch) {
      await handleConnectionRoute(req, res, chatManager, connCountMatch[1], async (bridge) => {
        const count = await bridge.getMessageCount(decodeChannelSegment(connCountMatch[2]));
        jsonResponse(res, 200, { count });
      });
      return true;
    }

    // GET /bridge/connections/:connId/channels/:id/messages
    const connMessagesMatch = url.match(/^\/bridge\/connections\/([^/]+)\/channels\/([^/]+)\/messages/);
    if (req.method === "GET" && connMessagesMatch) {
      await handleConnectionRoute(req, res, chatManager, connMessagesMatch[1], async (bridge) => {
        const query = parseQuery(req.url || "");
        const limit = Math.min(parseInt(query.get("limit") || String(DEFAULT_MESSAGE_LIMIT), 10), MAX_MESSAGE_LIMIT);
        const since = query.get("since") ?? undefined;
        const before = query.get("before") ?? undefined;
        const order = query.get("order") ?? "desc";
        const messages = await bridge.getMessages(decodeChannelSegment(connMessagesMatch[2]), { limit, since, before, order });
        jsonResponse(res, 200, { messages });
      });
      return true;
    }

    // GET /bridge/connections/:connId/channels/:id
    const connChannelMatch = url.match(/^\/bridge\/connections\/([^/]+)\/channels\/([^/]+)$/);
    if (req.method === "GET" && connChannelMatch) {
      await handleConnectionRoute(req, res, chatManager, connChannelMatch[1], async (bridge) => {
        const channel = await bridge.getChannel(decodeChannelSegment(connChannelMatch[2]));
        jsonResponse(res, 200, channel);
      });
      return true;
    }

    // ── Platform-prefixed routes ──────────────────────────────────────────

    // GET /bridge/platforms/:platform/channels
    const platformChannelsMatch = url.match(/^\/bridge\/platforms\/([^/]+)\/channels(\?|$)/);
    if (req.method === "GET" && platformChannelsMatch) {
      await handlePlatformChannelsAggregated(req, res, chatManager, platformChannelsMatch[1]);
      return true;
    }

    // GET /bridge/platforms/:platform/channels/:id/threads/:tid/messages
    const platformThreadMatch = url.match(
      /^\/bridge\/platforms\/([^/]+)\/channels\/([^/]+)\/threads\/([^/]+)\/messages/,
    );
    if (req.method === "GET" && platformThreadMatch) {
      await handleGetThreadMessages(req, res, chatManager, decodeChannelSegment(platformThreadMatch[2]), decodeChannelSegment(platformThreadMatch[3]), platformThreadMatch[1]);
      return true;
    }

    // GET /bridge/platforms/:platform/channels/:id/messages
    const platformMessagesMatch = url.match(/^\/bridge\/platforms\/([^/]+)\/channels\/([^/]+)\/messages/);
    if (req.method === "GET" && platformMessagesMatch) {
      await handleGetMessages(req, res, chatManager, decodeChannelSegment(platformMessagesMatch[2]), platformMessagesMatch[1]);
      return true;
    }

    // GET /bridge/platforms/:platform/channels/:id
    const platformChannelMatch = url.match(/^\/bridge\/platforms\/([^/]+)\/channels\/([^/]+)$/);
    if (req.method === "GET" && platformChannelMatch) {
      await handleGetChannel(req, res, chatManager, decodeChannelSegment(platformChannelMatch[2]), platformChannelMatch[1]);
      return true;
    }

    // GET /bridge/platforms/:platform/files?url=...&connection_id=...
    const platformFilesMatch = url.match(/^\/bridge\/platforms\/([^/]+)\/files/);
    if (req.method === "GET" && platformFilesMatch) {
      const fileQuery = parseQuery(url);
      const fileUrl = fileQuery.get("url");
      if (fileUrl) {
        const connId = fileQuery.get("connection_id") || undefined;
        await handleFileProxy(req, res, chatManager, fileUrl, platformFilesMatch[1], connId);
        return true;
      }
    }

    // Legacy routes (aggregate across all adapters for backward compatibility)

    // GET /bridge/channels
    if (req.method === "GET" && url.match(/^\/bridge\/channels(\?|$)/)) {
      await handleListChannels(req, res, chatManager);
      return true;
    }

    // GET /bridge/channels/:id/threads/:tid/messages
    const threadMatch = url.match(
      /^\/bridge\/channels\/([^/]+)\/threads\/([^/]+)\/messages/,
    );
    if (req.method === "GET" && threadMatch) {
      await handleGetThreadMessages(req, res, chatManager, decodeChannelSegment(threadMatch[1]), decodeChannelSegment(threadMatch[2]));
      return true;
    }

    // GET /bridge/channels/:id/count
    const countMatch = url.match(/^\/bridge\/channels\/([^/]+)\/count$/);
    if (req.method === "GET" && countMatch) {
      await handleGetMessageCount(req, res, chatManager, decodeChannelSegment(countMatch[1]));
      return true;
    }

    // GET /bridge/channels/:id/messages
    const messagesMatch = url.match(/^\/bridge\/channels\/([^/]+)\/messages/);
    if (req.method === "GET" && messagesMatch) {
      await handleGetMessages(req, res, chatManager, decodeChannelSegment(messagesMatch[1]));
      return true;
    }

    // GET /bridge/channels/:id
    const channelMatch = url.match(/^\/bridge\/channels\/([^/]+)$/);
    if (req.method === "GET" && channelMatch) {
      await handleGetChannel(req, res, chatManager, decodeChannelSegment(channelMatch[1]));
      return true;
    }

    // GET /bridge/files?url=...&connection_id=...
    if (req.method === "GET" && url.startsWith("/bridge/files")) {
      logger.debug("Bridge: /bridge/files route matched, url:", url.slice(0, 80));
      const fileQuery = parseQuery(url);
      const fileUrl = fileQuery.get("url");
      const connId = fileQuery.get("connection_id") || undefined;
      logger.debug("Bridge: parsed fileUrl:", fileUrl?.slice(0, 60), "connection_id:", connId || "(auto-detect)");
      if (fileUrl) {
        await handleFileProxy(req, res, chatManager, fileUrl, undefined, connId);
        return true;
      }
    }

    jsonResponse(res, 404, { error: "Bridge endpoint not found", code: "NOT_FOUND" });
    return true;
  };
}
