import {
  AttachmentBuilder,
  ChannelType,
  ChatInputCommandInteraction,
  Client,
  Events,
  GatewayIntentBits,
  GuildChannel,
  Message,
  MessageType,
  Partials,
  REST,
  Routes,
  SlashCommandBuilder,
  TextChannel,
} from 'discord.js';
import fs from 'fs';
import path from 'path';

import { readEnvFile } from '../env.js';
import { logger } from '../logger.js';
import { DiscordVoiceManager } from '../voice/discord-voice.js';
import { registerChannel, ChannelOpts } from './registry.js';
import {
  Channel,
  Attachment,
  OnChatMetadata,
  OnInboundMessage,
  RegisteredGroup,
} from '../types.js';


function sniffImageMediaType(buf: Buffer): string | null {
  if (buf.length >= 8 &&
      buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
    return 'image/png';
  }
  if (buf.length >= 3 &&
      buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
    return 'image/jpeg';
  }
  if (buf.length >= 6 &&
      (buf.slice(0, 6).toString('ascii') === 'GIF87a' ||
       buf.slice(0, 6).toString('ascii') === 'GIF89a')) {
    return 'image/gif';
  }
  if (buf.length >= 12 &&
      buf.slice(0, 4).toString('ascii') === 'RIFF' &&
      buf.slice(8, 12).toString('ascii') === 'WEBP') {
    return 'image/webp';
  }
  return null;
}

export interface DmAutoRegisterInfo {
  userId: string;
  channelId: string;
  senderName: string;
}

export interface MentionAutoRegisterInfo {
  userId: string;
  channelId: string;
  senderName: string;
  isDm: boolean;
  botLabel: string; // label of the bot channel (e.g. tenant ID)
}

export interface ThreadAutoRegisterInfo {
  /** Discord channel ID of the thread itself */
  threadId: string;
  /** Name of the thread channel */
  threadName: string;
  /** Discord channel ID of the parent text channel */
  parentChannelId: string;
  /** Discord user ID of the message sender */
  userId: string;
  /** Display name of the message sender */
  senderName: string;
  /** ISO timestamp of the first message in the thread (used to advance parent channel cursor) */
  starterTimestamp: string;
}

export interface DiscordChannelOpts {
  onMessage: OnInboundMessage;
  onChatMetadata: OnChatMetadata;
  registeredGroups: () => Record<string, RegisteredGroup>;
  /** Called when a DM arrives from an unregistered user. Returns true if a group was registered. */
  onDmAutoRegister?: (info: DmAutoRegisterInfo) => boolean;
  /** Called when the bot is mentioned (or DM'd) in an unregistered channel. Returns true if a group was registered. */
  onMentionAutoRegister?: (info: MentionAutoRegisterInfo) => boolean;
  /** Called when a new member joins the guild. `member` can be used to send DMs. */
  onGuildMemberAdd?: (userId: string, username: string, member: any) => void;
  /** Called when a DM contains a potential pairing code. Returns a reply message or null. */
  onPairingAttempt?: (userId: string, code: string) => Promise<string | null>;
  /** Called when a slash command interaction is received. */
  onSlashCommand?: (chatJid: string, command: string) => void;
  onChannelDelete?: (info: { channelId: string }) => void;
  /** Called when a message arrives from an unregistered channel — check if category auto-registers it. Returns true if registered. */
  onCategoryAutoRegister?: (info: { channelId: string; channelName: string; categoryId: string | null }) => boolean;
  /** Called when a message arrives in a Discord thread whose parent channel is owned/registered.
   *  Returns true if the thread was registered, allowing the message to proceed. */
  onThreadAutoRegister?: (info: ThreadAutoRegisterInfo) => boolean;
  /** Called for DMs — return true to allow processing, false to silently drop. */
  isDmAllowed?: (userId: string) => boolean;
  /** JIDs where bot messages should NOT be filtered out (e.g. a dedicated bug-reports channel). */
  botAllowedJids?: Set<string>;
}

export class DiscordChannel implements Channel {
  name = 'discord';

  private client: Client | null = null;
  private opts: DiscordChannelOpts;
  private botToken: string;
  /** Label for logging (e.g. tenant ID or 'platform') */
  label: string;
  /** If set, this channel only owns these specific jids (for multi-bot routing) */
  private ownedJids: Set<string> | null = null;
  /** Guild ID used for DM role lookups */
  private guildId: string | null = null;
  /** Voice channel integration (optional) */
  private voiceManager: DiscordVoiceManager | null = null;

  constructor(
    botToken: string,
    opts: DiscordChannelOpts,
    label = 'platform',
  ) {
    this.botToken = botToken;
    this.opts = opts;
    this.label = label;
  }

