/**

 * Account state + block application for the browser node.

 * Mirrors Kotlin StateProcessor / AccountStateDatabase reward semantics.

 */

import { Protocol, Payload } from "./protocol.js";

import { publicKeyToAddress, isValidSrmdAddress } from "./wallet_crypto.js";



export class AccountState {

  constructor() {

    this.balances = new Map();

    this.accounts = new Map();

    this.contractBalances = new Map();

    this.stakingShares = new Map();

    this.stakingPaidMonths = new Map();

    this.lastStakingPaidMonth = new Map();
    this.lastStakingPaidAt = new Map();

    this.stakeMatured = new Map();

    this.maturedStakingSharesTotal = 0;

    this.lastStakingClaimMonth = new Map();

    this.stakingClaimedTotal = new Map();

    /** Per-address materialized monthly staking payout (amount 0 = claimed/burned). */

    this.stakingRewardPeriod = new Map();

    this.stakingRewardClaimable = new Map();

    this.stakingRewardClaimableAt = new Map();

    this.lastMaterializedStakingPeriod = -1;

    this.trackedStakingAddresses = new Set();

    this.activityPoints = new Map();
    this.activityPointsUpdatedAt = new Map();
    this.activityRewardsEarnedTotal = new Map();
    this.lastActivityHeartbeatDay = new Map();
    this.activityMinutes = new Map();
    this.activityLastHeartbeatTs = new Map();
    this.activityEpochStart = 0;
    /** Settled ARSC shares waiting for ACTIVE:CLAIM / COORD:CLAIM (burn after 23h). */
    this.activityClaimable = new Map();
    this.activityClaimableAt = new Map();
    this.coordPoints = new Map();

    this.coordPointsUpdatedAt = new Map();

    this.lastCoordHeartbeatDay = new Map();

    this.coordRewardsEarnedTotal = new Map();

    this.lastMinedAt = new Map();
    this.lastMinedHeight = new Map();
    this.arscBlockEpoch = false;

    this.inviterClaimable = new Map();

    this.inviterClaimableUpdatedAt = new Map();

    this.inviterDailyAccrued = new Map(); // key `${addr}:${day}`

    this.inviterRewardsEarnedTotal = new Map();

    this.trackedRewardAddresses = new Set();

    /** How many wallets have locked this address as their inviter. */
    this.inviteeCount = new Map();

    /** Locked producer-admission bond (units). */
    this.producerBondLocked = new Map();

    /** Network ts when UNBOND was requested; 0 = active lock. */
    this.producerUnbondAt = new Map();

    /** Free-era on-chain producer queue (PRODUCER:JOIN). */
    this.producerJoined = new Set();

    /**
     * JOIN'd (or bonded) wallets dropped after one full offline slot.
     * They stay out of chainProducerSet until JOIN (or a produce-heartbeat unpark).
     */
    this.producerParked = new Set();

    /** Payout SRMD credited by a 0-amount claim tx (for History display). */
    this.txClaimPayouts = new Map();

  }



  getBalance(addr) {

    return this.balances.get(addr) || 0;

  }

  getTxClaimPayout(txId) {
    if (!txId) return 0;
    return this.txClaimPayouts.get(txId) || 0;
  }



  updateBalance(addr, delta) {

    this.balances.set(addr, Math.max(0, this.getBalance(addr) + delta));

  }



  getContractBalance(addr) {

    return this.contractBalances.get(addr) || 0;

  }



  setContractBalance(addr, v) {

    this.contractBalances.set(addr, Math.max(0, v));

  }



  monthIndex(ts) {

    return Math.floor(ts / Protocol.STAKING_MONTH_SECONDS);

  }



  activeClaimWindowSeconds() {

    return Protocol.ACTIVE_NODES_CLAIM_WINDOW_HOURS * 3600;

  }



  inviterClaimWindowSeconds() {

    return Protocol.INVITER_CLAIM_WINDOW_HOURS * 3600;

  }



  stakingClaimWindowSeconds() {

    return Protocol.STAKING_CLAIM_DAYS * Protocol.SECONDS_PER_DAY;

  }



  /** True when this account already has a staking payment registered for the current month. */

  hasPaidStakingThisMonth(address, networkTs) {

    return this.lastStakingPaidMonth.get(address) === this.monthIndex(networkTs || 0);

  }



  /**

   * Apply order matches Kotlin StateProcessor.applyBlock:

   * expire → materialize staking → txs → block reward.

   */

  /**
   * A transaction that fails its own contract rules is skipped, never fatal: the block is
   * already part of the chain, so voiding it here would silently drop every other
   * transaction in it plus the producer reward, and leave nodes on different states.
   */
  applyBlock(block, height) {

    const networkTs = block.header.timestamp;
    const h = Number(height || 0);

    this.expireUnclaimedRewards(networkTs, h);

    this.materializeStakingRewards(networkTs);

    const ver = Number(block.header && block.header.version) || 1;

    for (const tx of block.transactions) {

      const ledgerTx = this.scaleLegacyTx(tx, ver);

      this.applyTx(ledgerTx, networkTs, h);

    }

    this.applyReward(block, networkTs, h);
    this.accrueArscEligibleBlocks(h);
    this.settleActivityEpochIfDue(h, networkTs);
    return true;
  }

  scaleLegacyTx(tx, blockVersion) {
    if (Number(blockVersion) >= Protocol.BLOCK_VERSION_DECIMAL) return tx;
    return {
      ...tx,
      amount: Protocol.scaleLegacyAmount(tx.amount, blockVersion),
      gasFee: Protocol.scaleLegacyAmount(tx.gasFee, blockVersion),
    };
  }



