import { ClientSecretCredential } from "@azure/identity";
import { AzureIdentityAuthenticationProvider } from "@microsoft/kiota-authentication-azure";
import {
  createGraphServiceClient,
  GraphRequestAdapter,
  type GraphServiceClient,
} from "@microsoft/msgraph-sdk";
import type {
  ChatMessage,
  ChatMessageAttachment,
} from "@microsoft/msgraph-sdk/models";
// Register the chats, teams, and users fluent API extensions
import "@microsoft/msgraph-sdk-chats";
import "@microsoft/msgraph-sdk-teams";
import "@microsoft/msgraph-sdk-users";
import {
  ActivityTypes,
  CloudAdapter,
  ConfigurationBotFrameworkAuthentication,
  type ConversationReference,
  TeamsInfo,
  TurnContext,
} from "botbuilder";
import { PasswordServiceClientCredentialFactory } from "botframework-connector";

import { LRUCacheManager } from "@/cache-manager";
import logger from "@/logging";
import { ChatOpsChannelBindingModel } from "@/models";
import type {
  AddApprovalRequestFormOptions,
  ChatOpsApprovalDecision,
  ChatOpsEventHandler,
  ChatOpsProvider,
  ChatOpsProviderType,
  ChatReplyOptions,
  ChatThreadMessage,
  ChatThreadMessageFile,
  DiscoveredChannel,
  IncomingChatMessage,
  MsTeamsDbConfig,
  SkippedAttachment,
  ThreadFileOutcome,
  ThreadHistoryParams,
  UpdateApprovalRequestOptions,
} from "@/types";
import { detectImageType } from "@/utils/detect-image-type";
import { stripHtmlTags } from "@/utils/strip-html";
import { isUuid } from "@/utils/uuid";
import { isMuteReaction } from "./channel-activation";
import {
  CHATOPS_ATTACHMENT_LIMITS,
  CHATOPS_TEAM_CACHE,
  CHATOPS_THREAD_HISTORY,
} from "./constants";
import { errorMessage, formatApprovalToolArgs } from "./utils";

/**
 * MS Teams provider using Bot Framework SDK.
 *
 * Security:
 * - JWT validation handled automatically by CloudAdapter
 * - Supports single-tenant and multi-tenant Azure Bot configurations
 */
class MSTeamsProvider implements ChatOpsProvider {
  readonly providerId: ChatOpsProviderType = "ms-teams";
  readonly displayName = "Microsoft Teams";

  private adapter: CloudAdapter | null = null;
  private graphClient: GraphServiceClient | null = null;
  private eventHandler: ChatOpsEventHandler | null = null;
  private config: MsTeamsDbConfig;

  constructor(msTeamsConfig: MsTeamsDbConfig) {
    this.config = msTeamsConfig;
  }

  setEventHandler(handler: ChatOpsEventHandler): void {
    this.eventHandler = handler;
  }

  isConfigured(): boolean {
    return (
      this.config.enabled &&
      Boolean(this.config.appId) &&
      Boolean(this.config.appSecret)
    );
  }

  async initialize(): Promise<void> {
    if (!this.isConfigured()) {
      logger.info("[MSTeamsProvider] Not configured, skipping initialization");
      return;
    }

    const { appId, appSecret, tenantId } = this.config;
    const graph = {
      tenantId: this.config.graphTenantId,
      clientId: this.config.graphClientId,
      clientSecret: this.config.graphClientSecret,
    };

    // Initialize Bot Framework adapter
    const credentialsFactory = tenantId
      ? new PasswordServiceClientCredentialFactory(appId, appSecret, tenantId)
      : new PasswordServiceClientCredentialFactory(appId, appSecret);

    // A tenant ID means this is a single-tenant Azure Bot. The adapter must be
    // told so (MicrosoftAppType=SingleTenant) — otherwise it defaults to
    // MultiTenant and validates tokens against the wrong (common) authority,
    // rejecting every inbound activity with "Unauthorized. No valid identity."
    const auth = new ConfigurationBotFrameworkAuthentication(
      {
        MicrosoftAppId: appId,
        MicrosoftAppType: tenantId ? "SingleTenant" : "MultiTenant",
        MicrosoftAppTenantId: tenantId || undefined,
      },
      credentialsFactory,
    );

    this.adapter = new CloudAdapter(auth);
    this.adapter.onTurnError = async (_context, error) => {
      logger.error(
        { error: errorMessage(error) },
        "[MSTeamsProvider] Bot Framework error",
      );
    };

    logger.info(
      { tenantMode: tenantId ? "single-tenant" : "multi-tenant" },
      "[MSTeamsProvider] Bot Framework adapter initialized",
    );

    // Initialize Graph client if configured
    if (graph?.tenantId && graph?.clientId && graph?.clientSecret) {
      const credential = new ClientSecretCredential(
        graph.tenantId,
        graph.clientId,
        graph.clientSecret,
      );
      const authProvider = new AzureIdentityAuthenticationProvider(credential, [
        "https://graph.microsoft.com/.default",
      ]);
      const requestAdapter = new GraphRequestAdapter(authProvider);
      this.graphClient = createGraphServiceClient(requestAdapter);
      logger.info("[MSTeamsProvider] Graph client initialized");
    } else {
      logger.info(
        "[MSTeamsProvider] Graph API not configured, thread history unavailable",
      );
    }
  }

  async cleanup(): Promise<void> {
    this.adapter = null;
    this.graphClient = null;
    this.eventHandler = null;
    logger.info("[MSTeamsProvider] Cleaned up");
  }

  async validateWebhookRequest(
    _payload: unknown,
    headers: Record<string, string | string[] | undefined>,
  ): Promise<boolean> {
    const authHeader = headers.authorization || headers.Authorization;
    if (!authHeader) {
      logger.warn("[MSTeamsProvider] Missing Authorization header");
      return false;
    }
    return true;
  }

  handleValidationChallenge(_payload: unknown): unknown | null {
    return null;
  }

