// Property-based fuzz test for the sAERE + AereSink drip mechanism.
//
// Drives a deterministic pseudo-random walk of 800 user-level actions against
// the live ERC-4626 + 3-bucket-router stack:
//
//   action menu:
//     0  deposit(actor, amount)        — pull WAERE into vault, mint shares
//     1  withdraw(actor, shares)       — burn shares, return WAERE
//     2  sync()                        — permissionless drip refresh
//     3  AereSink.flush(WAERE, amount) — protocol revenue arrives, routes
//                                         STAKER_YIELD_BPS into sAERE then pings
//     4  time-advance(seconds)         — jump anywhere from 1s to ~2 weeks
//
// Invariants verified after EVERY action:
//
//   I-1  totalAssets() <= asset.balanceOf(sAERE)
//   I-2  totalAssets() + _undistributedNow() == asset.balanceOf(sAERE)
//        (exact equality — no epsilon needed; both come from the same balance)
//   I-3  pricePerShare (convertToAssets(1e18)) is monotonically non-decreasing
//        across the whole walk
//   I-4  sync() inside the same block is idempotent — calling it again does
//        not change rewardRate / periodFinish / lastObservedBalance
//   I-5  lastObservedBalance == asset.balanceOf(sAERE) after every state-
//        changing op (deposit / withdraw / sync / sink.flush)
//   I-6  periodFinish never DECREASES from one observation to the next as a
//        consequence of a fresh reward arrival (it only ever resets forward
//        to now + DRIP_DURATION; never backward)
//
// Run:  npx hardhat test test/saere-sink-fuzz.test.js
//
// Deterministic: a fixed PRNG seed drives the action stream so failures
// reproduce exactly.

const { expect } = require("chai");
const { ethers, network } = require("hardhat");

const ONE        = 10n ** 18n;
const DEAD_SEED  = 1000n;
const DEAD_ADDR  = "0x000000000000000000000000000000000000dEaD";
const DRIP_DUR   = 7n * 24n * 60n * 60n; // 7 days in seconds
const NUM_ACTIONS = 800;
const PRNG_SEED   = 0xA3E2E51An; // deterministic — change to re-fuzz

// --------------------------------------------------------------------------
// Tiny deterministic PRNG (xorshift64*). Stable across Node versions.
// --------------------------------------------------------------------------
function makePrng(seed) {
  let s = BigInt.asUintN(64, BigInt(seed));
  if (s === 0n) s = 1n;
  const MASK = (1n << 64n) - 1n;
  return {
    next64() {
      s ^= (s << 13n) & MASK;
      s ^= (s >> 7n)  & MASK;
      s ^= (s << 17n) & MASK;
      s &= MASK;
      return s;
    },
    nextInt(maxExclusive) {
      return Number(this.next64() % BigInt(maxExclusive));
    },
    // returns a bigint in [1n, hi]
    nextBig(hi) {
      const r = this.next64() % BigInt(hi);
      return r + 1n;
    },
  };
}

// --------------------------------------------------------------------------
// Stack deploy. We use the AERE-side of the sink (token == AERE) so no DEX
// router is touched. The sink's BURN bucket is a plain EOA (acts as a sink
// of AERE — no callback). STAKER bucket is the live sAERE vault.
// --------------------------------------------------------------------------
async function deployFuzzStack() {
  const signers = await ethers.getSigners();
  const [deployer, burnSinkEoa, a1, a2, a3, a4, feeder] = signers;
  const actors = [a1, a2, a3, a4];

  const WAERE_F = await ethers.getContractFactory("WAERE");
  const waere = await WAERE_F.deploy();
  await waere.waitForDeployment();
  const waereAddr = await waere.getAddress();

  // Pre-fund deployer dead-seed approval.
  const nonce = await ethers.provider.getTransactionCount(deployer.address);
  const futureSaere = ethers.getCreateAddress({ from: deployer.address, nonce: nonce + 2 });
  await waere.connect(deployer).deposit({ value: DEAD_SEED });
  await waere.connect(deployer).approve(futureSaere, DEAD_SEED);

  const sAERE_F = await ethers.getContractFactory("sAERE");
  const saere = await sAERE_F.deploy(waereAddr);
  await saere.waitForDeployment();
  const saereAddr = await saere.getAddress();

  // Sink — bps 1500 / 4000 / 4500. Router = a dummy EOA (never called on AERE path).
  const Sink_F = await ethers.getContractFactory("AereSink");
  const sink = await Sink_F.deploy(
    waereAddr,
    burnSinkEoa.address,
    saereAddr,
    burnSinkEoa.address, // unused router on AERE path
    1500, 4000, 4500,
    200,
    ethers.ZeroAddress
  );
  await sink.waitForDeployment();
  const sinkAddr = await sink.getAddress();

  // Mint WAERE to actors + feeder so they can play.
  for (const a of actors) {
    await waere.connect(a).deposit({ value: 1_000n * ONE });
    await waere.connect(a).approve(saereAddr, ethers.MaxUint256);
  }
  await waere.connect(feeder).deposit({ value: 5_000n * ONE });
  await waere.connect(feeder).approve(sinkAddr, ethers.MaxUint256);

  return { waere, saere, sink, actors, feeder, deployer, waereAddr, saereAddr, sinkAddr };
}

