// Discord plugin module implements send.components behavior.
import { ChannelType } from "discord-api-types/v10";
import { recordChannelActivity } from "openclaw/plugin-sdk/channel-activity-runtime";
import type { MarkdownTableMode, OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { OutboundMediaAccess } from "openclaw/plugin-sdk/media-runtime";
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
import type { ChunkMode } from "openclaw/plugin-sdk/reply-chunking";
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveDiscordAccount } from "./accounts.js";
import { registerDiscordComponentEntries } from "./components-registry.js";
import {
  buildDiscordComponentMessage,
  buildDiscordComponentMessageFlags,
  resolveDiscordComponentAttachmentName,
  type DiscordComponentBuildResult,
  type DiscordComponentMessageSpec,
} from "./components.js";
import {
  createChannelMessage,
  editChannelMessage,
  serializePayload,
  type MessagePayloadFile,
  type MessagePayloadObject,
  type RequestClient,
} from "./internal/discord.js";
import { parseAndResolveChannelRecipient } from "./recipient-resolution.js";
import type { DiscordReplyReference } from "./reply-reference.js";
import { loadOutboundMediaFromUrl } from "./runtime-api.js";
import { sendMessageDiscord } from "./send.outbound.js";
import { createDiscordSendResult } from "./send.receipt.js";
import {
  buildDiscordSendError,
  createDiscordClient,
  createDiscordMessageNonce,
  resolveChannelId,
  resolveDiscordChannel,
  stripUndefinedFields,
  SUPPRESS_NOTIFICATIONS_FLAG,
  type DiscordAllowedMentions,
} from "./send.shared.js";
import type { DiscordSendResult } from "./send.types.js";

const DISCORD_FORUM_LIKE_TYPES = new Set<number>([ChannelType.GuildForum, ChannelType.GuildMedia]);

function extractComponentAttachmentNames(spec: DiscordComponentMessageSpec): string[] {
  const names: string[] = [];
  for (const block of spec.blocks ?? []) {
    if (block.type === "file") {
      names.push(resolveDiscordComponentAttachmentName(block.file));
    }
  }
  return names;
}

function hasComponentAttachmentBlock(spec: DiscordComponentMessageSpec): boolean {
  return (spec.blocks ?? []).some((block) => block.type === "file");
}

function withImplicitComponentAttachmentBlock(
  spec: DiscordComponentMessageSpec,
  attachmentName: string | undefined,
): DiscordComponentMessageSpec {
  if (!attachmentName || hasComponentAttachmentBlock(spec)) {
    return spec;
  }
  // Discord File components must point at the uploaded attachment name. Add the
  // matching file block automatically so callers do not have to duplicate it.
  return {
    ...spec,
    blocks: [
      ...(spec.blocks ?? []),
      {
        type: "file",
        file: `attachment://${attachmentName}`,
      },
    ],
  };
}

function hasClassicOnlyBlocks(spec: DiscordComponentMessageSpec): boolean {
  return (spec.blocks ?? []).every((block) => block.type === "text" || block.type === "file");
}

function hasUnsupportedClassicFeatures(spec: DiscordComponentMessageSpec): boolean {
  return Boolean(spec.modal || spec.container);
}

function hasAtMostOneNonSpoilerFile(spec: DiscordComponentMessageSpec): boolean {
  let fileBlockCount = 0;
  for (const block of spec.blocks ?? []) {
    if (block.type !== "file") {
      continue;
    }
    fileBlockCount += 1;
    if (block.spoiler) {
      return false;
    }
  }
  return fileBlockCount <= 1;
}

type ClassicDiscordMessageDecision =
  | {
      mode: "classic";
      reason: "plain-text-single-file";
    }
  | {
      mode: "components";
      reason: "unsupported-feature" | "unsupported-block" | "multiple-or-spoiler-files";
    };

/**
 * Keep the downgrade rules explicit because this path is only safe when the
 * spec means exactly what a plain Discord message can represent.
 */
function getClassicDiscordMessageDecision(
  spec: DiscordComponentMessageSpec,
): ClassicDiscordMessageDecision {
  if (hasUnsupportedClassicFeatures(spec)) {
    return { mode: "components", reason: "unsupported-feature" };
  }
  if (!hasClassicOnlyBlocks(spec)) {
    return { mode: "components", reason: "unsupported-block" };
  }
  if (!hasAtMostOneNonSpoilerFile(spec)) {
    return { mode: "components", reason: "multiple-or-spoiler-files" };
  }
  return { mode: "classic", reason: "plain-text-single-file" };
}

function collapseClassicComponentText(spec: DiscordComponentMessageSpec): string {
  const parts: string[] = [];
  const addPart = (value: string | undefined) => {
    if (typeof value !== "string") {
      return;
    }
    const trimmed = value.trim();
    if (!trimmed || parts.includes(trimmed)) {
      return;
    }
    parts.push(trimmed);
  };

  addPart(spec.text);
  for (const block of spec.blocks ?? []) {
    if (block.type === "text") {
      addPart(block.text);
    }
  }
  return parts.join("\n\n");
}