  async parseWebhookNotification(
    payload: unknown,
    headers: Record<string, string | string[] | undefined>,
  ): Promise<IncomingChatMessage | null> {
    if (!this.adapter) {
      logger.error("[MSTeamsProvider] Adapter not initialized");
      return null;
    }

    const activity = payload as {
      type?: string;
      id?: string;
      text?: string;
      channelId?: string;
      conversation?: {
        id?: string;
        tenantId?: string;
        conversationType?: string;
      };
      from?: { id?: string; name?: string; aadObjectId?: string };
      recipient?: { id?: string; name?: string };
      timestamp?: string;
      replyToId?: string;
      serviceUrl?: string;
      channelData?: {
        team?: { id?: string; aadGroupId?: string };
        channel?: { id?: string };
        tenant?: { id?: string };
      };
      entities?: Array<{
        type?: string;
        mentioned?: { id?: string; name?: string };
      }>;
      attachments?: Array<{
        contentType?: string;
        contentUrl?: string;
        content?: string;
        name?: string;
      }>;
    };

    logger.debug(
      {
        conversationType: activity.conversation?.conversationType,
        teamId: activity.channelData?.team?.id,
        aadGroupId: activity.channelData?.team?.aadGroupId,
        isReply: Boolean(activity.replyToId),
      },
      "[MSTeamsProvider] Parsing activity",
    );

    if (activity.type !== ActivityTypes.Message) {
      return null;
    }

    // A file-only message (empty text but with a real file attachment) is still
    // meaningful, so accept it; only drop messages that carry neither text nor
    // a downloadable file. Adaptive Cards / hero cards are not files (and are
    // filtered out before download), so a card-only message stays dropped.
    // Addressing/mention gating is enforced by the webhook route, not here, so
    // this does not widen who the bot responds to.
    const hasFileAttachment = Boolean(
      activity.attachments?.some(
        (a) =>
          a.contentUrl &&
          a.contentType &&
          !a.contentType.startsWith("application/vnd.microsoft.card."),
      ),
    );
    if (!activity.text && !hasFileAttachment) {
      return null;
    }

    // Extract channel ID, stripping thread suffix if present
    let channelId =
      activity.channelData?.channel?.id || activity.conversation?.id;
    if (channelId?.includes(";messageid=")) {
      channelId = channelId.split(";messageid=")[0];
    }

    if (!channelId) {
      logger.warn(
        "[MSTeamsProvider] Cannot determine channel ID from activity",
      );
      return null;
    }

    const cleanedText = cleanBotMention(
      activity.text ?? "",
      activity.recipient?.name,
    );
    if (!cleanedText && !hasFileAttachment) {
      return null;
    }

    // Note: in team channels the bot stays quiet until @mentioned, then keeps
    // replying to the thread. That gating is enforced by the webhook route via
    // wasBotMentioned() + channel-activation, not here, so parsing stays pure.

    const conversationId = activity.conversation?.id;
    const isThreadReply =
      Boolean(activity.replyToId) ||
      Boolean(conversationId?.includes(";messageid="));

    // Extract team ID - prefer aadGroupId (proper UUID) over team.id (may be conversation ID)
    const teamData = activity.channelData?.team;
    const workspaceId = teamData?.aadGroupId || teamData?.id || null;

    // Download file attachments (skip Adaptive Cards and other non-file
    // attachments). Current-message drops are intentionally not surfaced for
    // Teams (unlike Slack) — only the delivered attachments are used.
    const fileOutcomes = await this.downloadTeamsFiles(
      filterTeamsFileAttachments(activity.attachments),
      activity.serviceUrl,
    );
    const attachments = fileOutcomes.flatMap((o) =>
      o.status === "delivered" ? [o.attachment] : [],
    );

    // A file-only message (empty text) is kept only when a file actually
    // survived download — oversized, expired, or failed downloads must not
    // leave the bot answering an empty turn.
    if (!cleanedText && attachments.length === 0) {
      return null;
    }

    return {
      messageId: activity.id || `teams-${Date.now()}`,
      channelId,
      workspaceId,
      threadId: extractThreadId(activity),
      senderId: activity.from?.aadObjectId || activity.from?.id || "unknown",
      senderName: activity.from?.name || "Unknown User",
      text: cleanedText,
      rawText: activity.text ?? "",
      timestamp: activity.timestamp ? new Date(activity.timestamp) : new Date(),
      isThreadReply,
      metadata: {
        tenantId:
          activity.channelData?.tenant?.id || activity.conversation?.tenantId,
        serviceUrl: activity.serviceUrl,
        conversationReference: TurnContext.getConversationReference(
          activity as Parameters<
            typeof TurnContext.getConversationReference
          >[0],
        ),
        authHeader: headers.authorization || headers.Authorization,
        // Lets the manager frame group conversations for the agent ("personal",
        // "groupChat", or "channel") and tell it whether it was addressed.
        conversationType: activity.conversation?.conversationType,
        botMentioned: this.wasBotMentioned(activity),
        botName: activity.recipient?.name,
        // Names of OTHER people @mentioned in the message — a message
        // @mentioning someone else is most likely addressed to them.
        mentionedOthers: extractMentionedOthers(activity),
      },
      ...(attachments.length > 0 && { attachments }),
    };
  }

  /**
   * Whether this activity @mentions the bot.
   *
   * Normalizes IDs before comparing (strips the "28:" prefix, case-insensitive)
   * since Teams may format recipient.id and the mention's id differently. The
   * webhook route uses this to gate team-channel replies (see channel-activation).
   */
  wasBotMentioned(activity: {
    recipient?: { id?: string } | null;
    entities?: Array<{
      type?: string;
      mentioned?: { id?: string } | null;
    } | null> | null;
  }): boolean {
    const botId = activity.recipient?.id;
    if (!botId) {
      return false;
    }
    return Boolean(
      activity.entities?.some(
        (e) =>
          e?.type === "mention" &&
          e.mentioned?.id != null &&
          normalizeTeamsId(e.mentioned.id) === normalizeTeamsId(botId),
      ),
    );
  }

  /**
   * If this is a "mute the thread" reaction on one of the bot's channel
   * messages, return the channel + thread to mute; otherwise null.
   *
   * Teams only delivers messageReaction events for the bot's OWN messages, so
   * no sender check is needed. The thread is taken from `conversation.id`'s
   * `;messageid=<root>` — NOT `replyToId`, which on a reaction points at the
   * reacted (bot reply) message rather than the thread root the activation was
   * keyed on. If the root can't be resolved we return null and the caller
   * no-ops loudly (no false "muted") rather than guessing a key — guessing the
   * channelId fallback could clear a different thread's activation.
   */
  parseMuteReaction(activity: {
    type?: string;
    conversation?: { id?: string; conversationType?: string };
    channelData?: { channel?: { id?: string } };
    reactionsAdded?: Array<{ type?: string } | null> | null;
  }): { channelId: string; threadId: string } | null {
    if (activity.type !== "messageReaction") return null;
    const hasMuteReaction = Boolean(
      activity.reactionsAdded?.some((r) => r?.type && isMuteReaction(r.type)),
    );
    if (!hasMuteReaction) return null;

    // Sticky auto-reply (and thus muting) only applies in team channels.
    if (activity.conversation?.conversationType !== "channel") return null;

    const conversationId = activity.conversation?.id;
    // Match how the gate derived the activation key's channelId
    // (parseWebhookNotification): prefer channelData.channel.id, else the
    // thread-suffix-stripped conversation id. Uses `||` (not `??`) to match the
    // gate exactly, so an empty channel.id falls through to the conversation id.
    const channelId =
      activity.channelData?.channel?.id || stripThreadSuffix(conversationId);
    // Assumes the activation key's threadId equals the conversation's
    // `;messageid=<root>`. Teams channel threads are flat (every reply's
    // replyToId is the root), so the gate's extractThreadId (replyToId-first)
    // and this resolve to the same root. If a payload ever breaks that, the
    // mute no-ops loudly (returns null → no false "muted") rather than guessing.
    const threadId = extractThreadIdFromConversationId(conversationId);
    if (!channelId || !threadId) return null;

    return { channelId, threadId };
  }