  applyTx(tx, networkTs, height) {

    const bytes =

      tx.payload instanceof Uint8Array

        ? tx.payload

        : tx.payloadHex

          ? Uint8Array.from(tx.payloadHex.match(/.{1,2}/g).map((h) => parseInt(h, 16)))

          : null;

    const op = Payload.opcode(bytes);
    const h = Number(height || 0);

    switch (op) {

      case Payload.STAKE_SUBSCRIBE:

        return this.stakeSubscribe(tx, networkTs);

      case Payload.STAKE_ACTIVATE:

        return this.stakeActivate(tx);

      case Payload.STAKE_CLAIM:

        return this.stakeClaim(tx, networkTs);

      case Payload.ACTIVE_HEARTBEAT:

        return this.activeHeartbeat(tx, networkTs, h);

      case Payload.ACTIVE_CLAIM:

        return this.activeClaim(tx, networkTs, h);

      case Payload.COORD_HEARTBEAT:

        return this.coordHeartbeat(tx, networkTs, h);

      case Payload.COORD_CLAIM:

        return this.coordClaim(tx, networkTs, h);

      case Payload.INVITE_CLAIM:

        return this.inviteClaim(tx);

      case Payload.INVITE_REGISTER:

        return this.inviteRegister(tx);

      case Payload.PRODUCER_JOIN:

        return this.producerJoin(tx);

      case Payload.PRODUCER_LEAVE:

        return this.producerLeave(tx);

      case Payload.PRODUCER_BOND:

        return this.producerBond(tx);

      case Payload.PRODUCER_UNBOND:

        return this.producerUnbond(tx, networkTs);

      case Payload.PRODUCER_CLAIM_BOND:

        return this.producerClaimBond(tx, networkTs);

      default:

        return this.transfer(tx);

    }

  }



  transfer(tx) {

    const total = tx.amount + tx.gasFee;

    if (this.getBalance(tx.fromAddress) < total) return false;

    this.updateBalance(tx.fromAddress, -total);

    this.updateBalance(tx.toAddress, tx.amount);

    if (tx.gasFee > 0) this.updateBalance(Protocol.BURN_ADDRESS, tx.gasFee);

    this.touchMeta(tx);

    return true;

  }



  charge(tx, principal) {

    const total = principal + tx.gasFee;

    if (this.getBalance(tx.fromAddress) < total) return false;

    this.updateBalance(tx.fromAddress, -total);

    if (tx.gasFee > 0) this.updateBalance(Protocol.BURN_ADDRESS, tx.gasFee);

    this.touchMeta(tx);

    return true;

  }



  canBindInviter(fromAddress, inviterAddress) {
    if (!inviterAddress || !isValidSrmdAddress(inviterAddress)) return false;
    if (inviterAddress === fromAddress) return false;
    const prev = this.accounts.get(fromAddress) || { publicKeyHex: "", inviterAddress: null };
    if (prev.inviterAddress) return true;
    if (this.getBalance(inviterAddress) < Protocol.INVITER_MIN_BALANCE) return false;
    const n = this.inviteeCount.get(inviterAddress) || 0;
    if (n >= Protocol.MAX_INVITEES_PER_ADDRESS) return false;
    return true;
  }

  bindInviter(fromAddress, inviterAddress) {
    if (!this.canBindInviter(fromAddress, inviterAddress)) return false;
    const prev = this.accounts.get(fromAddress) || { publicKeyHex: "", inviterAddress: null };
    if (prev.inviterAddress) return true;
    this.accounts.set(fromAddress, {
      publicKeyHex: prev.publicKeyHex || "",
      inviterAddress,
    });
    this.inviteeCount.set(inviterAddress, (this.inviteeCount.get(inviterAddress) || 0) + 1);
    return true;
  }

  rebuildInviteeCount() {
    this.inviteeCount.clear();
    for (const [, acct] of this.accounts) {
      const inv = acct && acct.inviterAddress;
      if (!inv) continue;
      this.inviteeCount.set(inv, (this.inviteeCount.get(inv) || 0) + 1);
    }
  }

  touchMeta(tx) {
    const prev = this.accounts.get(tx.fromAddress) || { publicKeyHex: "", inviterAddress: null };
    this.accounts.set(tx.fromAddress, {
      publicKeyHex: prev.publicKeyHex || tx.signerPublicKey || "",
      inviterAddress: prev.inviterAddress || null,
    });
    if (!prev.inviterAddress && tx.inviterAddress) {
      this.bindInviter(tx.fromAddress, tx.inviterAddress);
    }
  }

  isProducerBonded(address) {
    return Protocol.isActiveProducerBond(
      this.producerBondLocked.get(address) || 0,
      this.producerUnbondAt.get(address) || 0
    );
  }

  producerAdmitted(address, chainHeight) {
    if (!Protocol.producerAdmissionRequired(chainHeight)) return true;
    return this.isProducerBonded(address);
  }

  isProducerJoined(address) {
    return this.producerJoined.has(address);
  }

  addProducerJoined(address) {
    if (!address) return;
    this.producerJoined.add(address);
    this.unparkProducer(address);
  }

  removeProducerJoined(address) {
    this.producerJoined.delete(address);
    this.unparkProducer(address);
  }

  isProducerParked(address) {
    return this.producerParked.has(address);
  }

  parkProducer(address) {
    if (!address) return false;
    const before = this.producerParked.size;
    this.producerParked.add(address);
    return this.producerParked.size !== before;
  }

  unparkProducer(address) {
    return this.producerParked.delete(address);
  }

  replaceParkedProducers(addresses) {
    this.producerParked = new Set(
      (addresses || []).map((a) => String(a || "")).filter(Boolean)
    );
  }

  parkedProducers() {
    return [...this.producerParked].sort();
  }

  /** On-chain shuffle set: JOIN list minus parked; bond-era also requires an active bond. */
  chainProducerSet(chainHeight) {
    const parked = this.producerParked || new Set();
    if (Protocol.producerAdmissionRequired(chainHeight)) {
      const out = [];
      for (const addr of this.producerJoined) {
        if (parked.has(addr)) continue;
        const locked = this.producerBondLocked.get(addr) || 0;
        if (Protocol.isActiveProducerBond(locked, this.producerUnbondAt.get(addr) || 0)) {
          out.push(addr);
        }
      }
      return out.sort();
    }
    return [...this.producerJoined].filter((a) => !parked.has(a)).sort();
  }