// --------------------------------------------------------------------------
// Read every piece of vault state we care about in one shot.
// --------------------------------------------------------------------------
async function snapshot(ctx) {
  const { saere } = ctx;
  // R6 FIX: single atomic call so bal/totalAssets/undist are computed at the
  // SAME block.timestamp (no auto-mine race between RPC calls).
  const s = await saere.atomicState();
  let pps;
  try { pps = await saere.pricePerShare(); } catch (_) { pps = 0n; }
  return {
    bal: BigInt(s.balance),
    totalAssets: BigInt(s.totalAssets_),
    totalSupply: BigInt(s.totalSupply_),
    rewardRate: BigInt(s.rewardRate_),
    periodFinish: BigInt(s.periodFinish_),
    lastObserved: BigInt(s.lastObservedBalance_),
    pps: BigInt(pps),
    undist: BigInt(s.undistributed),
  };
}

// --------------------------------------------------------------------------
// Per-step invariant check. `prev` is the snapshot from the previous step,
// `cur` is now, `tag` is the action label for diagnostics.
// --------------------------------------------------------------------------
function checkInvariants(prev, cur, tag, step) {
  // I-1: totalAssets <= balance
  expect(cur.totalAssets, `[step ${step} ${tag}] I-1 totalAssets > balance`)
    .to.be.lessThanOrEqual(cur.bal);

  // I-2: totalAssets + undistributed === balance  (exact)
  expect(cur.totalAssets + cur.undist,
    `[step ${step} ${tag}] I-2 ta+undist != bal (ta=${cur.totalAssets} undist=${cur.undist} bal=${cur.bal})`
  ).to.equal(cur.bal);

  // I-3: price-per-share monotonic non-decreasing
  expect(cur.pps, `[step ${step} ${tag}] I-3 pps dropped from ${prev.pps} to ${cur.pps}`)
    .to.be.greaterThanOrEqual(prev.pps);

  // I-5: lastObservedBalance == balance after every state-changing op
  expect(cur.lastObserved, `[step ${step} ${tag}] I-5 lastObserved != balance`)
    .to.equal(cur.bal);
}

// --------------------------------------------------------------------------
// Helper: assert sync() is idempotent in the same block.
// We mine one tx that calls sync(), then call sync() again with auto-mine off
// inside the SAME block via hardhat_mine, comparing state. Simpler: we observe
// state immediately, call sync() once more (it gets mined in the NEXT block
// but with timestamp held), and check no reward classification fires because
// no balance moved in between.
// We additionally use eth_call to read the would-be effect: since sync() only
// changes state when bal > lastObservedBalance, after a successful sync the
// next sync MUST be a no-op as long as no AERE arrived in between.
// --------------------------------------------------------------------------
async function assertSyncIdempotent(ctx, tag, step) {
  const { saere } = ctx;
  // Disable auto-mine, send two sync() txs, mine ONE block with timestamp held.
  await network.provider.send("evm_setAutomine", [false]);
  try {
    const before = await snapshot(ctx);
    const tx1 = await saere.sync();
    const tx2 = await saere.sync();
    // mine both in the same block, holding timestamp
    await network.provider.send("evm_mine", []);
    await tx1.wait();
    await tx2.wait();
    const after = await snapshot(ctx);

    // No external token arrived between the two syncs, so rewardRate /
    // periodFinish / lastObservedBalance must be the SAME after the second
    // sync as they would be after the first. We approximate this by checking
    // that the combined effect equals the effect of a single sync — since
    // sync() is pure-function-of-current-balance when called twice with the
    // same balance, both calls collapse.
    expect(after.lastObserved, `[step ${step} ${tag}] I-4 lastObs drift in same-block double-sync`)
      .to.equal(after.bal);
    expect(after.periodFinish, `[step ${step} ${tag}] I-4 periodFinish drift in same-block double-sync`)
      .to.be.greaterThanOrEqual(before.periodFinish);
  } finally {
    await network.provider.send("evm_setAutomine", [true]);
  }
}