  async sendReply(options: ChatReplyOptions): Promise<string> {
    if (!this.adapter) {
      throw new Error("MSTeamsProvider not initialized");
    }

    let replyText = options.text;
    // An even-more-subtle hint (e.g. the one-time mute tip) on its own italic
    // line ABOVE the footer, so the agent footer stays the last line. Italics
    // keep it visually quieter than the footer.
    if (options.hint) {
      replyText += `\n\n---\n\n_${options.hint}_`;
    }
    if (options.footer) {
      replyText += options.hint
        ? `\n\n${options.footer}`
        : `\n\n---\n\n${options.footer}`;
    }

    // If a placeholder "Thinking..." message was sent (Teams channels),
    // update it with the actual response instead of sending a new message.
    const placeholderActivityId = options.originalMessage.metadata
      ?.placeholderActivityId as string | undefined;
    const turnContext = options.originalMessage.metadata?.turnContext;
    if (placeholderActivityId && turnContext instanceof TurnContext) {
      try {
        await turnContext.updateActivity({
          id: placeholderActivityId,
          type: ActivityTypes.Message,
          text: replyText,
        });
        return placeholderActivityId;
      } catch (error) {
        logger.debug(
          { error: errorMessage(error) },
          "[MSTeamsProvider] Failed to update placeholder, sending new message",
        );
        // Fall through to send a new message
      }
    }

    const ref =
      (options.conversationReference as ConversationReference | undefined) ||
      (options.originalMessage.metadata?.conversationReference as
        | ConversationReference
        | undefined);

    if (!ref) {
      throw new Error("No conversation reference available for reply");
    }

    let messageId = "";
    try {
      await this.adapter.continueConversationAsync(
        this.config.appId,
        ref,
        async (context) => {
          const response = await context.sendActivity(replyText);
          messageId = response?.id || "";
        },
      );
    } catch (error) {
      logger.error(
        { error: errorMessage(error) },
        "[MSTeamsProvider] continueConversationAsync failed",
      );
      throw error;
    }

    return messageId;
  }

  async getThreadHistory(
    params: ThreadHistoryParams,
  ): Promise<ChatThreadMessage[]> {
    if (!this.graphClient) {
      logger.warn(
        "[MSTeamsProvider] Graph client not initialized, skipping thread history",
      );
      return [];
    }

    const limit = Math.min(
      params.limit || CHATOPS_THREAD_HISTORY.DEFAULT_LIMIT,
      CHATOPS_THREAD_HISTORY.MAX_LIMIT,
    );

    try {
      // Determine if this is a group chat vs team channel:
      // - Group chats: no workspaceId, or workspaceId starts with "19:" (thread ID format)
      // - Team channels: workspaceId is a UUID (the team's aadGroupId), channelId contains @thread.tacv2
      let workspaceId = params.workspaceId;
      const isValidTeamId = workspaceId && isUuid(workspaceId);

      // If workspaceId isn't a valid UUID but channel looks like a team channel,
      // try to look up the actual team ID
      const looksLikeTeamChannel = params.channelId.includes("@thread.tacv2");
      if (!isValidTeamId && looksLikeTeamChannel) {
        // workspaceId should already be resolved by the route handler via TeamsInfo.
        // Falling back to lookupTeamIdFromChannel (requires Azure AD app permissions).
        logger.warn(
          { channelId: params.channelId, workspaceId },
          "[MSTeamsProvider] workspaceId not resolved to UUID — falling back to Graph API lookup",
        );
        const resolvedTeamId = await this.lookupTeamIdFromChannel(
          params.channelId,
          workspaceId || undefined,
        );
        if (resolvedTeamId) {
          workspaceId = resolvedTeamId;
        }
      }

      const isTeamIdValid = workspaceId && isUuid(workspaceId);
      const isTeamChannel = isTeamIdValid && looksLikeTeamChannel;
      const isGroupChat = !isTeamChannel;

      logger.debug(
        { isGroupChat, isTeamChannel, channelId: params.channelId },
        "[MSTeamsProvider] Fetching thread history",
      );

      const effectiveParams = { ...params, workspaceId };
      const messages = isGroupChat
        ? await this.fetchGroupChatHistory(effectiveParams, limit)
        : await this.fetchTeamChannelHistory(effectiveParams, limit);

      const converted = this.convertToThreadMessages(
        messages,
        params.excludeMessageId,
      );

      logger.debug(
        { historyCount: converted.length },
        "[MSTeamsProvider] Thread history fetched",
      );

      return converted;
    } catch (error) {
      logger.warn(
        { error: errorMessage(error), channelId: params.channelId },
        "[MSTeamsProvider] Failed to fetch thread history",
      );
      return [];
    }
  }

  async addApprovalRequestForm(
    options: AddApprovalRequestFormOptions,
  ): Promise<void> {
    if (!this.adapter) {
      throw new Error("MSTeamsProvider not initialized");
    }

    const buildAction = (approved: boolean) => ({
      type: "Action.Submit",
      title: approved ? "Approve" : "Decline",
      data: {
        action: "approvalDecision",
        approvalId: options.approvalId,
        approved,
        taskId: options.taskId,
        toolName: options.toolName,
        channelId: options.channelId,
        workspaceId: options.originalMessage.workspaceId,
        threadId: options.threadId,
        originalSenderEmail: options.originalMessage.senderEmail,
        messageId: options.originalMessage.messageId,
      },
    });

    const argsText = formatApprovalToolArgs(options.toolArgs);
    const approvalCard = {
      type: "AdaptiveCard",
      $schema: "http://adaptivecards.io/schemas/adaptive-card.json",
      version: "1.4",
      body: [
        {
          type: "TextBlock",
          text: `\`${options.toolName}\``,
          wrap: true,
        },
        ...(argsText
          ? [
              {
                type: "TextBlock",
                text: argsText,
                wrap: true,
                fontType: "Monospace",
                spacing: "Small",
              },
            ]
          : []),
        {
          type: "ActionSet",
          spacing: "Small",
          actions: [buildAction(true), buildAction(false)],
        },
      ],
    };

    const approvalMessage = {
      type: ActivityTypes.Message,
      text: "",
      attachments: [
        {
          contentType: "application/vnd.microsoft.card.adaptive",
          content: approvalCard,
        },
      ],
    };

    const turnContext = options.originalMessage.metadata?.turnContext;
    if (turnContext instanceof TurnContext) {
      await turnContext.sendActivity(approvalMessage);
      return;
    }

    const conversationReference = options.originalMessage.metadata
      ?.conversationReference as ConversationReference | undefined;
    if (!conversationReference) {
      throw new Error(
        "No conversation reference available for Teams approval request",
      );
    }

    await this.adapter.continueConversationAsync(
      this.config.appId,
      conversationReference,
      async (context) => {
        await context.sendActivity(approvalMessage);
      },
    );
  }

  async updateApprovalRequest(
    _options: UpdateApprovalRequestOptions,
  ): Promise<void> {
    // In ms-teams we use a callback for status update instead of this function.
  }

  getAdapter(): CloudAdapter | null {
    return this.adapter;
  }

  /**
   * Look up the team ID (UUID) from a channel ID using Graph API.
   * This is needed when the Bot Framework doesn't provide the team's aadGroupId.
   * Caches results to avoid repeated lookups.
   *
   * @param channelId - The specific channel ID where the message was sent
   * @param teamChannelHint - Optional: the team.id from activity (often the General channel ID)
   */
  private teamIdCache = new LRUCacheManager<string | null>({
    maxSize: CHATOPS_TEAM_CACHE.MAX_SIZE,
    defaultTtl: CHATOPS_TEAM_CACHE.TTL_MS,
  });

