/**
 * Discord Bridge — lightweight sidecar that maintains a parallel Discord
 * gateway connection and writes incoming messages directly to SQLite.
 *
 * Runs alongside nanoclaw. During restarts, nanoclaw's gateway disconnects
 * but this bridge keeps receiving messages and storing them. When nanoclaw
 * comes back, recoverPendingMessages() picks them up from the DB.
 *
 * Usage: node dist/discord-bridge.js
 * Working directory: /var/lib/nanoclaw/ (same as nanoclaw)
 */

import Database from 'better-sqlite3';
import {
  ChannelType,
  Client,
  GatewayIntentBits,
  Message,
  MessageType,
  Partials,
} from 'discord.js';
import fs from 'fs';
import net from 'net';
import path from 'path';

// --- Minimal config (no dependency on nanoclaw internals) ---

function readEnv(keys: string[]): Record<string, string> {
  const envFile = path.join(process.cwd(), '.env');
  let content: string;
  try {
    content = fs.readFileSync(envFile, 'utf-8');
  } catch {
    return {};
  }
  const result: Record<string, string> = {};
  for (const line of content.split('\n')) {
    const trimmed = line.trim();
    if (!trimmed || trimmed.startsWith('#')) continue;
    const eqIdx = trimmed.indexOf('=');
    if (eqIdx < 0) continue;
    const key = trimmed.slice(0, eqIdx).trim();
    if (keys.includes(key)) {
      result[key] = trimmed.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, '');
    }
  }
  return result;
}

const env = readEnv(['DISCORD_BOT_TOKEN']);
const PLATFORM_TOKEN = process.env.DISCORD_BOT_TOKEN || env.DISCORD_BOT_TOKEN || '';
const DB_PATH = path.join(process.cwd(), 'store', 'messages.db');
const WAKE_SOCKET_PATH = path.join(process.cwd(), 'data', 'wake.sock');

// Fire-and-forget: connect to nanoclaw's wake socket to interrupt its poll sleep.
// Errors are silently ignored — nanoclaw has a fallback timer if we miss a wake.
function sendWake(): void {
  const sock = net.createConnection(WAKE_SOCKET_PATH);
  sock.on('connect', () => sock.destroy());
  sock.on('error', () => { /* nanoclaw may not be running yet */ });
}

if (!PLATFORM_TOKEN) {
  console.error('discord-bridge: DISCORD_BOT_TOKEN not set');
  process.exit(1);
}

// --- Database ---

const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.pragma('busy_timeout = 5000');


const insertMsg = db.prepare(`
  INSERT OR IGNORE INTO messages (id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message, is_trigger, tenant_id)
  VALUES (?, ?, ?, ?, ?, ?, 0, 0, ?, ?)
`);

// Load registered groups and their tenant IDs
function loadRegisteredGroups(): Map<string, string | null> {
  const rows = db.prepare(
    'SELECT jid, tenant_id FROM registered_groups',
  ).all() as { jid: string; tenant_id: string | null }[];
  const map = new Map<string, string | null>();
  for (const row of rows) map.set(row.jid, row.tenant_id);
  return map;
}

// Load bot tenant tokens
function loadBotTenantTokens(): Map<string, string> {
  const rows = db.prepare(
    "SELECT id, discord_bot_token FROM tenants WHERE tenant_type = 'bot' AND discord_bot_token IS NOT NULL AND discord_bot_token != ''",
  ).all() as { id: string; discord_bot_token: string }[];
  const map = new Map<string, string>();
  for (const row of rows) map.set(row.id, row.discord_bot_token);
  return map;
}

let registeredGroups = loadRegisteredGroups();

// Reload groups periodically (picks up new channels without restart)
setInterval(() => {
  registeredGroups = loadRegisteredGroups();
}, 30_000);

// --- Bridge attachment marker format ---
//
// When attachments are present, the bridge embeds a machine-readable marker
// in the stored content so nanoclaw can lazily download them when it picks
// up the message for processing (rather than downloading eagerly here).
//
// Format: [discord-bridge-attachment:{"url":"...","name":"...","contentType":"..."}]
//
// nanoclaw's startMessageLoop parses these markers and downloads the files
// into pendingAttachments before handing the message to the agent.
// This avoids any timing/race dependency between the bridge and nanoclaw.

export interface BridgeAttachmentMeta {
  url: string;
  name: string;
  contentType: string;
}

export function encodeBridgeAttachment(meta: BridgeAttachmentMeta): string {
  return `[discord-bridge-attachment:${JSON.stringify(meta)}]`;
}

export function decodeBridgeAttachments(content: string): BridgeAttachmentMeta[] {
  const results: BridgeAttachmentMeta[] = [];
  const re = /\[discord-bridge-attachment:(\{[^}]+\})\]/g;
  let m: RegExpExecArray | null;
  while ((m = re.exec(content)) !== null) {
    try {
      results.push(JSON.parse(m[1]) as BridgeAttachmentMeta);
    } catch {
      // malformed marker, skip
    }
  }
  return results;
}

// --- Message handler ---

