require('dotenv').config();
const crypto = require('crypto');
const http = require('http');
const { Client, GatewayIntentBits, REST, Routes, SlashCommandBuilder } = require('discord.js');
const fetch = (...args) => import('node-fetch').then(({ default: f }) => f(...args));

const {
  DISCORD_BOT_TOKEN,
  DISCORD_APP_ID,
  DISCORD_GUILD_ID,
  SUPABASE_URL,
  SUPABASE_SERVICE_ROLE_KEY,
  PUSH_PORT,
  PUSH_STATS_TABLE
} = process.env;

const PUSH_PORT_NUM = Math.max(1, parseInt(PUSH_PORT, 10) || 4000);
const PUSH_DEDUPE_TTL_MS = 20_000;
const PUSH_STATUS_MAX_MESSAGES = 50;
const PUSH_STATS_TABLE_NAME = String(PUSH_STATS_TABLE || 'discord_push_stats').trim();
const recentPushKeys = new Map();
let pushStatsStore = { byOwner: {} };
let pushStatsMissingTableWarned = false;

function purgeRecentPushKeys(now = Date.now()) {
  for (const [key, expireAt] of recentPushKeys.entries()) {
    if (!Number.isFinite(expireAt) || expireAt <= now) recentPushKeys.delete(key);
  }
}

function makePushDedupeKey(ownerId, channelId, message) {
  return `${String(ownerId || '').trim()}|${String(channelId || '').trim()}|${String(message || '').trim()}`;
}

function registerRecentPush(ownerId, channelId, message) {
  const now = Date.now();
  purgeRecentPushKeys(now);
  const key = makePushDedupeKey(ownerId, channelId, message);
  const existing = recentPushKeys.get(key);
  if (Number.isFinite(existing) && existing > now) return true;
  recentPushKeys.set(key, now + PUSH_DEDUPE_TTL_MS);
  return false;
}

function defaultPushStatsRow() {
  return { sent_count: 0, last_sent_at: null, last_error: '', last_error_at: null, last_messages: [] };
}

function normalizePushStatsRow(row) {
  const src = row && typeof row === 'object' ? row : {};
  const rows = Array.isArray(src.last_messages) ? src.last_messages : [];
  return {
    sent_count: Number.isFinite(Number(src.sent_count)) ? Number(src.sent_count) : 0,
    last_sent_at: src.last_sent_at ? String(src.last_sent_at) : null,
    last_error: src.last_error ? String(src.last_error) : '',
    last_error_at: src.last_error_at ? String(src.last_error_at) : null,
    last_messages: rows.slice(0, PUSH_STATUS_MAX_MESSAGES).map((item) => {
      const msg = item && typeof item === 'object' ? item : {};
      return {
        sent_at: msg.sent_at ? String(msg.sent_at) : null,
        message: msg.message ? String(msg.message) : '',
        channel_id: msg.channel_id ? String(msg.channel_id) : null,
        guild_id: msg.guild_id ? String(msg.guild_id) : null
      };
    })
  };
}

function isMissingPushStatsTableError(err) {
  const text = String(err?.message || '');
  return text.includes('42P01') || /relation .* does not exist/i.test(text);
}

function getPushOwnerStatsFallback(ownerId) {
  const key = String(ownerId || '').trim();
  if (!key) return defaultPushStatsRow();
  const row = pushStatsStore.byOwner?.[key];
  return normalizePushStatsRow(row);
}

async function loadPushOwnerStats(ownerId) {
  const key = String(ownerId || '').trim();
  if (!key) return defaultPushStatsRow();
  try {
    const rows = await supabaseFetch(
      `${PUSH_STATS_TABLE_NAME}?owner_id=eq.${encodeURIComponent(key)}&select=owner_id,sent_count,last_sent_at,last_error,last_error_at,last_messages&limit=1`
    );
    const normalized = normalizePushStatsRow(rows?.[0]);
    pushStatsStore.byOwner[key] = normalized;
    return normalized;
  } catch (err) {
    if (isMissingPushStatsTableError(err)) {
      if (!pushStatsMissingTableWarned) {
        console.error(`[push-stats] Supabase table '${PUSH_STATS_TABLE_NAME}' not found. Using in-memory fallback.`);
        pushStatsMissingTableWarned = true;
      }
    } else {
      console.error('[push-stats] load failed:', err?.message || err);
    }
    return getPushOwnerStatsFallback(key);
  }
}