// --------------------------------------------------------------------------
// Action dispatchers — each returns a label string for diagnostics.
// --------------------------------------------------------------------------
async function doDeposit(ctx, prng) {
  const actor = ctx.actors[prng.nextInt(ctx.actors.length)];
  const bal = BigInt(await ctx.waere.balanceOf(actor.address));
  if (bal < 2n) return null;
  // deposit between 1 wei and min(bal, 50 AERE)
  const cap = bal < 50n * ONE ? bal : 50n * ONE;
  const amount = prng.nextBig(cap);
  try {
    await ctx.saere.connect(actor).deposit(amount, actor.address);
    return `deposit(actor=${actor.address.slice(2,6)},amt=${amount})`;
  } catch (e) {
    return `deposit-revert(${e.shortMessage || e.message.slice(0,40)})`;
  }
}

async function doWithdraw(ctx, prng) {
  const actor = ctx.actors[prng.nextInt(ctx.actors.length)];
  const shares = BigInt(await ctx.saere.balanceOf(actor.address));
  if (shares < 2n) return null;
  const burn = prng.nextBig(shares);
  try {
    await ctx.saere.connect(actor).redeem(burn, actor.address, actor.address);
    return `redeem(actor=${actor.address.slice(2,6)},shares=${burn})`;
  } catch (e) {
    return `redeem-revert(${e.shortMessage || e.message.slice(0,40)})`;
  }
}

async function doSync(ctx) {
  await ctx.saere.sync();
  return `sync`;
}

async function doFlush(ctx, prng) {
  const bal = BigInt(await ctx.waere.balanceOf(ctx.feeder.address));
  if (bal < 10n) return null;
  // flush between 1 wei and 100 AERE
  const cap = bal < 100n * ONE ? bal : 100n * ONE;
  const amount = prng.nextBig(cap);
  try {
    await ctx.sink.connect(ctx.feeder).flush(ctx.waereAddr, amount);
    return `flush(amt=${amount})`;
  } catch (e) {
    return `flush-revert(${e.shortMessage || e.message.slice(0,40)})`;
  }
}

async function doTimeAdvance(prng) {
  // between 1 second and ~2 weeks (so we cross periodFinish often)
  const secs = 1 + prng.nextInt(14 * 24 * 60 * 60);
  await network.provider.send("evm_increaseTime", [secs]);
  await network.provider.send("evm_mine", []);
  return `timeJump(+${secs}s)`;
}

