#!/usr/bin/env node
// Picks the winner of a draw. Read-only: it never signs or sends anything.
// A draw is named by its UTC date and hour, for example 2026-10-20-18.
//
//   node draw/draw.mjs                             the latest draw time that has passed
//   node draw/draw.mjs --draw 2026-10-20-18        that draw
//   node draw/draw.mjs --dry                       print the result, write nothing
//   node draw/draw.mjs --paid 2026-10-20-18 <tx>   record the payout transaction
//   node draw/draw.mjs --verify data/draws/2026-10-20-18.json   re-check a published draw
//
// Run it within about 12 hours of the draw time. Public nodes stop serving a block's contract storage after
// roughly 14 hours, and without it the complete holder list cannot be proven.
//
// Testing against another token:  --token x.nearlytrade.near --pot y.near --at 2026-10-12T14:00:00Z --dry

import { createHash } from "node:crypto";
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const RPCS = ["https://near.drpc.org", "https://archival-rpc.mainnet.fastnear.com", "https://free.rpc.fastnear.com"];
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

function loadConfig() {
  const window = {};
  new Function("window", readFileSync(join(ROOT, "config.js"), "utf8"))(window);
  return window.LUNEAR_CONFIG;
}

function parseArgs(argv) {
  const out = { _: [] };
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i];
    if (!a.startsWith("--")) out._.push(a);
    else if (["dry", "allow-gap"].includes(a.slice(2))) out[a.slice(2)] = true;
    else if (a === "--paid") out.paid = [argv[++i], argv[++i]];
    else out[a.slice(2)] = argv[++i];
  }
  return out;
}

// ---------- chain ----------

// Public endpoints rate-limit bursts, so a busy one is skipped and the round is retried after a pause.
// An answer from the node itself (unknown block, contract error) is final and is not retried.
async function rpc(method, params) {
  let busy;
  for (let round = 0; round < 6; round++) {
    let answered;
    busy = null;
    for (const url of RPCS) {
      try {
        const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }) });
        if (res.status === 429 || res.status >= 500) {
          busy = new Error(`${res.status} from ${url}`);
          continue;
        }
        const json = await res.json();
        if (json.result?.error) throw Object.assign(new Error(json.result.error), { final: true });
        if (json.result) return json.result;
        const text = JSON.stringify(json.error || json);
        answered = Object.assign(new Error(text.slice(0, 200)), { unknownBlock: /UNKNOWN_BLOCK|GARBAGE_COLLECTED|DB Not Found/i.test(text) });
      } catch (e) {
        if (e.final) throw e;
        busy = e;
      }
    }
    if (answered && !busy) throw answered;
    if (answered?.unknownBlock && round > 0) throw answered;
    await sleep(700 * (round + 1));
  }
  throw busy || new Error("rpc unavailable");
}

const toBlock = (r) => ({ height: r.header.height, hash: r.header.hash, ts: Number(BigInt(r.header.timestamp_nanosec) / 1_000_000n) });

const latestBlock = async () => toBlock(await rpc("block", { finality: "final" }));

// Heights can be skipped, so step forward until a block exists.
async function blockAtOrAfter(height) {
  for (let h = height; h < height + 40; h++) {
    try {
      return toBlock(await rpc("block", { block_id: h }));
    } catch (e) {
      if (!e.unknownBlock) throw e;
    }
  }
  throw new Error(`no block found in ${height}..${height + 40}`);
}

async function firstBlockAtOrAfter(ts) {
  let hi = await latestBlock();
  if (hi.ts < ts) throw new Error(`the draw time ${new Date(ts).toISOString()} has not been finalized yet`);

  let lo = await blockAtOrAfter(Math.max(1, hi.height - Math.ceil((hi.ts - ts) / 400) - 2000));
  while (lo.ts >= ts) lo = await blockAtOrAfter(Math.max(1, lo.height - 200_000));

  while (hi.height - lo.height > 1) {
    const mid = await blockAtOrAfter(Math.floor((lo.height + hi.height) / 2));
    if (mid.height >= hi.height) break; // only skipped heights are left between lo and hi
    if (mid.ts >= ts) hi = mid;
    else lo = mid;
  }
  return hi;
}

