/**
 * Coordinator chain snapshots for fast phone sync.
 * Interval: every SNAPSHOT_INTERVAL blocks; retain last SNAPSHOT_RETAIN files.
 * State is maintained by replaying relay blocks with web_node AccountState.
 */
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const zlib = require("zlib");
const { dataPath } = require("./data_dir");

const SNAPSHOT_INTERVAL = Number(process.env.SARMAD_SNAPSHOT_INTERVAL || 33333);
const SNAPSHOT_RETAIN = Number(process.env.SARMAD_SNAPSHOT_RETAIN || 33);
const AVG_BLOCK_BYTES = Number(process.env.SARMAD_AVG_BLOCK_BYTES || 2500);
const STATE_OVERHEAD_BYTES = Number(process.env.SARMAD_STATE_OVERHEAD_BYTES || 256_000);
const SNAPSHOT_SECRET =
  process.env.SARMAD_SNAPSHOT_SECRET ||
  process.env.SARMAD_RESET_TOKEN ||
  "sarmad-dev-snapshot-secret";

const snapDir = dataPath("snapshots");
const liveStateFile = dataPath("snapshot_live.json");
const parkedFile = dataPath("producer_parked.json");

let AccountStateCtor = null;
let state = null;
let ready = false;
let latestMeta = null;
let initPromise = null;

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

/** tmp+rename so a crash mid-write cannot leave a half-written snapshot behind. */
function writeFileAtomic(target, data) {
  const tmp = `${target}.tmp-${process.pid}`;
  fs.writeFileSync(tmp, data);
  fs.renameSync(tmp, target);
}

function mapToObject(map) {
  const out = {};
  if (!map || typeof map.entries !== "function") return out;
  for (const [k, v] of map.entries()) out[String(k)] = v;
  return out;
}

function setToArray(set) {
  return set && typeof set.values === "function" ? [...set.values()] : [];
}

function serializeState(accountState) {
  const accounts = [];
  for (const [address, acct] of accountState.accounts.entries()) {
    accounts.push({
      address,
      balance: Number(accountState.balances.get(address) || 0),
      nonce: Number(acct.nonce || 0),
      publicKeyHex: String(acct.publicKeyHex || ""),
      inviterAddress: acct.inviterAddress || null,
    });
  }
  // Include balance-only addresses (contracts / burn / founder credits without account meta).
  for (const [address, bal] of accountState.balances.entries()) {
    if (accounts.some((a) => a.address === address)) continue;
    accounts.push({
      address,
      balance: Number(bal || 0),
      nonce: 0,
      publicKeyHex: "",
      inviterAddress: null,
    });
  }
  return {
    accounts,
    contractBalances: mapToObject(accountState.contractBalances),
    stakingShares: mapToObject(accountState.stakingShares),
    stakingPaidMonths: mapToObject(accountState.stakingPaidMonths),
    lastStakingPaidMonth: mapToObject(accountState.lastStakingPaidMonth),
    stakeMatured: mapToObject(accountState.stakeMatured),
    maturedStakingSharesTotal: accountState.maturedStakingSharesTotal || 0,
    lastStakingClaimMonth: mapToObject(accountState.lastStakingClaimMonth),
    stakingClaimedTotal: mapToObject(accountState.stakingClaimedTotal),
    stakingRewardPeriod: mapToObject(accountState.stakingRewardPeriod),
    stakingRewardClaimable: mapToObject(accountState.stakingRewardClaimable),
    stakingRewardClaimableAt: mapToObject(accountState.stakingRewardClaimableAt),
    lastMaterializedStakingPeriod: accountState.lastMaterializedStakingPeriod,
    trackedStakingAddresses: setToArray(accountState.trackedStakingAddresses),
    activityPoints: mapToObject(accountState.activityPoints),
    activityPointsUpdatedAt: mapToObject(accountState.activityPointsUpdatedAt),
    activityRewardsEarnedTotal: mapToObject(accountState.activityRewardsEarnedTotal),
    lastActivityHeartbeatDay: mapToObject(accountState.lastActivityHeartbeatDay),
    activityMinutes: mapToObject(accountState.activityMinutes),
    activityLastHeartbeatTs: mapToObject(accountState.activityLastHeartbeatTs),
    activityEpochStart: accountState.activityEpochStart || 0,
    activityClaimable: mapToObject(accountState.activityClaimable),
    activityClaimableAt: mapToObject(accountState.activityClaimableAt),
    coordPoints: mapToObject(accountState.coordPoints),
    coordPointsUpdatedAt: mapToObject(accountState.coordPointsUpdatedAt),
    lastCoordHeartbeatDay: mapToObject(accountState.lastCoordHeartbeatDay),
    coordRewardsEarnedTotal: mapToObject(accountState.coordRewardsEarnedTotal),
    lastMinedAt: mapToObject(accountState.lastMinedAt),
    lastMinedHeight: mapToObject(accountState.lastMinedHeight),
    arscBlockEpoch: !!accountState.arscBlockEpoch,
    inviterClaimable: mapToObject(accountState.inviterClaimable),
    inviterClaimableUpdatedAt: mapToObject(accountState.inviterClaimableUpdatedAt),
    inviterDailyAccrued: mapToObject(accountState.inviterDailyAccrued),
    inviterRewardsEarnedTotal: mapToObject(accountState.inviterRewardsEarnedTotal),
    inviteeCount: mapToObject(accountState.inviteeCount),
    producerBondLocked: mapToObject(accountState.producerBondLocked),
    producerUnbondAt: mapToObject(accountState.producerUnbondAt),
    producerJoined: setToArray(accountState.producerJoined),
    producerParked: setToArray(accountState.producerParked),
    trackedRewardAddresses: setToArray(accountState.trackedRewardAddresses),
  };
}