  stakeSubscribe(tx, networkTs) {

    if (tx.toAddress !== Protocol.STAKING_ADDRESS) return false;

    if (tx.amount < Protocol.STAKING_MIN_SUBSCRIPTION || tx.amount > Protocol.STAKING_MAX_SUBSCRIPTION)

      return false;

    if (this.stakeMatured.get(tx.fromAddress)) return false;

    const month = this.monthIndex(networkTs);

    if (this.lastStakingPaidMonth.get(tx.fromAddress) === month) return false;

    if (!this.charge(tx, tx.amount)) return false;

    this.setContractBalance(

      Protocol.STAKING_ADDRESS,

      this.getContractBalance(Protocol.STAKING_ADDRESS) + tx.amount

    );

    this.stakingShares.set(

      tx.fromAddress,

      (this.stakingShares.get(tx.fromAddress) || 0) + tx.amount

    );

    this.stakingPaidMonths.set(

      tx.fromAddress,

      (this.stakingPaidMonths.get(tx.fromAddress) || 0) + 1

    );

    this.lastStakingPaidMonth.set(tx.fromAddress, month);
    this.lastStakingPaidAt.set(tx.fromAddress, Number(networkTs) || 0);

    return true;

  }



  stakeActivate(tx) {

    if (tx.toAddress !== Protocol.STAKING_ADDRESS || tx.amount !== 0) return false;

    if (this.stakeMatured.get(tx.fromAddress)) return false;

    const paid = this.stakingPaidMonths.get(tx.fromAddress) || 0;

    if (paid < Protocol.STAKING_ELIGIBILITY_MONTHS) return false;

    const shares = this.stakingShares.get(tx.fromAddress) || 0;

    if (shares <= 0) return false;

    if (!this.charge(tx, 0)) return false;

    this.stakeMatured.set(tx.fromAddress, true);

    this.maturedStakingSharesTotal += shares;

    this.trackedStakingAddresses.add(tx.fromAddress);

    return true;

  }



  stakeClaim(tx, networkTs) {

    if (tx.toAddress !== Protocol.STAKING_ADDRESS || tx.amount !== 0) return false;

    if (!this.stakeMatured.get(tx.fromAddress)) return false;

    const period = this.stakingRewardPeriod.get(tx.fromAddress);

    const payout = this.stakingRewardClaimable.get(tx.fromAddress) || 0;

    const claimableAt = this.stakingRewardClaimableAt.get(tx.fromAddress) || 0;

    const claimWindow = this.stakingClaimWindowSeconds();

    if (period == null || period < 0 || payout <= 0 || networkTs - claimableAt >= claimWindow) {

      return false;

    }

    const pool = this.getContractBalance(Protocol.STAKING_ADDRESS);

    if (payout > pool) return false;

    if (!this.charge(tx, 0)) return false;

    this.setContractBalance(Protocol.STAKING_ADDRESS, pool - payout);

    this.stakingRewardClaimable.set(tx.fromAddress, 0);

    this.updateBalance(tx.fromAddress, payout);

    this.lastStakingClaimMonth.set(tx.fromAddress, period);

    this.stakingClaimedTotal.set(

      tx.fromAddress,

      (this.stakingClaimedTotal.get(tx.fromAddress) || 0) + payout

    );

    const claimId = tx.txHash || tx.signature;
    if (claimId && payout > 0) this.txClaimPayouts.set(claimId, payout);

    return true;

  }



  activeHeartbeat(tx, networkTs, height) {
    if (tx.toAddress !== Protocol.ACTIVE_ADDRESS || tx.amount !== 0) return false;
    const h = Number(height || 0);
    const blockMode = Protocol.arscBlockEpochActive(h) || this.arscBlockEpoch;
    if (blockMode) {
      const gas = Number(tx.gasFee || 0);
      if (gas > 0) {
        if (!this.charge(tx, 0)) return false;
      } else {
        this.touchMeta(tx);
      }
      return true;
    }
    if (!this.activityEpochStart) this.activityEpochStart = networkTs;
    const lastHb = this.activityLastHeartbeatTs.get(tx.fromAddress) || 0;
    const interval = Protocol.ACTIVE_HEARTBEAT_INTERVAL_SECONDS || 1200;
    if (lastHb > 0 && networkTs - lastHb < interval) return false;
    const gas = Number(tx.gasFee || 0);
    if (gas > 0) {
      if (!this.charge(tx, 0)) return false;
    } else {
      this.touchMeta(tx);
    }
    this.activityLastHeartbeatTs.set(tx.fromAddress, networkTs);
    let credit = Protocol.ACTIVE_HEARTBEAT_CREDIT_MINUTES || 20;
    const raw = Payload.decode(
      tx.payload instanceof Uint8Array
        ? tx.payload
        : tx.payloadHex
          ? Uint8Array.from(String(tx.payloadHex).match(/.{1,2}/g).map((h) => parseInt(h, 16)))
          : null
    );
    if (
      (raw && raw.startsWith("ACTIVE:HEARTBEAT|HUNT|")) ||
      (raw && raw.startsWith("COORD:HEARTBEAT|HUNT|"))
    ) {
      const parts = raw.split("|");
      const window = Number(parts[2]);
      const sig = String(parts[3] || "");
      if (window === Protocol.huntWindowId(networkTs) && Protocol.verifyHuntHmac(tx.fromAddress, window, sig, networkTs)) {
        credit += Protocol.ACTIVE_HEARTBEAT_HUNT_EXTRA_MINUTES || 20;
      }
    }
    this.activityMinutes.set(
      tx.fromAddress,
      (this.activityMinutes.get(tx.fromAddress) || 0) + credit
    );
    this.activityPointsUpdatedAt.set(tx.fromAddress, networkTs);
    this.trackedRewardAddresses.add(tx.fromAddress);
    return true;
  }

  activeClaim(tx, networkTs, height) {
    if (tx.toAddress !== Protocol.ACTIVE_ADDRESS || tx.amount !== 0) return false;
    const claimable = this.activityClaimable.get(tx.fromAddress) || 0;
    if (claimable <= 0) return false;
    const claimableAt = this.activityClaimableAt.get(tx.fromAddress) || 0;
    const h = Number(height || 0);
    const blockMode = Protocol.arscBlockEpochActive(h) || this.arscBlockEpoch;
    if (!blockMode) {
      const window = this.activeClaimWindowSeconds();
      if (claimableAt > 0 && networkTs - claimableAt >= window) return false;
    }
    const gas = Number(tx.gasFee || 0);
    if (gas > 0) {
      if (!this.charge(tx, 0)) return false;
    } else {
      this.touchMeta(tx);
    }
    this.activityClaimable.set(tx.fromAddress, 0);
    this.activityClaimableAt.set(tx.fromAddress, 0);
    this.updateBalance(tx.fromAddress, claimable);
    this.activityRewardsEarnedTotal.set(
      tx.fromAddress,
      (this.activityRewardsEarnedTotal.get(tx.fromAddress) || 0) + claimable
    );
    const claimId = tx.txHash || tx.signature;
    if (claimId && claimable > 0) this.txClaimPayouts.set(claimId, claimable);
    return true;
  }

