import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
import { ChannelType, Client, GatewayIntentBits, type Message as DiscordMessage } from 'discord.js';
import { io, type Socket } from 'socket.io-client';

export interface DiscordPluginConfig {
  token: string;
  gatewayUrl: string;
  /** Shared service credential injected by the approved secret mechanism. */
  serviceToken: string;
  /** Which guild to bind to (single-guild only for v0.1.0). */
  guildId?: string;
  allowedGuildIds: readonly string[];
  allowedChannelIds: readonly string[];
  allowedUserIds: readonly string[];
  /** Provisioned interaction bindings; instance identity is configuration, never code. */
  interactionBindings?: readonly DiscordInteractionBinding[];
}

export type DiscordInteractionOperation = 'bind' | 'attach' | 'send' | 'approve' | 'stop';
export type DiscordInteractionRole = 'viewer' | 'operator' | 'admin';

/** A provisioned Discord-to-Mosaic identity pairing. */
export interface DiscordInteractionUserBinding {
  role: DiscordInteractionRole;
  /** Required for privileged approval and stop operations. */
  mosaicUserId?: string;
}

/** Legacy role-only pairings remain valid for non-privileged Discord ingress. */
export type DiscordInteractionPairing = DiscordInteractionRole | DiscordInteractionUserBinding;

export interface DiscordInteractionBinding {
  instanceId: string;
  guildId: string;
  channelId: string;
  /** Pairing roster keyed by Discord user ID. */
  pairedUsers: Readonly<Record<string, DiscordInteractionPairing>>;
}

const operationRoles: Readonly<
  Record<DiscordInteractionOperation, readonly DiscordInteractionRole[]>
> = {
  bind: ['admin'],
  attach: ['operator', 'admin'],
  send: ['operator', 'admin'],
  approve: ['admin'],
  stop: ['admin'],
};

/** Resolves a configuration-owned binding and applies pairing/RBAC before ingress. */
export function resolveDiscordInteractionBinding(
  bindings: readonly DiscordInteractionBinding[],
  guildId: string,
  channelId: string,
  userId: string,
  operation: DiscordInteractionOperation,
): DiscordInteractionBinding | null {
  const binding = bindings.find(
    (candidate) => candidate.guildId === guildId && candidate.channelId === channelId,
  );
  if (!binding) return null;
  const pairing = binding.pairedUsers[userId];
  const role = typeof pairing === 'string' ? pairing : pairing?.role;
  return role && operationRoles[operation].includes(role) ? binding : null;
}

/** Resolves the provisioned Mosaic identity for an already-authorized Discord user. */
export function resolveDiscordInteractionActorId(
  binding: DiscordInteractionBinding,
  discordUserId: string,
): string | null {
  const pairing = binding.pairedUsers[discordUserId];
  if (typeof pairing === 'string') return null;
  const mosaicUserId = pairing?.mosaicUserId?.trim();
  return mosaicUserId || null;
}

/** Parses provisioned binding roster JSON and rejects malformed or empty data. */
export function parseDiscordInteractionBindings(
  value: string | undefined,
): DiscordInteractionBinding[] {
  if (!value) throw new Error('DISCORD_INTERACTION_BINDINGS is required when Discord is enabled');
  const parsed: unknown = JSON.parse(value);
  if (!Array.isArray(parsed) || parsed.length === 0) {
    throw new Error('DISCORD_INTERACTION_BINDINGS must be a non-empty JSON array');
  }
  return parsed.map((binding: unknown): DiscordInteractionBinding => {
    if (typeof binding !== 'object' || binding === null)
      throw new Error('Invalid Discord interaction binding');
    const candidate = binding as Partial<DiscordInteractionBinding>;
    if (
      !candidate.instanceId ||
      !candidate.guildId ||
      !candidate.channelId ||
      !candidate.pairedUsers ||
      typeof candidate.pairedUsers !== 'object'
    ) {
      throw new Error('Invalid Discord interaction binding');
    }
    const pairedUsers = Object.fromEntries(
      Object.entries(candidate.pairedUsers).map(([discordUserId, pairing]: [string, unknown]) => {
        if (!discordUserId.trim()) {
          throw new Error('Invalid Discord interaction user binding');
        }
        if (typeof pairing === 'string') {
          if (!['viewer', 'operator', 'admin'].includes(pairing)) {
            throw new Error('Invalid Discord interaction user binding');
          }
          return [discordUserId, pairing];
        }
        if (typeof pairing !== 'object' || pairing === null) {
          throw new Error('Invalid Discord interaction user binding');
        }
        const userBinding = pairing as Partial<DiscordInteractionUserBinding>;
        if (
          !userBinding.role ||
          !['viewer', 'operator', 'admin'].includes(userBinding.role) ||
          (userBinding.mosaicUserId !== undefined && !userBinding.mosaicUserId.trim())
        ) {
          throw new Error('Invalid Discord interaction user binding');
        }
        return [
          discordUserId,
          {
            role: userBinding.role,
            ...(userBinding.mosaicUserId ? { mosaicUserId: userBinding.mosaicUserId } : {}),
          },
        ];
      }),
    ) as Record<string, DiscordInteractionPairing>;
    return {
      instanceId: candidate.instanceId,
      guildId: candidate.guildId,
      channelId: candidate.channelId,
      pairedUsers,
    };
  });
}

