/**
 * Block/tx relay for phones and browser nodes.
 * Keeps the last MAX_RAM_BLOCKS in memory for fast serve, and the last MAX_DISK_BLOCKS
 * on disk so offline devices can catch up without loading the full history into RAM.
 */
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const { dataPath } = require("./data_dir");

const MAX_RAM_BLOCKS = Number(process.env.SARMAD_MAX_RAM_BLOCKS || 3333);
const MAX_DISK_BLOCKS = Number(process.env.SARMAD_MAX_DISK_BLOCKS || 33333);
const MAX_MEMPOOL = 3333;

const blocksDir = dataPath("block_store");
const tipFile = dataPath("block_tip.json");

/** @type {Map<number, object>} height -> block (hot window only) */
const blocksByHeight = new Map();
/** @type {Map<string, object>} hash -> block (hot window only) */
const blocksByHash = new Map();
/** @type {Map<string, object>} txHash -> tx */
const mempool = new Map();
/** @type {Set<import('ws').WebSocket>} */
const sockets = new Set();

let tip = { height: 0, hash: "0".repeat(64), timestamp: 0 };

function ensureDirs() {
  try {
    fs.mkdirSync(blocksDir, { recursive: true });
  } catch (_) {}
}

function loadTip() {
  try {
    const raw = JSON.parse(fs.readFileSync(tipFile, "utf8"));
    tip = {
      height: Number(raw.height) || 0,
      hash: String(raw.hash || "0".repeat(64)),
      timestamp: Number(raw.timestamp) || 0,
    };
  } catch (_) {}
}

/**
 * Write via temp file + rename so a crash mid-write cannot leave a truncated
 * block or a tip that points at a block we never finished writing.
 */
function writeFileAtomic(target, data) {
  const tmp = `${target}.tmp-${process.pid}`;
  fs.writeFileSync(tmp, data, "utf8");
  fs.renameSync(tmp, target);
}

function saveTip() {
  try {
    writeFileAtomic(tipFile, JSON.stringify(tip, null, 2));
  } catch (e) {
    console.error("[relay] tip save failed:", e && e.message);
  }
}

function blockPath(height) {
  return path.join(blocksDir, `${Number(height)}.json`);
}

function readBlockFromDisk(height) {
  try {
    const raw = fs.readFileSync(blockPath(height), "utf8");
    const block = JSON.parse(raw);
    if (Number(block.height) !== Number(height)) return null;
    return block;
  } catch (_) {
    return null;
  }
}

function listDiskHeights() {
  try {
    return fs
      .readdirSync(blocksDir)
      .filter((f) => f.endsWith(".json"))
      .map((f) => Number(f.replace(/\.json$/, "")))
      .filter((h) => Number.isFinite(h))
      .sort((a, b) => a - b);
  } catch (_) {
    return [];
  }
}

function rememberInRam(block) {
  const height = Number(block.height);
  if (!Number.isFinite(height)) return;
  blocksByHeight.set(height, block);
  if (block.hash) blocksByHash.set(block.hash, block);
}

function forgetFromRam(height) {
  const b = blocksByHeight.get(height);
  blocksByHeight.delete(height);
  if (b && b.hash) blocksByHash.delete(b.hash);
}

function pruneRamWindow() {
  if (blocksByHeight.size <= MAX_RAM_BLOCKS) return;
  const heights = [...blocksByHeight.keys()].sort((a, b) => a - b);
  while (heights.length > MAX_RAM_BLOCKS) {
    forgetFromRam(heights.shift());
  }
}

function pruneDiskWindow() {
  const heights = listDiskHeights();
  if (heights.length <= MAX_DISK_BLOCKS) return;
  const remove = heights.length - MAX_DISK_BLOCKS;
  for (let i = 0; i < remove; i++) {
    const h = heights[i];
    forgetFromRam(h);
    try {
      fs.unlinkSync(blockPath(h));
    } catch (_) {}
  }
}

function loadBlocksFromDisk() {
  ensureDirs();
  loadTip();
  pruneDiskWindow();
  const heights = listDiskHeights();
  const start = Math.max(0, heights.length - MAX_RAM_BLOCKS);
  for (let i = start; i < heights.length; i++) {
    const block = readBlockFromDisk(heights[i]);
    if (block) rememberInRam(block);
  }
  console.log(
    `[relay] loaded ${blocksByHeight.size} blocks into RAM (cap ${MAX_RAM_BLOCKS}); ` +
      `${heights.length} on disk (cap ${MAX_DISK_BLOCKS}); tip=${tip.height}`
  );
}

function persistBlock(block) {
  ensureDirs();
  try {
    writeFileAtomic(blockPath(block.height), JSON.stringify(block));
  } catch (e) {
    console.error("[relay] block persist failed:", e && e.message);
  }
}

function getBlock(height) {
  const h = Number(height);
  return blocksByHeight.get(h) || readBlockFromDisk(h);
}

function broadcast(msg, except = null) {
  const data = JSON.stringify(msg);
  for (const ws of sockets) {
    if (ws === except) continue;
    if (ws.readyState === 1) {
      try {
        ws.send(data);
      } catch (_) {}
    }
  }
}

const WS_MSG_LIMIT = 60;
const WS_MSG_WINDOW_MS = 60_000;