function signPayload(height, tipHash, stateObj) {
  const body = JSON.stringify({ height, tipHash, state: stateObj });
  return crypto.createHmac("sha256", SNAPSHOT_SECRET).update(body).digest("hex");
}

function verifySignature(snapshot) {
  if (!snapshot || !snapshot.signature) return false;
  const expected = signPayload(snapshot.height, snapshot.tipHash, snapshot.state);
  try {
    return crypto.timingSafeEqual(
      Buffer.from(expected, "hex"),
      Buffer.from(String(snapshot.signature), "hex")
    );
  } catch (_) {
    return false;
  }
}

function listSnapshotFiles() {
  ensureDir();
  try {
    return fs
      .readdirSync(snapDir)
      .filter((f) => f.startsWith("snap-") && f.endsWith(".json.gz"))
      .sort();
  } catch (_) {
    return [];
  }
}

function snapshotPath(height) {
  return path.join(snapDir, `snap-${String(height).padStart(10, "0")}.json.gz`);
}

function pruneSnapshots() {
  const files = listSnapshotFiles();
  while (files.length > SNAPSHOT_RETAIN) {
    const oldest = files.shift();
    try {
      fs.unlinkSync(path.join(snapDir, oldest));
    } catch (_) {}
  }
}

function writeSnapshotFile(tip, tipBlock) {
  if (!state || !tip || tip.height <= 0) return null;
  const stateObj = serializeState(state);
    const snapshot = {
    version: 1,
    height: tip.height,
    tipHash: tip.hash,
    tipTimestamp: tip.timestamp || 0,
    tipBlock: tipBlock || null,
    interval: SNAPSHOT_INTERVAL,
    createdAtMs: Date.now(),
    srmdDecimals: 6,
    state: stateObj,
  };
  snapshot.signature = signPayload(snapshot.height, snapshot.tipHash, snapshot.state);
  ensureDir();
  const raw = Buffer.from(JSON.stringify(snapshot), "utf8");
  const gz = zlib.gzipSync(raw, { level: 6 });
  writeFileAtomic(snapshotPath(tip.height), gz);
  pruneSnapshots();
  latestMeta = {
    height: tip.height,
    tipHash: tip.hash,
    tipTimestamp: tip.timestamp || 0,
    bytes: gz.length,
    uncompressedBytes: raw.length,
    createdAtMs: snapshot.createdAtMs,
  };
  console.log(
    `[snapshot] wrote height=${tip.height} gz=${gz.length}B raw=${raw.length}B`
  );
  return latestMeta;
}