async function view(contract, method, args, height) {
  const r = await rpc("query", {
    request_type: "call_function",
    block_id: height,
    account_id: contract,
    method_name: method,
    args_base64: Buffer.from(JSON.stringify(args)).toString("base64"),
  });
  return JSON.parse(Buffer.from(r.result).toString());
}

async function pool(items, size, fn) {
  const out = new Array(items.length);
  let next = 0;
  await Promise.all(
    Array.from({ length: size }, async () => {
      while (next < items.length) {
        const i = next++;
        out[i] = await fn(items[i], i);
      }
    })
  );
  return out;
}

// ---------- holder list ----------
// First choice: read every balance straight from the token contract's storage at the draw block.
// Most public nodes refuse to dump a contract this size, so there is a second way: indexers suggest who
// might hold the token and each balance is read from the chain. Either way the balances must add up to the
// total supply at that block, which proves nobody was left out.

const STATE_RPCS = ["https://rpc.shitzuapes.xyz", ...RPCS];

async function stateBalances(token, height) {
  for (const url of STATE_RPCS) {
    try {
      const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "query", params: { request_type: "view_state", block_id: height, account_id: token, prefix_base64: Buffer.from("b").toString("base64") } }) });
      const json = await res.json();
      const values = json.result?.values;
      if (!values?.length) continue;
      return values.map((v) => {
        const value = Buffer.from(v.value, "base64");
        if (value.length !== 16) throw new Error("unexpected storage layout");
        return { account: Buffer.from(v.key, "base64").subarray(1).toString("utf8"), balance: String(BigInt("0x" + Buffer.from(value).reverse().toString("hex"))) };
      });
    } catch {}
  }
  return null;
}

async function getJson(url) {
  for (let attempt = 0; attempt < 6; attempt++) {
    const res = await fetch(url, { headers: { "User-Agent": "lunear-draw" } });
    if (res.status === 429) {
      await sleep(15_000);
      continue;
    }
    if (!res.ok) throw new Error(`${res.status} ${url}`);
    return res.json();
  }
  throw new Error(`rate limited: ${url}`);
}

async function candidateAccounts(token, extra) {
  const set = new Set(extra);
  try {
    for (let page = 1; page < 400; page++) {
      const j = await getJson(`https://api.nearblocks.io/v1/fts/${token}/holders?per_page=250&page=${page}`);
      const rows = j.holders || [];
      rows.forEach((r) => set.add(r.account));
      if (rows.length < 250) break;
    }
  } catch (e) {
    console.warn("nearblocks holder list failed:", e.message);
  }
  try {
    const j = await getJson(`https://api.fastnear.com/v1/ft/${token}/top`);
    (j.accounts || []).forEach((r) => set.add(r.account_id));
  } catch (e) {
    console.warn("fastnear holder list failed:", e.message);
  }
  return [...set];
}

// ---------- the draw ----------

export function pickWinner(holders, blockHash, token) {
  const sorted = [...holders].sort((a, b) => (a.account < b.account ? -1 : a.account > b.account ? 1 : 0));
  const total = sorted.reduce((s, h) => s + BigInt(h.balance), 0n);
  if (total === 0n) return { seed: null, ticket: null, total, winner: null };
  const seed = createHash("sha256").update(`${blockHash}:${token}`).digest("hex");
  const ticket = BigInt("0x" + seed) % total;
  let run = 0n;
  for (const h of sorted) {
    run += BigInt(h.balance);
    if (run > ticket) return { seed, ticket, total, winner: h.account };
  }
}

const fmtNear = (yocto) => (Number(yocto / 10n ** 18n) / 1e6).toFixed(4);