function handleMessage(message: Message, client: Client): void {
  // Skip bot's own messages
  if (client.user && message.author.id === client.user.id) return;
  // Skip other bot messages
  if (message.author.bot) return;
  // Skip Discord system messages (ThreadCreated, ChannelPinnedMessage, etc.)
  if (message.type !== MessageType.Default && message.type !== MessageType.Reply) return;
  // Skip guild thread rename notifications: Discord fires these as MessageType.Default with
  // content = the new thread name (unlike DM group renames which use ChannelNameChange/type 4).
  const channelName = (message.channel as { name?: string })?.name;
  if (channelName && message.content === channelName) return;

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

  // Resolve tenant: for unregistered channels check if it's a thread in a registered parent
  let tenantId = registeredGroups.get(chatJid);
  if (tenantId === undefined) {
    const isThread = message.channel?.type === ChannelType.PublicThread
                   || message.channel?.type === ChannelType.PrivateThread
                   || message.channel?.type === ChannelType.AnnouncementThread;
    if (isThread) {
      const parentId = (message.channel as { parentId?: string | null })?.parentId;
      if (parentId) {
        // Use parent channel's tenantId so the message is stored even before
        // the thread is formally registered (first-message resilience)
        tenantId = registeredGroups.get(`dc:${parentId}`);
      }
    }
    if (tenantId === undefined) return;
  }

  const senderName =
    message.member?.displayName ||
    message.author.displayName ||
    message.author.username;

  let content = message.content;

  // Strip bot mentions
  if (client.user) {
    const botId = client.user.id;
    const hadMention =
      message.mentions.users.has(botId) ||
      content.includes(`<@${botId}>`) ||
      content.includes(`<@!${botId}>`);
    if (hadMention) {
      content = content.replace(new RegExp(`<@!?${botId}>`, 'g'), '').trim();
    }
  }

  // Embed attachment metadata as machine-readable markers.
  // nanoclaw lazily downloads these when it picks up the message for
  // processing, so no timing dependency or race condition is introduced.
  if (message.attachments.size > 0) {
    const markers = Array.from(message.attachments.values())
      .map((att) =>
        encodeBridgeAttachment({
          url: att.url,
          name: att.name || 'attachment',
          contentType: att.contentType || 'application/octet-stream',
        }),
      )
      .join('\n');
    content = content ? `${content}\n${markers}` : markers;
  }

  if (!content) return;

  // Detect bot mention
  let isTrigger = false;
  if (client.user) {
    const botId = client.user.id;
    isTrigger =
      message.mentions.users.has(botId) ||
      message.content.includes(`<@${botId}>`) ||
      message.content.includes(`<@!${botId}>`);
  }
  // DMs always count as triggered
  if (!message.guild) isTrigger = true;

  try {
    insertMsg.run(
      message.id,
      chatJid,
      message.author.id,
      senderName,
      content,
      message.createdAt.toISOString(),
      isTrigger ? 1 : 0,
      tenantId,
    );
    sendWake();
  } catch (err: any) {
    // INSERT OR IGNORE — duplicates are expected and silently skipped
    if (!err.message?.includes('UNIQUE constraint')) {
      console.error(`discord-bridge: DB error: ${err.message}`);
    }
  }
}

// --- Connect ---

async function connectClient(token: string, label: string): Promise<Client> {
  const client = new Client({
    intents: [
      GatewayIntentBits.Guilds,
      GatewayIntentBits.GuildMessages,
      GatewayIntentBits.MessageContent,
      GatewayIntentBits.DirectMessages,
    ],
    partials: [Partials.Channel, Partials.Message],
  });

  client.on('messageCreate', (msg) => handleMessage(msg, client));
  client.on('error', (err) => {
    console.error(`discord-bridge [${label}]: client error: ${err.message}`);
  });

  await client.login(token);
  await new Promise<void>((resolve) => {
    client.once('clientReady', () => {
      console.log(`discord-bridge [${label}]: connected as ${client.user?.tag}`);
      resolve();
    });
  });

  return client;
}

async function main(): Promise<void> {
  console.log('discord-bridge: starting');

  // Connect platform bot
  const platformClient = await connectClient(PLATFORM_TOKEN, 'platform');

  // Connect bot tenant clients
  const botTokens = loadBotTenantTokens();
  for (const [tenantId, token] of botTokens) {
    try {
      await connectClient(token, tenantId);
    } catch (err: any) {
      console.error(`discord-bridge: failed to connect bot tenant ${tenantId}: ${err.message}`);
    }
  }

  console.log(`discord-bridge: ready (${1 + botTokens.size} bot(s), ${registeredGroups.size} groups)`);

  // Graceful shutdown
  const shutdown = () => {
    console.log('discord-bridge: shutting down');
    platformClient.destroy();
    db.close();
    process.exit(0);
  };
  process.on('SIGTERM', shutdown);
  process.on('SIGINT', shutdown);
}

main().catch((err) => {
  console.error(`discord-bridge: fatal: ${err.message}`);
  process.exit(1);
});