  private async lookupTeamIdFromChannel(
    channelId: string,
    teamChannelHint?: string,
  ): Promise<string | null> {
    // Use composite cache key including hint to ensure we re-lookup when hint changes
    const cacheKey = teamChannelHint
      ? `${channelId}|${teamChannelHint}`
      : channelId;

    const cached = this.teamIdCache.get(cacheKey);
    if (cached !== undefined) {
      return cached;
    }

    if (!this.graphClient) {
      logger.warn("[MSTeamsProvider] No graph client for team lookup");
      return null;
    }

    try {
      // List all teams the app has access to and find the one containing this channel.
      // Requires Team.ReadBasic.All and Channel.ReadBasic.All application permissions.
      // Paginate through all teams since Graph API defaults to 20 per page.

      // Build set of channel IDs to match - both the specific channel and the team hint (often General channel)
      const channelsToMatch = new Set([channelId]);
      if (teamChannelHint && teamChannelHint !== channelId) {
        channelsToMatch.add(teamChannelHint);
      }

      let teamsResponse = await this.graphClient.teams.get({
        queryParameters: { top: 999 },
      });

      while (teamsResponse) {
        const teams = teamsResponse.value || [];

        for (const team of teams) {
          if (!team.id) continue;

          try {
            const channelsResponse = await this.graphClient.teams
              .byTeamId(team.id)
              .channels.get();
            const channels = channelsResponse?.value || [];

            // Check if any of the team's channels matches either channelId or teamChannelHint
            const matchedChannel = channels.find(
              (ch) => ch.id && channelsToMatch.has(ch.id),
            );
            if (matchedChannel) {
              logger.info(
                {
                  channelId,
                  matchedChannelId: matchedChannel.id,
                  teamId: team.id,
                  teamName: team.displayName,
                },
                "[MSTeamsProvider] Found team for channel",
              );
              this.teamIdCache.set(cacheKey, team.id);
              return team.id;
            }
          } catch (err) {
            logger.debug(
              { teamId: team.id, error: errorMessage(err) },
              "[MSTeamsProvider] Could not access team channels",
            );
          }
        }

        // Follow @odata.nextLink for the next page of teams
        if (teamsResponse.odataNextLink) {
          teamsResponse = await this.graphClient.teams
            .withUrl(teamsResponse.odataNextLink)
            .get();
        } else {
          break;
        }
      }

      logger.warn(
        { channelId },
        "[MSTeamsProvider] Could not find team for channel - thread history may be limited",
      );
      this.teamIdCache.set(cacheKey, null);
      return null;
    } catch (error) {
      logger.warn(
        { error: errorMessage(error), channelId },
        "[MSTeamsProvider] Failed to lookup team from channel. " +
          "This is only needed with Azure AD application permissions (not RSC). " +
          "Team.ReadBasic.All and Channel.ReadBasic.All permissions are required.",
      );
      this.teamIdCache.set(cacheKey, null);
      return null;
    }
  }

  /**
   * Get user's email from their AAD Object ID using Microsoft Graph API.
   * Fallback method when TeamsInfo.getMember() is unavailable.
   * Requires User.Read.All application permission.
   */
  async getUserEmail(aadObjectId: string): Promise<string | null> {
    if (!this.graphClient) {
      logger.warn(
        "[MSTeamsProvider] Graph client not configured, cannot resolve user email",
      );
      return null;
    }

    try {
      const user = await this.graphClient.users.byUserId(aadObjectId).get();
      return user?.mail || user?.userPrincipalName || null;
    } catch (error) {
      logger.error(
        { error: errorMessage(error), aadObjectId },
        "[MSTeamsProvider] Failed to fetch user email via Graph API fallback. User.Read.All permission may be missing.",
      );
      return null;
    }
  }

  async getChannelName(_channelId: string): Promise<string | null> {
    // MS Teams channel names are resolved during discoverChannels via TurnContext
    return null;
  }

  getWorkspaceId(): string | null {
    // MS Teams requires a TurnContext to determine the team — no eager discovery
    return null;
  }

  getWorkspaceName(): string | null {
    // MS Teams workspace name is per-team — resolved from TurnContext at message time
    return null;
  }

  async discoverChannels(
    context: unknown,
  ): Promise<DiscoveredChannel[] | null> {
    if (!(context instanceof TurnContext)) return null;

    const teamData = context.activity.channelData?.team as
      | { id?: string; aadGroupId?: string }
      | undefined;
    if (!teamData?.id) return null;

    const [channels, teamDetails] = await Promise.all([
      TeamsInfo.getTeamChannels(context),
      TeamsInfo.getTeamDetails(context).catch(() => null),
    ]);

    if (!channels?.length) return null;

    // Prefer aadGroupId (stable UUID) over thread-format team.id.
    // channelData.team.aadGroupId is often absent, so fall back to
    // the value returned by TeamsInfo.getTeamDetails().
    const workspaceId =
      teamData.aadGroupId || teamDetails?.aadGroupId || teamData.id;

    return channels
      .filter((ch): ch is typeof ch & { id: string } => !!ch.id)
      .map((ch) => ({
        channelId: ch.id,
        channelName: ch.name ?? "General",
        workspaceId,
        workspaceName: teamDetails?.name ?? null,
      }));
  }

  async sendEphemeralMessage(params: {
    channelId: string;
    userId: string;
    text: string;
    threadId?: string;
  }): Promise<void> {
    // Teams doesn't have true ephemeral messages.
    // Send a 1:1 DM to the user via proactive messaging if possible.
    if (!this.adapter) return;
    // For now, log a note — the welcome message is sent as part of the
    // regular reply flow in the Teams route handler via context.sendActivity.
    logger.debug(
      { userId: params.userId },
      "[MSTeamsProvider] Ephemeral message requested (sent via turn context in route handler)",
    );
  }

  async setTypingStatus(
    _channelId: string,
    _threadTs: string,
    metadata?: Record<string, unknown>,
  ): Promise<void> {
    try {
      const turnContext = metadata?.turnContext;
      if (turnContext instanceof TurnContext) {
        const isChannel =
          turnContext.activity.conversation?.conversationType === "channel";

        if (isChannel) {
          // Teams channels don't render typing indicators from bots.
          // Send a placeholder message that will be updated with the real response.
          const response = await turnContext.sendActivity("Thinking...");
          if (response?.id && metadata) {
            metadata.placeholderActivityId = response.id;
          }
        } else {
          // DMs and group chats support native typing indicators
          await turnContext.sendActivity({ type: ActivityTypes.Typing });
        }
        return;
      }

      // Fallback: proactive messaging via continueConversationAsync.
      // Works for DMs/group chats but not for channel typing indicators.
      if (!this.adapter) return;
      const ref = metadata?.conversationReference as
        | ConversationReference
        | undefined;
      if (!ref) return;

      await this.adapter.continueConversationAsync(
        this.config.appId,
        ref,
        async (context) => {
          await context.sendActivity({ type: ActivityTypes.Typing });
        },
      );
    } catch (error) {
      logger.debug(
        { error: errorMessage(error) },
        "[MSTeamsProvider] setTypingStatus failed (non-fatal)",
      );
    }
  }