async function main() {
  const cfg = loadConfig();
  const args = parseArgs(process.argv.slice(2));
  const winnersPath = join(ROOT, "data", "winners.json");
  const readWinners = () => (existsSync(winnersPath) ? JSON.parse(readFileSync(winnersPath, "utf8")) : { draws: [] });

  if (args.paid) {
    const [id, tx] = args.paid;
    const file = readWinners();
    const d = file.draws.find((x) => x.id === id);
    if (!d || !tx) throw new Error("usage: --paid <YYYY-MM-DD-HH> <transaction hash>, for a draw that exists");
    d.payoutTx = tx;
    writeFileSync(winnersPath, JSON.stringify(file, null, 2) + "\n");
    console.log(`payout for ${id} recorded: ${tx}`);
    return;
  }

  if (args.verify) return verify(args.verify);

  const token = args.token || cfg.tokenAccount;
  const pot = args.pot || cfg.potAccount;
  if (!token || !pot) throw new Error("set tokenAccount and potAccount in config.js (or pass --token and --pot)");

  let drawTs;
  if (args.at) drawTs = Date.parse(args.at);
  else if (args.draw) {
    const m = /^(\d{4})-(\d{2})-(\d{2})-(\d{2})$/.exec(args.draw);
    if (!m || !cfg.drawHoursUTC.includes(Number(m[4]))) throw new Error(`--draw expects YYYY-MM-DD-HH with HH one of ${cfg.drawHoursUTC.join(", ")}`);
    drawTs = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4]);
  } else {
    const now = Date.now();
    const t = new Date(now);
    drawTs = [-1, 0]
      .flatMap((day) => cfg.drawHoursUTC.map((h) => Date.UTC(t.getUTCFullYear(), t.getUTCMonth(), t.getUTCDate() + day, h)))
      .filter((ts) => ts <= now)
      .sort((a, b) => b - a)[0];
  }
  const time = new Date(drawTs).toISOString();
  const id = time.slice(0, 13).replace("T", "-");
  const excluded = new Set([...cfg.excluded, pot, token]);

  console.log(`draw ${id} · token ${token} · pot ${pot}`);
  const block = await firstBlockAtOrAfter(drawTs);
  console.log(`block #${block.height} ${block.hash} at ${new Date(block.ts).toISOString()}`);

  const supply = BigInt(await view(token, "ft_total_supply", {}, block.height));
  const sum = (list) => list.reduce((s, h) => s + BigInt(h.balance), 0n);

  let source = "contract storage";
  let balances = await stateBalances(token, block.height);
  if (!balances || sum(balances) !== supply) {
    source = "indexers + ft_balance_of";
    const accounts = await candidateAccounts(token, [...excluded, ...(args.extra ? args.extra.split(",") : [])]);
    balances = await pool(accounts, 4, async (account) => ({ account, balance: await view(token, "ft_balance_of", { account_id: account }, block.height) }));
  }
  const gap = supply - sum(balances);
  console.log(`${balances.length} accounts from ${source} · supply ${supply} · gap ${gap}`);
  if (gap !== 0n && !args["allow-gap"]) {
    throw new Error(`holder list is incomplete: ${gap} units (${((Number(gap) / Number(supply)) * 100).toFixed(4)}% of supply) are held by accounts the indexers did not return. This usually means the draw block is too old for nodes to serve its contract storage (run the draw within about 12 hours). Otherwise add the missing accounts with --extra a.near,b.near and run again.`);
  }

  const holders = balances.filter((h) => !excluded.has(h.account) && BigInt(h.balance) > 0n);
  const { seed, ticket, total, winner } = pickWinner(holders, block.hash, token);

  // The prize is what the pot wallet held at the draw block, less the reserve and less any earlier prize
  // that has been won but not sent yet (that NEAR already belongs to someone).
  const account = await rpc("query", { request_type: "view_account", block_id: block.height, account_id: pot });
  const reserve = BigInt(Math.round(cfg.reserveNear * 1e6)) * 10n ** 18n;
  const unpaid = readWinners().draws.filter((d) => d.id < id && !d.payoutTx);
  const owed = unpaid.reduce((s, d) => s + BigInt(Math.round(Number(d.prizeNear) * 1e6)) * 10n ** 18n, 0n);
  if (unpaid.length) console.warn(`unpaid earlier draws: ${unpaid.map((d) => d.id).join(", ")} · ${fmtNear(owed)} NEAR held back from this prize`);
  const free = BigInt(account.amount) - reserve - owed;
  const prize = free > 0n ? free : 0n;

  console.log(`holders ${holders.length} · tickets ${total}`);
  if (!winner) {
    console.log("nobody holds tokens outside the pool: the pot rolls over");
    return;
  }
  console.log(`seed ${seed}\nticket ${ticket}\nWINNER ${winner}\nprize ${fmtNear(prize)} NEAR (pot balance at the draw block, minus the ${cfg.reserveNear} NEAR reserve)`);

  if (args.dry) return;

  const snapshot = { id, time, token, pot, block, source, excluded: [...excluded].sort(), supply: String(supply), tickets: String(total), seed, ticket: String(ticket), winner, prizeYocto: String(prize), holders: holders.sort((a, b) => (a.account < b.account ? -1 : 1)) };
  const dir = join(ROOT, "data", "draws");
  mkdirSync(dir, { recursive: true });
  writeFileSync(join(dir, `${id}.json`), JSON.stringify(snapshot, null, 2) + "\n");

  const file = readWinners();
  file.draws = [
    { id, time, winner, prizeNear: fmtNear(prize), tickets: String(total), holders: holders.length, blockHeight: block.height, blockHash: block.hash, snapshot: `data/draws/${id}.json`, payoutTx: null },
    ...file.draws.filter((d) => d.id !== id),
  ];
  writeFileSync(winnersPath, JSON.stringify(file, null, 2) + "\n");

  console.log(`\nsaved data/draws/${id}.json and data/winners.json`);
  console.log(`next: send ${fmtNear(prize)} NEAR from ${pot} to ${winner} in your wallet, then run`);
  console.log(`  node draw/draw.mjs --paid ${id} <transaction hash>`);
}