  /** Restrict this channel to only own specific jids. Pass null to clear. */
  setOwnedJids(jids: Set<string> | null): void {
    this.ownedJids = jids;
  }

  /** Add a jid to this channel's owned set. */
  addOwnedJid(jid: string): void {
    if (!this.ownedJids) this.ownedJids = new Set();
    this.ownedJids.add(jid);
  }

  /** Set the guild ID for DM role lookups. */
  setGuildId(guildId: string): void {
    this.guildId = guildId;
  }

  private summarizeDiscordAttachment(att: {
    id?: string;
    name?: string | null;
    contentType?: string | null;
    size?: number | null;
    url?: string | null;
  }): Record<string, unknown> {
    return {
      id: att.id ?? null,
      name: att.name ?? null,
      contentType: att.contentType ?? null,
      size: att.size ?? null,
      hasUrl: Boolean(att.url),
    };
  }

  private getAudioTempRoot(): string {
    if (process.env.NANOCLAW_ATTACHMENT_TMP_DIR) {
      return process.env.NANOCLAW_ATTACHMENT_TMP_DIR;
    }
    return path.join(process.env.HOME || '/var/lib/nanoclaw', 'store', 'attachments');
  }

  private createAudioTempFile(attName: string | null | undefined, bytes: Buffer): string {
    const ext = path.extname(attName || '') || '.audio';
    const safeBase = path.basename(attName || 'audio', ext).replace(/[^a-zA-Z0-9._-]/g, '-') || 'audio';
    const tempRoot = this.getAudioTempRoot();
    fs.mkdirSync(tempRoot, { recursive: true });
    const tempDir = fs.mkdtempSync(path.join(tempRoot, 'nanoclaw-audio-'));
    const filePath = path.join(tempDir, `${safeBase}${ext}`);
    fs.writeFileSync(filePath, bytes);
    return filePath;
  }

  private createFileTempFile(attName: string | null | undefined, bytes: Buffer): string {
    const ext = path.extname(attName || '') || '.bin';
    const safeBase = path.basename(attName || 'file', ext).replace(/[^a-zA-Z0-9._-]/g, '-') || 'file';
    const tempRoot = this.getAudioTempRoot();
    fs.mkdirSync(tempRoot, { recursive: true });
    const tempDir = fs.mkdtempSync(path.join(tempRoot, 'nanoclaw-file-'));
    const filePath = path.join(tempDir, `${safeBase}${ext}`);
    fs.writeFileSync(filePath, bytes);
    return filePath;
  }