async function savePushOwnerStats(ownerId, nextRow) {
  const key = String(ownerId || '').trim();
  if (!key) return defaultPushStatsRow();
  const normalized = normalizePushStatsRow(nextRow);
  pushStatsStore.byOwner[key] = normalized;
  const row = {
    owner_id: key,
    sent_count: normalized.sent_count,
    last_sent_at: normalized.last_sent_at,
    last_error: normalized.last_error || null,
    last_error_at: normalized.last_error_at,
    last_messages: normalized.last_messages,
    updated_at: new Date().toISOString()
  };
  try {
    await supabaseFetch(PUSH_STATS_TABLE_NAME, {
      method: 'POST',
      body: [row],
      prefer: 'resolution=merge-duplicates'
    });
  } catch (err) {
    if (isMissingPushStatsTableError(err)) {
      if (!pushStatsMissingTableWarned) {
        console.error(`[push-stats] Supabase table '${PUSH_STATS_TABLE_NAME}' not found. Keeping stats in memory.`);
        pushStatsMissingTableWarned = true;
      }
    } else {
      console.error('[push-stats] save failed:', err?.message || err);
    }
  }
  return normalized;
}

async function recordPushSuccess(ownerId, { message = '', channelId = null, guildId = null } = {}) {
  const key = String(ownerId || '').trim();
  if (!key) return;
  const prev = await loadPushOwnerStats(key);
  const nowIso = new Date().toISOString();
  const normalizedMessage = normalizePushMessageLine(message);
  const recent = [
    {
      sent_at: nowIso,
      message: normalizedMessage,
      channel_id: channelId ? String(channelId) : null,
      guild_id: guildId ? String(guildId) : null
    },
    ...prev.last_messages
  ].slice(0, PUSH_STATUS_MAX_MESSAGES);

  await savePushOwnerStats(key, {
    sent_count: Math.max(0, Math.trunc(prev.sent_count || 0)) + 1,
    last_sent_at: nowIso,
    last_error: '',
    last_error_at: null,
    last_messages: recent
  });
}

async function recordPushError(ownerId, errorMessage) {
  const key = String(ownerId || '').trim();
  if (!key) return;
  const prev = await loadPushOwnerStats(key);
  await savePushOwnerStats(key, {
    sent_count: Math.max(0, Math.trunc(prev.sent_count || 0)),
    last_sent_at: prev.last_sent_at || null,
    last_error: String(errorMessage || 'Unknown error'),
    last_error_at: new Date().toISOString(),
    last_messages: prev.last_messages
  });
}

async function buildPushStatusResponse(ownerId) {
  const row = await loadPushOwnerStats(ownerId);
  return {
    ok: true,
    sent_count: row.sent_count,
    last_sent_at: row.last_sent_at,
    last_error: row.last_error,
    last_error_at: row.last_error_at,
    last_messages: row.last_messages.slice(0, 5)
  };
}

function generatePushToken() {
  return crypto.randomBytes(24).toString('hex');
}

function normalizePushMessageLine(input) {
  const raw = String(input ?? '').trim();
  if (!raw) return '';
  let single = raw.replace(/\r?\n+/g, ' - ');
  single = single.replace(/\s+/g, ' ').trim();
  if (single.length > 1800) single = `${single.slice(0, 1797)}...`;
  return single;
}

const LIVE_CACHE_TABLE = 'live_round_cache';
const LINK_TABLE = 'discord_server_links';
const CLAN_MATCH_TABLE = 'clan_matchID';