export interface DiscordIngressPayload {
  correlationId: string;
  messageId: string;
  guildId: string;
  channelId: string;
  userId: string;
  conversationId: string;
  content: string;
  threadId?: string;
  attachments?: readonly DiscordAttachment[];
}

export interface DiscordAttachment {
  id: string;
  name: string;
  url: string;
  contentType: string | null;
}

export interface DiscordIngressEnvelope {
  payload: DiscordIngressPayload;
  signature: string;
}

export interface DiscordIngressAllowlists {
  guildIds: readonly string[];
  channelIds: readonly string[];
  userIds: readonly string[];
}

function signedPayload(payload: DiscordIngressPayload): string {
  return [
    payload.correlationId,
    payload.messageId,
    payload.guildId,
    payload.channelId,
    payload.userId,
    payload.conversationId,
    payload.content,
    payload.threadId ?? '',
    JSON.stringify(payload.attachments ?? []),
  ].join('\n');
}

function signPayload(payload: DiscordIngressPayload, serviceToken: string): string {
  return createHmac('sha256', serviceToken).update(signedPayload(payload)).digest('hex');
}

function isSignatureValid(actual: string, expected: string): boolean {
  const actualBuffer = Buffer.from(actual, 'hex');
  const expectedBuffer = Buffer.from(expected, 'hex');
  return (
    actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer)
  );
}

function includesId(allowedIds: readonly string[], id: string): boolean {
  return allowedIds.includes(id);
}

/** Creates the signed, auditable envelope accepted by the gateway Discord service boundary. */
export function createDiscordIngressEnvelope(
  payload: DiscordIngressPayload,
  serviceToken: string,
): DiscordIngressEnvelope {
  return { payload, signature: signPayload(payload, serviceToken) };
}

/**
 * Verifies service-origin integrity and applies default-deny Discord identity allowlists.
 * Returns null instead of a partially trusted payload on every failure path.
 */
export function verifyDiscordIngressEnvelope(
  envelope: DiscordIngressEnvelope,
  serviceToken: string,
  allowlists?: DiscordIngressAllowlists,
): DiscordIngressPayload | null {
  const expectedSignature = signPayload(envelope.payload, serviceToken);
  if (!isSignatureValid(envelope.signature, expectedSignature)) return null;

  if (
    allowlists &&
    (!includesId(allowlists.guildIds, envelope.payload.guildId) ||
      !includesId(allowlists.channelIds, envelope.payload.channelId) ||
      !includesId(allowlists.userIds, envelope.payload.userId))
  ) {
    return null;
  }

  return envelope.payload;
}

export class DiscordPlugin {
  private client: Client;
  private socket: Socket | null = null;
  /** Map Discord channel ID → Mosaic conversation ID. */
  private channelConversations = new Map<string, string>();
  /** Track in-flight responses to avoid duplicate streaming. */
  private pendingResponses = new Map<string, string>();

  constructor(private readonly config: DiscordPluginConfig) {
    this.client = new Client({
      intents: [
        GatewayIntentBits.Guilds,
        GatewayIntentBits.GuildMessages,
        GatewayIntentBits.MessageContent,
        GatewayIntentBits.DirectMessages,
      ],
    });
  }

  async start(): Promise<void> {
    this.socket = io(`${this.config.gatewayUrl}/chat`, {
      auth: { discordServiceToken: this.config.serviceToken },
      transports: ['websocket'],
    });

    this.socket.on('connect', () => {
      console.log('[discord] Connected to gateway');
    });

    this.socket.on('disconnect', (reason: string) => {
      console.error(`[discord] Disconnected from gateway: ${reason}`);
      this.pendingResponses.clear();
    });

    this.socket.on('connect_error', (err: Error) => {
      console.error(`[discord] Gateway connection error: ${err.message}`);
    });

    this.socket.on('agent:text', (data: { conversationId: string; text: string }) => {
      const pending = this.pendingResponses.get(data.conversationId);
      if (pending !== undefined) {
        this.pendingResponses.set(data.conversationId, pending + data.text);
      }
    });

    this.socket.on('agent:end', (data: { conversationId: string }) => {
      const text = this.pendingResponses.get(data.conversationId);
      if (text) {
        this.pendingResponses.delete(data.conversationId);
        this.sendToDiscord(data.conversationId, text).catch((err: unknown) => {
          console.error(`[discord] Error sending response for ${data.conversationId}:`, err);
        });
      }
    });

    this.socket.on('agent:start', (data: { conversationId: string }) => {
      this.pendingResponses.set(data.conversationId, '');
    });

    this.client.on('messageCreate', (message: DiscordMessage) =>
      this.handleDiscordMessage(message),
    );

    this.client.on('ready', () => {
      console.log(`[discord] Bot logged in as ${this.client.user?.tag}`);
    });

    await this.client.login(this.config.token);
  }