// Re-checks a published snapshot: the block is real, the winner follows from it, and the biggest balances match the chain.
async function verify(path) {
  const snap = JSON.parse(readFileSync(path, "utf8"));
  const checks = [];
  const { seed, ticket, total, winner } = pickWinner(snap.holders, snap.block.hash, snap.token);
  checks.push(["winner follows from the block hash and the balances", winner === snap.winner && seed === snap.seed && String(ticket) === snap.ticket]);
  checks.push(["tickets add up", String(total) === snap.tickets]);
  checks.push(["no excluded account holds a ticket", !snap.holders.some((h) => snap.excluded.includes(h.account))]);

  const chain = await blockAtOrAfter(snap.block.height);
  checks.push([`block #${snap.block.height} has this hash on chain`, chain.height === snap.block.height && chain.hash === snap.block.hash]);
  checks.push(["the block is the draw time's first block", chain.ts >= Date.parse(snap.time) && chain.ts - Date.parse(snap.time) < 60_000]);

  const top = [...snap.holders].sort((a, b) => (BigInt(b.balance) > BigInt(a.balance) ? 1 : -1)).slice(0, 5);
  const sample = [...new Map([...top, snap.holders.find((h) => h.account === snap.winner)].map((h) => [h.account, h])).values()];
  const live = await pool(sample, 2, (h) => view(snap.token, "ft_balance_of", { account_id: h.account }, snap.block.height));
  checks.push([`balances of ${sample.length} wallets (largest and winner) match the chain at that block`, sample.every((h, i) => h.balance === live[i])]);

  for (const [label, ok] of checks) console.log(`${ok ? "ok  " : "FAIL"} ${label}`);
  console.log(checks.every(([, ok]) => ok) ? `\nverified: ${snap.winner} won draw ${snap.id}` : "\nNOT verified");
  if (!checks.every(([, ok]) => ok)) process.exit(1);
}

if (process.argv[1] === fileURLToPath(import.meta.url)) {
  main().catch((e) => {
    console.error("error:", e.message);
    process.exit(1);
  });
}