// Leaderboard API (zelfde als Leaderboard-tab in de app)
const LEADERBOARD_API_URL = 'https://api.gomining.com/api/nft-game/clan-leaderboard/index-v2';
const LEADERBOARD_DEFAULT_LEAGUE_ID = 16;
const LEADERBOARD_LIMIT = 50;

// Supabase request timeout (ms) – voorkomt dat de bot blijft hangen
const SUPABASE_TIMEOUT_MS = 12000;
const GOMINING_API_TIMEOUT_MS = 15000;

const commands = [
  new SlashCommandBuilder()
    .setName('link')
    .setDescription('Link this server to your Miner Wars account')
    .addStringOption(opt =>
      opt.setName('owner_id')
        .setDescription('Your Supabase owner_id (from app)')
        .setRequired(true)
    ),
  new SlashCommandBuilder()
    .setName('top10')
    .setDescription('Show top 10 clans from leaderboard (like Leaderboard tab)')
    .addIntegerOption(opt =>
      opt.setName('league')
        .setDescription('League ID (default 16)')
        .setRequired(false)
    ),
  new SlashCommandBuilder()
    .setName('top10live')
    .setDescription('Show current top 10 clans from live round cache'),
  new SlashCommandBuilder().setName('round').setDescription('Show current round info'),
  new SlashCommandBuilder()
    .setName('setpushchannel')
    .setDescription('Set this channel to receive multiplier/winner push messages (instead of webhook)'),
  new SlashCommandBuilder()
    .setName('pushtoken')
    .setDescription('Show your push token for the app (keep it secret)')
].map(cmd => cmd.toJSON());

async function registerCommands() {
  const rest = new REST({ version: '10' }).setToken(DISCORD_BOT_TOKEN);
  const guildId = (DISCORD_GUILD_ID || '').trim();
  if (guildId) {
    await rest.put(Routes.applicationGuildCommands(DISCORD_APP_ID, guildId), { body: commands });
    console.log(`Registered ${commands.length} guild commands for guild ${guildId}`);
    return;
  }
  await rest.put(Routes.applicationCommands(DISCORD_APP_ID), { body: commands });
  console.log(`Registered ${commands.length} global commands (can take time to propagate)`);
}

async function supabaseFetch(path, { method = 'GET', body = null, prefer = null } = {}) {
  const url = `${SUPABASE_URL}/rest/v1/${path}`;
  const headers = {
    apikey: SUPABASE_SERVICE_ROLE_KEY,
    Authorization: `Bearer ${SUPABASE_SERVICE_ROLE_KEY}`,
    'Content-Type': 'application/json'
  };
  if (prefer) headers.Prefer = prefer;

  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), SUPABASE_TIMEOUT_MS);

  try {
    const res = await fetch(url, {
      method,
      headers,
      body: body ? JSON.stringify(body) : undefined,
      signal: controller.signal
    });
    const text = await res.text();
    if (!res.ok) {
      throw new Error(`Supabase error ${res.status}: ${text}`);
    }
    if (!text) return null;
    try { return JSON.parse(text); } catch { return text; }
  } finally {
    clearTimeout(timeoutId);
  }
}

const parseJsonMaybe = (value) => {
  if (typeof value !== 'string') return value;
  const trimmed = value.trim();
  if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return value;
  try { return JSON.parse(trimmed); } catch { return value; }
};

const normalizePayload = (payload) => {
  let p = payload;
  for (let i = 0; i < 3; i += 1) {
    const parsed = parseJsonMaybe(p);
    if (parsed === p) break;
    p = parsed;
  }
  if (p && typeof p === 'object' && p.payload) {
    const nested = parseJsonMaybe(p.payload);
    p = nested || p.payload;
  }
  return p;
};

async function getOwnerForServer(serverId) {
  const rows = await supabaseFetch(
    `${LINK_TABLE}?select=owner_id&server_id=eq.${encodeURIComponent(serverId)}&limit=1`
  );
  return rows?.[0]?.owner_id || null;
}