  async stop(): Promise<void> {
    this.socket?.disconnect();
    await this.client.destroy();
  }

  async createProjectChannel(project: {
    id: string;
    name: string;
    description?: string;
  }): Promise<{ channelId: string } | null> {
    if (!this.config.guildId) return null;

    const guild = this.client.guilds.cache.get(this.config.guildId);
    if (!guild) return null;

    const channelName = `mosaic-${project.name
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, '-')
      .replace(/^-|-$/g, '')}`;

    const channel = await guild.channels.create({
      name: channelName,
      type: ChannelType.GuildText,
      topic: project.description ?? `Mosaic project: ${project.name}`,
    });

    this.channelConversations.set(channel.id, `discord-${channel.id}`);
    return { channelId: channel.id };
  }

  private handleDiscordMessage(message: DiscordMessage): void {
    if (message.author.bot || !this.client.user) return;
    if (!message.guildId || !this.isAllowedMessage(message)) return;

    const isMention = message.mentions.has(this.client.user);
    if (!isMention) return;

    const content = message.content
      .replace(new RegExp(`<@!?${this.client.user.id}>`, 'g'), '')
      .trim();
    if (!content) return;
    if (!this.socket?.connected) {
      console.error(
        `[discord] Cannot forward message: not connected to gateway. channel=${message.channelId} message=${message.id}`,
      );
      return;
    }

    const channelId = message.channelId;
    const parentChannelId = 'parentId' in message.channel ? message.channel.parentId : null;
    const bindingChannelId = parentChannelId ?? channelId;
    const binding = resolveDiscordInteractionBinding(
      this.config.interactionBindings ?? [],
      message.guildId,
      bindingChannelId,
      message.author.id,
      'send',
    );
    if (!binding) return;
    const conversationId =
      this.channelConversations.get(channelId) ?? `${binding.instanceId}:discord:${channelId}`;
    this.channelConversations.set(channelId, conversationId);

    const envelope = createDiscordIngressEnvelope(
      {
        correlationId: randomUUID(),
        messageId: message.id,
        guildId: message.guildId,
        channelId: bindingChannelId,
        userId: message.author.id,
        conversationId,
        content,
        threadId: parentChannelId ? channelId : undefined,
        attachments: Array.from(message.attachments.values()).map((attachment) => ({
          id: attachment.id,
          name: attachment.name,
          url: attachment.url,
          contentType: attachment.contentType,
        })),
      },
      this.config.serviceToken,
    );
    this.socket.emit(
      /^\/approve$/i.test(content)
        ? 'discord:approve'
        : content.startsWith('/stop ')
          ? 'discord:stop'
          : 'message',
      envelope,
    );
  }

  private isAllowedMessage(message: DiscordMessage): boolean {
    const guildId = message.guildId;
    const parentChannelId = 'parentId' in message.channel ? message.channel.parentId : null;
    // Threads inherit their authorization boundary from their configured parent.
    const authorizationChannelId = parentChannelId ?? message.channelId;
    return (
      guildId !== null &&
      includesId(this.config.allowedGuildIds, guildId) &&
      includesId(this.config.allowedChannelIds, authorizationChannelId) &&
      includesId(this.config.allowedUserIds, message.author.id)
    );
  }

  private async sendToDiscord(conversationId: string, text: string): Promise<void> {
    const channelId = Array.from(this.channelConversations.entries()).find(
      ([, convId]) => convId === conversationId,
    )?.[0];

    if (!channelId) {
      console.error(`[discord] No channel found for conversation ${conversationId}`);
      return;
    }

    const channel = this.client.channels.cache.get(channelId);
    if (!channel || !('send' in channel)) {
      console.error(
        `[discord] Channel ${channelId} not sendable for conversation ${conversationId}`,
      );
      return;
    }

    for (const chunk of this.chunkText(text, 1900)) {
      try {
        await (channel as { send: (content: string) => Promise<unknown> }).send(chunk);
      } catch (err: unknown) {
        console.error(`[discord] Failed to send message to channel ${channelId}:`, err);
      }
    }
  }

  private chunkText(text: string, maxLength: number): string[] {
    if (text.length <= maxLength) return [text];

    const chunks: string[] = [];
    let remaining = text;
    while (remaining.length > 0) {
      if (remaining.length <= maxLength) {
        chunks.push(remaining);
        break;
      }
      let breakPoint = remaining.lastIndexOf('\n', maxLength);
      if (breakPoint <= 0) breakPoint = maxLength;
      chunks.push(remaining.slice(0, breakPoint));
      remaining = remaining.slice(breakPoint).trimStart();
    }
    return chunks;
  }
}