  async processActivity(
    req: {
      body: unknown;
      headers: Record<string, string | string[] | undefined>;
    },
    res: {
      status: (code: number) => { send: (data?: unknown) => void };
      send: (data?: unknown) => void;
    },
    handler: (context: TurnContext) => Promise<void>,
  ): Promise<void> {
    if (!this.adapter) {
      throw new Error("MSTeamsProvider not initialized");
    }

    // The Bot Framework SDK has a hardcoded `console.error(err)` in CloudAdapter.process()
    // for auth failures. MS Teams sends duplicate webhooks per message — one always fails
    // JWT validation with a different AppId. Suppress these expected 401s to avoid noisy logs.
    const origConsoleError = console.error;
    console.error = (...args: unknown[]) => {
      const err = args[0];
      if (
        err &&
        typeof err === "object" &&
        "statusCode" in err &&
        (err as { statusCode: number }).statusCode === 401
      ) {
        return;
      }
      origConsoleError.apply(console, args);
    };

    try {
      await this.adapter.process(
        {
          body: req.body as Record<string, unknown>,
          headers: req.headers,
          method: "POST",
        },
        {
          socket: null,
          end: () => {},
          header: () => {},
          send: res.send,
          status: res.status,
        },
        async (context) => {
          const approvalPayload = this.parseApprovalDecisionPayload(
            context.activity.value,
          );
          if (approvalPayload) {
            await this.handleApprovalDecisionSubmission(
              context,
              approvalPayload,
            );
            return;
          }

          await handler(context);
        },
      );
    } finally {
      console.error = origConsoleError;
    }
  }

  parseInteractivePayload(_payload: unknown): {
    agentId: string;
    channelId: string;
    workspaceId: string | null;
    threadTs?: string;
    userId: string;
    userName: string;
    responseUrl: string;
  } | null {
    // MS Teams handles interactive selections inline via Adaptive Card submissions
    // in the route handler (TurnContext.activity.value), not through this method.
    return null;
  }

  private parseApprovalDecisionPayload(payload: unknown): {
    approvalId: string;
    approved: boolean;
    toolName?: string;
    taskId: string;
    channelId?: string;
    workspaceId?: string | null;
    threadId?: string;
    originalSenderEmail?: string;
    messageId?: string;
  } | null {
    const p = payload as { action?: string };
    if (p?.action !== "approvalDecision") {
      return null;
    }
    const value = payload as {
      action?: string;
      approvalId?: unknown;
      approved?: unknown;
      toolName?: unknown;
      taskId?: unknown;
      channelId?: unknown;
      workspaceId?: unknown;
      threadId?: unknown;
      originalSenderEmail?: unknown;
      messageId?: unknown;
    };
    return {
      approvalId: value.approvalId as string,
      approved: value.approved as boolean,
      toolName: value.toolName as string | undefined,
      taskId: value.taskId as string,
      channelId: value.channelId as string | undefined,
      workspaceId: value.workspaceId as string | null | undefined,
      threadId: value.threadId as string | undefined,
      originalSenderEmail: value.originalSenderEmail as string | undefined,
      messageId: value.messageId as string | undefined,
    };
  }

  private async handleApprovalDecisionSubmission(
    context: TurnContext,
    payload: {
      approvalId: string;
      approved: boolean;
      toolName?: string;
      taskId: string;
      channelId?: string;
      workspaceId?: string | null;
      threadId?: string;
      messageId?: string;
      originalSenderEmail?: string;
    },
  ): Promise<void> {
    const senderId =
      context.activity.from?.aadObjectId ||
      context.activity.from?.id ||
      "unknown";
    let senderEmail: string | null = null;
    try {
      const member = await TeamsInfo.getMember(
        context,
        context.activity.from?.id || senderId,
      );
      if (member?.email || member?.userPrincipalName) {
        senderEmail = member.email ?? member.userPrincipalName ?? null;
      }
    } catch (error) {
      logger.debug(
        { error: error instanceof Error ? error.message : String(error) },
        "[MSTeamsProvider] TeamsInfo.getMember failed for approval decision, will fall back to Graph API if configured",
      );
    }

    if (!senderEmail) {
      senderEmail = await this.getUserEmail(senderId);
    }
    if (!senderEmail) {
      logger.warn(
        { senderId },
        "[MSTeamsProvider] Could not resolve user email for approval decision",
      );
      return;
    }

    const teamData = context.activity.channelData?.team as
      | { id?: string; aadGroupId?: string }
      | undefined;
    const channelId =
      payload.channelId ||
      context.activity.channelData?.channel?.id ||
      context.activity.conversation?.id ||
      "";
    const workspaceId =
      payload.workspaceId !== undefined
        ? payload.workspaceId
        : teamData?.aadGroupId || teamData?.id || null;
    const threadId =
      payload.threadId || extractThreadId(context.activity) || undefined;
    const messageId =
      payload.messageId ||
      context.activity.replyToId ||
      context.activity.id ||
      `teams-${Date.now()}`;

    const originalMessage: IncomingChatMessage = {
      messageId,
      channelId,
      workspaceId,
      threadId,
      senderId,
      senderEmail: payload.originalSenderEmail,
      senderName: context.activity.from?.name || "Unknown User",
      text: "",
      rawText: "",
      timestamp: context.activity.timestamp
        ? new Date(context.activity.timestamp)
        : new Date(),
      isThreadReply: Boolean(threadId),
      metadata: {
        turnContext: context,
        conversationReference: TurnContext.getConversationReference(
          context.activity as Parameters<
            typeof TurnContext.getConversationReference
          >[0],
        ),
      },
    };

    const decision: ChatOpsApprovalDecision = {
      taskId: payload.taskId,
      approvalId: payload.approvalId,
      approved: payload.approved,
      toolName: payload.toolName || "",
      messageTs: messageId,
      channelId,
      workspaceId,
      originalMessage,
      threadTs: threadId,
      userId: senderId,
      userName: originalMessage.senderName,
      responseUrl: "",
      approverEmail: senderEmail,
    };

    const updateApprovalRequestCallback = async (): Promise<void> => {
      const approvalMessageId =
        context.activity.replyToId || context.activity.id;
      if (!approvalMessageId) {
        return;
      }

      await context.updateActivity({
        id: approvalMessageId,
        type: ActivityTypes.Message,
        text: `${payload.toolName || "Approval"}: ${
          payload.approved ? "Approved" : "Declined"
        }`,
        attachments: [],
      });
    };

    if (!this.eventHandler) {
      logger.warn(
        "[MSTeamsProvider] No event handler registered for approval decision",
      );
      return;
    }

    await this.eventHandler.handleInteractiveApprovalDecision(
      this,
      decision,
      updateApprovalRequestCallback,
    );
  }