function attachSocket(ws) {
  sockets.add(ws);
  /** @type {number[]} */
  const msgTimes = [];
  ws.send(
    JSON.stringify({
      type: "hello",
      tip,
      mempoolSize: mempool.size,
      activeSockets: sockets.size,
    })
  );
  ws.on("close", () => sockets.delete(ws));
  ws.on("error", () => sockets.delete(ws));
  ws.on("message", (raw) => {
    const now = Date.now();
    while (msgTimes.length && msgTimes[0] <= now - WS_MSG_WINDOW_MS) {
      msgTimes.shift();
    }
    if (msgTimes.length >= WS_MSG_LIMIT) return;
    msgTimes.push(now);
    try {
      const msg = JSON.parse(String(raw));
      if (msg.type === "ping") {
        ws.send(JSON.stringify({ type: "pong", t: Date.now() }));
      }
    } catch (_) {}
  });
}

function storeBlock(blockBody, meta = {}) {
  if (!blockBody || !blockBody.header) return { ok: false, error: "invalid block" };
  const height = Number(meta.height != null ? meta.height : tip.height + 1);
  const hash = String(meta.blockHash || blockBody.hash || "");

  // Append-only: a node that fell behind must not overwrite stored history.
  const existing = getBlock(height);
  if (existing) {
    return existing.hash === hash
      ? { ok: true, duplicate: true, tip }
      : { ok: false, error: "height already stored", tip };
  }
  if (height !== tip.height + 1) {
    return { ok: false, error: "block does not extend tip", tip };
  }
  // tip.height 0 means only genesis exists, and the relay does not store genesis itself.
  if (tip.height > 0 && blockBody.header.previousBlockHash !== tip.hash) {
    return { ok: false, error: "previous hash does not match tip", tip };
  }
  const timestamp = Number(
    meta.tipTimestamp != null ? meta.tipTimestamp : blockBody.header.timestamp || 0
  );
  const record = {
    height,
    hash,
    header: blockBody.header,
    transactions: blockBody.transactions || [],
    storedAtMs: Date.now(),
  };
  rememberInRam(record);
  persistBlock(record);
  pruneRamWindow();
  pruneDiskWindow();

  if (height >= tip.height) {
    tip = { height, hash: hash || tip.hash, timestamp };
    saveTip();
  }

  // drop included txs from mempool
  for (const tx of record.transactions) {
    const key = tx.txHash || tx.signature || JSON.stringify(tx).slice(0, 64);
    mempool.delete(key);
  }

  broadcast({ type: "block", block: record });
  return { ok: true, tip };
}

function submitTx(tx) {
  if (!tx || !tx.signature || !tx.fromAddress || !tx.toAddress) {
    return { ok: false, error: "invalid tx" };
  }
  const sigLen = String(tx.signature).length;
  if (sigLen < 100 || sigLen > 20000) return { ok: false, error: "invalid tx" };
  const pkLen = String(tx.signerPublicKey || "").length;
  if (pkLen < 100 || pkLen > 20000) return { ok: false, error: "invalid tx" };
  const amount = Number(tx.amount);
  if (!Number.isFinite(amount) || amount < 0) return { ok: false, error: "invalid tx" };
  const payloadHex = String(tx.payloadHex || "");
  if (sigLen + pkLen + payloadHex.length > 100_000) {
    return { ok: false, error: "invalid tx" };
  }
  let blockValidator = null;
  try {
    blockValidator = require("./block_validator");
  } catch (_) {
    // Module genuinely absent (bootstrap): keep shape-only checks.
  }
  if (blockValidator) {
    let sigCheck;
    try {
      sigCheck = blockValidator.validateMempoolTx(tx);
    } catch (e) {
      // Verification threw. Accepting on shape alone here would let unverified txs
      // into the mempool and out over gossip, so fail closed instead.
      if (String((e && e.message) || "").includes("wallet crypto unavailable")) {
        return { ok: false, error: "tx crypto unavailable" };
      }
      return { ok: false, error: "tx verify failed" };
    }
    if (!sigCheck.valid) return { ok: false, error: sigCheck.message || "invalid tx" };
  }
  const key = String(tx.txHash || tx.signature);
  if (mempool.has(key)) return { ok: true, duplicate: true };
  if (mempool.size >= MAX_MEMPOOL) return { ok: false, error: "mempool full" };
  mempool.set(key, tx);
  broadcast({ type: "tx", tx });
  return { ok: true };
}

function getBlocksFrom(fromHeight, limit = 100) {
  const start = Math.max(0, Number(fromHeight) || 0);
  const lim = Math.min(500, Math.max(1, Number(limit) || 100));
  const out = [];
  for (let h = start; h < start + lim; h++) {
    const b = getBlock(h);
    if (!b) break;
    out.push(b);
  }
  return out;
}

function getMempool() {
  return [...mempool.values()];
}

function resetStore() {
  mempool.clear();
  blocksByHeight.clear();
  blocksByHash.clear();
  tip = { height: 0, hash: "0".repeat(64), timestamp: 0 };
  saveTip();
  try {
    for (const f of fs.readdirSync(blocksDir)) {
      if (f.endsWith(".json") || f.includes(".json.tmp-")) {
        fs.unlinkSync(path.join(blocksDir, f));
      }
    }
  } catch (_) {}
  broadcast({ type: "reset", tip });
}

function wsUrlHint(hostHeader, port, opts = {}) {
  const secure = Boolean(opts && opts.secure);
  const scheme = secure ? "wss" : "ws";
  const host = (hostHeader || `127.0.0.1:${port}`).split(":")[0];
  return `${scheme}://${host}:${port}/v1/ws`;
}

loadBlocksFromDisk();

module.exports = {
  attachSocket,
  storeBlock,
  submitTx,
  getBlocksFrom,
  getMempool,
  getTip: () => ({ ...tip }),
  resetStore,
  sockets,
  wsUrlHint,
  cryptoToken: () => crypto.randomBytes(8).toString("hex"),
  MAX_RAM_BLOCKS,
  MAX_DISK_BLOCKS,
};