  coordHeartbeat(tx, networkTs, height) {
    return this.activeHeartbeat(tx, networkTs, height);
  }

  coordClaim(tx, networkTs, height) {
    return this.activeClaim(tx, networkTs, height);
  }

  ensureArscBlockEpochMode(height) {
    const h = Number(height || 0);
    if (!Protocol.arscBlockEpochActive(h)) return;
    if (this.arscBlockEpoch) {
      if (!this.activityEpochStart) this.activityEpochStart = h;
      return;
    }
    for (const addr of this.trackedRewardAddresses) {
      this.activityMinutes.set(addr, 0);
    }
    this.activityEpochStart = h;
    this.arscBlockEpoch = true;
  }

  accrueArscEligibleBlocks(height) {
    this.ensureArscBlockEpochMode(height);
    if (!this.arscBlockEpoch) return;
    for (const addr of this.chainProducerSet(height)) {
      this.activityMinutes.set(addr, (this.activityMinutes.get(addr) || 0) + 1);
      this.trackedRewardAddresses.add(addr);
    }
  }

  settleActivityEpochIfDue(height, networkTs) {
    this.ensureArscBlockEpochMode(height);
    if (this.arscBlockEpoch) {
      this.settleActivityEpochBlocks(height, networkTs);
    } else {
      this.settleActivityEpochLegacy(networkTs);
    }
  }

  settleActivityEpochLegacy(networkTs) {
    if (!this.activityEpochStart) {
      this.activityEpochStart = networkTs;
      return;
    }
    const epochLen = (Protocol.ACTIVE_NODES_CLAIM_WINDOW_HOURS || 23) * 3600;
    if (networkTs - this.activityEpochStart < epochLen) return;

    this.burnUnclaimedActivity();

    const shares = [];
    for (const addr of [...this.trackedRewardAddresses].sort()) {
      const hours = Math.floor((this.activityMinutes.get(addr) || 0) / 60);
      if (hours <= 0) continue;
      const lastMined = this.lastMinedAt.get(addr) || 0;
      if (lastMined >= this.activityEpochStart) continue;
      shares.push([addr, hours]);
    }
    this.distributeActivityShares(shares, networkTs);
    for (const addr of this.trackedRewardAddresses) {
      this.activityMinutes.set(addr, 0);
    }
    this.activityEpochStart = networkTs;
  }

  settleActivityEpochBlocks(height, networkTs) {
    let epochStart = this.activityEpochStart || 0;
    if (epochStart <= 0) {
      this.activityEpochStart = height;
      return;
    }
    const epochLen = Protocol.ARSC_EPOCH_BLOCKS || 3333;
    while (height - epochStart >= epochLen) {
      this.burnUnclaimedActivity();
      const epochEnd = epochStart + epochLen;
      const shares = [];
      for (const addr of [...this.trackedRewardAddresses].sort()) {
        const eligible = this.activityMinutes.get(addr) || 0;
        const shareCount = Protocol.arscSharesFromEligibleBlocks(eligible);
        if (shareCount <= 0) continue;
        const minedH = this.lastMinedHeight.get(addr) || 0;
        if (minedH >= epochStart && minedH < epochEnd) continue;
        shares.push([addr, shareCount]);
      }
      this.distributeActivityShares(shares, networkTs);
      for (const addr of this.trackedRewardAddresses) {
        this.activityMinutes.set(addr, 0);
      }
      epochStart = epochEnd;
      this.activityEpochStart = epochStart;
    }
  }

  burnUnclaimedActivity() {
    for (const addr of [...this.trackedRewardAddresses].sort()) {
      const pending = this.activityClaimable.get(addr) || 0;
      if (pending > 0) {
        this.activityClaimable.set(addr, 0);
        this.activityClaimableAt.set(addr, 0);
        this.updateBalance(Protocol.BURN_ADDRESS, pending);
      }
    }
  }

  distributeActivityShares(shares, networkTs) {
    const totalShares = shares.reduce((s, [, n]) => s + n, 0);
    const pool = this.getContractBalance(Protocol.ACTIVE_ADDRESS);
    if (totalShares <= 0 || pool <= 0) return;
    const shareValue = Math.floor(pool / totalShares);
    if (shareValue <= 0) return;
    let reserved = 0;
    for (const [addr, count] of shares) {
      const payout = count * shareValue;
      if (payout <= 0) continue;
      this.activityClaimable.set(addr, payout);
      this.activityClaimableAt.set(addr, networkTs);
      this.trackedRewardAddresses.add(addr);
      reserved += payout;
    }
    this.setContractBalance(Protocol.ACTIVE_ADDRESS, pool - reserved);
  }

  /**
   * Locks the sender's inviter on-chain. Zero amount / zero gas; mining never carries
   * the inviter, so this must succeed without moving balances.
   */
  inviteRegister(tx) {
    if (tx.toAddress !== Protocol.INVITER_ADDRESS || Number(tx.amount || 0) !== 0) return false;
    if (!tx.inviterAddress || !isValidSrmdAddress(tx.inviterAddress)) return false;
    if (tx.inviterAddress === tx.fromAddress) return false;
    const prev = this.accounts.get(tx.fromAddress) || { publicKeyHex: "", inviterAddress: null };
    if (prev.inviterAddress) return true;
    if (!this.canBindInviter(tx.fromAddress, tx.inviterAddress)) return false;
    const gas = Number(tx.gasFee || 0);
    if (gas > 0 && !this.charge(tx, 0)) return false;
    if (gas <= 0) {
      const cur = this.accounts.get(tx.fromAddress) || prev;
      this.accounts.set(tx.fromAddress, {
        publicKeyHex: cur.publicKeyHex || tx.signerPublicKey || "",
        inviterAddress: cur.inviterAddress || null,
      });
    }
    return this.bindInviter(tx.fromAddress, tx.inviterAddress);
  }