async function upsertOwnerForServer(serverId, ownerId) {
  const row = { server_id: serverId, owner_id: ownerId, updated_at: new Date().toISOString() };
  await supabaseFetch(LINK_TABLE, {
    method: 'POST',
    body: [row],
    prefer: 'resolution=merge-duplicates'
  });
}

function normalizeLinkRow(row) {
  if (!row) return null;
  const serverId = row.server_id ? String(row.server_id).trim() : null;
  const ownerId = row.owner_id ? String(row.owner_id).trim() : null;
  const channelId = row.push_channel_id ? String(row.push_channel_id).trim() : null;
  const token = row.push_token ? String(row.push_token).trim() : null;
  return {
    serverId: serverId || null,
    ownerId: ownerId || null,
    channelId: channelId || null,
    pushToken: token || null
  };
}

async function getLinkByServerId(serverId) {
  const rows = await supabaseFetch(
    `${LINK_TABLE}?server_id=eq.${encodeURIComponent(serverId)}&select=server_id,owner_id,push_channel_id,push_token&limit=1`
  );
  return normalizeLinkRow(rows?.[0]);
}

/** Voor /push: match expliciet op owner_id + push_token zodat multi-server per owner veilig werkt. */
async function getLinkForPush(ownerId, pushToken) {
  const rows = await supabaseFetch(
    `${LINK_TABLE}?owner_id=eq.${encodeURIComponent(ownerId)}&push_token=eq.${encodeURIComponent(pushToken)}&select=server_id,owner_id,push_channel_id,push_token&limit=1`
  );
  return normalizeLinkRow(rows?.[0]);
}

async function updatePushChannelForServer(serverId, channelId) {
  const rows = await supabaseFetch(
    `${LINK_TABLE}?server_id=eq.${encodeURIComponent(serverId)}&select=push_token&limit=1`
  );
  const existingToken = rows?.[0]?.push_token ? String(rows[0].push_token).trim() : null;
  const pushToken = existingToken || generatePushToken();
  await supabaseFetch(`${LINK_TABLE}?server_id=eq.${encodeURIComponent(serverId)}`, {
    method: 'PATCH',
    body: {
      push_channel_id: channelId,
      push_token: pushToken,
      updated_at: new Date().toISOString()
    }
  });
  return pushToken;
}

async function getLatestLiveCacheForOwner(ownerId) {
  const rows = await supabaseFetch(
    `${LIVE_CACHE_TABLE}?select=payload,updated_at&owner_id=eq.${encodeURIComponent(ownerId)}&order=updated_at.desc.nullslast&limit=1`
  );
  const row = rows?.[0];
  return row ? { payload: normalizePayload(row.payload), updatedAt: row.updated_at } : null;
}

async function fetchClanMatchMap() {
  const rows = await supabaseFetch(`${CLAN_MATCH_TABLE}?select=clan_id,clan_name&limit=1000`);
  const map = new Map();
  if (Array.isArray(rows)) {
    rows.forEach(r => {
      if (r?.clan_id && r?.clan_name) map.set(String(r.clan_id), r.clan_name);
    });
  }
  return map;
}

const formatUtcTime = (value) =>
  value ? new Date(value).toLocaleTimeString('en-GB', { timeZone: 'UTC', hour12: false }) + ' UTC' : '-';

const extractSummary = (payload) => {
  if (!payload || typeof payload !== 'object') return null;
  return payload.liveSummary || payload.live_summary || payload.summary || payload.liveSummaryV2 || payload.latestSummary || null;
};

const extractRows = (payload) => {
  if (!payload || typeof payload !== 'object') return [];
  let rows = payload.allUsersStateV2 || payload.allUsersState || payload.liveApiClans || payload.clans || payload.rows || [];
  if (rows && rows.rows) rows = rows.rows;
  if (!Array.isArray(rows) && rows && typeof rows === 'object') rows = Object.values(rows);
  return Array.isArray(rows) ? rows : [];
};

const pickScore = (r) =>
  r.currentAddedScore ?? r.current_added_score ?? r.activeBoostScore ?? r.active_boost_score ?? r.score ?? r.currentScore ?? null;

