#!/usr/bin/env bun
/// <reference types="bun-types" />
/**
 * Microsoft Teams MCP channel server for Exult Healthcare AI agent.
 *
 * Two-way MCP channel server using the Bot Framework REST API.
 * - Inbound: HTTP webhook listener via Bun.serve (POST /api/messages)
 * - Outbound: POST to Bot Framework REST API
 * - JWT validation via @microsoft/teams.apps JwtValidator
 *
 * Env vars:
 *   MSTEAMS_APP_ID       - Azure AD App ID (required)
 *   MSTEAMS_APP_PASSWORD - Azure AD App secret (required)
 *   MSTEAMS_TENANT_ID    - Azure AD Tenant ID (required)
 *   MSTEAMS_WEBHOOK_PORT - Local webhook port (default: 3978)
 */

import {
  readFileSync,
  writeFileSync,
  mkdirSync,
  existsSync,
} from "fs";
import { appendFile } from "fs/promises";
import { timingSafeEqual } from "crypto";
import { homedir } from "os";
import { join, extname, resolve, basename } from "path";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  ListToolsRequestSchema,
  CallToolRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import type { CallToolRequest } from "@modelcontextprotocol/sdk/types.js";
import { serveMcpOverHttp, MCP_PORTS } from "../mcp-shared/index.ts";
import {
  chunkText,
  stripMentionTags,
  wasBotMentioned,
  normalizeConversationId,
  sanitizeFilename,
  accumulateStreamText,
} from "./utils.js";
import {
  isRcConfigured,
  initRcNotifications,
  handleRcWebhook,
  verifyRcWebhook,
} from "./rc-notifications.js";

// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------

const APP_ID = process.env.MSTEAMS_APP_ID ?? "";
const APP_PASSWORD = process.env.MSTEAMS_APP_PASSWORD ?? "";
const TENANT_ID = process.env.MSTEAMS_TENANT_ID ?? "";
const WEBHOOK_PORT = parseInt(process.env.MSTEAMS_WEBHOOK_PORT ?? "3978", 10);
// Shared secret for the /api/notify compat endpoint used by migrated CF
// workers (e.g. exult-amd-notify). If unset, the endpoint refuses all calls.
const NOTIFY_API_KEY = process.env.MSTEAMS_NOTIFY_API_KEY ?? "";

// HTTP-only mode: when set, skip stdio MCP transport, Bot Framework webhook
// listener (3978), RC notifications, and Graph polling. Only the Streamable
// HTTP MCP listener on MCP_PORTS["teams-mcp"] (18811) starts. This lets a
// separate supervisor expose the teams-channel tools over HTTP without
// fighting the primary teams-channel process for port 3978 or duplicating
// the stdio peer.
const HTTP_ONLY_MODE = process.env.TEAMS_HTTP_ONLY === "1";

if (HTTP_ONLY_MODE) {
  // Single startup banner instead of one log line per disabled subsystem.
  process.stderr.write(
    "teams-channel: HTTP_ONLY mode — stdio MCP, Bot Framework webhook (3978), " +
      "RingCentral notifications, and Graph inbound polling are disabled\n",
  );
}

if (!APP_ID || !APP_PASSWORD || !TENANT_ID) {
  process.stderr.write(
    "teams-channel: MSTEAMS_APP_ID, MSTEAMS_APP_PASSWORD, and MSTEAMS_TENANT_ID are required\n",
  );
  process.exit(1);
}

// ---------------------------------------------------------------------------
// Unhandled error safety net
// ---------------------------------------------------------------------------

process.on("unhandledRejection", (err) => {
  process.stderr.write(`teams-channel: unhandled rejection: ${String(err)}\n`);
});
process.on("uncaughtException", (err) => {
  const msg = String(err);
  process.stderr.write(`teams-channel: uncaught exception: ${msg}\n`);
  // EPIPE on stdout means the MCP pipe broke — don't crash, just log.
  if (msg.includes("EPIPE") || msg.includes("broken pipe") || msg.includes("write after end")) {
    process.stderr.write("teams-channel: MCP pipe broken, webhook server continues\n");
    return;
  }
});

// Prevent EPIPE from killing the process when writing to broken stdout
process.stdout?.on?.("error", (err: Error) => {
  process.stderr.write(`teams-channel: stdout error (ignored): ${err.message}\n`);
});

// ---------------------------------------------------------------------------
// Audit logging
// ---------------------------------------------------------------------------

const AUDIT_LOG = "/tmp/teams-channel-audit.log";

function audit(direction: "IN" | "OUT", who: string, convId: string, preview: string): void {
  const line = `[${new Date().toISOString()}] [${direction}] [${who}] [${convId}] ${preview.slice(0, 120)}\n`;
  appendFile(AUDIT_LOG, line).catch(() => {});
}

// ---------------------------------------------------------------------------
// Teams SDK — token management and JWT validation
// ---------------------------------------------------------------------------

import { App } from "@microsoft/teams.apps";

const teamsApp = new App({
  clientId: APP_ID,
  clientSecret: APP_PASSWORD,
  tenantId: TENANT_ID,
});

async function getBotToken(): Promise<string> {
  const token = await (
    teamsApp as unknown as { getBotToken(): Promise<{ toString(): string } | null> }
  ).getBotToken();
  return token ? String(token) : "";
}

async function getGraphToken(): Promise<string> {
  const token = await (
    teamsApp as unknown as { getAppGraphToken(): Promise<{ toString(): string } | null> }
  ).getAppGraphToken();
  return token ? String(token) : "";
}

// JWT validator — dual issuer (Bot Framework + Entra)
let jwtValidator: { validate: (authHeader: string, serviceUrl?: string) => Promise<boolean> } | null = null;

async function initJwtValidator(): Promise<void> {
  try {
    const { JwtValidator } = await import(
      "@microsoft/teams.apps/dist/middleware/auth/jwt-validator.js"
    );

    const botFrameworkValidator = new JwtValidator({
      clientId: APP_ID,
      tenantId: TENANT_ID,
      validateIssuer: { allowedIssuer: "https://api.botframework.com" },
      jwksUriOptions: {
        type: "uri",
        uri: "https://login.botframework.com/v1/.well-known/keys",
      },
    });

    const entraValidator = new JwtValidator({
      clientId: APP_ID,
      tenantId: TENANT_ID,
      validateIssuer: { allowedTenantIds: [TENANT_ID] },
      jwksUriOptions: {
        type: "uri",
        uri: "https://login.microsoftonline.com/common/discovery/v2.0/keys",
      },
    });

    jwtValidator = {
      async validate(authHeader: string, serviceUrl?: string): Promise<boolean> {
        const token = authHeader.startsWith("Bearer ")
          ? authHeader.slice(7)
          : authHeader;
        if (!token) return false;

        const overrides = serviceUrl
          ? ({ validateServiceUrl: { expectedServiceUrl: serviceUrl } } as const)
          : undefined;

        for (const validator of [botFrameworkValidator, entraValidator]) {
          try {
            const result = await validator.validateAccessToken(token, overrides);
            if (result != null) return true;
          } catch {
            continue;
          }
        }
        return false;
      },
    };

    process.stderr.write("teams-channel: JWT validator initialized\n");
  } catch (err) {
    process.stderr.write(
      `teams-channel: JWT validator init failed — all inbound requests will be rejected: ${String(err)}\n`,
    );
    jwtValidator = {
      async validate(): Promise<boolean> {
        return false;
      },
    };
  }
}

// ---------------------------------------------------------------------------
// Conversation reference store
// ---------------------------------------------------------------------------

const STATE_DIR = join(homedir(), ".claude", "channels", "teams");
const CONVERSATIONS_FILE = join(STATE_DIR, "conversations.json");

interface ConversationReference {
  serviceUrl: string;
  bot: { id: string; name: string };
  user: { id: string; name: string };
  conversationType: string;
  lastActive: string;
}

let conversations: Record<string, ConversationReference> = {};

function loadConversations(): void {
  try {
    if (existsSync(CONVERSATIONS_FILE)) {
      const raw = readFileSync(CONVERSATIONS_FILE, "utf-8");
      conversations = JSON.parse(raw) as Record<string, ConversationReference>;
      // Prune entries older than 30 days
      const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000;
      let pruned = 0;
      for (const [id, ref] of Object.entries(conversations)) {
        if (new Date(ref.lastActive).getTime() < cutoff) {
          delete conversations[id];
          pruned++;
        }
      }
      if (pruned > 0) {
        saveConversations();
        process.stderr.write(`teams-channel: pruned ${pruned} stale conversation references\n`);
      }
      process.stderr.write(
        `teams-channel: loaded ${Object.keys(conversations).length} conversation references\n`,
      );
    }
  } catch (err) {
    process.stderr.write(
      `teams-channel: failed to load conversations: ${String(err)}\n`,
    );
    conversations = {};
  }
}