  async sendAgentSelectionCard(params: {
    message: IncomingChatMessage;
    agents: { id: string; name: string }[];
    isWelcome: boolean;
    providerContext?: unknown;
  }): Promise<void> {
    const context = params.providerContext;
    if (!(context instanceof TurnContext)) {
      throw new Error(
        "MSTeamsProvider.sendAgentSelectionCard requires a TurnContext",
      );
    }

    const choices = params.agents.map((agent) => ({
      title: agent.name,
      value: agent.id,
    }));

    // Check for existing binding to pre-select
    const existingBinding = await ChatOpsChannelBindingModel.findByChannel({
      provider: "ms-teams",
      channelId: params.message.channelId,
      workspaceId: params.message.workspaceId,
    });

    const cardBody = existingBinding?.agentId
      ? [
          {
            type: "TextBlock",
            size: "Medium",
            weight: "Bolder",
            text: "Change Default Agent",
          },
          {
            type: "TextBlock",
            text: "Select a different agent to handle messages in this channel:",
            wrap: true,
          },
          {
            type: "Input.ChoiceSet",
            id: "agentId",
            style: "compact",
            value: existingBinding.agentId,
            choices,
          },
        ]
      : [
          {
            type: "TextBlock",
            text: "Each Microsoft Teams channel needs a **default agent** assigned to it. This agent will handle all your requests in this channel by default.",
            wrap: true,
            spacing: "Small",
          },
          {
            type: "TextBlock",
            text: "**Tip:** You can use other agents with the syntax **AgentName >** (e.g., @Archestra Sales > what's the status?).",
            wrap: true,
            spacing: "Small",
          },
          {
            type: "TextBlock",
            text: "**Available commands:**",
            wrap: true,
            spacing: "Medium",
          },
          {
            type: "FactSet",
            spacing: "Small",
            facts: [
              {
                title: "/select-agent",
                value:
                  "Change the default agent handling requests in the channel",
              },
              {
                title: "/status",
                value:
                  "Check the current agent handling requests in the channel",
              },
              { title: "/help", value: "Show available commands" },
            ],
          },
          {
            type: "TextBlock",
            text: "**Let's set the default agent for this channel:**",
            wrap: true,
            spacing: "Medium",
          },
          {
            type: "Input.ChoiceSet",
            id: "agentId",
            style: "compact",
            value: choices[0]?.value || "",
            choices,
          },
        ];

    const card = {
      type: "AdaptiveCard",
      $schema: "http://adaptivecards.io/schemas/adaptive-card.json",
      version: "1.4",
      body: cardBody,
      actions: [
        {
          type: "Action.Submit",
          title: "Confirm Selection",
          data: {
            action: "selectAgent",
            channelId: params.message.channelId,
            workspaceId: params.message.workspaceId,
            originalMessageText: params.message.text || undefined,
          },
        },
      ],
    };

    await context.sendActivity({
      attachments: [
        {
          contentType: "application/vnd.microsoft.card.adaptive",
          content: card,
        },
      ],
    });
  }

  hasMissingScopes(): boolean {
    return false;
  }

  async notifyMissingScopes(): Promise<void> {
    // No-op: MS Teams permissions are managed in Azure AD and can't be detected at runtime
  }

  async downloadFiles(
    files: ChatThreadMessageFile[],
  ): Promise<ThreadFileOutcome[]> {
    // No serviceUrl for history messages — Azure Blob URLs are pre-authenticated
    return this.downloadTeamsFiles(
      files.map((f) => ({
        contentType: f.mimetype,
        contentUrl: f.url,
        name: f.name,
        size: f.size,
      })),
    );
  }

  // ===========================================================================
  // Private Methods

  /**
   * Download Teams file attachments and convert to A2AAttachment format.
   *
   * Returns exactly one outcome per input file, positionally aligned with
   * `files` — the delivered attachment or the reason it was dropped — so
   * callers can attribute every skip to the message the file came from.
   *
   * Authentication: Files uploaded directly in Teams chat use pre-authenticated Azure
   * Blob Storage URLs. Files shared from SharePoint/OneDrive may require a Bearer token.
   * When the contentUrl hostname matches the Bot Framework serviceUrl, we authenticate
   * using client credentials (appId/appSecret) to obtain a Bot Connector token.
   */
  private async downloadTeamsFiles(
    files: TeamsFileAttachment[],
    serviceUrl?: string,
  ): Promise<ThreadFileOutcome[]> {
    if (files.length === 0) return [];

    const outcomes: ThreadFileOutcome[] = [];
    const skip = (skipped: SkippedAttachment): void => {
      outcomes.push({ status: "skipped", skipped });
    };
    let totalSize = 0;
    let deliveredCount = 0;
    // Once the combined budget is spent, every remaining file is recorded as
    // over-budget rather than silently dropped (the old code `break`-ed here).
    let budgetExhausted = false;

    // Lazily obtain a Bot Connector token when needed for authenticated downloads
    let botToken: string | null = null;

    for (const [index, attachment] of files.entries()) {
      // Anything past the per-message cap is dropped from processing; record
      // it rather than letting it vanish silently.
      if (index >= CHATOPS_ATTACHMENT_LIMITS.MAX_ATTACHMENTS_PER_MESSAGE) {
        skip({
          name: attachment.name,
          sizeBytes: attachment.size,
          reason: "too_many",
        });
        continue;
      }

      if (budgetExhausted) {
        skip({
          name: attachment.name,
          sizeBytes: attachment.size,
          reason: "total_limit_reached",
        });
        continue;
      }

      // SSRF protection: only allow downloads from known Microsoft domains
      if (!isAllowedTeamsFileHost(attachment.contentUrl, serviceUrl)) {
        logger.warn(
          {
            name: attachment.name,
            host: safeHostname(attachment.contentUrl),
          },
          "[MSTeamsProvider] Skipping attachment from unexpected domain",
        );
        skip({
          name: attachment.name,
          sizeBytes: attachment.size,
          reason: "download_failed",
        });
        continue;
      }

      try {
        // Determine if the URL needs authentication (same host as serviceUrl)
        const headers: Record<string, string> = {};
        if (serviceUrl && needsBotAuth(attachment.contentUrl, serviceUrl)) {
          if (!botToken) {
            botToken = await this.getBotConnectorToken();
          }
          if (botToken) {
            headers.Authorization = `Bearer ${botToken}`;
          }
        }

        const response = await fetch(
          attachment.contentUrl,
          Object.keys(headers).length > 0 ? { headers } : undefined,
        );

        if (!response.ok) {
          logger.warn(
            {
              name: attachment.name,
              contentType: attachment.contentType,
              status: response.status,
            },
            "[MSTeamsProvider] Failed to download attachment",
          );
          skip({
            name: attachment.name,
            sizeBytes: attachment.size,
            reason: "download_failed",
          });
          continue;
        }

        // Pre-check Content-Length to avoid buffering oversized files
        const contentLength = Number.parseInt(
          response.headers.get("content-length") || "0",
          10,
        );
        if (
          contentLength > 0 &&
          contentLength > CHATOPS_ATTACHMENT_LIMITS.MAX_ATTACHMENT_SIZE
        ) {
          logger.info(
            { name: attachment.name, contentLength },
            "[MSTeamsProvider] Skipping oversized attachment (Content-Length)",
          );
          skip({
            name: attachment.name,
            sizeBytes: attachment.size || contentLength,
            reason: "too_large",
          });
          continue;
        }

        const buffer = Buffer.from(await response.arrayBuffer());

        // Skip files that exceed individual size limit
        if (buffer.length > CHATOPS_ATTACHMENT_LIMITS.MAX_ATTACHMENT_SIZE) {
          logger.info(
            {
              name: attachment.name,
              size: buffer.length,
              maxSize: CHATOPS_ATTACHMENT_LIMITS.MAX_ATTACHMENT_SIZE,
            },
            "[MSTeamsProvider] Skipping attachment exceeding size limit",
          );
          skip({
            name: attachment.name,
            sizeBytes: buffer.length,
            reason: "too_large",
          });
          continue;
        }

        // Skip if total size would exceed limit
        if (
          totalSize + buffer.length >
          CHATOPS_ATTACHMENT_LIMITS.MAX_TOTAL_ATTACHMENTS_SIZE
        ) {
          logger.info(
            {
              name: attachment.name,
              totalSize,
              maxTotalSize:
                CHATOPS_ATTACHMENT_LIMITS.MAX_TOTAL_ATTACHMENTS_SIZE,
            },
            "[MSTeamsProvider] Total attachments size limit reached",
          );
          skip({
            name: attachment.name,
            sizeBytes: buffer.length,
            reason: "total_limit_reached",
          });
          budgetExhausted = true;
          continue;
        }

        totalSize += buffer.length;
        deliveredCount++;
        // Resolve content type: prefer HTTP header when specific, fall back to
        // attachment metadata, and detect from magic bytes as last resort when
        // both are generic (e.g. "application/octet-stream" or "image/*").
        const httpContentType =
          response.headers.get("content-type")?.split(";")[0]?.trim() || "";
        const isGenericContentType = (ct: string) =>
          !ct || ct === "application/octet-stream" || ct.includes("*");
        const resolvedContentType = !isGenericContentType(httpContentType)
          ? httpContentType
          : !isGenericContentType(attachment.contentType)
            ? attachment.contentType
            : detectImageType(buffer);
        outcomes.push({
          status: "delivered",
          attachment: {
            contentType: resolvedContentType,
            contentBase64: buffer.toString("base64"),
            name: attachment.name,
          },
        });

        logger.debug(
          {
            name: attachment.name,
            contentType: attachment.contentType,
            size: buffer.length,
          },
          "[MSTeamsProvider] Downloaded Teams attachment",
        );
      } catch (error) {
        logger.warn(
          { name: attachment.name, error: errorMessage(error) },
          "[MSTeamsProvider] Error downloading attachment",
        );
        skip({
          name: attachment.name,
          sizeBytes: attachment.size,
          reason: "download_failed",
        });
      }
    }

    if (deliveredCount > 0) {
      logger.info(
        {
          fileCount: deliveredCount,
          totalSize,
          originalCount: files.length,
        },
        "[MSTeamsProvider] Downloaded attachments from Teams message",
      );
    }

    return outcomes;
  }

