const { expect } = require("chai");
const { ethers } = require("hardhat");
const { time } = require("@nomicfoundation/hardhat-network-helpers");

const E = ethers.parseEther;

/* ----------------------------- merkle helpers -----------------------------
 * AereMiningDistributor leaf = keccak256(abi.encodePacked(index, account, amount))
 *   index  : uint256
 *   account: address
 *   amount : uint256
 * Internal nodes use sorted-pair keccak256 hashing (matches contract _verify).
 * -------------------------------------------------------------------------- */
function leafHash(index, account, amount) {
  return ethers.solidityPackedKeccak256(
    ["uint256", "address", "uint256"],
    [index, account, amount]
  );
}
function hashPair(a, b) {
  return BigInt(a) < BigInt(b)
    ? ethers.keccak256(ethers.concat([a, b]))
    : ethers.keccak256(ethers.concat([b, a]));
}
function buildLayers(leaves) {
  const layers = [leaves.slice()];
  while (layers[layers.length - 1].length > 1) {
    const prev = layers[layers.length - 1];
    const next = [];
    for (let i = 0; i < prev.length; i += 2) {
      if (i + 1 < prev.length) next.push(hashPair(prev[i], prev[i + 1]));
      else next.push(prev[i]); // promote odd node
    }
    layers.push(next);
  }
  return layers;
}
function getProof(layers, index) {
  const proof = [];
  let idx = index;
  for (let l = 0; l < layers.length - 1; l++) {
    const layer = layers[l];
    const sib = idx ^ 1;
    if (sib < layer.length) proof.push(layer[sib]);
    idx = Math.floor(idx / 2);
  }
  return proof;
}
// entries: [{ index, account, amount }] -> { root, proofFor(index) }
function makeTree(entries) {
  const leaves = entries.map((e) => leafHash(e.index, e.account, e.amount));
  const layers = buildLayers(leaves);
  const root = layers[layers.length - 1][0];
  return { root, proofFor: (index) => getProof(layers, index) };
}

/* ================================= TESTS ================================== */