function saveConversations(): void {
  try {
    mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
    writeFileSync(
      CONVERSATIONS_FILE,
      JSON.stringify(conversations, null, 2) + "\n",
      { mode: 0o600 },
    );
  } catch (err) {
    process.stderr.write(
      `teams-channel: failed to save conversations: ${String(err)}\n`,
    );
  }
}


function saveConversationReference(
  conversationId: string,
  activity: Record<string, unknown>,
): void {
  const convId = normalizeConversationId(conversationId);
  const conversation = activity.conversation as Record<string, unknown> | undefined;
  const from = activity.from as Record<string, unknown> | undefined;
  const recipient = activity.recipient as Record<string, unknown> | undefined;

  conversations[convId] = {
    serviceUrl: String(activity.serviceUrl ?? ""),
    bot: {
      id: String(recipient?.id ?? ""),
      name: String(recipient?.name ?? ""),
    },
    user: {
      id: String(from?.id ?? ""),
      name: String(from?.name ?? ""),
    },
    conversationType: String(conversation?.conversationType ?? "personal"),
    lastActive: new Date().toISOString(),
  };
  saveConversations();
}

function getConversationRef(chatId: string): ConversationReference | undefined {
  return conversations[normalizeConversationId(chatId)];
}

// ---------------------------------------------------------------------------
// Access control
// ---------------------------------------------------------------------------

const ACCESS_FILE = join(STATE_DIR, "access.json");

interface AccessConfig {
  dmPolicy: "allowlist";
  allowFrom: string[];
  groups: Record<string, { requireMention: boolean }>;
}

function loadAccess(): AccessConfig {
  try {
    if (existsSync(ACCESS_FILE)) {
      const raw = readFileSync(ACCESS_FILE, "utf-8");
      return JSON.parse(raw) as AccessConfig;
    }
  } catch {
    // fall through to defaults
  }
  const defaults: AccessConfig = {
    dmPolicy: "allowlist",
    allowFrom: [],
    groups: {},
  };
  saveAccessConfig(defaults);
  return defaults;
}

function saveAccessConfig(cfg: AccessConfig): void {
  mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
  writeFileSync(ACCESS_FILE, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 });
}

const accessConfig = loadAccess();
const allowedSenders = new Set(
  accessConfig.allowFrom.map((s) => s.trim().toLowerCase()),
);

function isAllowed(senderId: string, senderUpn?: string): boolean {
  if (allowedSenders.size === 0) return true; // no allowlist = allow all
  if (allowedSenders.has(senderId.toLowerCase())) return true;
  if (senderUpn && allowedSenders.has(senderUpn.toLowerCase())) return true;
  return false;
}

// ---------------------------------------------------------------------------
// Teams activity helpers
// ---------------------------------------------------------------------------


// ---------------------------------------------------------------------------
// Bot Framework REST API helpers
// ---------------------------------------------------------------------------

async function botPost(
  serviceUrl: string,
  path: string,
  body: Record<string, unknown>,
): Promise<Record<string, unknown>> {
  const token = await getBotToken();
  const baseUrl = serviceUrl.replace(/\/+$/, "");
  const url = `${baseUrl}${path}`;

  const res = await fetch(url, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${token}`,
    },
    body: JSON.stringify(body),
  });

  if (!res.ok) {
    const text = await res.text().catch(() => "(no body)");
    throw new Error(`Bot Framework POST ${path} failed (${res.status}): ${text}`);
  }

  const responseText = await res.text();
  if (!responseText) return {};
  try {
    return JSON.parse(responseText) as Record<string, unknown>;
  } catch {
    return {};
  }
}

async function botPut(
  serviceUrl: string,
  path: string,
  body: Record<string, unknown>,
): Promise<Record<string, unknown>> {
  const token = await getBotToken();
  const baseUrl = serviceUrl.replace(/\/+$/, "");
  const url = `${baseUrl}${path}`;

  const res = await fetch(url, {
    method: "PUT",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${token}`,
    },
    body: JSON.stringify(body),
  });

  if (!res.ok) {
    const text = await res.text().catch(() => "(no body)");
    throw new Error(`Bot Framework PUT ${path} failed (${res.status}): ${text}`);
  }

  const responseText = await res.text();
  if (!responseText) return {};
  try {
    return JSON.parse(responseText) as Record<string, unknown>;
  } catch {
    return {};
  }
}

async function botGet(
  serviceUrl: string,
  path: string,
): Promise<Record<string, unknown>> {
  const token = await getBotToken();
  const baseUrl = serviceUrl.replace(/\/+$/, "");
  const url = `${baseUrl}${path}`;

  const res = await fetch(url, {
    method: "GET",
    headers: {
      Authorization: `Bearer ${token}`,
    },
  });

  if (!res.ok) {
    const text = await res.text().catch(() => "(no body)");
    throw new Error(`Bot Framework GET ${path} failed (${res.status}): ${text}`);
  }

  return (await res.json()) as Record<string, unknown>;
}

// ---------------------------------------------------------------------------
// Text chunking for Teams 4000-char limit
// ---------------------------------------------------------------------------


// ---------------------------------------------------------------------------
// Welcome card
// ---------------------------------------------------------------------------

function buildWelcomeCard(): Record<string, unknown> {
  return {
    type: "AdaptiveCard",
    version: "1.5",
    body: [
      {
        type: "TextBlock",
        text: "Hi! I'm Exult Agent.",
        weight: "bolder",
        size: "medium",
      },
      {
        type: "TextBlock",
        text: "I can help with scheduling, patient lookups, call logs, and more.",
        wrap: true,
      },
    ],
    actions: [
      {
        type: "Action.Submit",
        title: "What can you do?",
        data: { msteams: { type: "imBack", value: "What can you do?" } },
      },
      {
        type: "Action.Submit",
        title: "Check today's schedule",
        data: {
          msteams: { type: "imBack", value: "Check today's schedule" },
        },
      },
      {
        type: "Action.Submit",
        title: "Pull call logs",
        data: { msteams: { type: "imBack", value: "Pull call logs" } },
      },
    ],
  };
}

// ---------------------------------------------------------------------------
// FileConsentCard builder
// ---------------------------------------------------------------------------

function buildFileConsentCard(
  filename: string,
  sizeInBytes: number,
  context?: Record<string, unknown>,
) {
  return {
    contentType: "application/vnd.microsoft.teams.card.file.consent",
    name: filename,
    content: {
      description: `File: ${filename}`,
      sizeInBytes,
      acceptContext: { filename, ...context },
      declineContext: { filename, ...context },
    },
  };
}

// ---------------------------------------------------------------------------
// Streaming support — stream info entity protocol
// ---------------------------------------------------------------------------

function buildStreamInfoEntity(
  streamId: string | undefined,
  streamType: "informative" | "streaming" | "final",
  streamSequence?: number,
): Record<string, unknown> {
  const entity: Record<string, unknown> = {
    type: "streaminfo",
    streamType,
  };
  if (streamId) entity.streamId = streamId;
  if (streamSequence != null) entity.streamSequence = streamSequence;
  return entity;
}

// Active streams: keyed by a caller-chosen stream ID
const activeStreams = new Map<
  string,
  {
    serviceUrl: string;
    conversationId: string;
    botId: string;
    streamId: string | undefined;
    sequence: number;
    lastSendTime: number;
    accumulatedText: string;
  }
>();

// ---------------------------------------------------------------------------
// MCP server
//
// We construct TWO Server instances with identical tool registrations:
//   - `mcp` is connected to a StdioServerTransport for the local Claude
//     parent process (the supervisor spawns this bun as its MCP child).
//   - `mcpHttp` is served over Streamable HTTP via mcp-shared on
//     MCP_PORTS["teams-mcp"] (18811) for remote clients (e.g. claude.ai).
//
// The MCP SDK's single-transport-per-Server constraint means we cannot
// mount both transports on one Server. Module-scoped state (conversations,
// accessConfig, token cache, etc.) is shared naturally because both
// handlers close over the same module bindings. Inbound webhook
// notifications (mcp.notification(...)) are delivered ONLY over the stdio
// transport to the local Claude parent; HTTP clients use tool calls
// (request/response) and don't subscribe to push notifications today.
// ---------------------------------------------------------------------------

const SERVER_INFO = { name: "teams-channel", version: "0.1.0" } as const;
const SERVER_OPTIONS = {
  capabilities: {
    experimental: {
      "claude/channel": {},
      "claude/channel/permission": {},
    },
    tools: {},
  },
  instructions: [
    "Microsoft Teams channel for Exult Healthcare AI agent.",
    "",
    "You are the LEAD ORCHESTRATOR. Teams messages arrive as your user turns.",
    "Reply concisely. Use the reply tool with the chat_id from the inbound message.",
    "",
    'Messages arrive as <channel source="teams-channel" chat_id="..." sender="...">.',
    "Reply with the reply tool, passing the chat_id from the tag.",
    "",
    "Tools: reply, typing_indicator, react, edit_message, chat_messages,",
    "send_file, send_card, mark_read, stream_reply.",
  ].join("\n"),
} as const;