  /**
   * Obtain a Bot Connector token via OAuth2 client credentials grant.
   * Used to authenticate downloads from the Bot Framework service URL.
   * Cached for 50 minutes (tokens expire after 60 minutes).
   */
  private botConnectorTokenCache: { token: string; expiresAt: number } | null =
    null;

  private async getBotConnectorToken(): Promise<string | null> {
    // Return cached token if still valid (with 10-minute safety margin)
    if (
      this.botConnectorTokenCache &&
      Date.now() < this.botConnectorTokenCache.expiresAt
    ) {
      return this.botConnectorTokenCache.token;
    }

    const { appId, appSecret, tenantId } = this.config;
    if (!appId || !appSecret) return null;

    const tokenTenant = tenantId || "botframework.com";
    const tokenUrl = `https://login.microsoftonline.com/${tokenTenant}/oauth2/v2.0/token`;

    try {
      const response = await fetch(tokenUrl, {
        method: "POST",
        headers: { "Content-Type": "application/x-www-form-urlencoded" },
        body: new URLSearchParams({
          grant_type: "client_credentials",
          client_id: appId,
          client_secret: appSecret,
          scope: "https://api.botframework.com/.default",
        }),
      });

      if (!response.ok) {
        logger.warn(
          { status: response.status },
          "[MSTeamsProvider] Failed to obtain Bot Connector token for attachment download",
        );
        return null;
      }

      const data = (await response.json()) as {
        access_token?: string;
        expires_in?: number;
      };
      const token = data.access_token ?? null;

      if (token) {
        // Cache with 10-minute safety margin before expiry (default 60 min)
        const expiresInMs = ((data.expires_in ?? 3600) - 600) * 1000;
        this.botConnectorTokenCache = {
          token,
          expiresAt: Date.now() + expiresInMs,
        };
      }

      return token;
    } catch (error) {
      logger.warn(
        { error: errorMessage(error) },
        "[MSTeamsProvider] Error obtaining Bot Connector token",
      );
      return null;
    }
  }

  // ===========================================================================

  private async fetchGroupChatHistory(
    params: ThreadHistoryParams,
    limit: number,
  ): Promise<ChatMessage[]> {
    const client = this.graphClient;
    if (!client) return [];

    const chatMessages = client.chats.byChatId(params.channelId).messages;

    // For thread replies, fetch parent message and attempt to get replies
    if (params.threadId && !params.threadId.includes("@thread")) {
      const parentMessage = await chatMessages
        .byChatMessageId(params.threadId)
        .get();

      try {
        const repliesResponse = await chatMessages
          .byChatMessageId(params.threadId)
          .replies.get({ queryParameters: { top: limit - 1 } });
        return [parentMessage, ...(repliesResponse?.value || [])].filter(
          (msg): msg is ChatMessage => msg !== undefined,
        );
      } catch (error) {
        // /replies endpoint not supported for group chats - use parent message only
        logger.warn(
          { error: errorMessage(error), threadId: params.threadId },
          "[MSTeamsProvider] Thread replies unavailable for group chat (API limitation)",
        );
        return parentMessage ? [parentMessage] : [];
      }
    }

    // No thread - fetch recent messages
    const response = await chatMessages.get({
      queryParameters: { top: limit },
    });
    return response?.value || [];
  }

  private async fetchTeamChannelHistory(
    params: ThreadHistoryParams,
    limit: number,
  ): Promise<ChatMessage[]> {
    const client = this.graphClient;
    if (!client || !params.workspaceId) return [];

    const channelMessages = client.teams
      .byTeamId(params.workspaceId)
      .channels.byChannelId(params.channelId).messages;

    const isThreadReply =
      params.threadId &&
      params.threadId !== params.channelId &&
      !params.threadId.includes("@thread");

    if (isThreadReply) {
      const messageBuilder = channelMessages.byChatMessageId(params.threadId);
      try {
        const [parentResponse, repliesResponse] = await Promise.all([
          messageBuilder.get(),
          messageBuilder.replies.get({ queryParameters: { top: limit - 1 } }),
        ]);
        return [parentResponse, ...(repliesResponse?.value || [])].filter(
          (msg): msg is ChatMessage => msg !== undefined,
        );
      } catch (error) {
        logger.warn(
          { error: errorMessage(error), threadId: params.threadId },
          "[MSTeamsProvider] Failed to fetch thread, falling back to replies only",
        );
        const response = await messageBuilder.replies.get({
          queryParameters: { top: limit },
        });
        return response?.value || [];
      }
    }

    const response = await channelMessages.get({
      queryParameters: { top: limit },
    });
    return response?.value || [];
  }

  private convertToThreadMessages(
    messages: ChatMessage[],
    excludeMessageId?: string,
  ): ChatThreadMessage[] {
    const botAppId = this.config.appId;

    return (
      messages
        .filter((msg) => msg.id && msg.id !== excludeMessageId)
        .map((msg) => {
          const isUserMessage = Boolean(msg.from?.user);

          // Extract file attachment metadata from Graph API ChatMessage.attachments
          const files: ChatThreadMessageFile[] = (msg.attachments ?? [])
            .filter(
              (a) =>
                a.contentUrl &&
                a.contentType &&
                !a.contentType.startsWith("application/vnd.microsoft.card."),
            )
            .map((a) => ({
              url: a.contentUrl as string,
              mimetype: a.contentType as string,
              name: a.name ?? undefined,
            }));

          return {
            messageId: msg.id as string,
            senderId: isUserMessage
              ? msg.from?.user?.id || "unknown"
              : msg.from?.application?.id || "unknown",
            senderName: isUserMessage
              ? msg.from?.user?.displayName || "Unknown"
              : msg.from?.application?.displayName || "App",
            text: extractMessageText(
              msg.body?.content ?? undefined,
              msg.attachments ?? undefined,
            ),
            timestamp: msg.createdDateTime
              ? new Date(msg.createdDateTime)
              : new Date(),
            isFromBot:
              msg.from?.user?.id === botAppId ||
              msg.from?.application?.id === botAppId,
            ...(files.length > 0 && { files }),
          };
        })
        // Keep text-less USER messages that carry files: a screenshot posted
        // alone is a turn the model must know about — its file is either
        // delivered or surfaced as skipped by the manager. Bot file-only
        // messages stay filtered: the manager never downloads bot files, so
        // retaining them would render a turn with no file and no skip note.
        .filter(
          (msg) =>
            msg.text.trim().length > 0 ||
            (!msg.isFromBot && (msg.files?.length ?? 0) > 0),
        )
        .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime())
    );
  }
}