const formatScore = (value) => {
  if (value === null || value === undefined || Number.isNaN(Number(value))) return '-';
  const num = Number(value);
  return Number.isFinite(num) ? Math.round(num).toLocaleString('en-US') : '-';
};

/** Leaderboard API (zelfde payload als Leaderboard-tab). */
async function fetchLeaderboard(leagueId = LEADERBOARD_DEFAULT_LEAGUE_ID) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), GOMINING_API_TIMEOUT_MS);
  try {
    const res = await fetch(LEADERBOARD_API_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        calculatedAt: new Date().toISOString(),
        leagueId: Math.trunc(Number(leagueId)) || LEADERBOARD_DEFAULT_LEAGUE_ID,
        pagination: { skip: 0, limit: LEADERBOARD_LIMIT }
      }),
      signal: controller.signal
    });
    const data = await res.json().catch(() => null);
    if (!res.ok) {
      throw new Error(data?.message || data?.error || `API ${res.status}`);
    }
    return data;
  } finally {
    clearTimeout(timeoutId);
  }
}

/** Top 10 uit leaderboard API, formaat zoals Leaderboard-tab: Position, Clan ID, Clan Name, NFT Power, Blocks Mined */
function buildLeaderboardTop10(data) {
  const allClans = [
    ...(data?.data?.clansPromoted || []),
    ...(data?.data?.clansRelegated || []),
    ...(data?.data?.clansRemaining || [])
  ];
  const top = allClans.slice(0, 10);
  const pad = (s, w) => String(s ?? '-').slice(0, w).padEnd(w);
  const num = (v) => (v != null && Number.isFinite(Number(v))) ? Number(v).toLocaleString('en-US') : '-';
  const headerRow = `${pad('#', 3)} ${pad('Clan ID', 10)} ${pad('Clan Name', 22)} ${pad('NFT Power', 12)} ${pad('Blocks', 8)}`;
  const bodyRows = top.length
    ? top.map((c, i) => {
        const pos = c.position ?? i + 1;
        const id = c.clanId ?? c.clan_id ?? '-';
        const name = (c.clan?.name || c.clanName || c.clan_name || '-').slice(0, 22);
        const power = num(c.nftPower ?? c.nft_power);
        const blocks = num(c.blocksMined ?? c.blocks_mined ?? 0);
        return `${pad(pos, 3)} ${pad(String(id), 10)} ${pad(name, 22)} ${pad(power, 12)} ${pad(blocks, 8)}`;
      }).join('\n')
    : '(no data)';
  return '```\n' + headerRow + '\n' + bodyRows + '\n```';
}

/** Stuur altijd een reactie naar de gebruiker, ook als deferReply eerder is mislukt. */
async function safeReply(interaction, content) {
  try {
    if (interaction.deferred || interaction.replied) {
      await interaction.editReply(content).catch(() => interaction.followUp({ content, ephemeral: true }));
    } else {
      await interaction.reply({ content, ephemeral: true });
    }
  } catch (e) {
    console.error('safeReply failed:', e);
  }
}

const client = new Client({ intents: [GatewayIntentBits.Guilds] });