  producerBond(tx) {
    if (tx.toAddress !== Protocol.PRODUCER_BOND_ADDRESS) return false;
    if (Number(tx.amount || 0) !== Protocol.PRODUCER_ADMISSION_BOND) return false;
    if (this.isProducerBonded(tx.fromAddress)) return false;
    const unbondAt = Number(this.producerUnbondAt.get(tx.fromAddress) || 0);
    if (unbondAt > 0) return false;
    if ((this.producerBondLocked.get(tx.fromAddress) || 0) > 0) return false;
    if (!this.charge(tx, Protocol.PRODUCER_ADMISSION_BOND)) return false;
    this.setContractBalance(
      Protocol.PRODUCER_BOND_ADDRESS,
      this.getContractBalance(Protocol.PRODUCER_BOND_ADDRESS) + Protocol.PRODUCER_ADMISSION_BOND
    );
    this.producerBondLocked.set(tx.fromAddress, Protocol.PRODUCER_ADMISSION_BOND);
    this.producerUnbondAt.set(tx.fromAddress, 0);
    this.addProducerJoined(tx.fromAddress);
    return true;
  }

  producerJoin(tx) {
    if (tx.toAddress !== Protocol.PRODUCER_BOND_ADDRESS || Number(tx.amount || 0) !== 0) return false;
    if (!this.charge(tx, 0)) return false;
    this.addProducerJoined(tx.fromAddress);
    return true;
  }

  producerLeave(tx) {
    if (tx.toAddress !== Protocol.PRODUCER_BOND_ADDRESS || Number(tx.amount || 0) !== 0) return false;
    // Bond stays locked; Produce off only leaves the mining queue.
    if (!this.charge(tx, 0)) return false;
    this.removeProducerJoined(tx.fromAddress);
    return true;
  }

  producerUnbond(tx, networkTs) {
    if (tx.toAddress !== Protocol.PRODUCER_BOND_ADDRESS || Number(tx.amount || 0) !== 0) return false;
    if (!this.isProducerBonded(tx.fromAddress)) return false;
    if (!this.charge(tx, 0)) return false;
    this.producerUnbondAt.set(tx.fromAddress, Number(networkTs || 0) || 1);
    this.removeProducerJoined(tx.fromAddress);
    return true;
  }

  producerClaimBond(tx, networkTs) {
    if (tx.toAddress !== Protocol.PRODUCER_BOND_ADDRESS || Number(tx.amount || 0) !== 0) return false;
    const locked = Number(this.producerBondLocked.get(tx.fromAddress) || 0);
    const unbondAt = Number(this.producerUnbondAt.get(tx.fromAddress) || 0);
    if (locked < Protocol.PRODUCER_ADMISSION_BOND || unbondAt <= 0) return false;
    if (Number(networkTs || 0) - unbondAt < Protocol.PRODUCER_ADMISSION_EXIT_SECONDS) return false;
    const pool = this.getContractBalance(Protocol.PRODUCER_BOND_ADDRESS);
    if (pool < locked) return false;
    if (!this.charge(tx, 0)) return false;
    this.setContractBalance(Protocol.PRODUCER_BOND_ADDRESS, pool - locked);
    this.updateBalance(tx.fromAddress, locked);
    this.producerBondLocked.delete(tx.fromAddress);
    this.producerUnbondAt.delete(tx.fromAddress);
    return true;
  }

  inviteClaim(tx) {

    if (tx.toAddress !== Protocol.INVITER_ADDRESS || tx.amount !== 0) return false;

    const claimable = this.inviterClaimable.get(tx.fromAddress) || 0;

    if (claimable < Protocol.INVITER_MIN_CLAIM) return false;

    if (!this.charge(tx, 0)) return false;

    this.inviterClaimable.set(tx.fromAddress, 0);

    this.updateBalance(tx.fromAddress, claimable);

    this.inviterRewardsEarnedTotal.set(

      tx.fromAddress,

      (this.inviterRewardsEarnedTotal.get(tx.fromAddress) || 0) + claimable

    );

    const claimId = tx.txHash || tx.signature;
    if (claimId && claimable > 0) this.txClaimPayouts.set(claimId, claimable);

    return true;

  }



  resolveMinerAddress(block) {

    const pub = block.header && block.header.minerPublicKey;

    if (!pub || pub === "genesis") return null;

    try {

      const hex = String(pub);

      if (!/^[0-9a-fA-F]+$/.test(hex) || hex.length % 2 !== 0) return null;

      return publicKeyToAddress(

        Uint8Array.from((hex.match(/.{1,2}/g) || []).map((h) => parseInt(h, 16)))

      );

    } catch {

      return null;

    }

  }



  /**
   * Genesis pays the same shares as any block with no producer: the pools are funded,
   * the inviter share is burned, and the miner share is never minted. Phones apply this
   * too, so skipping it here would leave the browser on a different state.
   */
  applyGenesis() {

    this.setContractBalance(

      Protocol.STAKING_ADDRESS,

      this.getContractBalance(Protocol.STAKING_ADDRESS) + Protocol.STAKING_CONTRACT_REWARD

    );

    this.setContractBalance(

      Protocol.ACTIVE_ADDRESS,

      this.getContractBalance(Protocol.ACTIVE_ADDRESS) + Protocol.ACTIVE_NODES_REWARD

    );

    this.updateBalance(Protocol.BURN_ADDRESS, Protocol.INVITER_REWARD);

  }



  applyReward(block, networkTs, height) {

    const miner = this.resolveMinerAddress(block);

    if (miner) {

      this.updateBalance(miner, Protocol.MINER_REWARD);

      this.lastMinedAt.set(miner, networkTs);
      this.lastMinedHeight.set(miner, Number(height || 0));

    }

    this.setContractBalance(

      Protocol.STAKING_ADDRESS,

      this.getContractBalance(Protocol.STAKING_ADDRESS) + Protocol.STAKING_CONTRACT_REWARD

    );

    this.setContractBalance(

      Protocol.ACTIVE_ADDRESS,

      this.getContractBalance(Protocol.ACTIVE_ADDRESS) + Protocol.ACTIVE_NODES_REWARD

    );

    const acct = miner ? this.accounts.get(miner) : null;

    const inviter = acct && acct.inviterAddress;

    if (inviter && inviter !== miner) {

      this.accrueInviterReward(inviter, networkTs);

    } else {

      this.updateBalance(Protocol.BURN_ADDRESS, Protocol.INVITER_REWARD);

    }

  }