export default MSTeamsProvider;

// =============================================================================
// Internal Helpers
// =============================================================================

/** A Teams attachment that points at a downloadable file (not a card). */
interface TeamsFileAttachment {
  contentType: string;
  contentUrl: string;
  name?: string;
  size?: number;
}

/**
 * Keep only file/image attachments (skip Adaptive Cards, hero cards, and
 * entries without a download URL).
 */
function filterTeamsFileAttachments(
  attachments?: Array<{
    contentType?: string;
    contentUrl?: string;
    content?: string;
    name?: string;
  }>,
): TeamsFileAttachment[] {
  return (attachments ?? []).flatMap((a) =>
    a.contentUrl &&
    a.contentType &&
    !a.contentType.startsWith("application/vnd.microsoft.card.")
      ? [{ contentType: a.contentType, contentUrl: a.contentUrl, name: a.name }]
      : [],
  );
}

function cleanBotMention(text: string, botName?: string): string {
  let cleaned = text.replace(/<at>.*?<\/at>/gi, "").trim();
  if (botName) {
    const escapedName = escapeRegExp(botName);
    cleaned = cleaned
      .replace(new RegExp(`@${escapedName}\\s*`, "gi"), "")
      .trim();
  }
  return cleaned;
}

function escapeRegExp(string: string): string {
  return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

/**
 * Check if a content URL likely needs Bot Framework authentication.
 * URLs hosted on the same domain as the Bot Framework serviceUrl
 * (e.g., smba.trafficmanager.net) require a Bot Connector token.
 * Azure Blob Storage URLs (*.blob.core.windows.net) are pre-authenticated.
 */
function needsBotAuth(contentUrl: string, serviceUrl: string): boolean {
  try {
    const contentHost = new URL(contentUrl).hostname;
    const serviceHost = new URL(serviceUrl).hostname;
    return contentHost === serviceHost;
  } catch {
    return false;
  }
}

/**
 * Extract thread message ID from Teams activity.
 * Teams format: "channelId;messageid=messageId" for thread replies.
 *
 * Prefers replyToId, which on a normal message points at the thread root. For
 * REACTION activities replyToId instead points at the reacted message, so the
 * reaction path uses extractThreadIdFromConversationId directly instead.
 */
function extractThreadId(activity: {
  conversation?: { id?: string };
  replyToId?: string;
}): string | undefined {
  if (activity.replyToId) {
    return activity.replyToId;
  }
  return extractThreadIdFromConversationId(activity.conversation?.id);
}

/** The `;messageid=<root>` thread id encoded in a Teams conversation id, if any. */
function extractThreadIdFromConversationId(
  conversationId?: string,
): string | undefined {
  return conversationId?.match(/;messageid=(\d+)/)?.[1];
}

/** A Teams conversation id with any `;messageid=...` thread suffix removed. */
function stripThreadSuffix(conversationId?: string): string | undefined {
  return conversationId?.split(";messageid=")[0] || undefined;
}

/**
 * Extract text from message body and/or Adaptive Card attachments.
 */
function extractMessageText(
  bodyContent?: string,
  attachments?: ChatMessageAttachment[],
): string {
  const parts: string[] = [];

  if (bodyContent) {
    const cleanedBody = stripHtmlTags(bodyContent).trim();
    if (cleanedBody) parts.push(cleanedBody);
  }

  if (attachments?.length) {
    for (const attachment of attachments) {
      if (
        attachment.contentType === "application/vnd.microsoft.card.adaptive" &&
        attachment.content
      ) {
        try {
          const card =
            typeof attachment.content === "string"
              ? JSON.parse(attachment.content)
              : attachment.content;
          const cardText = extractAdaptiveCardText(card);
          if (cardText) parts.push(cardText);
        } catch {
          if (typeof attachment.content === "string") {
            parts.push(attachment.content);
          }
        }
      }
    }
  }

  return parts.join("\n\n");
}

function extractAdaptiveCardText(element: unknown): string {
  if (!element || typeof element !== "object") return "";

  const parts: string[] = [];
  const el = element as Record<string, unknown>;

  if (el.type === "TextBlock" && typeof el.text === "string") {
    parts.push(el.text);
  }

  if (el.type === "FactSet" && Array.isArray(el.facts)) {
    for (const fact of el.facts as { title?: string; value?: string }[]) {
      if (fact.title && fact.value) {
        parts.push(`${fact.title}: ${fact.value}`);
      }
    }
  }

  for (const key of ["body", "items", "columns"] as const) {
    if (Array.isArray(el[key])) {
      for (const item of el[key] as unknown[]) {
        const text = extractAdaptiveCardText(item);
        if (text) parts.push(text);
      }
    }
  }

  return parts.join("\n");
}

function normalizeTeamsId(id: string): string {
  return id.replace(/^28:/, "").toLowerCase();
}

/** Display names of @mentioned participants other than the bot itself. */
function extractMentionedOthers(activity: {
  recipient?: { id?: string };
  entities?: Array<{
    type?: string;
    mentioned?: { id?: string; name?: string };
  }>;
}): string[] {
  const botId = activity.recipient?.id;
  const names = (activity.entities ?? [])
    .filter(
      (e) =>
        e?.type === "mention" &&
        e.mentioned?.id != null &&
        (botId == null ||
          normalizeTeamsId(e.mentioned.id) !== normalizeTeamsId(botId)),
    )
    .map((e) => e.mentioned?.name)
    .filter((name): name is string => Boolean(name));
  return [...new Set(names)];
}

/**
 * SSRF protection: only allow Teams file downloads from known Microsoft domains.
 * Accepts Azure Blob Storage, SharePoint, and the Bot Framework service URL host.
 */
function isAllowedTeamsFileHost(
  contentUrl: string,
  serviceUrl?: string,
): boolean {
  try {
    const hostname = new URL(contentUrl).hostname;
    if (
      hostname.endsWith(".blob.core.windows.net") ||
      hostname.endsWith(".sharepoint.com")
    ) {
      return true;
    }
    // Also allow the Bot Framework serviceUrl host (e.g., smba.trafficmanager.net)
    if (serviceUrl) {
      const serviceHost = new URL(serviceUrl).hostname;
      if (hostname === serviceHost) return true;
    }
    return false;
  } catch {
    return false;
  }
}

function safeHostname(url: string): string {
  try {
    return new URL(url).hostname;
  } catch {
    return "invalid-url";
  }
}