/** HTTP server voor push. Alles via de link: body { owner_id, message, push_token }. Geen gedeelde API-key. */
function startPushServer(discordClient) {
  const server = http.createServer(async (req, res) => {
    const sendJson = (statusCode, payload) => {
      res.writeHead(statusCode, {
        'Content-Type': 'application/json',
        'Access-Control-Allow-Origin': '*'
      });
      res.end(JSON.stringify(payload));
    };
    if (req.method === 'OPTIONS') {
      res.writeHead(204, {
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Methods': 'POST, OPTIONS',
        'Access-Control-Allow-Headers': 'Content-Type'
      });
      res.end();
      return;
    }
    const rawPath = String(req.url || '').split('?')[0];
    const pathname = rawPath.length > 1 ? rawPath.replace(/\/+$/, '') : rawPath;
    const isPushPath = pathname === '/push' || pathname === '/push/push';
    const isStatusPath = pathname === '/push/status' || pathname === '/push/push/status';
    const isPushRoute = req.method === 'POST' && isPushPath;
    const isStatusRoute = req.method === 'POST' && isStatusPath;
    if (!isPushRoute && !isStatusRoute) {
      sendJson(404, { ok: false, error: 'Not found' });
      return;
    }
    let body = '';
    req.on('data', (chunk) => { body += chunk; });
    req.on('end', async () => {
      let ownerId = '';
      try {
        const data = JSON.parse(body || '{}');
        ownerId = (data.owner_id ?? data.ownerId ?? '').toString().trim();
        const pushToken = (data.push_token ?? data.pushToken ?? '').toString().trim();
        if (!ownerId) {
          sendJson(400, { ok: false, error: 'owner_id required' });
          return;
        }
        if (!pushToken) {
          sendJson(401, { ok: false, error: 'push_token required. Get it with /pushtoken in Discord.' });
          return;
        }
        const link = await getLinkForPush(ownerId, pushToken);
        if (!link) {
          sendJson(401, { ok: false, error: 'Invalid push_token. Use /pushtoken in Discord to see your token.' });
          return;
        }

        if (isStatusRoute) {
          sendJson(200, await buildPushStatusResponse(ownerId));
          return;
        }

        const message = normalizePushMessageLine(data.message ?? data.content ?? '');
        if (!message) {
          sendJson(400, { ok: false, error: 'message required' });
          return;
        }
        if (!link.channelId) {
          const errMsg = 'No push channel set. Use /setpushchannel in Discord first.';
          await recordPushError(ownerId, errMsg);
          sendJson(400, { ok: false, error: errMsg });
          return;
        }
        const channel = await discordClient.channels.fetch(link.channelId).catch(() => null);
        if (!channel) {
          const errMsg = 'Channel not found';
          await recordPushError(ownerId, errMsg);
          sendJson(500, { ok: false, error: errMsg });
          return;
        }
        const isDuplicate = registerRecentPush(ownerId, link.channelId, message);
        if (isDuplicate) {
          sendJson(200, { ok: true, deduped: true });
          return;
        }
        await channel.send(message);
        await recordPushSuccess(ownerId, {
          message,
          channelId: link.channelId,
          guildId: link.serverId || channel.guildId || null
        });
        sendJson(200, { ok: true });
      } catch (err) {
        console.error('[push]', err);
        if (ownerId) {
          await recordPushError(ownerId, err?.message || 'Server error');
        }
        sendJson(500, { ok: false, error: err.message || 'Server error' });
      }
    });
  });
  server.listen(PUSH_PORT_NUM, '0.0.0.0', () => {
    console.log(`[push] Server listening on port ${PUSH_PORT_NUM} (auth via link push_token)`);
  });
}

client.on('ready', () => {
  console.log(`Bot ready: ${client.user.tag}`);
  startPushServer(client);
});