  accrueInviterReward(inviterAddress, networkTs) {

    const dayIndex = Math.floor(networkTs / Protocol.SECONDS_PER_DAY);

    const key = `${inviterAddress}:${dayIndex}`;

    const accruedToday = this.inviterDailyAccrued.get(key) || 0;

    const room = Protocol.MAX_INVITER_REWARDS_PER_DAY - accruedToday;

    if (room <= 0) {

      this.updateBalance(Protocol.BURN_ADDRESS, Protocol.INVITER_REWARD);

      return;

    }

    const credit = Math.min(Protocol.INVITER_REWARD, room);

    const overflow = Protocol.INVITER_REWARD - credit;

    if (overflow > 0) this.updateBalance(Protocol.BURN_ADDRESS, overflow);

    this.inviterClaimable.set(

      inviterAddress,

      (this.inviterClaimable.get(inviterAddress) || 0) + credit

    );

    this.inviterClaimableUpdatedAt.set(inviterAddress, networkTs);

    this.inviterDailyAccrued.set(key, accruedToday + credit);

    this.trackedRewardAddresses.add(inviterAddress);

  }



  expireUnclaimedRewards(networkTs, height) {

    const inviterWindow = this.inviterClaimWindowSeconds();

    const stakingWindow = this.stakingClaimWindowSeconds();
    const h = Number(height || 0);
    const arscBlockMode = Protocol.arscBlockEpochActive(h) || this.arscBlockEpoch;



    for (const addr of [...this.trackedStakingAddresses].sort()) {

      const claimable = this.stakingRewardClaimable.get(addr) || 0;

      if (claimable > 0) {

        const claimableAt = this.stakingRewardClaimableAt.get(addr) || 0;

        if (networkTs - claimableAt >= stakingWindow) {

          const pool = this.getContractBalance(Protocol.STAKING_ADDRESS);

          const burnAmount = Math.min(claimable, pool);

          this.stakingRewardClaimable.set(addr, 0);

          if (burnAmount > 0) {

            this.setContractBalance(Protocol.STAKING_ADDRESS, pool - burnAmount);

            this.updateBalance(Protocol.BURN_ADDRESS, burnAmount);

          }

        }

      }

    }



    for (const addr of [...this.trackedRewardAddresses].sort()) {

      const invClaimable = this.inviterClaimable.get(addr) || 0;

      if (invClaimable > 0) {

        const updated = this.inviterClaimableUpdatedAt.get(addr) || 0;

        if (networkTs - updated >= inviterWindow) {

          this.inviterClaimable.set(addr, 0);

          this.updateBalance(Protocol.BURN_ADDRESS, invClaimable);

        }

      }

      if (!arscBlockMode) {
        const actClaimable = this.activityClaimable.get(addr) || 0;
        if (actClaimable > 0) {
          const at = this.activityClaimableAt.get(addr) || 0;
          const activityWindow = this.activeClaimWindowSeconds();
          if (at > 0 && networkTs - at >= activityWindow) {
            this.activityClaimable.set(addr, 0);
            this.activityClaimableAt.set(addr, 0);
            this.updateBalance(Protocol.BURN_ADDRESS, actClaimable);
          }
        }
      }
    }

  }



  /**

   * Materializes each monthly staking payout from one fixed pool/share snapshot.

   * Mirrors StateProcessor.materializeStakingRewards.

   */

  materializeStakingRewards(networkTs) {

    const currentPeriod = this.monthIndex(networkTs);

    let lastPeriod = this.lastMaterializedStakingPeriod;

    if (lastPeriod < 0) {

      // Fresh-state rule: materialize only the current period, never invent past rewards.

      lastPeriod = currentPeriod - 1;

    }

    if (lastPeriod >= currentPeriod) return;



    const addresses = [...this.trackedStakingAddresses].sort();

    const maturedTotal = this.maturedStakingSharesTotal;

    for (let period = lastPeriod + 1; period <= currentPeriod; period++) {

      const claimableAt = period * Protocol.STAKING_MONTH_SECONDS;

      const pool = this.getContractBalance(Protocol.STAKING_ADDRESS);

      const monthlyPool = Math.floor(pool / Protocol.STAKING_DISTRIBUTION_DIVISOR);

      const shareValue = maturedTotal === 0 ? 0 : Math.floor(monthlyPool / maturedTotal);

      const expired = networkTs - claimableAt >= this.stakingClaimWindowSeconds();

      let burnTotal = 0;



      for (const address of addresses) {

        const shares = this.stakingShares.get(address) || 0;

        const payout = shares * shareValue;

        this.stakingRewardPeriod.set(address, period);

        this.stakingRewardClaimableAt.set(address, claimableAt);

        this.stakingRewardClaimable.set(address, expired ? 0 : payout);

        if (expired) burnTotal += payout;

      }



      const burnAmount = Math.min(burnTotal, pool);

      if (burnAmount > 0) {

        this.setContractBalance(Protocol.STAKING_ADDRESS, pool - burnAmount);

        this.updateBalance(Protocol.BURN_ADDRESS, burnAmount);

      }

      this.lastMaterializedStakingPeriod = period;

    }

  }