  async connect(): Promise<void> {
    this.client = new Client({
      intents: [
        GatewayIntentBits.Guilds,
        GatewayIntentBits.GuildMessages,
        GatewayIntentBits.MessageContent,
        GatewayIntentBits.DirectMessages,
        GatewayIntentBits.GuildMembers,
        GatewayIntentBits.GuildVoiceStates,
      ],
      partials: [Partials.Channel, Partials.Message],
    });

    // Notify when new members join
    this.client.on(Events.GuildMemberAdd, async (member) => {
      if (member.user.bot) return;
      if (this.opts.onGuildMemberAdd) {
        this.opts.onGuildMemberAdd(member.user.id, member.user.username, member);
      }
    });

    // Channel binding happens on first message via onCategoryAutoRegister,
    // not on channelCreate — single code path, resilient to missed events.

    this.client.on(Events.ChannelDelete, async (channel) => {
      if (channel.type !== ChannelType.GuildText) return;
      const chatJid = `dc:${channel.id}`;
      if (this.opts.onChannelDelete) this.opts.onChannelDelete({ channelId: channel.id });
      if (this.ownedJids) {
        this.ownedJids.delete(chatJid);
      }
    });

    this.client.on(Events.MessageCreate, async (message: Message) => {
      // Ignore bot messages (including own), unless the channel explicitly allows them
      const chatJidForBotCheck = `dc:${message.channelId}`;
      const isSelf = this.client?.user && message.author.id === this.client.user.id;
      if (message.author.bot && (isSelf || !this.opts.botAllowedJids?.has(chatJidForBotCheck))) return;

      // Ignore Discord system messages (ThreadCreated, ChannelPinnedMessage, etc.)
      // These are fired as MessageCreate events but are not user messages.
      if (message.type !== MessageType.Default && message.type !== MessageType.Reply) return;
      // Ignore guild thread rename notifications: Discord fires these as MessageType.Default
      // with content = the new thread name (DM group renames use ChannelNameChange/type 4,
      // but guild threads do not — so we detect by content matching the channel name).
      {
        const chName = (message.channel as { name?: string })?.name;
        if (chName && message.content === chName) return;
      }

      // Intercept DMs for pairing code attempts
      const isDmMsg = !message.guild;
      if (isDmMsg && this.opts.onPairingAttempt) {
        const code = message.content.trim();
        // Only try pairing for short messages that look like codes (4 chars alphanumeric)
        if (/^[A-Za-z0-9]{4}$/.test(code)) {
          try {
            const reply = await this.opts.onPairingAttempt(message.author.id, code.toUpperCase());
            if (reply) {
              await message.reply(reply);
              return; // Pairing handled, don't process as normal message
            }
          } catch (err) {
            logger.error({ userId: message.author.id, err }, 'Pairing attempt error');
          }
        }
      }

      // Block DMs that aren't allowed (pairing codes already handled above)
      if (isDmMsg && this.opts.isDmAllowed && !this.opts.isDmAllowed(message.author.id)) {
        logger.debug({ userId: message.author.id, label: this.label }, 'DM blocked by isDmAllowed');
        message.reply("Hey! I'm not able to chat over DMs. Head over to your team's channel and message your agent there — they'll be happy to help!").catch(() => {});
        return;
      }

      const channelId = message.channelId;
      const chatJid = `dc:${channelId}`;

      // Detect Discord thread channels
      const isThread = message.channel.type === ChannelType.PublicThread
                     || message.channel.type === ChannelType.PrivateThread
                     || message.channel.type === ChannelType.AnnouncementThread;
      const parentChannelId = isThread
        ? (message.channel as { parentId: string | null }).parentId
        : null;
      const parentChatJid = parentChannelId ? `dc:${parentChannelId}` : null;

      if (isThread) {
        // Thread ownership: determined by whether the parent channel is owned
        if (this.ownedJids !== null && parentChatJid && !this.ownedJids.has(parentChatJid)) {
          return; // Parent channel not owned by this bot — skip thread
        }
      } else {
        // Non-thread: apply existing ownedJids + category auto-register logic
        if (this.ownedJids !== null && !this.ownedJids.has(chatJid) && message.guild !== null) {
          if (!message.channel.isDMBased() && this.opts.onCategoryAutoRegister) {
            const textCh = message.channel as TextChannel;
            const registered = this.opts.onCategoryAutoRegister({
              channelId,
              channelName: textCh.name,
              categoryId: textCh.parentId,
            });
            if (registered) {
              this.ownedJids.add(chatJid);
              logger.info({ chatJid, channelName: textCh.name }, 'Channel auto-registered via category (pre-ownership check)');
            } else {
              return; // Not in a known tenant category — skip
            }
          } else {
            return; // Skip channels owned by another bot instance
          }
        }
      }

      let content = message.content;
      const timestamp = message.createdAt.toISOString();
      const senderName =
        message.member?.displayName ||
        message.author.displayName ||
        message.author.username;
      const sender = message.author.id;
      const msgId = message.id;
      const isDm = !message.guild;

      // Determine chat name
      let chatName: string;
      if (message.guild) {
        const textChannel = message.channel as TextChannel;
        chatName = `${message.guild.name} #${textChannel.name}`;
      } else {
        chatName = `DM: ${senderName}`;
      }

      // Detect if the bot was @mentioned in this message
      let isBotMentioned = false;
      if (this.client?.user) {
        const botId = this.client.user.id;
        isBotMentioned =
          message.mentions.users.has(botId) ||
          content.includes(`<@${botId}>`) ||
          content.includes(`<@!${botId}>`);

        // Also count role mentions that the bot belongs to (e.g. @treebot role)
        if (!isBotMentioned && message.mentions.roles.size > 0 && message.guild) {
          const botMember = message.guild.members.me;
          if (botMember) {
            isBotMentioned = message.mentions.roles.some((role) => botMember.roles.cache.has(role.id));
          }
        }

        if (isBotMentioned) {
          // Strip the <@botId> mention to avoid visual clutter
          content = content
            .replace(new RegExp(`<@!?${botId}>`, 'g'), '')
            .trim();
        }
      }

      // DMs always count as triggered (user directly messaged the bot)
      if (isDm) {
        isBotMentioned = true;
      }

      // Handle attachments — images, PDFs, and text files passed to Claude
      const attachments: Attachment[] = [];
      if (message.attachments.size > 0) {
        const TEXT_EXTS = new Set(["txt","md","js","ts","jsx","tsx","py","rb","go","rs","java","c","cpp","h","hpp","cs","php","sh","bash","zsh","fish","json","yaml","yml","toml","ini","env","xml","html","css","scss","sql","graphql","vue","svelte","csv","log","conf","cfg"]);
        const MAX_TEXT_BYTES = 200_000; // ~200KB
        const nonInlineDescriptions: string[] = [];
        logger.info(
          {
            chatJid,
            msgId,
            attachmentCount: message.attachments.size,
            attachments: Array.from(message.attachments.values()).map((att) =>
              this.summarizeDiscordAttachment(att),
            ),
          },
          'Discord attachments received',
        );
        for (const att of message.attachments.values()) {
          const contentType = att.contentType || '';
          const ext = (att.name || '').split('.').pop()?.toLowerCase() || '';
          if (contentType.startsWith('image/')) {
            try {
              const resp = await fetch(att.url);
              const buf = await resp.arrayBuffer();
              attachments.push({
                kind: 'image',
                data: Buffer.from(buf).toString('base64'),
                mediaType: sniffImageMediaType(Buffer.from(buf)) ?? contentType.split(';')[0],
                name: att.name || 'image',
              });
              logger.info(
                {
                  chatJid,
                  msgId,
                  attachment: this.summarizeDiscordAttachment(att),
                  fetchedBytes: buf.byteLength,
                  classifiedKind: 'image',
                },
                'Discord attachment fetched and inlined',
              );
            } catch {
              logger.warn(
                {
                  chatJid,
                  msgId,
                  attachment: this.summarizeDiscordAttachment(att),
                  classifiedKind: 'image',
                },
                'Discord attachment fetch failed',
              );
              nonInlineDescriptions.push(`[Image: ${att.name || 'image'} (failed to load)]`);
            }
          } else if (contentType === 'application/pdf' || ext === 'pdf') {
            try {
              const resp = await fetch(att.url);
              const buf = await resp.arrayBuffer();
              if (buf.byteLength <= 32 * 1024 * 1024) { // 32MB limit
                attachments.push({
                  kind: 'document',
                  data: Buffer.from(buf).toString('base64'),
                  mediaType: 'application/pdf',
                  name: att.name || 'document.pdf',
                });
                logger.info(
                  {
                    chatJid,
                    msgId,
                    attachment: this.summarizeDiscordAttachment(att),
                    fetchedBytes: buf.byteLength,
                    classifiedKind: 'document',
                  },
                  'Discord attachment fetched and inlined',
                );
              } else {
                logger.warn(
                  {
                    chatJid,
                    msgId,
                    attachment: this.summarizeDiscordAttachment(att),
                    fetchedBytes: buf.byteLength,
                    classifiedKind: 'document',
                  },
                  'Discord attachment too large to inline',
                );
                nonInlineDescriptions.push(`[PDF: ${att.name} (too large to inline)]`);
              }
            } catch {
              logger.warn(
                {
                  chatJid,
                  msgId,
                  attachment: this.summarizeDiscordAttachment(att),
                  classifiedKind: 'document',
                },
                'Discord attachment fetch failed',
              );
              nonInlineDescriptions.push(`[PDF: ${att.name || 'file'} (failed to load)]`);
            }
          } else if (TEXT_EXTS.has(ext)) {
            try {
              const resp = await fetch(att.url);
              const buf = await resp.arrayBuffer();
              if (buf.byteLength <= MAX_TEXT_BYTES) {
                attachments.push({
                  kind: 'text',
                  data: Buffer.from(buf).toString('utf8'),
                  mediaType: contentType || 'text/plain',
                  name: att.name || 'file.txt',
                });
                logger.info(
                  {
                    chatJid,
                    msgId,
                    attachment: this.summarizeDiscordAttachment(att),
                    fetchedBytes: buf.byteLength,
                    classifiedKind: 'text',
                  },
                  'Discord attachment fetched and inlined',
                );
              } else {
                logger.warn(
                  {
                    chatJid,
                    msgId,
                    attachment: this.summarizeDiscordAttachment(att),
                    fetchedBytes: buf.byteLength,
                    classifiedKind: 'text',
                  },
                  'Discord attachment too large to inline',
                );
                nonInlineDescriptions.push(`[File: ${att.name} (too large to inline, ${Math.round(buf.byteLength/1024)}KB)]`);
              }
            } catch {
              logger.warn(
                {
                  chatJid,
                  msgId,
                  attachment: this.summarizeDiscordAttachment(att),
                  classifiedKind: 'text',
                },
                'Discord attachment fetch failed',
              );
              nonInlineDescriptions.push(`[File: ${att.name || 'file'} (failed to load)]`);
            }
          } else if (contentType.startsWith('video/')) {
            logger.info(
              {
                chatJid,
                msgId,
                attachment: this.summarizeDiscordAttachment(att),
                classifiedKind: 'video',
              },
              'Discord attachment noted but not inlined',
            );
            nonInlineDescriptions.push(`[Video: ${att.name || 'video'}]`);
          } else if (contentType.startsWith('audio/')) {
            try {
              const resp = await fetch(att.url);
              const buf = Buffer.from(await resp.arrayBuffer());
              const sourcePath = this.createAudioTempFile(att.name, buf);
              attachments.push({
                kind: 'audio',
                mediaType: contentType.split(';')[0] || 'audio/mpeg',
                name: att.name || 'audio',
                sourcePath,
              });
              logger.info(
                {
                  chatJid,
                  msgId,
                  attachment: this.summarizeDiscordAttachment(att),
                  fetchedBytes: buf.byteLength,
                  classifiedKind: 'audio',
                  sourcePath,
                },
                'Discord audio attachment fetched for mounted processing',
              );
            } catch {
              logger.warn(
                {
                  chatJid,
                  msgId,
                  attachment: this.summarizeDiscordAttachment(att),
                  classifiedKind: 'audio',
                },
                'Discord audio attachment fetch failed',
              );
              nonInlineDescriptions.push(`[Audio: ${att.name || 'audio'} (failed to load)]`);
            }
          } else {
            // Binary/archive/unknown — download and mount into the container workspace
            try {
              const resp = await fetch(att.url);
              const buf = Buffer.from(await resp.arrayBuffer());
              const MAX_BINARY_BYTES = 50 * 1024 * 1024; // 50MB
              if (buf.byteLength <= MAX_BINARY_BYTES) {
                const sourcePath = this.createFileTempFile(att.name, buf);
                attachments.push({
                  kind: 'file',
                  mediaType: contentType || 'application/octet-stream',
                  name: att.name || 'file',
                  sourcePath,
                });
                logger.info(
                  {
                    chatJid,
                    msgId,
                    attachment: this.summarizeDiscordAttachment(att),
                    fetchedBytes: buf.byteLength,
                    classifiedKind: 'file',
                    sourcePath,
                  },
                  'Discord binary attachment fetched for mounted processing',
                );
              } else {
                logger.warn(
                  {
                    chatJid,
                    msgId,
                    attachment: this.summarizeDiscordAttachment(att),
                    fetchedBytes: buf.byteLength,
                    classifiedKind: 'file',
                  },
                  'Discord binary attachment too large to mount',
                );
                nonInlineDescriptions.push(`[File: ${att.name || 'file'} (too large, ${Math.round(buf.byteLength / 1024 / 1024)}MB)]`);
              }
            } catch {
              logger.warn(
                {
                  chatJid,
                  msgId,
                  attachment: this.summarizeDiscordAttachment(att),
                  classifiedKind: 'file',
                },
                'Discord binary attachment fetch failed',
              );
              nonInlineDescriptions.push(`[File: ${att.name || 'file'} (failed to load)]`);
            }
          }
        }
        if (nonInlineDescriptions.length > 0) {
          content = content
            ? `${content}\n${nonInlineDescriptions.join('\n')}`
            : nonInlineDescriptions.join('\n');
        }
      }

      // Handle reply context — include who the user is replying to
      if (message.reference?.messageId) {
        try {
          const repliedTo = await message.channel.messages.fetch(
            message.reference.messageId,
          );
          const replyAuthor =
            repliedTo.member?.displayName ||
            repliedTo.author.displayName ||
            repliedTo.author.username;
          content = `[Reply to ${replyAuthor}] ${content}`;
        } catch {
          // Referenced message may have been deleted
        }
      }

      // Store chat metadata for discovery
      const isGroup = !isDm;
      this.opts.onChatMetadata(
        chatJid,
        timestamp,
        chatName,
        'discord',
        isGroup,
      );

      // Check if group is registered
      let group = this.opts.registeredGroups()[chatJid];

      // DM auto-registration: if this is a DM and group is not registered, try to bind
      if (!group && isDm && this.opts.onDmAutoRegister) {
        const registered = this.opts.onDmAutoRegister({
          userId: sender,
          channelId,
          senderName,
        });

        if (registered) {
          // Re-check — group should now be registered
          group = this.opts.registeredGroups()[chatJid];
          if (group) {
            // Add to owned jids if we're using explicit ownership
            if (this.ownedJids) {
              this.ownedJids.add(chatJid);
            }
            logger.info(
              { chatJid, userId: sender, senderName },
              'DM auto-registered for tenant',
            );
          }
        }
      }

      // Mention auto-registration: if bot is mentioned (or DM'd) and channel is unregistered
      if (!group && isBotMentioned && this.opts.onMentionAutoRegister) {
        const registered = this.opts.onMentionAutoRegister({
          userId: sender,
          channelId,
          senderName,
          isDm,
          botLabel: this.label,
        });
        if (registered) {
          group = this.opts.registeredGroups()[chatJid];
          if (group && this.ownedJids) {
            this.ownedJids.add(chatJid);
          }
        }
      }

      // Category-based auto-registration fallback (when ownedJids is null / no bot tenants)
      if (!group && !isDm && !isThread && this.opts.onCategoryAutoRegister) {
        const textCh = message.channel as TextChannel;
        const registered = this.opts.onCategoryAutoRegister({
          channelId,
          channelName: textCh.name,
          categoryId: textCh.parentId,
        });
        if (registered) {
          group = this.opts.registeredGroups()[chatJid];
          if (group && this.ownedJids) {
            this.ownedJids.add(chatJid);
          }
          logger.info({ chatJid, chatName }, 'Channel auto-registered via category fallback');
        }
      }

      // Thread auto-registration: register thread under its parent channel's tenant
      let threadJustRegistered = false;
      if (!group && isThread && parentChatJid && this.opts.onThreadAutoRegister) {
        const parentGroup = this.opts.registeredGroups()[parentChatJid];
        if (parentGroup) {
          const threadName = (message.channel as { name?: string }).name || `thread-${channelId}`;
          const registered = this.opts.onThreadAutoRegister({
            threadId: channelId,
            threadName,
            parentChannelId: parentChannelId!,
            userId: sender,
            senderName,
            starterTimestamp: timestamp,
          });
          if (registered) {
            group = this.opts.registeredGroups()[chatJid];
            if (group && this.ownedJids) {
              this.ownedJids.add(chatJid);
            }
            threadJustRegistered = true;
            logger.info({ chatJid, parentChatJid, threadName }, 'Thread group auto-registered');
          }
        }
      }

      if (!group) {
        if (isDm) {
          logger.debug(
            { chatJid, senderName, userId: sender },
            'DM from user not bound to any tenant',
          );
        } else {
          logger.debug(
            { chatJid, chatName },
            'Message from unregistered Discord channel',
          );
        }
        return;
      }

      // Keep explicit JID ownership in sync for registered channels.
      if (this.ownedJids) {
        this.ownedJids.add(chatJid);
      }

      // Ensure content is non-empty (DB filters out empty-string messages)
      if (!content && attachments.length > 0) {
        const kinds = attachments.map(a => a.kind === 'image' ? 'image' : a.kind === 'document' ? 'PDF' : a.name).join(', ');
        content = `[Sent ${attachments.length} attachment${attachments.length > 1 ? 's' : ''}: ${kinds}]`;
      }
      if (!content && message.attachments.size === 0) return; // truly empty

      // Seed thread context on the first message: inject the starter message plus
      // up to 5 preceding messages from the parent channel so the agent understands
      // the conversation that led to this thread being created.
      if (isThread && threadJustRegistered && parentChannelId) {
        try {
          // Ensure we have a full (non-partial) thread channel so .parent is accessible
          const fullThread = (message.channel as { partial?: boolean; fetch?: () => Promise<typeof message.channel> }).partial
            ? await (message.channel as { fetch: () => Promise<typeof message.channel> }).fetch()
            : message.channel;

          const threadCh = fullThread as {
            fetchStarterMessage?: () => Promise<Message | null>;
            parent?: { messages?: { fetch: (opts: { before: string; limit: number }) => Promise<Map<string, Message>> } } | null;
          };

          const starter = await threadCh.fetchStarterMessage?.();
          logger.debug({ chatJid, starterId: starter?.id, hasParent: !!threadCh.parent }, 'Thread context seeding: starter fetched');

          // Fetch up to 5 messages preceding the starter from the parent channel
          let precedingLines: string[] = [];
          if (starter && threadCh.parent?.messages) {
            try {
              const preceding = await threadCh.parent.messages.fetch({
                before: starter.id,
                limit: 5,
              });
              // fetch() returns newest-first; reverse to chronological order
              const ordered = Array.from(preceding.values()).reverse();
              precedingLines = ordered
                .filter((m) => m.content)
                .map((m) => {
                  const author = m.member?.displayName || m.author.displayName || m.author.username;
                  return `${author}: ${m.content.slice(0, 300)}`;
                });
            } catch (err) {
              logger.warn({ chatJid, err }, 'Thread context seeding: failed to fetch preceding messages');
            }
          }

          const contextParts: string[] = [];

          if (precedingLines.length > 0 && parentChannelId) {
            contextParts.push(`[Recent conversation in the parent channel before this thread was created:]`);
            contextParts.push(...precedingLines);
          }

          if (starter && starter.content && starter.id !== message.id) {
            const starterAuthor =
              starter.member?.displayName ||
              starter.author.displayName ||
              starter.author.username;
            contextParts.push(`[Thread created from this message by ${starterAuthor}: "${starter.content.slice(0, 500)}"]`);
          }

          if (contextParts.length > 0) {
            content = contextParts.join('\n') + '\n\n' + content;
            logger.info({ chatJid, starterId: starter?.id, precedingCount: precedingLines.length }, 'Thread context seeded');
          } else {
            logger.warn({ chatJid, starterId: starter?.id, hasParent: !!threadCh.parent }, 'Thread context seeding produced no context');
          }
        } catch (err) {
          logger.warn({ chatJid, err }, 'Thread context seeding failed — proceeding without context');
        }
      }

      // Deliver message — startMessageLoop() will pick it up
      this.opts.onMessage(chatJid, {
        id: msgId,
        chat_jid: chatJid,
        sender,
        sender_name: senderName,
        content,
        timestamp,
        is_from_me: false,
        is_trigger: isBotMentioned,
        attachments: attachments.length > 0 ? attachments : undefined,
      });
      logger.info(
        {
          chatJid,
          msgId,
          attachmentCount: attachments.length,
          attachments: attachments.map((attachment) => ({
            kind: attachment.kind,
            mediaType: attachment.mediaType,
            name: attachment.name,
          })),
        },
        'Discord message delivered with attachment summary',
      );

      logger.info(
        { chatJid, chatName, sender: senderName, isDm },
        'Discord message stored',
      );
    });

    // Handle errors gracefully
    this.client.on(Events.Error, (err) => {
      logger.error({ err: err.message }, 'Discord client error');
    });

    return new Promise<void>((resolve) => {
      this.client!.once(Events.ClientReady, async (readyClient) => {
        logger.info(
          {
            username: readyClient.user.tag,
            id: readyClient.user.id,
            label: this.label,
          },
          'Discord bot connected',
        );
        console.log(
          `\n  Discord bot [${this.label}]: ${readyClient.user.tag}\n`,
        );

        // Auto-detect guild ID if not set (use first guild)
        if (!this.guildId && readyClient.guilds.cache.size > 0) {
          this.guildId = readyClient.guilds.cache.first()!.id;
          logger.info({ guildId: this.guildId }, 'Auto-detected guild for DM role lookup');
        }


        if (this.label === 'platform') {
          // Only the platform bot should own shared Discord slash commands.
          const commands = [
            new SlashCommandBuilder()
              .setName('stop')
              .setDescription('Stop the currently running agent'),
            new SlashCommandBuilder()
              .setName('new')
              .setDescription('Clear session and start a fresh conversation'),
          ].map((cmd) => cmd.toJSON());

          try {
            const rest = new REST().setToken(this.botToken);
            // Register globally so commands appear in both guild channels and DMs.
            await rest.put(
              Routes.applicationCommands(readyClient.user.id),
              { body: commands },
            );
            logger.info('Registered global slash commands');
            // Clear any stale guild-scoped commands to avoid duplicates in channels.
            if (this.guildId) {
              await rest.put(
                Routes.applicationGuildCommands(readyClient.user.id, this.guildId),
                { body: [] },
              );
            }
          } catch (err: any) {
            logger.warn({ err: err.message }, 'Failed to register slash commands');
          }

          // Handle slash command interactions
          this.client!.on(Events.InteractionCreate, async (interaction) => {
            if (!interaction.isChatInputCommand()) return;
            const cmd = interaction as ChatInputCommandInteraction;
            if (cmd.commandName !== 'stop' && cmd.commandName !== 'new') return;

            const channelId = cmd.channelId;
            const jid = `dc:${channelId}`;

            // Acknowledge immediately to avoid "Interaction failed"
            // ephemeral is guild-only — use it in servers, plain defer in DMs
            await cmd.deferReply({ ephemeral: !!cmd.guildId }).catch(() => {});

            // Delegate to the slash command handler in index.ts
            this.opts.onSlashCommand?.(jid, cmd.commandName);

            // Delete the deferred reply (the real reply comes via sendMessage)
            await cmd.deleteReply().catch(() => {});
          });
        } else {
          // Bot tenant channels must not own any slash commands.
          // Clear any stale commands that may have been registered previously.
          try {
            const rest = new REST().setToken(this.botToken);
            if (this.guildId) {
              await rest.put(
                Routes.applicationGuildCommands(readyClient.user.id, this.guildId),
                { body: [] },
              );
            } else {
              await rest.put(
                Routes.applicationCommands(readyClient.user.id),
                { body: [] },
              );
            }
            logger.debug({ label: this.label }, 'Cleared stale slash commands for bot tenant');
          } catch (err: any) {
            logger.warn({ err: err.message, label: this.label }, 'Failed to clear slash commands for bot tenant');
          }
        }

        // Wire voice channel integration if configured
        const voiceChannelId =
          process.env.DISCORD_VOICE_CHANNEL_ID ||
          readEnvFile(['DISCORD_VOICE_CHANNEL_ID']).DISCORD_VOICE_CHANNEL_ID ||
          '';
        const voiceTextJid =
          process.env.DISCORD_VOICE_TEXT_JID ||
          readEnvFile(['DISCORD_VOICE_TEXT_JID']).DISCORD_VOICE_TEXT_JID ||
          '';
        if (voiceChannelId && voiceTextJid && this.label === (process.env.DISCORD_VOICE_BOT_LABEL || 'platform')) {
          this.voiceManager = new DiscordVoiceManager({
            client: this.client!,
            targetVoiceChannelId: voiceChannelId,
            textChannelJid: voiceTextJid,
            onMessage: this.opts.onMessage,
          });
          this.voiceManager.wire();
          logger.info(
            { voiceChannelId, voiceTextJid },
            'Discord voice manager initialized',
          );
        }

        resolve();
      });

      this.client!.login(this.botToken);
    });
  }