const mcp = new Server(SERVER_INFO, SERVER_OPTIONS);

// ---------------------------------------------------------------------------
// MCP Tools
// ---------------------------------------------------------------------------

const listToolsHandler = async () => ({
  tools: [
    {
      name: "reply",
      description:
        "Send a message to a Teams conversation. Auto-chunks at 4000 chars.",
      inputSchema: {
        type: "object" as const,
        properties: {
          chat_id: {
            type: "string",
            description: "Conversation ID (from inbound message metadata)",
          },
          text: { type: "string", description: "Message text to send" },
          reply_to: {
            type: "string",
            description: "Optional activity ID to thread under",
          },
        },
        required: ["chat_id", "text"],
      },
    },
    {
      name: "typing_indicator",
      description: "Show typing indicator in a Teams conversation.",
      inputSchema: {
        type: "object" as const,
        properties: {
          chat_id: { type: "string", description: "Conversation ID" },
        },
        required: ["chat_id"],
      },
    },
    {
      name: "react",
      description:
        "React to a message. Since Bot Framework lacks a reaction API, sends a threaded emoji reply.",
      inputSchema: {
        type: "object" as const,
        properties: {
          chat_id: { type: "string", description: "Conversation ID" },
          message_id: {
            type: "string",
            description: "Activity ID of the message to react to",
          },
          reaction: {
            type: "string",
            description:
              "Reaction type: like, heart, laugh, surprised, sad, angry",
          },
        },
        required: ["chat_id", "message_id", "reaction"],
      },
    },
    {
      name: "edit_message",
      description: "Edit a previously sent message.",
      inputSchema: {
        type: "object" as const,
        properties: {
          chat_id: { type: "string", description: "Conversation ID" },
          message_id: {
            type: "string",
            description: "Activity ID of the message to edit",
          },
          text: { type: "string", description: "New message text" },
        },
        required: ["chat_id", "message_id", "text"],
      },
    },
    {
      name: "chat_messages",
      description: "Fetch recent message history from a Teams conversation.",
      inputSchema: {
        type: "object" as const,
        properties: {
          chat_id: { type: "string", description: "Conversation ID" },
          limit: {
            type: "number",
            description: "Max messages to return (default 20)",
          },
        },
        required: ["chat_id"],
      },
    },
    {
      name: "send_file",
      description:
        "Upload and send a file to a Teams conversation. Small files (<4MB) in personal chats are sent inline. Larger files use FileConsentCard.",
      inputSchema: {
        type: "object" as const,
        properties: {
          chat_id: { type: "string", description: "Conversation ID" },
          file_path: {
            type: "string",
            description: "Absolute path to the file to send",
          },
          description: {
            type: "string",
            description: "Optional file description",
          },
        },
        required: ["chat_id", "file_path"],
      },
    },
    {
      name: "send_card",
      description: "Send an Adaptive Card to a Teams conversation.",
      inputSchema: {
        type: "object" as const,
        properties: {
          chat_id: { type: "string", description: "Conversation ID" },
          card: {
            type: "object",
            description: "Adaptive Card JSON object",
          },
        },
        required: ["chat_id", "card"],
      },
    },
    {
      name: "mark_read",
      description:
        "Mark messages as read in a conversation. No-op stub for API compatibility.",
      inputSchema: {
        type: "object" as const,
        properties: {
          chat_id: { type: "string", description: "Conversation ID" },
        },
        required: ["chat_id"],
      },
    },
    {
      name: "stream_reply",
      description:
        "Send a streaming message to a Teams conversation. Use for progressive responses. Only works in personal (1:1) chats.",
      inputSchema: {
        type: "object" as const,
        properties: {
          chat_id: { type: "string", description: "Conversation ID" },
          text: { type: "string", description: "Current accumulated text" },
          stream_id: {
            type: "string",
            description:
              "Stream ID from a previous stream_reply call. Omit to start a new stream.",
          },
          is_final: {
            type: "boolean",
            description: "Set to true to finalize the stream.",
          },
        },
        required: ["chat_id", "text"],
      },
    },
    {
      name: "send_proactive",
      description:
        "Send a proactive message to a known conversation without needing an inbound trigger. Use for notifications, alerts, and scheduled messages.",
      inputSchema: {
        type: "object" as const,
        properties: {
          chat_id: {
            type: "string",
            description: "Conversation ID (must be a previously seen conversation)",
          },
          text: { type: "string", description: "Message text (optional if card is provided)" },
          card: {
            type: "object",
            description: "Optional Adaptive Card JSON to send instead of or with text",
          },
        },
        required: ["chat_id"],
      },
    },
    {
      name: "list_conversations",
      description:
        "List all known conversations the bot has been added to, with type and last active time.",
      inputSchema: {
        type: "object" as const,
        properties: {},
        required: [] as string[],
      },
    },
  ],
});