type DiscordComponentSendOpts = {
  cfg: OpenClawConfig;
  accountId?: string;
  token?: string;
  rest?: RequestClient;
  silent?: boolean;
  reply?: DiscordReplyReference;
  sessionKey?: string;
  agentId?: string;
  mediaUrl?: string;
  mediaAccess?: OutboundMediaAccess;
  mediaLocalRoots?: readonly string[];
  mediaReadFile?: (filePath: string) => Promise<Buffer>;
  filename?: string;
  textLimit?: number;
  maxLinesPerMessage?: number;
  tableMode?: MarkdownTableMode;
  chunkMode?: ChunkMode;
  suppressEmbeds?: boolean;
  allowedMentions?: DiscordAllowedMentions;
  /** Persist the concrete platform send before component bookkeeping can fail. */
  onDeliveryResult?: (result: DiscordSendResult) => Promise<void> | void;
  onPlatformSendDispatch?: () => Promise<void>;
};

export function registerBuiltDiscordComponentMessage(params: {
  buildResult: DiscordComponentBuildResult;
  messageId: string;
  ttlMs?: number;
}): void {
  registerDiscordComponentEntries({
    entries: params.buildResult.entries,
    modals: params.buildResult.modals,
    messageId: params.messageId,
    ttlMs: params.ttlMs,
  });
}

function resolveDiscordComponentRegistryTtlMs(
  accountConfig: { agentComponents?: { ttlMs?: number } } | undefined,
): number | undefined {
  const ttlMs = accountConfig?.agentComponents?.ttlMs;
  return typeof ttlMs === "number" && Number.isFinite(ttlMs) && ttlMs > 0
    ? Math.floor(ttlMs)
    : undefined;
}

async function buildDiscordComponentPayload(params: {
  spec: DiscordComponentMessageSpec;
  opts: DiscordComponentSendOpts;
  accountId: string;
}): Promise<{
  body: ReturnType<typeof stripUndefinedFields>;
  buildResult: ReturnType<typeof buildDiscordComponentMessage>;
}> {
  const messageReference = params.opts.reply
    ? { message_id: params.opts.reply.messageId, fail_if_not_exists: false }
    : undefined;

  let spec = params.spec;
  let resolvedFileName: string | undefined;
  let files: MessagePayloadFile[] | undefined;
  if (params.opts.mediaUrl) {
    const media = await loadOutboundMediaFromUrl(params.opts.mediaUrl, {
      mediaAccess: params.opts.mediaAccess,
      mediaLocalRoots: params.opts.mediaLocalRoots,
      mediaReadFile: params.opts.mediaReadFile,
    });
    const filenameOverride = params.opts.filename?.trim();
    resolvedFileName = filenameOverride || media.fileName || "upload";
    spec = withImplicitComponentAttachmentBlock(spec, resolvedFileName);
    files = [{ data: media.buffer, name: resolvedFileName, contentType: media.contentType }];
  }

  const attachmentNames = extractComponentAttachmentNames(spec);
  const uniqueAttachmentNames = uniqueStrings(attachmentNames);
  if (uniqueAttachmentNames.length > 1) {
    throw new Error(
      "Discord component attachments currently support a single file. Use media-gallery for multiple files.",
    );
  }
  const expectedAttachmentName = uniqueAttachmentNames[0];
  if (expectedAttachmentName && resolvedFileName && expectedAttachmentName !== resolvedFileName) {
    throw new Error(
      `Component file block expects attachment "${expectedAttachmentName}", but the uploaded file is "${resolvedFileName}". Update components.blocks[].file or provide a matching filename.`,
    );
  }
  if (!params.opts.mediaUrl && expectedAttachmentName) {
    throw new Error(
      "Discord component file blocks require a media attachment (media/path/filePath).",
    );
  }

  const buildResult = buildDiscordComponentMessage({
    spec,
    sessionKey: params.opts.sessionKey,
    agentId: params.opts.agentId,
    accountId: params.accountId,
  });
  const flags = buildDiscordComponentMessageFlags(buildResult.components);
  const finalFlags = params.opts.silent
    ? (flags ?? 0) | SUPPRESS_NOTIFICATIONS_FLAG
    : (flags ?? undefined);

  const payload: MessagePayloadObject = {
    components: buildResult.components,
    allowed_mentions: params.opts.allowedMentions,
    ...(finalFlags ? { flags: finalFlags } : {}),
    ...(files ? { files } : {}),
  };
  const body = stripUndefinedFields({
    ...serializePayload(payload),
    ...(messageReference ? { message_reference: messageReference } : {}),
  });

  return { body, buildResult };
}