  /** Returns true if this JID is the voice companion channel (TTS-enabled). */
  isVoiceTextChannel(jid: string): boolean {
    return !!(this.voiceManager && this.voiceManager.textChannelJid === jid);
  }

  async sendMessage(jid: string, text: string): Promise<void> {
    if (!this.client) {
      logger.warn('Discord client not initialized');
      return;
    }

    try {
      const channelId = jid.replace(/^dc:/, '');
      let sendable: { send: (text: string) => Promise<unknown> } | null = null;

      // Try fetching as a channel first; fall back to opening a user DM
      try {
        const channel = await this.client.channels.fetch(channelId);
        if (channel && 'send' in channel) {
          sendable = channel as TextChannel;
        }
      } catch {
        // Channel not found — may be a user ID; try creating a DM channel
      }

      if (!sendable) {
        try {
          const user = await this.client.users.fetch(channelId);
          sendable = await user.createDM();
        } catch {
          logger.warn({ jid }, 'Discord channel not found and could not open DM for user');
          return;
        }
      }

      const textChannel = sendable;

      // Discord has a 2000 character limit per message — split if needed
      const MAX_LENGTH = 2000;
      if (text.length <= MAX_LENGTH) {
        await textChannel.send(text);
      } else {
        for (let i = 0; i < text.length; i += MAX_LENGTH) {
          await textChannel.send(text.slice(i, i + MAX_LENGTH));
        }
      }
      logger.info({ jid, length: text.length }, 'Discord message sent');
      // If this is the voice companion channel, also speak the response
      if (this.voiceManager?.textChannelJid === jid) {
        void this.voiceManager.speakResponse(text);
      }
    } catch (err) {
      logger.error({ jid, err }, 'Failed to send Discord message');
    }
  }