  rewardStats(address, networkTs = 0, chainHeight = 0) {
    const ts = Number(networkTs) || 0;
    const height = Number(chainHeight || 0);
    const required = Protocol.STAKING_ELIGIBILITY_MONTHS;
    const paidMonths = this.stakingPaidMonths.get(address) || 0;
    const matured = !!this.stakeMatured.get(address);
    const shares = this.stakingShares.get(address) || 0;
    const monthIndex = this.monthIndex(ts);
    const lastPaid = this.lastStakingPaidMonth.has(address)
      ? this.lastStakingPaidMonth.get(address)
      : -1;

    const stakeWindow = this.stakingClaimWindowSeconds();
    const stakeRaw = this.stakingRewardClaimable.get(address) || 0;
    const stakeAt = this.stakingRewardClaimableAt.get(address) || 0;
    const stakingClaimable =
      matured && stakeRaw > 0 && stakeAt > 0 && ts - stakeAt < stakeWindow ? stakeRaw : 0;

    const canSubscribeStaking = !matured && lastPaid !== monthIndex;
    const stakingDaysUntilNextPayment =
      !canSubscribeStaking && !matured && lastPaid >= 0
        ? Math.min(
            Protocol.STAKING_MONTH_DAYS,
            Math.max(
              0,
              Math.ceil(
                ((lastPaid + 1) * Protocol.STAKING_MONTH_DAYS * Protocol.SECONDS_PER_DAY - ts) /
                  Protocol.SECONDS_PER_DAY
              )
            )
          )
        : 0;
    const canActivateStaking = !matured && paidMonths >= required && shares > 0;
    const canClaimStaking = stakingClaimable > 0;

    const minutes = this.activityMinutes.get(address) || 0;
    const blockMode = this.arscBlockEpoch || Protocol.arscBlockEpochActive(height);
    const activityHoursLogged = blockMode
      ? Protocol.arscSharesFromEligibleBlocks(minutes)
      : Math.floor(minutes / 60);
    const activityEligibleBlocks = blockMode ? minutes : 0;
    const activityWindow = this.activeClaimWindowSeconds();
    const epochStart = this.activityEpochStart || 0;
    const lastMined = this.lastMinedAt.get(address) || 0;
    const lastMinedH = this.lastMinedHeight.get(address) || 0;
    const epochLenBlocks = Protocol.ARSC_EPOCH_BLOCKS || 3333;
    const minedThisEpoch = blockMode
      ? epochStart > 0 && lastMinedH >= epochStart && lastMinedH < epochStart + epochLenBlocks
      : epochStart > 0 && lastMined >= epochStart;
    const epochRemainingBlocks =
      blockMode && epochStart > 0
        ? Math.max(0, epochStart + epochLenBlocks - height)
        : 0;
    const epochRemaining = blockMode
      ? epochRemainingBlocks
      : epochStart > 0
        ? Math.max(0, epochStart + activityWindow - ts)
        : activityWindow;
    const actRaw = this.activityClaimable.get(address) || 0;
    const actAt = this.activityClaimableAt.get(address) || 0;
    const activityClaimable = blockMode
      ? actRaw > 0
        ? actRaw
        : 0
      : actRaw > 0 && actAt > 0 && ts - actAt < activityWindow
        ? actRaw
        : 0;
    const canClaimActivity = activityClaimable > 0;
    const activityClaimCooldownSeconds = canClaimActivity
      ? blockMode
        ? epochRemainingBlocks
        : Math.max(0, actAt + activityWindow - ts)
      : minedThisEpoch
        ? -1
        : epochRemaining;

    const invRaw = this.inviterClaimable.get(address) || 0;
    const invAt = this.inviterClaimableUpdatedAt.get(address) || 0;
    const invWindow = this.inviterClaimWindowSeconds();
    const inviterClaimable =
      invRaw > 0 && invAt > 0 && ts - invAt < invWindow ? invRaw : 0;
    const canClaimReferral = inviterClaimable >= Protocol.INVITER_MIN_CLAIM;

    let allStakingShares = 0;
    for (const v of this.stakingShares.values()) allStakingShares += v;
    const stakingPool = this.getContractBalance(Protocol.STAKING_ADDRESS);
    const stakingNextMonthEstimate = Protocol.estimateStakingNextMonth(
      stakingPool,
      allStakingShares,
      shares
    );

    return {
      balance: this.getBalance(address),
      stakingClaimable,
      stakingNextMonthEstimate,
      activityClaimable,
      inviterClaimable,
      stakingShares: shares,
      paidMonths,
      stakeMatured: matured,
      stakingClaimedTotal: this.stakingClaimedTotal.get(address) || 0,
      activityRewardsEarnedTotal: this.activityRewardsEarnedTotal.get(address) || 0,
      inviterRewardsEarnedTotal: this.inviterRewardsEarnedTotal.get(address) || 0,
      canSubscribeStaking,
      stakingDaysUntilNextPayment,
      canActivateStaking,
      canClaimStaking,
      canClaimActivity,
      canClaimReferral,
      activityHoursLogged,
      activityEligibleBlocks,
      activityClaimCooldownSeconds,
      activityEpochRemainingSeconds: epochRemaining,
      activityEpochRemainingBlocks: epochRemainingBlocks,
      activityEpochStart: epochStart,
      activityBlockEpoch: blockMode,
      inviteeCount: this.inviteeCount.get(address) || 0,
      canRefer:
        this.getBalance(address) >= Protocol.INVITER_MIN_BALANCE &&
        (this.inviteeCount.get(address) || 0) < Protocol.MAX_INVITEES_PER_ADDRESS,
      producerBonded: this.isProducerBonded(address),
      producerBondLocked: this.producerBondLocked.get(address) || 0,
      producerUnbondAt: this.producerUnbondAt.get(address) || 0,
    };
  }