function loadLatestSnapshotFile() {
  const files = listSnapshotFiles();
  if (!files.length) return null;
  const latest = files[files.length - 1];
  try {
    const gz = fs.readFileSync(path.join(snapDir, latest));
    const raw = zlib.gunzipSync(gz);
    const snap = JSON.parse(raw.toString("utf8"));
    latestMeta = {
      height: snap.height,
      tipHash: snap.tipHash,
      tipTimestamp: snap.tipTimestamp || 0,
      bytes: gz.length,
      uncompressedBytes: raw.length,
      createdAtMs: snap.createdAtMs || 0,
    };
    return snap;
  } catch (e) {
    console.error("[snapshot] load failed:", e && e.message);
    return null;
  }
}

function scaleMoneyMap(map, scale) {
  if (!(map instanceof Map)) return;
  for (const [k, v] of map.entries()) map.set(k, Number(v || 0) * scale);
}

function scaleLegacySnapshotState(accountState) {
  const scale = 1_000_000;
  for (const [k, v] of accountState.balances.entries()) {
    accountState.balances.set(k, Number(v || 0) * scale);
  }
  scaleMoneyMap(accountState.contractBalances, scale);
  scaleMoneyMap(accountState.stakingShares, scale);
  scaleMoneyMap(accountState.stakingClaimedTotal, scale);
  scaleMoneyMap(accountState.stakingRewardClaimable, scale);
  scaleMoneyMap(accountState.activityRewardsEarnedTotal, scale);
  scaleMoneyMap(accountState.activityClaimable, scale);
  scaleMoneyMap(accountState.coordPoints, scale);
  scaleMoneyMap(accountState.coordRewardsEarnedTotal, scale);
  scaleMoneyMap(accountState.inviterClaimable, scale);
  scaleMoneyMap(accountState.inviterRewardsEarnedTotal, scale);
  scaleMoneyMap(accountState.inviterDailyAccrued, scale);
  accountState.maturedStakingSharesTotal =
    Number(accountState.maturedStakingSharesTotal || 0) * scale;
}