// --------------------------------------------------------------------------
// The actual fuzz.
// --------------------------------------------------------------------------
describe("sAERE + AereSink — 800-action property fuzz", function () {
  this.timeout(15 * 60 * 1000);

  it("preserves all drip invariants under random action stream", async function () {
    const ctx = await deployFuzzStack();
    const prng = makePrng(PRNG_SEED);

    // Seed: alice puts in some real stake so totalSupply / pps are non-trivial.
    await ctx.saere.connect(ctx.actors[0]).deposit(100n * ONE, ctx.actors[0].address);

    let prev = await snapshot(ctx);

    // Track periodFinish "as last seen after a flush" — we assert that a NEW
    // flush never produces a periodFinish less than block.timestamp (i.e. it
    // always projects forward), and never less than the prior periodFinish
    // when the prior period was still live AND no time passed. The contract
    // resets periodFinish to (now + DRIP_DURATION) on every arrival; this
    // is non-decreasing iff no time-travel happened between observations.
    for (let step = 0; step < NUM_ACTIONS; step++) {
      const choice = prng.nextInt(100);
      // distribution: deposit 25 / withdraw 20 / sync 15 / flush 25 / time 15
      let tag;
      let preBlockBal, prePeriodFinish, preBlockTs;
      if (choice < 25) {
        tag = await doDeposit(ctx, prng);
      } else if (choice < 45) {
        tag = await doWithdraw(ctx, prng);
      } else if (choice < 60) {
        tag = await doSync(ctx);
      } else if (choice < 85) {
        // Capture pre-flush state for I-6 check
        preBlockBal = BigInt(await ctx.waere.balanceOf(ctx.saereAddr));
        prePeriodFinish = BigInt(await ctx.saere.periodFinish());
        preBlockTs = BigInt((await ethers.provider.getBlock("latest")).timestamp);
        tag = await doFlush(ctx, prng);
      } else {
        tag = await doTimeAdvance(prng);
      }
      if (tag === null) continue; // skipped (no funds available)

      const cur = await snapshot(ctx);

      // I-6 — flush-specific: periodFinish never decreases as a side-effect
      // of a new arrival. Caveat: time-advance can move us PAST periodFinish,
      // after which the next flush LEGITIMATELY sets periodFinish forward to
      // now + DRIP_DURATION. So our assertion is: post-flush periodFinish
      // >= max(prePeriodFinish, blockTimestamp). The contract always sets
      // it to (now + DRIP_DURATION) on arrival, which is trivially > now,
      // and >= prePeriodFinish unless we already crossed it (in which case
      // it was effectively zero anyway). The strict "never decreases due to
      // a new arrival" guarantee: a flush arrival cannot set periodFinish
      // earlier than now — i.e. it must extend, not shrink.
      if (tag && tag.startsWith("flush(") && cur.bal > preBlockBal) {
        const nowTs = BigInt((await ethers.provider.getBlock("latest")).timestamp);
        expect(cur.periodFinish,
          `[step ${step} ${tag}] I-6 flush moved periodFinish backward of now`
        ).to.be.greaterThanOrEqual(nowTs);
        // R6 HIGH fix: periodFinish only extends to now+DRIP if arrival is
        // >= 1% of undistributed reserve. For tiny arrivals it stays the same.
        // So we just assert: periodFinish is in the future AND did not decrease.
        expect(cur.periodFinish,
          `[step ${step} ${tag}] I-6 periodFinish must not decrease vs previous observation`
        ).to.be.greaterThanOrEqual(prev.periodFinish);
      }

      // Core invariants every step
      checkInvariants(prev, cur, tag || "noop", step);

      // I-4 — sample sync-idempotence on ~5% of steps (cheap, broad coverage)
      if (prng.nextInt(20) === 0) {
        await assertSyncIdempotent(ctx, tag || "noop", step);
        const post = await snapshot(ctx);
        // Idempotence must not decrease pps either
        expect(post.pps,
          `[step ${step}] I-3 pps dropped across same-block double-sync`
        ).to.be.greaterThanOrEqual(cur.pps);
        prev = post;
      } else {
        prev = cur;
      }
    }

    // Final solvency check: dead shares still exist, vault balance covers
    // every share-holder's pro-rata claim against totalAssets (no over-mint).
    const finalDeadBal = BigInt(await ctx.saere.balanceOf(DEAD_ADDR));
    expect(finalDeadBal).to.equal(DEAD_SEED);
    const totalSupply = BigInt(await ctx.saere.totalSupply());
    const totalAssets = BigInt(await ctx.saere.totalAssets());
    const vaultBal = BigInt(await ctx.waere.balanceOf(ctx.saereAddr));
    expect(totalAssets).to.be.lessThanOrEqual(vaultBal);
    // Every actor can in principle redeem their shares back into <= their
    // pro-rata assets without underflow.
    for (const a of ctx.actors) {
      const sh = BigInt(await ctx.saere.balanceOf(a.address));
      if (sh === 0n) continue;
      const previewed = BigInt(await ctx.saere.previewRedeem(sh));
      expect(previewed).to.be.lessThanOrEqual(totalAssets);
      expect(sh).to.be.lessThanOrEqual(totalSupply);
    }
  });
});