  /** Portable snapshot for EXE prune/resume (mirrors coordinator snapshot_store serialize). */
  exportSnapshot() {
    const mapToObject = (m) => {
      const o = {};
      if (!m) return o;
      for (const [k, v] of m.entries()) o[k] = v;
      return o;
    };
    const accounts = [];
    for (const [address, acct] of this.accounts.entries()) {
      accounts.push({
        address,
        balance: Number(this.balances.get(address) || 0),
        nonce: Number(acct.nonce || 0),
        publicKeyHex: String(acct.publicKeyHex || ""),
        inviterAddress: acct.inviterAddress || null,
      });
    }
    for (const [address, bal] of this.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(this.contractBalances),
      stakingShares: mapToObject(this.stakingShares),
      stakingPaidMonths: mapToObject(this.stakingPaidMonths),
      lastStakingPaidMonth: mapToObject(this.lastStakingPaidMonth),
      lastStakingPaidAt: mapToObject(this.lastStakingPaidAt),
      stakeMatured: mapToObject(this.stakeMatured),
      maturedStakingSharesTotal: this.maturedStakingSharesTotal || 0,
      lastStakingClaimMonth: mapToObject(this.lastStakingClaimMonth),
      stakingClaimedTotal: mapToObject(this.stakingClaimedTotal),
      stakingRewardPeriod: mapToObject(this.stakingRewardPeriod),
      stakingRewardClaimable: mapToObject(this.stakingRewardClaimable),
      stakingRewardClaimableAt: mapToObject(this.stakingRewardClaimableAt),
      lastMaterializedStakingPeriod: this.lastMaterializedStakingPeriod,
      trackedStakingAddresses: [...(this.trackedStakingAddresses || [])],
      activityPoints: mapToObject(this.activityPoints),
      activityPointsUpdatedAt: mapToObject(this.activityPointsUpdatedAt),
      activityRewardsEarnedTotal: mapToObject(this.activityRewardsEarnedTotal),
      lastActivityHeartbeatDay: mapToObject(this.lastActivityHeartbeatDay),
      activityMinutes: mapToObject(this.activityMinutes),
      activityLastHeartbeatTs: mapToObject(this.activityLastHeartbeatTs),
      activityEpochStart: this.activityEpochStart || 0,
      activityClaimable: mapToObject(this.activityClaimable),
      activityClaimableAt: mapToObject(this.activityClaimableAt),
      coordPoints: mapToObject(this.coordPoints),
      coordPointsUpdatedAt: mapToObject(this.coordPointsUpdatedAt),
      lastCoordHeartbeatDay: mapToObject(this.lastCoordHeartbeatDay),
      coordRewardsEarnedTotal: mapToObject(this.coordRewardsEarnedTotal),
      lastMinedAt: mapToObject(this.lastMinedAt),
      lastMinedHeight: mapToObject(this.lastMinedHeight),
      arscBlockEpoch: !!this.arscBlockEpoch,
      inviterClaimable: mapToObject(this.inviterClaimable),
      inviterClaimableUpdatedAt: mapToObject(this.inviterClaimableUpdatedAt),
      inviterDailyAccrued: mapToObject(this.inviterDailyAccrued),
      inviterRewardsEarnedTotal: mapToObject(this.inviterRewardsEarnedTotal),
      inviteeCount: mapToObject(this.inviteeCount),
      producerBondLocked: mapToObject(this.producerBondLocked),
      producerUnbondAt: mapToObject(this.producerUnbondAt),
      producerJoined: [...(this.producerJoined || [])],
      producerParked: [...(this.producerParked || [])],
      trackedRewardAddresses: [...(this.trackedRewardAddresses || [])],
      txClaimPayouts: mapToObject(this.txClaimPayouts),
    };
  }

  importSnapshot(s) {
    if (!s || typeof s !== "object") return false;
    const assignMap = (target, obj) => {
      target.clear();
      if (!obj) return;
      for (const [k, v] of Object.entries(obj)) target.set(k, v);
    };
    this.balances.clear();
    this.accounts.clear();
    for (const a of s.accounts || []) {
      this.balances.set(a.address, Number(a.balance || 0));
      this.accounts.set(a.address, {
        publicKeyHex: a.publicKeyHex || "",
        inviterAddress: a.inviterAddress || null,
        nonce: Number(a.nonce || 0),
      });
    }
    assignMap(this.contractBalances, s.contractBalances);
    assignMap(this.stakingShares, s.stakingShares);
    assignMap(this.stakingPaidMonths, s.stakingPaidMonths);
    assignMap(this.lastStakingPaidMonth, s.lastStakingPaidMonth);
    assignMap(this.lastStakingPaidAt, s.lastStakingPaidAt);
    assignMap(this.stakeMatured, s.stakeMatured);
    this.maturedStakingSharesTotal = s.maturedStakingSharesTotal || 0;
    assignMap(this.lastStakingClaimMonth, s.lastStakingClaimMonth);
    assignMap(this.stakingClaimedTotal, s.stakingClaimedTotal);
    assignMap(this.stakingRewardPeriod, s.stakingRewardPeriod);
    assignMap(this.stakingRewardClaimable, s.stakingRewardClaimable);
    assignMap(this.stakingRewardClaimableAt, s.stakingRewardClaimableAt);
    this.lastMaterializedStakingPeriod = s.lastMaterializedStakingPeriod;
    this.trackedStakingAddresses = new Set(s.trackedStakingAddresses || []);
    assignMap(this.activityPoints, s.activityPoints);
    assignMap(this.activityPointsUpdatedAt, s.activityPointsUpdatedAt);
    assignMap(this.activityRewardsEarnedTotal, s.activityRewardsEarnedTotal);
    assignMap(this.lastActivityHeartbeatDay, s.lastActivityHeartbeatDay);
    assignMap(this.activityMinutes, s.activityMinutes);
    assignMap(this.activityLastHeartbeatTs, s.activityLastHeartbeatTs);
    this.activityEpochStart = s.activityEpochStart || 0;
    assignMap(this.activityClaimable, s.activityClaimable);
    assignMap(this.activityClaimableAt, s.activityClaimableAt);
    assignMap(this.coordPoints, s.coordPoints);
    assignMap(this.coordPointsUpdatedAt, s.coordPointsUpdatedAt);
    assignMap(this.lastCoordHeartbeatDay, s.lastCoordHeartbeatDay);
    assignMap(this.coordRewardsEarnedTotal, s.coordRewardsEarnedTotal);
    assignMap(this.lastMinedAt, s.lastMinedAt);
    assignMap(this.lastMinedHeight, s.lastMinedHeight);
    this.arscBlockEpoch = !!s.arscBlockEpoch;
    assignMap(this.inviterClaimable, s.inviterClaimable);
    assignMap(this.inviterClaimableUpdatedAt, s.inviterClaimableUpdatedAt);
    assignMap(this.inviterDailyAccrued, s.inviterDailyAccrued);
    assignMap(this.inviterRewardsEarnedTotal, s.inviterRewardsEarnedTotal);
    assignMap(this.producerBondLocked, s.producerBondLocked);
    assignMap(this.producerUnbondAt, s.producerUnbondAt);
    this.producerJoined = new Set(s.producerJoined || []);
    this.producerParked = new Set(s.producerParked || []);
    this.trackedRewardAddresses = new Set(s.trackedRewardAddresses || []);
    assignMap(this.txClaimPayouts, s.txClaimPayouts);
    this.rebuildInviteeCount();
    return true;
  }
}