const callToolHandler = async (req: CallToolRequest) => {
  const args = (req.params.arguments ?? {}) as Record<string, unknown>;
  try {
    switch (req.params.name) {
      // ---------------------------------------------------------------
      // reply
      // ---------------------------------------------------------------
      case "reply": {
        const chatId = args.chat_id as string;
        const text = args.text as string;
        const replyTo = args.reply_to as string | undefined;
        if (!chatId || !text) throw new Error("chat_id and text are required");

        const ref = getConversationRef(chatId);
        if (!ref) throw new Error(`No conversation reference for ${chatId}`);

        const chunks = chunkText(text);
        const activityIds: string[] = [];

        for (const chunk of chunks) {
          const activity: Record<string, unknown> = {
            type: "message",
            text: chunk,
            from: { id: ref.bot.id, name: ref.bot.name },
            conversation: {
              id: normalizeConversationId(chatId),
              conversationType: ref.conversationType,
            },
          };
          if (replyTo && activityIds.length === 0) {
            activity.replyToId = replyTo;
          }

          const result = await botPost(
            ref.serviceUrl,
            `/v3/conversations/${encodeURIComponent(normalizeConversationId(chatId))}/activities`,
            activity,
          );
          if (result.id) activityIds.push(String(result.id));
          audit("OUT", ref.bot.name, chatId, chunk);
        }

        return {
          content: [
            {
              type: "text",
              text:
                activityIds.length === 1
                  ? `sent (id: ${activityIds[0]})`
                  : `sent ${activityIds.length} chunks (ids: ${activityIds.join(", ")})`,
            },
          ],
        };
      }

      // ---------------------------------------------------------------
      // typing_indicator
      // ---------------------------------------------------------------
      case "typing_indicator": {
        const chatId = args.chat_id as string;
        if (!chatId) throw new Error("chat_id is required");

        const ref = getConversationRef(chatId);
        if (!ref) throw new Error(`No conversation reference for ${chatId}`);

        await botPost(
          ref.serviceUrl,
          `/v3/conversations/${encodeURIComponent(normalizeConversationId(chatId))}/activities`,
          {
            type: "typing",
            from: { id: ref.bot.id, name: ref.bot.name },
            conversation: {
              id: normalizeConversationId(chatId),
              conversationType: ref.conversationType,
            },
          },
        );

        return { content: [{ type: "text", text: "typing indicator sent" }] };
      }

      // ---------------------------------------------------------------
      // react
      // ---------------------------------------------------------------
      case "react": {
        const chatId = args.chat_id as string;
        const reactMessageId = args.message_id as string;
        const reaction = args.reaction as string;
        if (!chatId || !reactMessageId || !reaction) {
          throw new Error("chat_id, message_id, and reaction are required");
        }

        const ref = getConversationRef(chatId);
        if (!ref) throw new Error(`No conversation reference for ${chatId}`);

        const token = await getBotToken();
        const baseUrl = ref.serviceUrl.replace(/\/+$/, "");
        const normChatId = normalizeConversationId(chatId);
        const reactRes = await fetch(
          `${baseUrl}/v3/conversations/${encodeURIComponent(normChatId)}/activities/${encodeURIComponent(reactMessageId)}/reactions/${encodeURIComponent(reaction)}`,
          { method: "PUT", headers: { Authorization: `Bearer ${token}` } },
        );

        if (!reactRes.ok) {
          const errBody = await reactRes.text().catch(() => "(no body)");
          throw new Error(`Reaction API failed (${reactRes.status}): ${errBody}`);
        }

        return {
          content: [{ type: "text", text: `${reaction} reaction set on message ${reactMessageId}` }],
        };
      }

      // ---------------------------------------------------------------
      // edit_message
      // ---------------------------------------------------------------
      case "edit_message": {
        const chatId = args.chat_id as string;
        const messageId = args.message_id as string;
        const text = args.text as string;
        if (!chatId || !messageId || !text) {
          throw new Error("chat_id, message_id, and text are required");
        }

        const ref = getConversationRef(chatId);
        if (!ref) throw new Error(`No conversation reference for ${chatId}`);

        await botPut(
          ref.serviceUrl,
          `/v3/conversations/${encodeURIComponent(normalizeConversationId(chatId))}/activities/${encodeURIComponent(messageId)}`,
          {
            type: "message",
            text,
            id: messageId,
          },
        );

        return { content: [{ type: "text", text: "message edited" }] };
      }

      // ---------------------------------------------------------------
      // chat_messages
      // ---------------------------------------------------------------
      case "chat_messages": {
        const chatId = args.chat_id as string;
        const limit = (args.limit as number) ?? 20;
        if (!chatId) throw new Error("chat_id is required");

        // Try Graph API for chat history
        let messages: Array<Record<string, unknown>> = [];
        try {
          const graphToken = await getGraphToken();
          if (graphToken) {
            const convId = normalizeConversationId(chatId);
            // Graph API for chats — requires Chat.Read application permission
            const res = await fetch(
              `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(convId)}/messages?$top=${limit}&$orderby=createdDateTime desc`,
              {
                headers: { Authorization: `Bearer ${graphToken}` },
              },
            );
            if (res.ok) {
              const data = (await res.json()) as {
                value: Array<Record<string, unknown>>;
              };
              messages = data.value ?? [];
            }
          }
        } catch (err) {
          process.stderr.write(
            `teams-channel: Graph API chat_messages failed: ${String(err)}\n`,
          );
        }

        // Fallback: try Bot Framework conversation history
        if (messages.length === 0) {
          try {
            const ref = getConversationRef(chatId);
            if (ref) {
              const data = await botGet(
                ref.serviceUrl,
                `/v3/conversations/${encodeURIComponent(normalizeConversationId(chatId))}/activities`,
              );
              const activities = (data.activities as Array<Record<string, unknown>>) ?? [];
              messages = activities
                .filter((a) => a.type === "message")
                .slice(-limit);
            }
          } catch (err) {
            process.stderr.write(
              `teams-channel: Bot Framework chat_messages failed: ${String(err)}\n`,
            );
          }
        }

        if (messages.length === 0) {
          return { content: [{ type: "text", text: "(no messages)" }] };
        }

        const formatted = messages
          .map((m) => {
            const ts =
              (m.createdDateTime as string) ??
              (m.timestamp as string) ??
              "";
            const from = m.from as Record<string, unknown> | undefined;
            const user = from?.user as Record<string, unknown> | undefined;
            const who =
              (user?.displayName as string) ??
              (from?.name as string) ??
              "unknown";
            const body = m.body as Record<string, unknown> | undefined;
            const text =
              (body?.content as string) ?? (m.text as string) ?? "";
            return `[${ts}] ${who}: ${text}`;
          })
          .join("\n");

        return { content: [{ type: "text", text: formatted }] };
      }

      // ---------------------------------------------------------------
      // send_file
      // ---------------------------------------------------------------
      case "send_file": {
        const chatId = args.chat_id as string;
        const filePath = args.file_path as string;
        const description = args.description as string | undefined;
        if (!chatId || !filePath) {
          throw new Error("chat_id and file_path are required");
        }

        const ref = getConversationRef(chatId);
        if (!ref) throw new Error(`No conversation reference for ${chatId}`);

        const SAFE_DIRS = ["/tmp", join(homedir(), "Downloads")];
        const resolved = resolve(filePath);
        if (!SAFE_DIRS.some((dir) => resolved.startsWith(dir + "/"))) {
          throw new Error(`file_path must be under /tmp or ~/Downloads`);
        }

        const fileBuffer = readFileSync(resolved);
        const filename = basename(resolved);
        const ext = extname(filename).slice(1).toLowerCase();
        const contentType = mimeFromExt(ext);
        const isPersonal = ref.conversationType === "personal";
        const isSmallImage =
          contentType.startsWith("image/") && fileBuffer.length < 4 * 1024 * 1024;

        if (isPersonal && isSmallImage) {
          // Inline as base64 data URL
          const base64 = fileBuffer.toString("base64");
          const dataUrl = `data:${contentType};base64,${base64}`;
          const result = await botPost(
            ref.serviceUrl,
            `/v3/conversations/${encodeURIComponent(normalizeConversationId(chatId))}/activities`,
            {
              type: "message",
              text: description ?? "",
              from: { id: ref.bot.id, name: ref.bot.name },
              conversation: {
                id: normalizeConversationId(chatId),
                conversationType: ref.conversationType,
              },
              attachments: [
                {
                  contentType,
                  contentUrl: dataUrl,
                  name: filename,
                },
              ],
            },
          );
          audit("OUT", ref.bot.name, chatId, `[file: ${filename}]`);
          return {
            content: [
              {
                type: "text",
                text: `file sent inline${result.id ? ` (id: ${String(result.id)})` : ""}`,
              },
            ],
          };
        }

        if (isPersonal) {
          // FileConsentCard for large or non-image files in personal chats
          const consentCard = buildFileConsentCard(filename, fileBuffer.length, {
            filename,
          });
          const result = await botPost(
            ref.serviceUrl,
            `/v3/conversations/${encodeURIComponent(normalizeConversationId(chatId))}/activities`,
            {
              type: "message",
              text: "",
              from: { id: ref.bot.id, name: ref.bot.name },
              conversation: {
                id: normalizeConversationId(chatId),
                conversationType: ref.conversationType,
              },
              attachments: [consentCard],
            },
          );
          audit("OUT", ref.bot.name, chatId, `[file consent: ${filename}]`);
          return {
            content: [
              {
                type: "text",
                text: `file consent card sent for ${filename}${result.id ? ` (id: ${String(result.id)})` : ""}`,
              },
            ],
          };
        }

        // Group chats: upload to OneDrive via Graph API, send link
        try {
          const graphToken = await getGraphToken();
          if (!graphToken) throw new Error("No Graph token available");

          // Upload to bot's OneDrive
          const uploadRes = await fetch(
            `https://graph.microsoft.com/v1.0/me/drive/root:/teams-uploads/${filename}:/content`,
            {
              method: "PUT",
              headers: {
                Authorization: `Bearer ${graphToken}`,
                "Content-Type": contentType,
              },
              body: fileBuffer,
            },
          );

          if (!uploadRes.ok) {
            throw new Error(
              `OneDrive upload failed: ${uploadRes.status}`,
            );
          }

          const uploadData = (await uploadRes.json()) as {
            webUrl?: string;
          };
          const webUrl = uploadData.webUrl ?? "";

          await botPost(
            ref.serviceUrl,
            `/v3/conversations/${encodeURIComponent(normalizeConversationId(chatId))}/activities`,
            {
              type: "message",
              text: `${description ?? filename}: ${webUrl}`,
              from: { id: ref.bot.id, name: ref.bot.name },
              conversation: {
                id: normalizeConversationId(chatId),
                conversationType: ref.conversationType,
              },
            },
          );
          audit("OUT", ref.bot.name, chatId, `[file link: ${filename}]`);
          return {
            content: [{ type: "text", text: `file uploaded and link sent: ${webUrl}` }],
          };
        } catch (err) {
          throw new Error(
            `Group file upload failed: ${err instanceof Error ? err.message : String(err)}`,
          );
        }
      }

      // ---------------------------------------------------------------
      // send_card
      // ---------------------------------------------------------------
      case "send_card": {
        const chatId = args.chat_id as string;
        const card = args.card as Record<string, unknown>;
        if (!chatId || !card) {
          throw new Error("chat_id and card are required");
        }

        const ref = getConversationRef(chatId);
        if (!ref) throw new Error(`No conversation reference for ${chatId}`);

        const result = await botPost(
          ref.serviceUrl,
          `/v3/conversations/${encodeURIComponent(normalizeConversationId(chatId))}/activities`,
          {
            type: "message",
            from: { id: ref.bot.id, name: ref.bot.name },
            conversation: {
              id: normalizeConversationId(chatId),
              conversationType: ref.conversationType,
            },
            attachments: [
              {
                contentType: "application/vnd.microsoft.card.adaptive",
                content: card,
              },
            ],
          },
        );

        audit("OUT", ref.bot.name, chatId, "[adaptive card]");
        return {
          content: [
            {
              type: "text",
              text: `card sent${result.id ? ` (id: ${String(result.id)})` : ""}`,
            },
          ],
        };
      }

      // ---------------------------------------------------------------
      // mark_read — no-op stub
      // ---------------------------------------------------------------
      case "mark_read": {
        return {
          content: [
            {
              type: "text",
              text: "mark_read acknowledged (no-op: Bot Framework does not support read receipts)",
            },
          ],
        };
      }

      // ---------------------------------------------------------------
      // stream_reply
      // ---------------------------------------------------------------
      case "stream_reply": {
        const chatId = args.chat_id as string;
        const text = args.text as string;
        const callerStreamId = args.stream_id as string | undefined;
        const isFinal = (args.is_final as boolean) ?? false;
        if (!chatId || !text) {
          throw new Error("chat_id and text are required");
        }

        const ref = getConversationRef(chatId);
        if (!ref) throw new Error(`No conversation reference for ${chatId}`);

        // Group chats don't support streaming — fall back to regular message
        if (ref.conversationType !== "personal") {
          if (isFinal) {
            const result = await botPost(
              ref.serviceUrl,
              `/v3/conversations/${encodeURIComponent(normalizeConversationId(chatId))}/activities`,
              {
                type: "message",
                text,
                from: { id: ref.bot.id, name: ref.bot.name },
                conversation: {
                  id: normalizeConversationId(chatId),
                  conversationType: ref.conversationType,
                },
              },
            );
            audit("OUT", ref.bot.name, chatId, text);
            return {
              content: [
                {
                  type: "text",
                  text: `sent (non-streaming fallback, id: ${String(result.id ?? "unknown")})`,
                },
              ],
            };
          }
          return {
            content: [
              {
                type: "text",
                text: "streaming not supported in group chats; send is_final=true to deliver",
              },
            ],
          };
        }

        const convPath = `/v3/conversations/${encodeURIComponent(normalizeConversationId(chatId))}/activities`;

        if (!callerStreamId) {
          // Start a new stream
          const localId = `stream-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
          const seq = 1;
          const activity: Record<string, unknown> = {
            type: "typing",
            text,
            from: { id: ref.bot.id, name: ref.bot.name },
            conversation: {
              id: normalizeConversationId(chatId),
              conversationType: ref.conversationType,
            },
            entities: [buildStreamInfoEntity(undefined, "streaming", seq)],
          };

          const result = await botPost(ref.serviceUrl, convPath, activity);
          const teamsStreamId = result.id ? String(result.id) : undefined;

          activeStreams.set(localId, {
            serviceUrl: ref.serviceUrl,
            conversationId: normalizeConversationId(chatId),
            botId: ref.bot.id,
            streamId: teamsStreamId,
            sequence: seq,
            lastSendTime: Date.now(),
            accumulatedText: text,
          });

          return {
            content: [
              { type: "text", text: `stream started (stream_id: ${localId})` },
            ],
          };
        }

        // Continue or finalize an existing stream
        const stream = activeStreams.get(callerStreamId);
        if (!stream) {
          throw new Error(`Unknown stream_id: ${callerStreamId}`);
        }

        // Teams requires each chunk to contain all previously streamed content
        const fullText = accumulateStreamText(stream.accumulatedText, text);
        stream.accumulatedText = fullText;

        if (isFinal) {
          const finalActivity: Record<string, unknown> = {
            type: "message",
            text: fullText,
            from: { id: ref.bot.id, name: ref.bot.name },
            conversation: {
              id: stream.conversationId,
              conversationType: ref.conversationType,
            },
            entities: [buildStreamInfoEntity(stream.streamId, "final")],
          };

          await botPost(ref.serviceUrl, convPath, finalActivity);
          activeStreams.delete(callerStreamId);
          audit("OUT", ref.bot.name, chatId, fullText);
          return {
            content: [{ type: "text", text: "stream finalized" }],
          };
        }

        // Continue streaming — throttle to 1500ms
        const elapsed = Date.now() - stream.lastSendTime;
        if (elapsed < 1500) {
          const wait = 1500 - elapsed;
          await new Promise((r) => setTimeout(r, wait));
        }

        stream.sequence++;
        const activity: Record<string, unknown> = {
          type: "typing",
          text: fullText,
          from: { id: ref.bot.id, name: ref.bot.name },
          conversation: {
            id: stream.conversationId,
            conversationType: ref.conversationType,
          },
          entities: [
            buildStreamInfoEntity(stream.streamId, "streaming", stream.sequence),
          ],
        };

        const result = await botPost(ref.serviceUrl, convPath, activity);
        if (!stream.streamId && result.id) {
          stream.streamId = String(result.id);
        }
        stream.lastSendTime = Date.now();

        return {
          content: [
            {
              type: "text",
              text: `stream continued (seq: ${stream.sequence}, stream_id: ${callerStreamId})`,
            },
          ],
        };
      }

      // ---------------------------------------------------------------
      // send_proactive
      // ---------------------------------------------------------------
      case "send_proactive": {
        const chatId = args.chat_id as string;
        const text = args.text as string | undefined;
        const card = args.card as Record<string, unknown> | undefined;
        if (!chatId) throw new Error("chat_id is required");
        if (!text && !card) throw new Error("text or card is required");

        const ref = getConversationRef(chatId);
        if (!ref) throw new Error(`No conversation reference for ${chatId}. The bot must have been messaged at least once.`);

        const activity: Record<string, unknown> = {
          type: "message",
          from: { id: ref.bot.id, name: ref.bot.name },
          conversation: {
            id: normalizeConversationId(chatId),
            conversationType: ref.conversationType,
          },
        };

        if (text) activity.text = text;
        if (card) {
          activity.attachments = [{
            contentType: "application/vnd.microsoft.card.adaptive",
            content: card,
          }];
        }

        const result = await botPost(
          ref.serviceUrl,
          `/v3/conversations/${encodeURIComponent(normalizeConversationId(chatId))}/activities`,
          activity,
        );
        audit("OUT", ref.bot.name, chatId, text ?? "[card]");
        return {
          content: [{
            type: "text",
            text: `proactive message sent${result.id ? ` (id: ${String(result.id)})` : ""}`,
          }],
        };
      }

      // ---------------------------------------------------------------
      // list_conversations
      // ---------------------------------------------------------------
      case "list_conversations": {
        const entries = Object.entries(conversations).map(([id, ref]) => ({
          id,
          type: ref.conversationType,
          user: ref.user.name,
          lastActive: ref.lastActive,
        }));
        return {
          content: [{
            type: "text",
            text: entries.length > 0
              ? JSON.stringify(entries, null, 2)
              : "(no conversations)",
          }],
        };
      }

      // ---------------------------------------------------------------
      // unknown tool
      // ---------------------------------------------------------------
      default:
        return {
          content: [{ type: "text", text: `unknown tool: ${req.params.name}` }],
          isError: true,
        };
    }
  } catch (err) {
    const msg = err instanceof Error ? err.message : String(err);
    process.stderr.write(`teams-channel: ${req.params.name} error: ${msg}\n`);
    return {
      content: [
        { type: "text", text: `${req.params.name} failed: ${msg}` },
      ],
      isError: true,
    };
  }
};

/**
 * Register the listTools + callTool handlers on an MCP Server instance.
 * Called once for the stdio Server (local Claude parent) and once for the
 * HTTP Server (remote clients via mcp-shared/Streamable HTTP).
 */
function registerMcpHandlers(server: Server): void {
  server.setRequestHandler(ListToolsRequestSchema, listToolsHandler);
  server.setRequestHandler(CallToolRequestSchema, callToolHandler);
}

registerMcpHandlers(mcp);

// ---------------------------------------------------------------------------
// MIME type helper
// ---------------------------------------------------------------------------

function mimeFromExt(ext: string): string {
  const map: Record<string, string> = {
    png: "image/png",
    jpg: "image/jpeg",
    jpeg: "image/jpeg",
    gif: "image/gif",
    webp: "image/webp",
    svg: "image/svg+xml",
    pdf: "application/pdf",
    doc: "application/msword",
    docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
    xls: "application/vnd.ms-excel",
    xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
    ppt: "application/vnd.ms-powerpoint",
    pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
    csv: "text/csv",
    txt: "text/plain",
    json: "application/json",
    zip: "application/zip",
    mp3: "audio/mpeg",
    mp4: "video/mp4",
  };
  return map[ext] ?? "application/octet-stream";
}

// ---------------------------------------------------------------------------
// Connect MCP
// ---------------------------------------------------------------------------

let mcpConnected = false;

if (!HTTP_ONLY_MODE) {
  const transport = new StdioServerTransport();

  transport.onerror = (err: Error) => {
    process.stderr.write(`teams-channel: MCP transport error (non-fatal): ${err.message}\n`);
    mcpConnected = false;
  };
  transport.onclose = () => {
    process.stderr.write("teams-channel: MCP transport closed — webhook server continues\n");
    mcpConnected = false;
  };

  try {
    await mcp.connect(transport);
    mcpConnected = true;
    process.stderr.write("teams-channel: MCP connected\n");
  } catch (err) {
    process.stderr.write(`teams-channel: MCP connect failed (webhook still starts): ${String(err)}\n`);
  }
}

// ---------------------------------------------------------------------------
// MCP over Streamable HTTP (Phase C: same process, separate Server instance)
//
// Mounts on MCP_PORTS["teams-mcp"] (18811) with /mcp + legacy /sse routes.
// The Tailscale funnel exposes this at https://<host>/teams-mcp/mcp.
// This listener runs IN ADDITION TO the stdio transport above and the
// Bot Framework webhook listener (Bun.serve on 3978) below -- three
// concerns, three isolated network/IO boundaries in one bun process.
//
// If the bearer token is missing we log loudly and skip starting the HTTP
// listener; the stdio path keeps working so the local Claude parent is
// unaffected. This is intentional: a missing bearer must not take down
// the production Bot Framework webhook listener that runs alongside.
// ---------------------------------------------------------------------------

const MCP_BEARER_TOKEN = process.env.MCP_BEARER_TOKEN ?? "";
// Hoisted so the SIGTERM/SIGINT shutdown handler below can call
// `mcpHttpHandle.close()` and let in-flight HTTP MCP requests drain.
let mcpHttpHandle: { close: () => Promise<void> } | undefined;
if (MCP_BEARER_TOKEN) {
  try {
    // Per-session Server factory for the HTTP transport. The SDK's
    // Protocol.connect() refuses a second transport on the same Server,
    // so concurrent HTTP clients (Claude Desktop + Codex hitting
    // /teams-mcp/mcp simultaneously) each need their own Server. The
    // stdio `mcp` instance above is unaffected -- it stays a singleton
    // because stdio has exactly one peer.
    //
    // mcp.notification(...) calls below still target the stdio Server
    // only (the inbound webhook is local-Claude-parent oriented); HTTP
    // clients use tool calls (request/response), not push notifications.
    const buildHttpServer = (): Server => {
      const s = new Server(SERVER_INFO, SERVER_OPTIONS);
      registerMcpHandlers(s);
      return s;
    };
    mcpHttpHandle = serveMcpOverHttp({
      serverFactory: buildHttpServer,
      port: MCP_PORTS["teams-mcp"],
      token: MCP_BEARER_TOKEN,
    });
    process.stderr.write(
      `teams-channel: MCP HTTP listener started on :${MCP_PORTS["teams-mcp"]}\n`,
    );
  } catch (err) {
    process.stderr.write(
      `teams-channel: MCP HTTP listener FAILED to start (stdio + webhooks continue): ${String(err)}\n`,
    );
  }
} else {
  process.stderr.write(
    "teams-channel: MCP_BEARER_TOKEN not set; skipping HTTP MCP listener (stdio + webhooks unaffected)\n",
  );
}

// ---------------------------------------------------------------------------
// Drain queued messages from offline period
// ---------------------------------------------------------------------------

async function drainPendingMessages(): Promise<void> {
  const queueFile = join(STATE_DIR, "pending-messages.jsonl");
  if (!existsSync(queueFile)) return;

  let raw: string;
  try {
    raw = readFileSync(queueFile, "utf-8").trim();
  } catch {
    return;
  }
  if (!raw) return;

  const lines = raw.split("\n").filter(Boolean);
  process.stderr.write(`teams-channel: draining ${lines.length} queued message(s)\n`);

  let delivered = 0;
  for (const line of lines) {
    try {
      const payload = JSON.parse(line) as { content: string; meta: Record<string, unknown> };
      await mcp.notification({
        method: "notifications/claude/channel",
        params: { content: payload.content, meta: payload.meta },
      });
      delivered++;
    } catch (err) {
      process.stderr.write(`teams-channel: drain failed: ${String(err)}\n`);
      break;
    }
  }

  if (delivered === lines.length) {
    writeFileSync(queueFile, "", { mode: 0o600 });
    process.stderr.write(`teams-channel: drained all ${delivered} queued messages\n`);
  } else {
    const remaining = lines.slice(delivered).join("\n") + "\n";
    writeFileSync(queueFile, remaining, { mode: 0o600 });
    process.stderr.write(`teams-channel: drained ${delivered}/${lines.length}, ${lines.length - delivered} remain\n`);
  }
}

// ---------------------------------------------------------------------------
// Initialize state
// ---------------------------------------------------------------------------

loadConversations();
await initJwtValidator();

if (mcpConnected) {
  drainPendingMessages().catch((err) => {
    process.stderr.write(`teams-channel: drain error: ${String(err)}\n`);
  });
}

// ---------------------------------------------------------------------------
// Message dedup (shared between webhook and poll)
// ---------------------------------------------------------------------------

const seenMessageIds = new Set<string>();

function claimMessage(id: string): boolean {
  if (seenMessageIds.has(id)) return false;
  seenMessageIds.add(id);
  if (seenMessageIds.size > 500) {
    const arr = [...seenMessageIds];
    for (let i = 0; i < 200; i++) seenMessageIds.delete(arr[i]);
  }
  return true;
}

// ---------------------------------------------------------------------------
// Inbound webhook listener (Bun.serve)
// ---------------------------------------------------------------------------

async function handleInboundActivity(
  activity: Record<string, unknown>,
): Promise<{ isInvoke: boolean }> {
  const activityType = String(activity.type ?? "");
  const conversation = activity.conversation as Record<string, unknown> | undefined;
  const conversationId = String(conversation?.id ?? "");
  const conversationType = String(conversation?.conversationType ?? "personal");
  const from = activity.from as Record<string, unknown> | undefined;
  const senderName = String(from?.name ?? "unknown");
  const senderId = String(from?.aadObjectId ?? from?.id ?? "");
  const senderUpn = from?.userPrincipalName as string | undefined;
  const messageId = String(activity.id ?? "");

  // Save conversation reference for every activity
  if (conversationId) {
    saveConversationReference(conversationId, activity);
  }

  // Bot added to conversation — send welcome card
  if (
    activityType === "installationUpdate" ||
    activityType === "conversationUpdate"
  ) {
    const membersAdded = activity.membersAdded as
      | Array<{ id?: string }>
      | undefined;
    const botId = (activity.recipient as Record<string, unknown> | undefined)
      ?.id;
    const isBotAdded =
      activityType === "installationUpdate" ||
      (membersAdded ?? []).some((m) => m.id === botId);

    if (isBotAdded && conversationId) {
      const ref = getConversationRef(conversationId);
      if (ref) {
        try {
          await botPost(
            ref.serviceUrl,
            `/v3/conversations/${encodeURIComponent(normalizeConversationId(conversationId))}/activities`,
            {
              type: "message",
              from: { id: ref.bot.id, name: ref.bot.name },
              conversation: {
                id: normalizeConversationId(conversationId),
                conversationType: ref.conversationType,
              },
              attachments: [
                {
                  contentType: "application/vnd.microsoft.card.adaptive",
                  content: buildWelcomeCard(),
                },
              ],
            },
          );
          process.stderr.write(
            `teams-channel: sent welcome card to ${conversationId}\n`,
          );
        } catch (err) {
          process.stderr.write(
            `teams-channel: welcome card failed: ${String(err)}\n`,
          );
        }
      }
    }
    return { isInvoke: false };
  }

  // Handle card Action.Submit callbacks (invoke activities)
  if (activityType === "invoke" || (activityType === "message" && activity.value)) {
    const submitData = activity.value as Record<string, unknown> | undefined;
    if (submitData && Object.keys(submitData).length > 0) {
      if (messageId && !claimMessage(messageId)) return { isInvoke: false };

      audit("IN", senderName, conversationId, `[card action: ${JSON.stringify(submitData).slice(0, 100)}]`);

      const actionText = `[Card Action] ${JSON.stringify(submitData)}`;
      const meta = {
        chat_id: normalizeConversationId(conversationId),
        sender: senderName,
        sender_id: senderId,
        message_id: messageId,
        conversation_type: conversationType,
        was_mentioned: "true",
        source: "teams",
        is_card_action: "true",
        card_action_data: JSON.stringify(submitData),
      };

      if (mcpConnected) {
        try {
          await mcp.notification({
            method: "notifications/claude/channel",
            params: { content: actionText, meta },
          });
        } catch {
          mcpConnected = false;
        }
      }

      return { isInvoke: activityType === "invoke" };
    }
  }

  // Only process message activities
  if (activityType !== "message") {
    process.stderr.write(
      `teams-channel: ignoring activity type: ${activityType}\n`,
    );
    return { isInvoke: false };
  }

  const rawText = String(activity.text ?? "");
  if (!rawText.trim()) return { isInvoke: false };

  // Dedup before any side effects (auto-ack, delivery)
  if (messageId && !claimMessage(messageId)) {
    process.stderr.write(`teams-channel: [webhook] dedup — ${messageId} already delivered\n`);
    return { isInvoke: false };
  }

  // Auto-ack: instant thumbs-up reaction via Bot Framework experimental Reactions API
  if (conversationId && messageId) {
    const ackRef = getConversationRef(conversationId);
    if (ackRef) {
      const normConvId = normalizeConversationId(conversationId);
      getBotToken().then((token) => {
        const baseUrl = ackRef.serviceUrl.replace(/\/+$/, "");
        return fetch(
          `${baseUrl}/v3/conversations/${encodeURIComponent(normConvId)}/activities/${encodeURIComponent(messageId)}/reactions/like`,
          {
            method: "PUT",
            headers: { Authorization: `Bearer ${token}` },
          },
        );
      }).then((res) => {
        if (res.ok) {
          process.stderr.write(`teams-channel: auto-ack reaction OK (${res.status})\n`);
        } else {
          res.text().then((t) => {
            process.stderr.write(`teams-channel: auto-ack reaction ${res.status}: ${t.slice(0, 300)}\n`);
          }).catch(() => {
            process.stderr.write(`teams-channel: auto-ack reaction ${res.status}\n`);
          });
        }
      }).catch((err) => {
        process.stderr.write(`teams-channel: auto-ack reaction FAIL: ${String(err)}\n`);
      });
    }
  }

  // Access control
  if (!isAllowed(senderId, senderUpn)) {
    process.stderr.write(
      `teams-channel: blocked message from ${senderName} (${senderId})\n`,
    );
    // Send denial message
    const ref = getConversationRef(conversationId);
    if (ref && conversationType === "personal") {
      try {
        await botPost(
          ref.serviceUrl,
          `/v3/conversations/${encodeURIComponent(normalizeConversationId(conversationId))}/activities`,
          {
            type: "message",
            text: "I'm not authorized to chat with you. Contact your admin.",
            from: { id: ref.bot.id, name: ref.bot.name },
            conversation: {
              id: normalizeConversationId(conversationId),
              conversationType: ref.conversationType,
            },
          },
        );
      } catch {
        // best effort
      }
    }
    return { isInvoke: false };
  }

  // In group chats, check if mention is required
  const wasMentioned = wasBotMentioned(activity);
  const groupConfig = accessConfig.groups[normalizeConversationId(conversationId)];
  const requireMention = groupConfig?.requireMention ?? true;
  if (
    conversationType !== "personal" &&
    requireMention &&
    !wasMentioned
  ) {
    return { isInvoke: false }; // silently ignore non-mentioned messages in groups
  }

  // Strip bot mention tags
  const strippedText = stripMentionTags(rawText);
  if (!strippedText) return { isInvoke: false };

  audit("IN", senderName, conversationId, strippedText);

  // Download inbound attachments
  const attachmentPaths: string[] = [];
  const attachments = activity.attachments as Array<Record<string, unknown>> | undefined;
  if (attachments && attachments.length > 0) {
    for (const att of attachments) {
      const contentUrl = att.contentUrl as string | undefined;
      const contentType = att.contentType as string | undefined;
      const name = att.name as string | undefined;
      if (!contentUrl || contentType === "application/vnd.microsoft.card.adaptive") continue;

      try {
        const headers: Record<string, string> = {};
        if (contentUrl.includes("graph.microsoft.com")) {
          const graphToken = await getGraphToken().catch(() => "");
          if (graphToken) headers.Authorization = `Bearer ${graphToken}`;
        }
        const dlRes = await fetch(contentUrl, { headers });
        if (dlRes.ok) {
          const buf = Buffer.from(await dlRes.arrayBuffer());
          const safeName = (name ?? `attachment-${Date.now()}`).replace(/[^a-zA-Z0-9._-]/g, "_");
          const localPath = `/tmp/teams-attachment-${Date.now()}-${safeName}`;
          writeFileSync(localPath, buf, { mode: 0o600 });
          attachmentPaths.push(localPath);
          process.stderr.write(`teams-channel: downloaded attachment ${safeName} -> ${localPath}\n`);
        }
      } catch (err) {
        process.stderr.write(`teams-channel: attachment download failed: ${String(err)}\n`);
      }
    }
  }

  const meta: Record<string, string> = {
    chat_id: normalizeConversationId(conversationId),
    sender: senderName,
    sender_id: senderId,
    message_id: messageId,
    conversation_type: conversationType,
    was_mentioned: String(wasMentioned),
    source: "teams",
  };
  if (attachmentPaths.length > 0) {
    meta.attachments = JSON.stringify(attachmentPaths);
  }

  const channelPayload = {
    content: strippedText,
    meta,
  };

  if (!mcpConnected) {
    process.stderr.write(
      `teams-channel: MCP disconnected — queuing message from ${senderName}\n`,
    );
    const queueFile = join(STATE_DIR, "pending-messages.jsonl");
    const line = JSON.stringify({ ts: new Date().toISOString(), ...channelPayload }) + "\n";
    appendFile(queueFile, line).catch(() => {});
    return { isInvoke: false };
  }

  try {
    await mcp.notification({
      method: "notifications/claude/channel",
      params: channelPayload,
    });
    process.stderr.write(
      `teams-channel: delivered message from ${senderName}: ${strippedText.slice(0, 80)}\n`,
    );
  } catch (err) {
    mcpConnected = false;
    process.stderr.write(
      `teams-channel: notification failed (marking MCP disconnected): ${String(err)}\n`,
    );
    const queueFile = join(STATE_DIR, "pending-messages.jsonl");
    const line = JSON.stringify({ ts: new Date().toISOString(), ...channelPayload }) + "\n";
    appendFile(queueFile, line).catch(() => {});
  }
  return { isInvoke: false };
}

const PORT_BIND_MAX_RETRIES = 5;
const PORT_BIND_RETRY_DELAY_MS = 2000;

async function startWebhookServer(): Promise<void> {
  for (let attempt = 1; attempt <= PORT_BIND_MAX_RETRIES; attempt++) {
    try {
      Bun.serve({
        port: WEBHOOK_PORT,
        hostname: "0.0.0.0",
        reusePort: false,
        // idleTimeout=0 disables Bun's default 10s deadline so the Streamable
        // HTTP MCP listener (mounted via mcp-shared `attachTo` mode below)
        // can hold long-lived SSE streams open. Without this, GET /mcp gets
        // killed at 10s idle, surfacing as `Failed to open SSE stream: Bad
        // Gateway` to mcp-remote / Claude Desktop bridges through Tailscale
        // Funnel. The Bot Framework webhook is short-lived (request/response)
        // so the looser deadline doesn't hurt it.
        idleTimeout: 0,
        async fetch(req) {
          const url = new URL(req.url);

          // Health check
          if (req.method === "GET" && url.pathname === "/health") {
            return new Response(
              JSON.stringify({ ok: true, channel: "teams" }),
              { headers: { "Content-Type": "application/json" } },
            );
          }

          // Bot Framework webhook
          if (req.method === "POST" && url.pathname === "/api/messages") {
            // JWT validation
            const authHeader = req.headers.get("authorization") ?? "";
            if (jwtValidator && authHeader) {
              let body: Record<string, unknown>;
              try {
                body = (await req.json()) as Record<string, unknown>;
              } catch {
                return new Response("bad json", { status: 400 });
              }

              const serviceUrl = body.serviceUrl as string | undefined;
              const valid = await jwtValidator.validate(
                authHeader,
                serviceUrl,
              );
              if (!valid) {
                process.stderr.write(
                  "teams-channel: JWT validation failed\n",
                );
                return new Response("unauthorized", { status: 401 });
              }

              // Process the activity
              try {
                const result = await handleInboundActivity(body);
                if (result?.isInvoke) {
                  return new Response(
                    JSON.stringify({ status: 200, body: {} }),
                    { status: 200, headers: { "Content-Type": "application/json" } },
                  );
                }
              } catch (err) {
                process.stderr.write(
                  `teams-channel: activity handler error: ${String(err)}\n`,
                );
              }
              return new Response("ok", { status: 200 });
            }

            // No auth header — reject immediately
            process.stderr.write(
              "teams-channel: rejected request — no Authorization header\n",
            );
            return new Response("unauthorized", { status: 401 });
          }

          // Compat: legacy {chat_id, text} notification endpoint used by
          // migrated CF workers (amd-notify, etc). Auth via X-API-Key against
          // MSTEAMS_NOTIFY_API_KEY. Translates to a Bot Framework activity and
          // posts via the saved conversation reference.
          if (req.method === "POST" && url.pathname === "/api/notify") {
            if (!NOTIFY_API_KEY) {
              return new Response("notify endpoint disabled", { status: 503 });
            }
            const key = req.headers.get("x-api-key") ?? "";
            // Constant-time comparison. Buffer.byteLength is checked first
            // because timingSafeEqual throws on length mismatch.
            const keyBuf = Buffer.from(key);
            const expectedBuf = Buffer.from(NOTIFY_API_KEY);
            const keyOk =
              keyBuf.length === expectedBuf.length &&
              timingSafeEqual(keyBuf, expectedBuf);
            if (!keyOk) {
              return new Response("unauthorized", { status: 401 });
            }
            let body: { chat_id?: string; text?: string; card?: Record<string, unknown> };
            try {
              body = (await req.json()) as typeof body;
            } catch {
              return new Response(
                JSON.stringify({ ok: false, error: "bad json" }),
                { status: 400, headers: { "Content-Type": "application/json" } },
              );
            }
            const chatId = body.chat_id;
            const text = body.text;
            const card = body.card;
            if (!chatId || (!text && !card)) {
              return new Response(
                JSON.stringify({ ok: false, error: "chat_id and (text or card) required" }),
                { status: 400, headers: { "Content-Type": "application/json" } },
              );
            }
            const ref = getConversationRef(chatId);
            if (!ref) {
              return new Response(
                JSON.stringify({ ok: false, error: `no conversation reference for ${chatId}` }),
                { status: 404, headers: { "Content-Type": "application/json" } },
              );
            }
            try {
              const normId = normalizeConversationId(chatId);
              const activity: Record<string, unknown> = {
                type: "message",
                from: { id: ref.bot.id, name: ref.bot.name },
                conversation: { id: normId, conversationType: ref.conversationType },
              };
              if (text) activity.text = text;
              if (card) {
                activity.attachments = [
                  {
                    contentType: "application/vnd.microsoft.card.adaptive",
                    content: card,
                  },
                ];
              }
              const result = await botPost(
                ref.serviceUrl,
                `/v3/conversations/${encodeURIComponent(normId)}/activities`,
                activity,
              );
              audit(
                "OUT",
                ref.bot.name,
                chatId,
                `[notify] ${(text ?? "(card)").slice(0, 200)}`,
              );
              return new Response(
                JSON.stringify({ ok: true, id: result.id ?? null }),
                { status: 200, headers: { "Content-Type": "application/json" } },
              );
            } catch (err) {
              process.stderr.write(
                `teams-channel: /api/notify failed: ${String(err)}\n`,
              );
              return new Response(
                JSON.stringify({ ok: false, error: String(err) }),
                { status: 502, headers: { "Content-Type": "application/json" } },
              );
            }
          }

          // RingCentral call-log webhook
          if (req.method === "POST" && url.pathname === "/api/rc-webhook") {
            try {
              const rawBody = await req.text();
              const sig = req.headers.get("x-rc-webhook-signature");
              if (!verifyRcWebhook(rawBody, sig)) {
                process.stderr.write("teams-channel: RC webhook signature invalid\n");
                return new Response("unauthorized", { status: 401 });
              }
              const body = JSON.parse(rawBody) as Record<string, unknown>;
              handleRcWebhook(body).catch((err) => {
                process.stderr.write(
                  `teams-channel: RC webhook processing error: ${String(err)}\n`,
                );
              });
              return new Response("ok", { status: 200 });
            } catch {
              return new Response("bad json", { status: 400 });
            }
          }

          return new Response("not found", { status: 404 });
        },
      });
      process.stderr.write(
        `teams-channel: webhook listener on port ${WEBHOOK_PORT}\n`,
      );
      return;
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      process.stderr.write(
        `teams-channel: port ${WEBHOOK_PORT} bind failed (attempt ${attempt}/${PORT_BIND_MAX_RETRIES}): ${msg}\n`,
      );

      // Kill stale process on the port
      try {
        const proc = Bun.spawnSync({
          cmd: [
            "lsof",
            "-t",
            "-iTCP:" + String(WEBHOOK_PORT),
            "-sTCP:LISTEN",
          ],
        });
        const pids = new TextDecoder()
          .decode(proc.stdout)
          .trim()
          .split("\n")
          .filter(Boolean);
        const myPid = String(process.pid);
        for (const pid of pids) {
          if (pid !== myPid) {
            process.stderr.write(
              `teams-channel: killing stale listener PID ${pid}\n`,
            );
            try {
              process.kill(parseInt(pid, 10), "SIGTERM");
            } catch {
              // already dead
            }
          }
        }
      } catch {
        // lsof not available
      }

      if (attempt < PORT_BIND_MAX_RETRIES) {
        await new Promise((r) => setTimeout(r, PORT_BIND_RETRY_DELAY_MS));
      }
    }
  }
  process.stderr.write(
    `teams-channel: FATAL — could not bind port ${WEBHOOK_PORT} after ${PORT_BIND_MAX_RETRIES} attempts\n`,
  );
}