  async sendFile(jid: string, filePath: string, caption?: string): Promise<void> {
    if (!this.client) {
      logger.warn('Discord client not initialized');
      return;
    }

    try {
      if (!fs.existsSync(filePath)) {
        logger.warn({ jid, filePath }, 'File not found for Discord attachment');
        return;
      }

      const channelId = jid.replace(/^dc:/, '');
      const channel = await this.client.channels.fetch(channelId);

      if (!channel || !('send' in channel)) {
        logger.warn({ jid }, 'Discord channel not found or not text-based');
        return;
      }

      const textChannel = channel as TextChannel;
      const attachment = new AttachmentBuilder(filePath, {
        name: path.basename(filePath),
      });

      await textChannel.send({
        content: caption || undefined,
        files: [attachment],
      });
      logger.info({ jid, filePath }, 'Discord file sent');
    } catch (err) {
      logger.error({ jid, filePath, err }, 'Failed to send Discord file');
    }
  }

  isConnected(): boolean {
    return this.client !== null && this.client.isReady();
  }

  ownsJid(jid: string): boolean {
    if (!jid.startsWith('dc:')) return false;
    // If owned jids are set, only claim those specific channels
    if (this.ownedJids) return this.ownedJids.has(jid);
    return true;
  }

  async disconnect(): Promise<void> {
    if (this.client) {
      this.client.destroy();
      this.client = null;
      logger.info({ label: this.label }, 'Discord bot stopped');
    }
  }

  async setTyping(jid: string, isTyping: boolean): Promise<void> {
    if (!this.client || !isTyping) return;
    try {
      const channelId = jid.replace(/^dc:/, '');
      const channel = await this.client.channels.fetch(channelId);
      if (channel && 'sendTyping' in channel) {
        await (channel as TextChannel).sendTyping();
      }
    } catch (err) {
      logger.debug({ jid, err }, 'Failed to send Discord typing indicator');
    }
  }
}

registerChannel('discord', (opts: ChannelOpts) => {
  const envVars = readEnvFile(['DISCORD_BOT_TOKEN', 'DISCORD_GUILD_ID']);
  const token =
    process.env.DISCORD_BOT_TOKEN || envVars.DISCORD_BOT_TOKEN || '';
  if (!token) {
    logger.warn('Discord: DISCORD_BOT_TOKEN not set');
    return null;
  }
  const ch = new DiscordChannel(token, opts);
  const guildId = process.env.DISCORD_GUILD_ID || envVars.DISCORD_GUILD_ID;
  if (guildId) ch.setGuildId(guildId);
  return ch;
});