export async function sendDiscordComponentMessage(
  to: string,
  spec: DiscordComponentMessageSpec,
  opts: DiscordComponentSendOpts,
): Promise<DiscordSendResult> {
  const classicDecision = getClassicDiscordMessageDecision(spec);
  if (opts.mediaUrl && classicDecision.mode === "classic") {
    return await sendMessageDiscord(to, collapseClassicComponentText(spec), {
      cfg: opts.cfg,
      accountId: opts.accountId,
      token: opts.token,
      rest: opts.rest,
      mediaUrl: opts.mediaUrl,
      filename: opts.filename,
      mediaLocalRoots: opts.mediaLocalRoots,
      mediaReadFile: opts.mediaReadFile,
      mediaAccess: opts.mediaAccess,
      reply: opts.reply,
      silent: opts.silent,
      textLimit: opts.textLimit,
      maxLinesPerMessage: opts.maxLinesPerMessage,
      tableMode: opts.tableMode,
      chunkMode: opts.chunkMode,
      onDeliveryResult: opts.onDeliveryResult,
      onPlatformSendDispatch: opts.onPlatformSendDispatch,
      ...(opts.suppressEmbeds === undefined ? {} : { suppressEmbeds: opts.suppressEmbeds }),
    });
  }

  const cfg = requireRuntimeConfig(opts.cfg, "Discord component send");
  const accountInfo = resolveDiscordAccount({ cfg, accountId: opts.accountId });
  const { token, rest, request } = createDiscordClient({ ...opts, cfg });
  const recipient = await parseAndResolveChannelRecipient(to, cfg, opts.accountId);
  const { channelId } = await resolveChannelId(rest, recipient, request);

  const channel = await resolveDiscordChannel(rest, channelId);

  if (channel && DISCORD_FORUM_LIKE_TYPES.has(channel.type)) {
    throw new Error("Discord components are not supported in forum-style channels");
  }

  const { body: componentBody, buildResult } = await buildDiscordComponentPayload({
    spec,
    opts,
    accountId: accountInfo.accountId,
  });
  // Nonce enforcement belongs to Create Message; the shared builder also serves edits.
  const body = {
    ...componentBody,
    nonce: createDiscordMessageNonce(),
    enforce_nonce: true,
  };

  let result: { id: string; channel_id: string };
  try {
    await opts.onPlatformSendDispatch?.();
    result = (await request(
      () =>
        createChannelMessage<{ id: string; channel_id: string }>(rest, channelId, {
          body,
        }),
      "components",
      { safety: "nonce-protected-create" },
    )) as { id: string; channel_id: string };
  } catch (err) {
    throw await buildDiscordSendError(err, {
      channelId,
      cfg,
      rest,
      token,
      hasMedia: Boolean(opts.mediaUrl),
    });
  }

  const deliveryResult = createDiscordSendResult({
    result,
    fallbackChannelId: channelId,
    kind: "card",
    ...(opts.reply ? { reply: opts.reply } : {}),
  });
  await opts.onDeliveryResult?.(deliveryResult);

  registerBuiltDiscordComponentMessage({
    buildResult,
    messageId: result.id,
    ttlMs: resolveDiscordComponentRegistryTtlMs(accountInfo.config),
  });

  recordChannelActivity({
    channel: "discord",
    accountId: accountInfo.accountId,
    direction: "outbound",
  });

  return deliveryResult;
}

export async function editDiscordComponentMessage(
  to: string,
  messageId: string,
  spec: DiscordComponentMessageSpec,
  opts: DiscordComponentSendOpts,
): Promise<DiscordSendResult> {
  const cfg = requireRuntimeConfig(opts.cfg, "Discord component edit");
  const accountInfo = resolveDiscordAccount({ cfg, accountId: opts.accountId });
  const { token, rest, request } = createDiscordClient({ ...opts, cfg });
  const recipient = await parseAndResolveChannelRecipient(to, cfg, opts.accountId);
  const { channelId } = await resolveChannelId(rest, recipient, request);
  const { body, buildResult } = await buildDiscordComponentPayload({
    spec,
    opts,
    accountId: accountInfo.accountId,
  });

  let result: { id: string; channel_id: string };
  try {
    result = (await request(
      () =>
        editChannelMessage(rest, channelId, messageId, {
          body,
        }) as Promise<{ id: string; channel_id: string }>,
      "components",
    )) as { id: string; channel_id: string };
  } catch (err) {
    throw await buildDiscordSendError(err, {
      channelId,
      cfg,
      rest,
      token,
      hasMedia: Boolean(opts.mediaUrl),
    });
  }

  registerBuiltDiscordComponentMessage({
    buildResult,
    messageId: result.id ?? messageId,
    ttlMs: resolveDiscordComponentRegistryTtlMs(accountInfo.config),
  });

  recordChannelActivity({
    channel: "discord",
    accountId: accountInfo.accountId,
    direction: "outbound",
  });

  return createDiscordSendResult({
    result: {
      id: result.id ?? messageId,
      channel_id: result.channel_id,
    },
    fallbackChannelId: channelId,
    kind: "card",
    ...(opts.reply ? { reply: opts.reply } : {}),
  });
}