if (!HTTP_ONLY_MODE) {
  await startWebhookServer();
}

// ---------------------------------------------------------------------------
// RingCentral call notifications
// ---------------------------------------------------------------------------

if (!HTTP_ONLY_MODE && isRcConfigured()) {
  initRcNotifications({
    botPost,
    getGraphToken,
    audit,
    appId: APP_ID,
  }).catch((err) => {
    process.stderr.write(`teams-channel: RC notifications init failed: ${String(err)}\n`);
  });
}

// ---------------------------------------------------------------------------
// Inbound polling (fallback — Graph API message fetch)
// ---------------------------------------------------------------------------

const POLL_INTERVAL_MS = 5000;
let pollWatermark = new Date().toISOString();
let pollStopped = false;

async function pollInbound(): Promise<void> {
  const graphToken = await getGraphToken().catch(() => "");
  if (!graphToken) return;

  for (const [convId, ref] of Object.entries(conversations)) {
    const isGroup = ref.conversationType !== "personal";
    const groupCfg = accessConfig.groups[convId];
    const requireMention = isGroup && (groupCfg?.requireMention ?? true);

    try {
      const res = await fetch(
        `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(convId)}/messages?$top=5&$orderby=createdDateTime desc`,
        { headers: { Authorization: `Bearer ${graphToken}` } },
      );
      if (!res.ok) continue;

      const data = (await res.json()) as { value: Array<Record<string, unknown>> };
      const messages = (data.value ?? [])
        .filter((m) => {
          const ts = (m.createdDateTime as string) ?? "";
          if (ts <= pollWatermark) return false;
          const fromObj = m.from as Record<string, unknown> | undefined;
          const userObj = fromObj?.user as Record<string, unknown> | undefined;
          if (!userObj) return false;
          const body = m.body as Record<string, unknown> | undefined;
          const text = (body?.content as string) ?? "";
          return text.trim().length > 0;
        })
        .reverse();

      for (const msg of messages) {
        const msgId = String(msg.id ?? "");
        if (!claimMessage(msgId)) continue;

        const fromObj = msg.from as Record<string, unknown>;
        const userObj = fromObj.user as Record<string, unknown>;
        const senderName = String(userObj.displayName ?? "unknown");
        const senderId = String(userObj.id ?? "");
        const body = msg.body as Record<string, unknown>;
        let text = String(body.content ?? "");

        // In group chats, check for @mention if required
        const mentioned = /<at[^>]*>.*?<\/at>/i.test(text) || text.includes(APP_ID);
        if (requireMention && !mentioned) continue;

        text = text.replace(/<[^>]*>/g, "").trim();
        if (!text) continue;

        const ts = (msg.createdDateTime as string) ?? "";
        if (ts > pollWatermark) pollWatermark = ts;

        audit("IN", senderName, convId, text);

        const meta = {
          chat_id: convId,
          sender: senderName,
          sender_id: senderId,
          message_id: msgId,
          conversation_type: ref.conversationType,
          was_mentioned: String(mentioned),
          source: "teams",
        };

        if (!mcpConnected) {
          const queueFile = join(STATE_DIR, "pending-messages.jsonl");
          const line = JSON.stringify({ ts: new Date().toISOString(), content: text, meta }) + "\n";
          appendFile(queueFile, line).catch(() => {});
          continue;
        }

        try {
          await mcp.notification({
            method: "notifications/claude/channel",
            params: { content: text, meta },
          });
          process.stderr.write(`teams-channel: [poll] delivered from ${senderName}: ${text.slice(0, 80)}\n`);
        } catch (err) {
          mcpConnected = false;
          seenMessageIds.delete(msgId);
          process.stderr.write(`teams-channel: [poll] notification failed: ${String(err)}\n`);
        }
      }
    } catch (err) {
      process.stderr.write(`teams-channel: [poll] error for ${convId}: ${String(err)}\n`);
    }
  }
}

