// Discord plugin module implements message handler.context behavior.
import {
  buildChannelInboundEventContext,
  formatInboundEnvelope,
  formatInboundMediaUnavailableText,
  resolveEnvelopeFormatOptions,
  toHistoryMediaEntries,
  toInboundMediaFactsWithMetadata,
} from "openclaw/plugin-sdk/channel-inbound";
import { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/context-visibility-runtime";
import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/conversation-runtime";
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
import {
  buildHistoryContextFromEntries,
  buildInboundHistoryFromEntries,
  createChannelHistoryWindow,
} from "openclaw/plugin-sdk/reply-history";
import { buildAgentSessionKey, resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
import { evaluateSupplementalContextVisibility } from "openclaw/plugin-sdk/security-runtime";
import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import { resolveDiscordConversationIdentity } from "../conversation-identity.js";
import { ChannelType } from "../internal/discord.js";
import { normalizeDiscordAllowList, normalizeDiscordSlug } from "./allow-list.js";
import { resolveTimestampMs } from "./format.js";
import {
  buildDiscordInboundAccessContext,
  createDiscordSupplementalContextAccessChecker,
} from "./inbound-context.js";
import { resolveDiscordMessageStickers } from "./message-forwarded.js";
import {
  createDiscordHistorySenderProvenance,
  filterDiscordHistoryEntriesForContext,
  type DiscordHistoryEntry,
} from "./message-handler.history.js";
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js";
import { removeDiscordReplayHistoryEntry } from "./message-handler.retry.js";
import {
  formatDiscordMediaText,
  resolveReferencedReplyMediaList,
  resolveDiscordMessageText,
  type DiscordMediaInfo,
} from "./message-utils.js";
import { buildDirectLabel, buildGuildLabel, resolveReplyContext } from "./reply-context.js";
import { resolveDiscordAutoThreadReplyPlan, resolveDiscordThreadStarter } from "./threading.js";
import {
  DISCORD_ATTACHMENT_IDLE_TIMEOUT_MS,
  DISCORD_ATTACHMENT_TOTAL_TIMEOUT_MS,
} from "./timeouts.js";

function normalizeDiscordDmOwnerEntry(entry: string): string | undefined {
  const normalized = normalizeDiscordAllowList([entry], ["discord:", "user:", "pk:"]);
  const candidate = normalized?.ids.values().next().value;
  return typeof candidate === "string" && /^\d+$/.test(candidate) ? candidate : undefined;
}

function isContextAborted(abortSignal?: AbortSignal): boolean {
  return Boolean(abortSignal?.aborted);
}

export async function buildDiscordMessageProcessContext(params: {
  ctx: DiscordMessagePreflightContext;
  text: string;
  mediaList: DiscordMediaInfo[];
}) {
  const { ctx, text, mediaList } = params;
  const {
    cfg,
    discordConfig,
    accountId,
    runtime,
    botUserId,
    mediaMaxBytes,
    discordRestFetch,
    abortSignal,
    guildHistories,
    historyLimit,
    replyToMode,
    message,
    author,
    sender,
    canonicalMessageId,
    data,
    client,
    channelInfo,
    channelName,
    messageChannelId,
    isGuildMessage,
    isDirectMessage,
    baseText,
    preflightAudioTranscript,
    threadChannel,
    threadParentId,
    threadParentName,
    threadParentType,
    threadName,
    displayChannelSlug,
    guildInfo,
    guildSlug,
    memberRoleIds,
    channelConfig,
    baseSessionKey,
    boundSessionKey,
    route,
    commandAuthorized,
  } = ctx;

  const fromLabel = isDirectMessage
    ? buildDirectLabel(author)
    : buildGuildLabel({
        guild: data.guild ?? undefined,
        channelName: channelName ?? messageChannelId,
        channelId: messageChannelId,
      });
  const senderLabel = sender.label;
  const isForumParent =
    threadParentType === ChannelType.GuildForum || threadParentType === ChannelType.GuildMedia;
  const forumParentSlug =
    isForumParent && threadParentName ? normalizeDiscordSlug(threadParentName) : "";
  const threadChannelId = threadChannel?.id;
  const threadParentInheritanceEnabled = discordConfig?.thread?.inheritParent ?? false;
  const isForumStarter =
    Boolean(threadChannelId && isForumParent && forumParentSlug) && message.id === threadChannelId;
  const forumContextLine = isForumStarter ? `[Forum parent: #${forumParentSlug}]` : null;
  const groupChannel = isGuildMessage && displayChannelSlug ? `#${displayChannelSlug}` : undefined;
  const senderName = sender.isPluralKit
    ? (sender.name ?? author.username)
    : (data.member?.nickname ?? author.globalName ?? author.username);
  const senderUsername = sender.isPluralKit
    ? (sender.tag ?? sender.name ?? author.username)
    : author.username;
  const { groupSystemPrompt, ownerAllowFrom, channelStructuredContext } =
    buildDiscordInboundAccessContext({
      channelConfig,
      guildInfo,
      sender: { id: sender.id, name: sender.name, tag: sender.tag },
      allowNameMatching: isDangerousNameMatchingEnabled(discordConfig),
      isGuild: isGuildMessage,
      channelTopic: channelInfo?.topic,
    });
  const pinnedMainDmOwner = isDirectMessage
    ? resolvePinnedMainDmOwnerFromAllowlist({
        dmScope: cfg.session?.dmScope,
        allowFrom: channelConfig?.users ?? guildInfo?.users,
        normalizeEntry: normalizeDiscordDmOwnerEntry,
      })
    : null;
  const contextVisibilityMode = resolveChannelContextVisibilityMode({
    cfg,
    channel: "discord",
    accountId,
  });
  const allowNameMatching = isDangerousNameMatchingEnabled(discordConfig);
  const isSupplementalContextSenderAllowed = createDiscordSupplementalContextAccessChecker({
    channelConfig,
    guildInfo,
    allowNameMatching,
    isGuild: isGuildMessage,
  });
  const storePath = resolveStorePath(cfg.session?.store, {
    agentId: route.agentId,
  });
  const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
  const previousTimestamp = readSessionUpdatedAt({
    storePath,
    sessionKey: route.sessionKey,
  });
  const channelHistory = createChannelHistoryWindow({ historyMap: guildHistories });
  let visibleChannelHistory: DiscordHistoryEntry[] | undefined;
  // Failed downloads (CDN error, SSRF block, size cap, timeout) produce
  // path-less facts that core drops from the media projection. Record the
  // outcome in the body like sibling channels so the turn never silently
  // ignores an attachment the user sent.
  const unavailableMediaCount = mediaList.filter((media) => !media.path).length;
  const appendMediaUnavailableNotice = (body: string | undefined) =>
    unavailableMediaCount > 0
      ? formatInboundMediaUnavailableText({
          body,
          notice: `[discord ${unavailableMediaCount > 1 ? `${unavailableMediaCount} attachments` : "attachment"} unavailable]`,
        })
      : body;
  const bodyWithMediaNotice = appendMediaUnavailableNotice(text) ?? text;
  let combinedBody = formatInboundEnvelope({
    channel: "Discord",
    from: fromLabel,
    timestamp: resolveTimestampMs(message.timestamp),
    body: bodyWithMediaNotice,
    chatType: isDirectMessage ? "direct" : "channel",
    senderLabel,
    previousTimestamp,
    envelope: envelopeOptions,
  });
  const shouldIncludeChannelHistory =
    !isDirectMessage &&
    (ctx.inboundEventKind === "room_event" ||
      !(isGuildMessage && channelConfig?.autoThread && !threadChannel));
  if (shouldIncludeChannelHistory) {
    removeDiscordReplayHistoryEntry(guildHistories, messageChannelId, message.id);
    if (historyLimit > 0) {
      const filteredHistory = filterDiscordHistoryEntriesForContext({
        entries: guildHistories.get(messageChannelId) ?? [],
        mode: contextVisibilityMode,
        isSenderAllowed: isSupplementalContextSenderAllowed,
      });
      visibleChannelHistory = filteredHistory.entries;
      if (filteredHistory.omitted > 0) {
        logVerbose(
          `discord: omit ${filteredHistory.omitted} pending history entries (mode=${contextVisibilityMode})`,
        );
      }
      combinedBody = buildHistoryContextFromEntries({
        entries: visibleChannelHistory,
        currentMessage: combinedBody,
        formatEntry: (entry) =>
          formatInboundEnvelope({
            channel: "Discord",
            from: fromLabel,
            timestamp: entry.timestamp,
            body: `${entry.body} [id:${entry.messageId ?? "unknown"} channel:${messageChannelId}]`,
            chatType: "channel",
            senderLabel: entry.sender,
            envelope: envelopeOptions,
          }),
        excludeLast: false,
      });
    }
  }
  const replyContext = resolveReplyContext(message, resolveDiscordMessageText);
  const replySenderAllowed = replyContext
    ? isSupplementalContextSenderAllowed({
        id: replyContext.senderId,
        name: replyContext.senderName,
        tag: replyContext.senderTag,
        memberRoleIds: replyContext.memberRoleIds,
      })
    : true;
  const replyVisible = evaluateSupplementalContextVisibility({
    mode: contextVisibilityMode,
    kind: "quote",
    senderAllowed: replySenderAllowed,
  }).include;
  if (replyContext && !replyVisible && isGuildMessage) {
    logVerbose(`discord: drop reply context (mode=${contextVisibilityMode})`);
  }
  if (forumContextLine) {
    combinedBody = `${combinedBody}\n${forumContextLine}`;
  }

  let threadStarterBody: string | undefined;
  let threadLabel: string | undefined;
  let parentSessionKey: string | undefined;
  let modelParentSessionKey: string | undefined;
  if (threadChannel) {
    const includeThreadStarter = channelConfig?.includeThreadStarter !== false;
    if (includeThreadStarter) {
      const starter = await resolveDiscordThreadStarter({
        channel: threadChannel,
        client,
        parentId: threadParentId,
        parentType: threadParentType,
        resolveTimestampMs,
      });
      if (starter?.text) {
        const starterVisibility = evaluateSupplementalContextVisibility({
          mode: contextVisibilityMode,
          kind: "thread",
          senderAllowed: isSupplementalContextSenderAllowed({
            id: starter.authorId,
            name: starter.authorName ?? starter.author,
            tag: starter.authorTag,
            memberRoleIds: starter.memberRoleIds,
          }),
        });
        if (starterVisibility.include) {
          threadStarterBody = starter.text;
        } else {
          logVerbose(`discord: drop thread starter context (mode=${contextVisibilityMode})`);
        }
      }
    }
    const parentName = threadParentName ?? "parent";
    threadLabel = threadName
      ? `Discord thread #${normalizeDiscordSlug(parentName)} › ${threadName}`
      : `Discord thread #${normalizeDiscordSlug(parentName)}`;
    if (threadParentId) {
      parentSessionKey = buildAgentSessionKey({
        agentId: route.agentId,
        channel: route.channel,
        peer: { kind: "channel", id: threadParentId },
      });
      modelParentSessionKey = parentSessionKey;
    }
    if (!threadParentInheritanceEnabled) {
      parentSessionKey = undefined;
    }
  }
  const preflightAudioIndex =
    preflightAudioTranscript === undefined
      ? -1
      : mediaList.findIndex((media) => media.contentType?.startsWith("audio/"));
  const threadKeys = resolveThreadSessionKeys({
    baseSessionKey,
    threadId: threadChannel ? messageChannelId : undefined,
    parentSessionKey,
    useSuffix: false,
  });
  const replyPlan = await resolveDiscordAutoThreadReplyPlan({
    client,
    message,
    messageChannelId,
    isGuildMessage,
    channelConfig: ctx.inboundEventKind === "room_event" ? null : channelConfig,
    threadChannel,
    channelType: channelInfo?.type,
    channelName: channelInfo?.name,
    channelDescription: channelInfo?.topic,
    baseText: baseText ?? "",
    combinedBody,
    replyToMode,
    agentId: route.agentId,
    channel: route.channel,
    cfg,
    threadParentInheritanceEnabled,
  });
  const deliverTarget = replyPlan.deliverTarget;
  const replyTarget = replyPlan.replyTarget;
  const replyReference = replyPlan.replyReference;
  const autoThreadContext = replyPlan.autoThreadContext;

  const effectiveFrom = isDirectMessage
    ? `discord:${author.id}`
    : (autoThreadContext?.From ?? `discord:channel:${messageChannelId}`);
  const dmConversationTarget = isDirectMessage
    ? resolveDiscordConversationIdentity({
        isDirectMessage,
        userId: author.id,
      })
    : undefined;
  const effectiveTo = autoThreadContext?.To ?? dmConversationTarget ?? replyTarget;
  if (!effectiveTo) {
    runtime.error(danger("discord: missing reply target"));
    return null;
  }
  const lastRouteTo = dmConversationTarget ?? effectiveTo;
  const inboundHistory = shouldIncludeChannelHistory
    ? buildInboundHistoryFromEntries({
        entries: visibleChannelHistory ?? [],
        limit: historyLimit,
      })
    : undefined;
  const originatingTo = autoThreadContext?.OriginatingTo ?? dmConversationTarget ?? replyTarget;
  const effectiveSessionKey =
    boundSessionKey ?? autoThreadContext?.SessionKey ?? threadKeys.sessionKey;
  const effectivePreviousTimestamp =
    effectiveSessionKey === route.sessionKey
      ? previousTimestamp
      : readSessionUpdatedAt({
          storePath,
          sessionKey: effectiveSessionKey,
        });

  const ctxPayload = await buildChannelInboundEventContext({
    channel: "discord",
    resolveSupplementalMedia: true,
    contextVisibility: contextVisibilityMode,
    accountId: route.accountId,
    messageId: canonicalMessageId ?? message.id,
    messageIdFull: canonicalMessageId && canonicalMessageId !== message.id ? message.id : undefined,
    timestamp: resolveTimestampMs(message.timestamp),
    from: effectiveFrom,
    sender: {
      id: sender.id,
      name: senderName,
      username: senderUsername,
      tag: sender.tag,
      roles: memberRoleIds,
      displayLabel: senderLabel,
      // PluralKit replaces bot authors with human identity; mark only genuine non-PluralKit bots.
      isBot: author.bot && !sender.isPluralKit ? true : undefined,
    },
    conversation: {
      kind: isDirectMessage ? "direct" : "channel",
      id: messageChannelId,
      nativeChannelId: messageChannelId,
      label: fromLabel,
      spaceId: isGuildMessage ? (guildInfo?.id ?? guildSlug) || undefined : undefined,
      parentId: threadChannel ? threadParentId : undefined,
      threadId: threadChannel?.id ?? autoThreadContext?.createdThreadId ?? undefined,
    },
    route: {
      agentId: route.agentId,
      dmScope: route.dmScope,
      accountId: route.accountId,
      routeSessionKey: route.sessionKey,
      dispatchSessionKey: effectiveSessionKey,
      parentSessionKey: autoThreadContext?.ParentSessionKey ?? threadKeys.parentSessionKey,
      modelParentSessionKey:
        autoThreadContext?.ModelParentSessionKey ?? modelParentSessionKey ?? undefined,
    },
    reply: {
      to: effectiveTo,
      ...(originatingTo !== effectiveTo ? { originatingTo } : {}),
    },
    message: {
      inboundEventKind: ctx.inboundEventKind,
      body: combinedBody,
      rawBody: preflightAudioTranscript ?? baseText,
      // BodyForAgent wins over Body for the model's text, so the notice has to
      // ride the agent-facing source too — keeping transcript precedence.
      bodyForAgent: appendMediaUnavailableNotice(preflightAudioTranscript ?? baseText ?? text),
      commandBody: preflightAudioTranscript ?? baseText,
      inboundHistory,
    },
    sessionTranscript: {
      historyLimit: shouldIncludeChannelHistory ? historyLimit : 0,
    },
    access: {
      mentions: {
        canDetectMention: ctx.canDetectMention,
        wasMentioned: ctx.effectiveWasMentioned,
        hasAnyMention: ctx.hasAnyMention,
        requireMention: ctx.shouldRequireMention,
        effectiveWasMentioned: ctx.effectiveWasMentioned,
      },
      commands: {
        authorized: commandAuthorized,
      },
    },
    commandTurn: {
      kind: "text-slash" as const,
      source: "text" as const,
      authorized: commandAuthorized,
      body: preflightAudioTranscript ?? baseText,
    },
    media: await toInboundMediaFactsWithMetadata(mediaList, {
      transcribed: (_media, index) => index === preflightAudioIndex,
    }),
    supplemental: {
      quote:
        replyContext && replyVisible
          ? {
              id: replyContext.id,
              body: replyContext.body,
              sender: replyContext.sender,
              senderAllowed: replySenderAllowed,
              isSelf: Boolean(botUserId && replyContext.senderId === botUserId),
              media: async () => {
                const referencedReplyMediaList = await resolveReferencedReplyMediaList(
                  message,
                  mediaMaxBytes,
                  {
                    fetchImpl: discordRestFetch,
                    ssrfPolicy: cfg.browser?.ssrfPolicy,
                    readIdleTimeoutMs: DISCORD_ATTACHMENT_IDLE_TIMEOUT_MS,
                    totalTimeoutMs: DISCORD_ATTACHMENT_TOTAL_TIMEOUT_MS,
                    abortSignal,
                  },
                );
                return isContextAborted(abortSignal)
                  ? []
                  : await toInboundMediaFactsWithMetadata(referencedReplyMediaList);
              },
            }
          : undefined,
      thread: {
        starterBody: !effectivePreviousTimestamp ? threadStarterBody : undefined,
        label: threadLabel,
        senderAllowed: true,
      },
      groupSystemPrompt: isGuildMessage ? groupSystemPrompt : undefined,
    },
    extra: {
      ...(preflightAudioTranscript !== undefined ? { Transcript: preflightAudioTranscript } : {}),
      GroupSubject: isDirectMessage ? undefined : groupChannel,
      GroupChannel: groupChannel,
      ...(isGuildMessage ? { GroupRequireMention: ctx.groupRequireMention } : {}),
      ChannelStructuredContext: channelStructuredContext,
      OwnerAllowFrom: ownerAllowFrom,
    },
  });
  const persistedSessionKey = ctxPayload.SessionKey ?? route.sessionKey;
  if (ctx.inboundEventKind === "room_event" && shouldIncludeChannelHistory) {
    const nativeMediaText = formatDiscordMediaText({
      attachments: message.attachments ?? undefined,
      stickers: resolveDiscordMessageStickers(message),
    });
    const historyText = [text, nativeMediaText].filter(Boolean).join("\n");
    await channelHistory.recordWithMedia({
      historyKey: messageChannelId,
      limit: historyLimit,
      entry: {
        sender: senderName,
        body: historyText,
        timestamp: resolveTimestampMs(message.timestamp),
        messageId: message.id,
        senderProvenance: createDiscordHistorySenderProvenance({
          sender,
          memberRoleIds,
        }),
      },
      media: toHistoryMediaEntries(mediaList, { messageId: message.id }),
      messageId: message.id,
    });
  }

  if (shouldLogVerbose()) {
    const preview = truncateUtf16Safe(combinedBody, 200).replace(/\n/g, "\\n");
    logVerbose(
      `discord inbound: channel=${messageChannelId} deliver=${deliverTarget} from=${ctxPayload.From} preview="${preview}"`,
    );
  }

  return {
    ctxPayload,
    persistedSessionKey,
    turn: {
      storePath,
      record: {
        updateLastRoute: {
          sessionKey: persistedSessionKey,
          channel: "discord",
          to: lastRouteTo,
          accountId: route.accountId,
          mainDmOwnerPin:
            isDirectMessage && persistedSessionKey === route.mainSessionKey && pinnedMainDmOwner
              ? {
                  ownerRecipient: pinnedMainDmOwner,
                  senderRecipient: author.id,
                  onSkip: ({
                    ownerRecipient,
                    senderRecipient,
                  }: {
                    ownerRecipient: string;
                    senderRecipient: string;
                  }) => {
                    logVerbose(
                      `discord: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
                    );
                  },
                }
              : undefined,
        },
        onRecordError: (err: unknown) => {
          logVerbose(`discord: failed updating session meta: ${String(err)}`);
        },
      },
    },
    replyPlan,
    deliverTarget,
    replyTarget,
    replyReference,
  };
}