function hydrateStateFromSnapshot(snap) {
  if (!AccountStateCtor || !snap || !snap.state) return false;
  state = new AccountStateCtor();
  const s = snap.state;
  for (const a of s.accounts || []) {
    state.balances.set(a.address, Number(a.balance || 0));
    state.accounts.set(a.address, {
      publicKeyHex: a.publicKeyHex || "",
      inviterAddress: a.inviterAddress || null,
      nonce: Number(a.nonce || 0),
    });
  }
  const assignMap = (target, obj) => {
    if (!obj) return;
    for (const [k, v] of Object.entries(obj)) target.set(k, v);
  };
  assignMap(state.contractBalances, s.contractBalances);
  assignMap(state.stakingShares, s.stakingShares);
  assignMap(state.stakingPaidMonths, s.stakingPaidMonths);
  assignMap(state.lastStakingPaidMonth, s.lastStakingPaidMonth);
  assignMap(state.stakeMatured, s.stakeMatured);
  state.maturedStakingSharesTotal = s.maturedStakingSharesTotal || 0;
  assignMap(state.lastStakingClaimMonth, s.lastStakingClaimMonth);
  assignMap(state.stakingClaimedTotal, s.stakingClaimedTotal);
  assignMap(state.stakingRewardPeriod, s.stakingRewardPeriod);
  assignMap(state.stakingRewardClaimable, s.stakingRewardClaimable);
  assignMap(state.stakingRewardClaimableAt, s.stakingRewardClaimableAt);
  state.lastMaterializedStakingPeriod = s.lastMaterializedStakingPeriod;
  for (const a of s.trackedStakingAddresses || []) state.trackedStakingAddresses.add(a);
  assignMap(state.activityPoints, s.activityPoints);
  assignMap(state.activityPointsUpdatedAt, s.activityPointsUpdatedAt);
  assignMap(state.activityRewardsEarnedTotal, s.activityRewardsEarnedTotal);
  assignMap(state.lastActivityHeartbeatDay, s.lastActivityHeartbeatDay);
  assignMap(state.activityMinutes, s.activityMinutes);
  assignMap(state.activityLastHeartbeatTs, s.activityLastHeartbeatTs);
  state.activityEpochStart = s.activityEpochStart || 0;
  assignMap(state.activityClaimable, s.activityClaimable);
  assignMap(state.activityClaimableAt, s.activityClaimableAt);
  assignMap(state.coordPoints, s.coordPoints);
  assignMap(state.coordPointsUpdatedAt, s.coordPointsUpdatedAt);
  assignMap(state.lastCoordHeartbeatDay, s.lastCoordHeartbeatDay);
  assignMap(state.coordRewardsEarnedTotal, s.coordRewardsEarnedTotal);
  assignMap(state.lastMinedAt, s.lastMinedAt);
  assignMap(state.inviterClaimable, s.inviterClaimable);
  assignMap(state.inviterClaimableUpdatedAt, s.inviterClaimableUpdatedAt);
  assignMap(state.inviterDailyAccrued, s.inviterDailyAccrued);
  assignMap(state.inviterRewardsEarnedTotal, s.inviterRewardsEarnedTotal);
  assignMap(state.producerBondLocked, s.producerBondLocked);
  assignMap(state.producerUnbondAt, s.producerUnbondAt);
  state.producerJoined = new Set(s.producerJoined || []);
  state.producerParked = new Set(s.producerParked || []);
  for (const a of s.trackedRewardAddresses || []) state.trackedRewardAddresses.add(a);
  if (typeof state.rebuildInviteeCount === "function") state.rebuildInviteeCount();
  if (Number(snap.srmdDecimals || 0) < 6) {
    scaleLegacySnapshotState(state);
    console.log("[snapshot] scaled pre-decimal money fields ×1,000,000");
  }
  return true;
}

async function loadAccountStateModule() {
  const { pathToFileURL } = require("url");
  const candidates = [
    path.join(__dirname, "web_node", "js", "state.js"),
    path.join(__dirname, "..", "web_node", "js", "state.js"),
  ];
  for (const p of candidates) {
    if (!fs.existsSync(p)) continue;
    const mod = await import(pathToFileURL(p).href);
    if (mod.AccountState) {
      AccountStateCtor = mod.AccountState;
      return;
    }
  }
  throw new Error("AccountState module not found (web_node/js/state.js)");
}

/**
 * @param {object} relay relay_store module
 */
async function init(relay) {
  if (initPromise) return initPromise;
  initPromise = (async () => {
    ensureDir();
    await loadAccountStateModule();
    state = new AccountStateCtor();
    state.applyGenesis();

    const existing = loadLatestSnapshotFile();
    let fromHeight = 1;
    if (existing && existing.height > 0) {
      hydrateStateFromSnapshot(existing);
      fromHeight = Number(existing.height) + 1;
      console.log(`[snapshot] hydrated from snap height=${existing.height}`);
    }

    const tip = relay.getTip();
    for (let h = fromHeight; h <= tip.height; h++) {
      const block = relay.getBlocksFrom(h, 1)[0];
      if (!block) break;
      try {
        state.applyBlock(block, h);
      } catch (e) {
        console.error(`[snapshot] apply block ${h} failed:`, e && e.message);
        break;
      }
    }

    // Bootstrap: if tip is past interval boundary with no snap, or no snaps yet and tip>0.
    maybeWriteForTip(relay, { forceBootstrap: !listSnapshotFiles().length && tip.height > 0 });
    loadParkedSidecar();
    ready = true;
    console.log(
      `[snapshot] ready tip=${tip.height} latest=${latestMeta ? latestMeta.height : "none"} interval=${SNAPSHOT_INTERVAL}`
    );
  })().catch((e) => {
    console.error("[snapshot] init failed:", e && e.message);
    ready = false;
  });
  return initPromise;
}