client.on('interactionCreate', async (interaction) => {
  if (!interaction.isChatInputCommand()) return;

  const cmd = interaction.commandName;
  console.log(`[cmd] ${cmd} guild=${interaction.guildId}`);

  // Eerst zo snel mogelijk deferren, anders "applicatie heeft niet gereageerd"
  let deferred = false;
  try {
    await interaction.deferReply({ ephemeral: true });
    deferred = true;
  } catch (err) {
    console.error('deferReply failed:', err);
    await safeReply(interaction, `Could not acknowledge command: ${err.message}`);
    return;
  }

  try {
    if (cmd === 'link') {
      const ownerId = interaction.options.getString('owner_id', true).trim();
      await upsertOwnerForServer(interaction.guildId, ownerId);
      return interaction.editReply(`Linked. owner_id: ${ownerId}`);
    }

    if (cmd === 'setpushchannel') {
      const ownerId = await getOwnerForServer(interaction.guildId);
      if (!ownerId) {
        return interaction.editReply('Not linked yet. Use /link owner_id:<your id> first.');
      }
      const pushToken = await updatePushChannelForServer(interaction.guildId, interaction.channelId);
      return interaction.editReply(
        `Push channel set to this channel.\n\n` +
        `**Your push token** (enter this in the app, keep it secret):\n\`\`\`${pushToken}\`\`\`\n` +
        `You can see it again anytime with \`/pushtoken\`.`
      );
    }

    if (cmd === 'pushtoken') {
      const link = await getLinkByServerId(interaction.guildId);
      if (!link?.ownerId) {
        return interaction.editReply('Not linked yet. Use /link owner_id:<your id> first.');
      }
      if (!link || !link.pushToken) {
        return interaction.editReply('Set a push channel first with /setpushchannel, then your token will appear here.');
      }
      return interaction.editReply(
        `**Your push token** (for the app, keep it secret):\n\`\`\`${link.pushToken}\`\`\``
      );
    }

    // /top10 = leaderboard API (geen link nodig)
    if (cmd === 'top10') {
      const leagueId = interaction.options.getInteger('league') ?? LEADERBOARD_DEFAULT_LEAGUE_ID;
      const data = await fetchLeaderboard(leagueId);
      const table = buildLeaderboardTop10(data);
      const header = `**Clan Leaderboard** (League ${leagueId})\n${table}`;
      return interaction.editReply(header);
    }

    // round en top10live hebben link nodig
    const ownerId = await getOwnerForServer(interaction.guildId);
    if (!ownerId) {
      return interaction.editReply('Not linked yet. Use /link owner_id:<your id> first.');
    }

    const latest = await getLatestLiveCacheForOwner(ownerId);
    const payload = latest?.payload || null;

    if (cmd === 'round') {
      const summary = extractSummary(payload);
      if (!summary) return interaction.editReply('No live data yet.');
      const { blockId, roundTime, multiplier, leagueId } = summary;
      return interaction.editReply(
        `Round: ${blockId ?? '-'} | Time: ${roundTime ?? '-'} | Mult: ${multiplier ?? '-'}x | League: ${leagueId ?? '-'} | Updated: ${formatUtcTime(latest?.updatedAt)}`
      );
    }

    if (cmd === 'top10live') {
      const summary = extractSummary(payload);
      const header = summary
        ? `Round: ${summary.blockId ?? '-'} | Time: ${summary.roundTime ?? '-'} | Mult: ${summary.multiplier ?? '-'}x | League: ${summary.leagueId ?? '-'} | Updated: ${formatUtcTime(latest?.updatedAt)}`
        : `Round: - | Time: - | Updated: ${formatUtcTime(latest?.updatedAt)}`;

      const liveTop = Array.isArray(payload?.liveTopClans) ? payload.liveTopClans : null;
      const rows = liveTop || extractRows(payload);
      const clanMap = await fetchClanMatchMap();

      const sorted = rows.slice().sort((a, b) => {
        const av = Number(a?.score ?? pickScore(a) ?? 0);
        const bv = Number(b?.score ?? pickScore(b) ?? 0);
        return bv - av;
      });

      const top = sorted.slice(0, 10).map((r, i) => {
        const id = r.clan_id ?? r.clanId ?? r.id ?? null;
        const name =
          r.clan_name || r.clanName || r.name ||
          (id ? (clanMap.get(String(id)) || `Clan ${id}`) : 'Unknown');
        const score = r.score ?? pickScore(r);
        return `${i + 1}. ${name} — ${formatScore(score)}`;
      });

      const tailNote = rows.length < 10 ? `\n(Only ${rows.length} clans available in live cache)` : '';
      return interaction.editReply(header + '\n' + (top.length ? top.join('\n') : 'No live data yet.') + tailNote);
    }

    return interaction.editReply('Unknown command.');
  } catch (err) {
    console.error(err);
    const msg = err.name === 'AbortError'
      ? 'Request timed out. Try again in a moment.'
      : `Error: ${err.message}`;
    await safeReply(interaction, msg);
  }
});

(async () => {
  await registerCommands();
  await client.login(DISCORD_BOT_TOKEN);
})();