async function pollLoop(): Promise<void> {
  try {
    await pollInbound();
  } catch (err) {
    process.stderr.write(`teams-channel: poll error: ${String(err)}\n`);
  }
  if (!pollStopped) {
    setTimeout(pollLoop, POLL_INTERVAL_MS);
  }
}

if (!HTTP_ONLY_MODE) {
  setTimeout(pollLoop, POLL_INTERVAL_MS);
  process.stderr.write(`teams-channel: polling scheduled (${POLL_INTERVAL_MS}ms interval)\n`);
}

// ---------------------------------------------------------------------------
// Graceful shutdown
// ---------------------------------------------------------------------------

let shuttingDown = false;
function shutdown(): void {
  if (shuttingDown) return;
  shuttingDown = true;
  process.stderr.write("teams-channel: shutting down\n");
  // Best-effort: drain in-flight HTTP MCP requests. We don't await -- the
  // subsequent process.exit(0) is the hard backstop. Any close() rejection
  // is swallowed; webhook + stdio shutdown is what actually matters.
  if (mcpHttpHandle) {
    mcpHttpHandle.close().catch(() => {});
  }
  process.exit(0);
}
// Keep the process alive even if MCP stdio transport disconnects.
// The webhook server (Bun.serve) should keep the event loop alive,
// but add a belt-and-suspenders ref'd interval to be safe.
setInterval(() => {
  // Heartbeat — prevents process exit if all other handles are unref'd
}, 15000);

process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
process.on("SIGHUP", shutdown);

// Orphan detection disabled — Claude Code manages child lifecycle via signals.
// The webhook server must stay alive even if the parent process tree changes.