function maybeWriteForTip(relay, opts = {}) {
  const tip = relay.getTip();
  if (!tip || tip.height <= 0 || !state) return;
  const atBoundary = tip.height % SNAPSHOT_INTERVAL === 0;
  const forceBootstrap = Boolean(opts.forceBootstrap);
  if (!atBoundary && !forceBootstrap) return;
  if (latestMeta && latestMeta.height === tip.height) return;
  const tipBlock = relay.getBlocksFrom(tip.height, 1)[0] || null;
  writeSnapshotFile(tip, tipBlock);
}

function persistParkedSidecar() {
  if (!state) return;
  const addrs = typeof state.parkedProducers === "function" ? state.parkedProducers() : [];
  try {
    writeFileAtomic(parkedFile, JSON.stringify({ addresses: addrs }));
  } catch (e) {
    console.error("[snapshot] parked persist failed:", e && e.message);
  }
}

function loadParkedSidecar() {
  if (!state || typeof state.replaceParkedProducers !== "function") return;
  try {
    if (!fs.existsSync(parkedFile)) return;
    const raw = JSON.parse(fs.readFileSync(parkedFile, "utf8"));
    const addrs = Array.isArray(raw && raw.addresses) ? raw.addresses : [];
    state.replaceParkedProducers(addrs);
    if (addrs.length) {
      console.log(`[snapshot] parked queue: ${addrs.length} wallet(s)`);
    }
  } catch (e) {
    console.error("[snapshot] parked load failed:", e && e.message);
  }
}

function parkProducer(address) {
  if (!state || typeof state.parkProducer !== "function") return false;
  const changed = state.parkProducer(String(address || ""));
  if (changed) persistParkedSidecar();
  return changed;
}

function unparkProducer(address) {
  if (!state || typeof state.unparkProducer !== "function") return false;
  const changed = state.unparkProducer(String(address || ""));
  if (changed) persistParkedSidecar();
  return changed;
}

function replaceParkedProducers(addresses) {
  if (!state || typeof state.replaceParkedProducers !== "function") return;
  state.replaceParkedProducers(addresses || []);
  persistParkedSidecar();
}

function parkedProducers() {
  if (!state || typeof state.parkedProducers !== "function") return [];
  return state.parkedProducers();
}

function isParked(address) {
  if (!state || typeof state.isProducerParked !== "function") return false;
  return state.isProducerParked(String(address || ""));
}

function onBlockStored(relay, blockRecord) {
  if (!ready || !state || !blockRecord) return;
  try {
    state.applyBlock(blockRecord, blockRecord.height);
    persistParkedSidecar();
  } catch (e) {
    console.error("[snapshot] live apply failed:", e && e.message);
    return;
  }
  maybeWriteForTip(relay, {});
}

function getLatestMeta() {
  if (!latestMeta) loadLatestSnapshotFile();
  return latestMeta ? { ...latestMeta, interval: SNAPSHOT_INTERVAL, retain: SNAPSHOT_RETAIN } : null;
}

function readLatestSnapshotBuffer() {
  const files = listSnapshotFiles();
  if (!files.length) return null;
  const latest = files[files.length - 1];
  const full = path.join(snapDir, latest);
  return { filename: latest, buffer: fs.readFileSync(full), meta: getLatestMeta() };
}