describe("AereMiningDistributor", () => {
  let dist, owner, alice, bob, carol;

  beforeEach(async () => {
    [owner, alice, bob, carol] = await ethers.getSigners();
    dist = await (await ethers.getContractFactory("AereMiningDistributor")).deploy();
  });

  async function fund(amount) {
    await owner.sendTransaction({ to: await dist.getAddress(), value: amount });
  }

  /* --------------------------- baseline behaviour --------------------------- */

  it("deploys with no epochs and owner = deployer", async () => {
    expect(await dist.epochCount()).to.equal(0);
    expect(await dist.owner()).to.equal(owner.address);
    expect(await ethers.provider.getBalance(await dist.getAddress())).to.equal(0);
  });

  it("posts an epoch and pays a valid claimant in full", async () => {
    const tree = makeTree([
      { index: 0, account: alice.address, amount: E("60") },
      { index: 1, account: bob.address, amount: E("40") },
    ]);
    await fund(E("100"));
    const deadline = (await time.latest()) + 30 * 24 * 3600;
    await dist.postEpoch(tree.root, E("100"), deadline);

    const before = await ethers.provider.getBalance(alice.address);
    // relayer (owner) submits; alice receives, pays no gas
    await dist.connect(owner).claim(0, 0, alice.address, E("60"), tree.proofFor(0));
    const after = await ethers.provider.getBalance(alice.address);
    expect(after - before).to.equal(E("60"));
    expect(await dist.isClaimed(0, 0)).to.equal(true);

    // double-claim reverts
    await expect(
      dist.claim(0, 0, alice.address, E("60"), tree.proofFor(0))
    ).to.be.revertedWith("MiningDist: already claimed");
  });

  it("rejects a bad proof / wrong amount", async () => {
    const tree = makeTree([
      { index: 0, account: alice.address, amount: E("60") },
      { index: 1, account: bob.address, amount: E("40") },
    ]);
    await fund(E("100"));
    const deadline = (await time.latest()) + 30 * 24 * 3600;
    await dist.postEpoch(tree.root, E("100"), deadline);
    await expect(
      dist.claim(0, 0, alice.address, E("999"), tree.proofFor(0))
    ).to.be.revertedWith("MiningDist: bad proof");
  });

  it("postEpoch reverts underfunded when balance < live + total", async () => {
    const tree = makeTree([{ index: 0, account: alice.address, amount: E("100") }]);
    await fund(E("99")); // one wei short of a single 100 epoch
    const deadline = (await time.latest()) + 30 * 24 * 3600;
    await expect(
      dist.postEpoch(tree.root, E("100"), deadline)
    ).to.be.revertedWith("MiningDist: underfunded");
  });

  it("reserves funds for TWO concurrent in-window epochs (must fund both)", async () => {
    const a = makeTree([{ index: 0, account: alice.address, amount: E("100") }]);
    const b = makeTree([{ index: 0, account: bob.address, amount: E("100") }]);
    await fund(E("100"));
    const d1 = (await time.latest()) + 30 * 24 * 3600;
    await dist.postEpoch(a.root, E("100"), d1);
    // epoch0 still live -> its 100 is reserved, so epoch1 needs another 100
    const d2 = (await time.latest()) + 30 * 24 * 3600;
    await expect(dist.postEpoch(b.root, E("100"), d2)).to.be.revertedWith(
      "MiningDist: underfunded"
    );
    await fund(E("100")); // total 200
    await dist.postEpoch(b.root, E("100"), d2);
    expect(await dist.epochCount()).to.equal(2);
  });

  /* --------------------------- sweep behaviour ------------------------------ */

  it("sweepUnclaimed reverts before deadline, sends unclaimed to owner after", async () => {
    const tree = makeTree([{ index: 0, account: alice.address, amount: E("100") }]);
    await fund(E("100"));
    const deadline = (await time.latest()) + 1000;
    await dist.postEpoch(tree.root, E("100"), deadline);

    await expect(dist.sweepUnclaimed(0)).to.be.revertedWith("MiningDist: too early");

    await time.increaseTo(deadline + 1);
    const distAddr = await dist.getAddress();
    expect(await ethers.provider.getBalance(distAddr)).to.equal(E("100"));
    // measure owner delta net of gas via a non-owner-less path: use static call then tx
    await dist.sweepUnclaimed(0);
    expect(await ethers.provider.getBalance(distAddr)).to.equal(0);
    // double-sweep reverts
    await expect(dist.sweepUnclaimed(0)).to.be.revertedWith("MiningDist: already swept");
  });

  /* ---------------- THE FIX: expired-unswept reserve is protected ----------- *
   * Regression for the confirmed HIGH: an epoch past its claimDeadline but not
   * yet swept still physically holds its unclaimed AERE. Before the fix,
   * _liveFunds() dropped it the instant it expired, so postEpoch treated that
   * still-present reserve as free and let it back a brand-new epoch — while
   * sweepUnclaimed could independently send the SAME wei to the owner. The
   * same funds were counted twice and an honest next-epoch claimant was
   * stranded on a zero balance.
   * ------------------------------------------------------------------------- */

  it("expired-unswept reserve can NOT back a new epoch until it is swept (double-count fix)", async () => {
    // balance = 100 backs epoch0.
    const a = makeTree([{ index: 0, account: alice.address, amount: E("100") }]);
    await fund(E("100"));
    const dA = (await time.latest()) + 1000;
    await dist.postEpoch(a.root, E("100"), dA); // epoch0

    // Nobody claims; epoch0 expires (unswept, totalClaimed = 0).
    await time.increaseTo(dA + 1);

    // The exact strand sequence's post1 step: with the SAME 100 balance, posting
    // epoch1 for another 100 MUST now revert — epoch0's 100 is still reserved
    // because it has not been swept.
    const b = makeTree([{ index: 0, account: bob.address, amount: E("100") }]);
    const dB = (await time.latest()) + 30 * 24 * 3600;
    await expect(dist.postEpoch(b.root, E("100"), dB)).to.be.revertedWith(
      "MiningDist: underfunded"
    );

    // Sweep epoch0 first (recovers the 100 to the owner, sets swept = true so the
    // reserve leaves _liveFunds exactly once); contract balance -> 0.
    await dist.sweepUnclaimed(0);
    expect(await ethers.provider.getBalance(await dist.getAddress())).to.equal(0);

    // Even now epoch1 cannot be posted against an empty contract — the swept
    // funds went to the owner, who must re-fund to legitimately back epoch1.
    await expect(dist.postEpoch(b.root, E("100"), dB)).to.be.revertedWith(
      "MiningDist: underfunded"
    );

    // Owner re-funds with the recovered 100; now epoch1 posts and its claimant
    // is paid IN FULL — no strand.
    await fund(E("100"));
    await dist.postEpoch(b.root, E("100"), dB); // epoch1
    const before = await ethers.provider.getBalance(bob.address);
    await dist.connect(owner).claim(1, 0, bob.address, E("100"), b.proofFor(0));
    const after = await ethers.provider.getBalance(bob.address);
    expect(after - before).to.equal(E("100"));
  });

  it("the pre-fix strand sequence (post0 -> expire -> post1 -> sweep0 -> claim1) can no longer strand a claimant", async () => {
    // This reproduces the reported failure path. Under the OLD code, post1
    // succeeded (100 >= 0 + 100 because expired epoch0 was dropped from
    // _liveFunds), then sweep0 drained the 100 to the owner, and epoch1's
    // claimant reverted on "payout failed". With the fix, the sequence is
    // stopped dead at post1 (underfunded), so the claimant can never be
    // stranded via this path.
    const a = makeTree([{ index: 0, account: alice.address, amount: E("100") }]);
    const b = makeTree([{ index: 0, account: bob.address, amount: E("100") }]);
    await fund(E("100"));
    const dA = (await time.latest()) + 1000;
    await dist.postEpoch(a.root, E("100"), dA); // epoch0
    await time.increaseTo(dA + 1); // epoch0 expired, unswept, unclaimed

    const dB = (await time.latest()) + 30 * 24 * 3600;
    // The double-spend attempt: reuse epoch0's still-present 100 to back epoch1.
    await expect(dist.postEpoch(b.root, E("100"), dB)).to.be.revertedWith(
      "MiningDist: underfunded"
    );

    // Because post1 reverted, epochCount is still 1 and no epoch1 claim exists
    // to be stranded. The funds remain fully backing epoch0 until swept.
    expect(await dist.epochCount()).to.equal(1);
    expect(await ethers.provider.getBalance(await dist.getAddress())).to.equal(E("100"));
  });
});