function buildSyncPlan(localHeight, mode) {
  const tip = require("./relay_store").getTip();
  const tipH = Number(tip.height) || 0;
  const local = Math.max(0, Number(localHeight) || 0);
  const meta = getLatestMeta();
  const m = String(mode || "full").toLowerCase() === "snapshot" ? "snapshot" : "full";

  if (m === "snapshot" && meta && meta.height > 0) {
    const snapH = meta.height;
    const after = Math.max(0, tipH - snapH);
    const downloadBlocks = after;
    const downloadBytes = (meta.bytes || 0) + downloadBlocks * AVG_BLOCK_BYTES;
    const diskBytes = Math.min(tipH, 333333) * AVG_BLOCK_BYTES + STATE_OVERHEAD_BYTES;
    return {
      mode: "snapshot",
      available: true,
      localHeight: local,
      tipHeight: tipH,
      snapshotHeight: snapH,
      blocksToDownload: downloadBlocks,
      estimatedDownloadBytes: downloadBytes,
      estimatedDiskBytes: diskBytes,
      snapshotBytes: meta.bytes || 0,
      replacesLocalHistory: true,
      snapshotAvailable: true,
      summary: "Fast sync: checkpoint + newer blocks only.",
    };
  }

  const blocksToDownload = Math.max(0, tipH - local);
  const downloadBytes = blocksToDownload * AVG_BLOCK_BYTES;
  const diskBytes =
    Math.min(Math.max(local, tipH), 333333) * AVG_BLOCK_BYTES + STATE_OVERHEAD_BYTES;
  return {
    mode: "full",
    available: true,
    localHeight: local,
    tipHeight: tipH,
    snapshotHeight: meta ? meta.height : null,
    blocksToDownload,
    estimatedDownloadBytes: downloadBytes,
    estimatedDiskBytes: diskBytes,
    snapshotBytes: 0,
    replacesLocalHistory: false,
    snapshotAvailable: Boolean(meta && meta.height > 0),
    summary: "Full sync: keep saved blocks and download the rest.",
  };
}

function statusFields() {
  const meta = getLatestMeta();
  return {
    snapshotInterval: SNAPSHOT_INTERVAL,
    snapshotRetain: SNAPSHOT_RETAIN,
    latestSnapshotHeight: meta ? meta.height : 0,
    latestSnapshotBytes: meta ? meta.bytes : 0,
    snapshotReady: Boolean(meta && meta.height > 0),
  };
}

function resetSnapshots() {
  ensureDir();
  for (const f of listSnapshotFiles()) {
    try {
      fs.unlinkSync(path.join(snapDir, f));
    } catch (_) {}
  }
  latestMeta = null;
  if (AccountStateCtor) {
    state = new AccountStateCtor();
    state.applyGenesis();
  }
  try {
    if (fs.existsSync(parkedFile)) fs.unlinkSync(parkedFile);
  } catch (_) {}
}

function isProducerBonded(address) {
  if (!state || typeof state.isProducerBonded !== "function") return false;
  return state.isProducerBonded(String(address || ""));
}

function chainProducerSet(chainHeight) {
  if (!state || typeof state.chainProducerSet !== "function") return [];
  return state.chainProducerSet(chainHeight);
}

function producerPublicKey(address) {
  if (!state || !state.accounts) return "";
  const acct = state.accounts.get(String(address || ""));
  return (acct && acct.publicKeyHex) || "";
}

module.exports = {
  init,
  onBlockStored,
  getLatestMeta,
  readLatestSnapshotBuffer,
  buildSyncPlan,
  statusFields,
  resetSnapshots,
  verifySignature,
  isProducerBonded,
  chainProducerSet,
  producerPublicKey,
  parkProducer,
  unparkProducer,
  replaceParkedProducers,
  parkedProducers,
  isParked,
  SNAPSHOT_INTERVAL,
  SNAPSHOT_RETAIN,
};
