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

// AereGovLock is the CUSTODY-LOCK vote source that replaces the flawed lazy
// mirror (AereStakedGovVotes / gAERE). The whole point of this suite is to prove
// that voting power CANNOT detach from the native AERE that backs it:
//   - votes are minted only when capital enters (payable lock),
//   - votes are burned only when capital leaves (gated withdraw),
//   - so at every snapshot totalSupply() == custody balance, and one unit of
//     capital can never yield more than one unit of votes.

async function mine(n = 1) {
  for (let i = 0; i < n; i++) await network.provider.send("evm_mine");
}

async function increaseTime(seconds) {
  await network.provider.send("evm_increaseTime", [seconds]);
  await network.provider.send("evm_mine");
}

async function now() {
  const b = await ethers.provider.getBlock("latest");
  return b.timestamp;
}

const DAY = 24 * 60 * 60;
const E = (n) => ethers.parseEther(String(n));

async function deployGovLock(minLockDuration = 0) {
  const F = await ethers.getContractFactory("AereGovLock");
  const c = await F.deploy(minLockDuration);
  await c.waitForDeployment();
  return c;
}

describe("AereGovLock (custody-lock vote source)", function () {
  // -------------------------------------------------------------------------
  // 1. Normal lock / vote / withdraw lifecycle.
  // -------------------------------------------------------------------------
  describe("normal lock / vote / withdraw lifecycle", function () {
    it("locks native AERE, mints 1:1 votes, delegates, and withdraws after unlock", async function () {
      const [user] = await ethers.getSigners();
      const gl = await deployGovLock(0);
      const addr = await gl.getAddress();

      const amount = E(1000);
      const unlock = (await now()) + 30 * DAY;

      // lock: capital IN, votes minted 1:1.
      await (await gl.lock(amount, unlock, { value: amount })).wait();

      expect(await gl.balanceOf(user.address)).to.equal(amount);
      expect(await gl.totalSupply()).to.equal(amount);
      expect(await gl.totalLocked()).to.equal(amount);
      expect(await gl.lockedBalanceOf(user.address)).to.equal(amount);
      // Custody invariant: contract native balance == totalSupply.
      expect(await ethers.provider.getBalance(addr)).to.equal(amount);

      // Votes require delegation to become countable.
      expect(await gl.getVotes(user.address)).to.equal(0n);
      await (await gl.delegate(user.address)).wait();
      expect(await gl.getVotes(user.address)).to.equal(amount);

      // Cannot withdraw before unlock.
      await expect(gl.withdraw()).to.be.revertedWith("govAERE: still locked");

      // After unlock, withdraw burns votes and returns exact principal.
      await increaseTime(30 * DAY + 1);
      const balBefore = await ethers.provider.getBalance(user.address);
      const tx = await gl.withdraw();
      const rc = await tx.wait();
      const gas = rc.gasUsed * rc.gasPrice;
      const balAfter = await ethers.provider.getBalance(user.address);

      expect(balAfter).to.equal(balBefore + amount - gas);
      expect(await gl.balanceOf(user.address)).to.equal(0n);
      expect(await gl.getVotes(user.address)).to.equal(0n);
      expect(await gl.totalSupply()).to.equal(0n);
      expect(await ethers.provider.getBalance(addr)).to.equal(0n);
    });

    it("rejects amount == 0 and value != amount", async function () {
      const gl = await deployGovLock(0);
      const unlock = (await now()) + 10 * DAY;
      await expect(gl.lock(0, unlock, { value: 0 })).to.be.revertedWith("govAERE: zero amount");
      await expect(gl.lock(E(5), unlock, { value: E(4) })).to.be.revertedWith("govAERE: value != amount");
    });

    it("additive lock increases amount and can only push unlock forward", async function () {
      const [user] = await ethers.getSigners();
      const gl = await deployGovLock(0);
      const t0 = await now();
      await (await gl.lock(E(100), t0 + 20 * DAY, { value: E(100) })).wait();

      // adding more with an EARLIER unlock is rejected (cannot shorten).
      await expect(
        gl.lock(E(50), t0 + 10 * DAY, { value: E(50) })
      ).to.be.revertedWith("govAERE: cannot shorten lock");

      // adding more with a later unlock works and accumulates.
      await (await gl.lock(E(50), t0 + 40 * DAY, { value: E(50) })).wait();
      expect(await gl.lockedBalanceOf(user.address)).to.equal(E(150));
      expect(await gl.totalSupply()).to.equal(E(150));
      const [amt, un] = await gl.locks(user.address);
      expect(amt).to.equal(E(150));
      expect(un).to.equal(BigInt(t0 + 40 * DAY));
    });

    it("extendLock moves unlock forward without changing votes", async function () {
      const [user] = await ethers.getSigners();
      const gl = await deployGovLock(0);
      const t0 = await now();
      await (await gl.lock(E(100), t0 + 20 * DAY, { value: E(100) })).wait();
      await (await gl.extendLock(t0 + 60 * DAY)).wait();
      const [amt, un] = await gl.locks(user.address);
      expect(amt).to.equal(E(100));
      expect(un).to.equal(BigInt(t0 + 60 * DAY));
      // cannot move it backward.
      await expect(gl.extendLock(t0 + 30 * DAY)).to.be.revertedWith("govAERE: not later");
    });
  });

  // -------------------------------------------------------------------------
  // 2. Optional minimum lock duration.
  // -------------------------------------------------------------------------
  describe("minimum lock duration", function () {
    it("enforces min duration and rejects above max", async function () {
      const gl = await deployGovLock(7 * DAY);
      // below min -> revert.
      await expect(
        gl.lock(E(1), (await now()) + 3 * DAY, { value: E(1) })
      ).to.be.revertedWith("govAERE: below min duration");
      // above max -> revert.
      await expect(
        gl.lock(E(1), (await now()) + 2000 * DAY, { value: E(1) })
      ).to.be.revertedWith("govAERE: above max duration");
      // comfortably past min -> ok.
      await (await gl.lock(E(1), (await now()) + 8 * DAY, { value: E(1) })).wait();
      expect(await gl.totalSupply()).to.equal(E(1));
    });

    it("constructor rejects min > MAX_LOCK_DURATION", async function () {
      const F = await ethers.getContractFactory("AereGovLock");
      await expect(F.deploy(2000 * DAY)).to.be.revertedWith("govAERE: min > max");
    });
  });

  // -------------------------------------------------------------------------
  // 3. Non-transferability (transfer / transferFrom / approve revert), while
  //    delegation still works.
  // -------------------------------------------------------------------------
  describe("non-transferability", function () {
    it("reverts transfer, transferFrom, approve, increase/decreaseAllowance", async function () {
      const [user, other] = await ethers.getSigners();
      const gl = await deployGovLock(0);
      await (await gl.lock(E(10), (await now()) + 10 * DAY, { value: E(10) })).wait();

      await expect(gl.transfer(other.address, E(1))).to.be.revertedWith("govAERE: non-transferable");
      await expect(
        gl.transferFrom(user.address, other.address, E(1))
      ).to.be.revertedWith("govAERE: non-transferable");
      await expect(gl.approve(other.address, E(1))).to.be.revertedWith("govAERE: non-transferable");
      await expect(gl.increaseAllowance(other.address, E(1))).to.be.revertedWith("govAERE: non-transferable");
      await expect(gl.decreaseAllowance(other.address, E(1))).to.be.revertedWith("govAERE: non-transferable");
    });

    it("delegation moves voting units without moving custody balances", async function () {
      const [user, rep] = await ethers.getSigners();
      const gl = await deployGovLock(0);
      await (await gl.lock(E(10), (await now()) + 10 * DAY, { value: E(10) })).wait();

      // delegate to a representative: votes move, balance stays with the locker.
      await (await gl.delegate(rep.address)).wait();
      expect(await gl.getVotes(rep.address)).to.equal(E(10));
      expect(await gl.getVotes(user.address)).to.equal(0n);
      expect(await gl.balanceOf(user.address)).to.equal(E(10));
      expect(await gl.balanceOf(rep.address)).to.equal(0n);
      // rep never received a transferable balance, only voting weight.
    });
  });

  // -------------------------------------------------------------------------
  // 4. getPastVotes / getPastTotalSupply snapshot accuracy.
  // -------------------------------------------------------------------------
  describe("snapshot accuracy", function () {
    it("getPastVotes and getPastTotalSupply are fixed at historical blocks", async function () {
      const [user] = await ethers.getSigners();
      const gl = await deployGovLock(0);
      const t0 = await now();

      await (await gl.lock(E(1000), t0 + 100 * DAY, { value: E(1000) })).wait();
      await (await gl.delegate(user.address)).wait();
      await mine(2);
      const blockA = await ethers.provider.getBlockNumber();
      await mine(1); // move past blockA so it is queryable

      // add more capital, changing current votes but not the historical value.
      await (await gl.lock(E(500), t0 + 100 * DAY, { value: E(500) })).wait();
      await mine(1);
      const blockB = await ethers.provider.getBlockNumber();
      await mine(1);

      expect(await gl.getPastVotes(user.address, blockA)).to.equal(E(1000));
      expect(await gl.getPastTotalSupply(blockA)).to.equal(E(1000));
      expect(await gl.getPastVotes(user.address, blockB)).to.equal(E(1500));
      expect(await gl.getPastTotalSupply(blockB)).to.equal(E(1500));
      expect(await gl.getVotes(user.address)).to.equal(E(1500));
    });

    it("withdraw does not rewrite history: past snapshot still shows the old weight", async function () {
      const [user] = await ethers.getSigners();
      const gl = await deployGovLock(0);
      const t0 = await now();
      await (await gl.lock(E(1000), t0 + 5 * DAY, { value: E(1000) })).wait();
      await (await gl.delegate(user.address)).wait();
      await mine(2);
      const snap = await ethers.provider.getBlockNumber();
      await mine(1);

      await increaseTime(5 * DAY + 1);
      await (await gl.withdraw()).wait();
      await mine(1);

      // current weight is gone, but the historical snapshot is immutable.
      expect(await gl.getVotes(user.address)).to.equal(0n);
      expect(await gl.getPastVotes(user.address, snap)).to.equal(E(1000));
      expect(await gl.getPastTotalSupply(snap)).to.equal(E(1000));
    });
  });

  // -------------------------------------------------------------------------
  // 5. CRITICAL: reproduce the old recycling / detach attack and prove it is
  //    impossible here. One unit of capital yields at most one unit of votes at
  //    any snapshot; capital cannot be recycled across sybils while keeping votes.
  // -------------------------------------------------------------------------
  describe("recycling / detach attack is impossible", function () {
    it("cannot hold votes while the principal leaves, and cannot fund a sybil without burning votes", async function () {
      const [attacker, sybilA, sybilB] = await ethers.getSigners();
      const gl = await deployGovLock(0);
      const addr = await gl.getAddress();

      const CAPITAL = E(1000); // one single unit of capital, all the attacker has to work with

      // ---- Phase A: attacker locks the capital and delegates votes to sybilA.
      const t0 = await now();
      await (await gl.connect(attacker).lock(CAPITAL, t0 + 7 * DAY, { value: CAPITAL })).wait();
      await (await gl.connect(attacker).delegate(sybilA.address)).wait();
      await mine(2);
      const blockA = await ethers.provider.getBlockNumber();
      await mine(1);

      expect(await gl.getVotes(sybilA.address)).to.equal(CAPITAL);
      expect(await gl.totalSupply()).to.equal(CAPITAL);
      // Backing invariant: custody balance == totalSupply.
      expect(await ethers.provider.getBalance(addr)).to.equal(CAPITAL);

      // ---- Phase B: attacker tries to ALSO give sybilB 1000 votes from the SAME
      //      capital. That needs the capital back to fund a second lock. It is
      //      still locked, so withdraw reverts. The capital cannot detach.
      await expect(gl.connect(attacker).withdraw()).to.be.revertedWith("govAERE: still locked");

      // There is no other exit: no earlyExit, no admin drain, no burn-less claim.
      // sybilB has zero votes; the recycle attempt is stuck.
      expect(await gl.getVotes(sybilB.address)).to.equal(0n);
      expect(await gl.totalSupply()).to.equal(CAPITAL); // still exactly one unit of votes
      // At snapshot blockA, total votes == one unit of capital, never N-fold.
      expect(await gl.getPastTotalSupply(blockA)).to.equal(CAPITAL);

      // ---- Phase C: the ONLY way to reclaim the capital is to wait and
      //      withdraw, which BURNS the votes first. Delegation cannot save them:
      //      burning the holder's balance removes weight from sybilA too.
      await increaseTime(7 * DAY + 1);
      await (await gl.connect(attacker).withdraw()).wait();
      await mine(1);

      expect(await gl.getVotes(sybilA.address)).to.equal(0n); // votes burned with the withdraw
      expect(await gl.totalSupply()).to.equal(0n);
      expect(await ethers.provider.getBalance(addr)).to.equal(0n);

      // ---- Phase D: attacker re-locks the SAME capital into a second account to
      //      try to hand sybilB votes. It works for sybilB now, but sybilA's
      //      votes are already gone. The 1000 capital never backs more than 1000
      //      votes at any single moment.
      const t1 = await now();
      // send the reclaimed capital to a second controlled account and lock from there
      await (await attacker.sendTransaction({ to: sybilB.address, value: CAPITAL })).wait();
      await (await gl.connect(sybilB).lock(CAPITAL, t1 + 7 * DAY, { value: CAPITAL })).wait();
      await (await gl.connect(sybilB).delegate(sybilB.address)).wait();
      await mine(2);
      const blockD = await ethers.provider.getBlockNumber();
      await mine(1);

      expect(await gl.getVotes(sybilB.address)).to.equal(CAPITAL);
      expect(await gl.getVotes(sybilA.address)).to.equal(0n); // NOT simultaneously 1000
      expect(await gl.totalSupply()).to.equal(CAPITAL);
      expect(await ethers.provider.getBalance(addr)).to.equal(CAPITAL);

      // The decisive proof: at EVERY historical snapshot, total voting supply
      // equalled the custody balance at that time, i.e. at most one unit of votes
      // per one unit of capital. Recycling produced no extra power.
      expect(await gl.getPastTotalSupply(blockA)).to.equal(CAPITAL); // phase A
      expect(await gl.getPastTotalSupply(blockD)).to.equal(CAPITAL); // phase D
      // sum of the two sybils' weight at blockD is exactly one unit, not two.
      const wA = await gl.getPastVotes(sybilA.address, blockD);
      const wB = await gl.getPastVotes(sybilB.address, blockD);
      expect(wA + wB).to.equal(CAPITAL);
    });

    it("invariant: totalSupply always equals the contract's custody balance", async function () {
      const [a, b, c] = await ethers.getSigners();
      const gl = await deployGovLock(0);
      const addr = await gl.getAddress();
      const t0 = await now();

      const check = async () =>
        expect(await gl.totalSupply()).to.equal(await ethers.provider.getBalance(addr));

      await (await gl.connect(a).lock(E(100), t0 + 3 * DAY, { value: E(100) })).wait();
      await check();
      await (await gl.connect(b).lock(E(250), t0 + 3 * DAY, { value: E(250) })).wait();
      await check();
      await (await gl.connect(c).lock(E(75), t0 + 3 * DAY, { value: E(75) })).wait();
      await check();

      await increaseTime(3 * DAY + 1);
      await (await gl.connect(b).withdraw()).wait();
      await check();
      await (await gl.connect(a).withdraw()).wait();
      await check();
      await (await gl.connect(c).withdraw()).wait();
      await check();
      expect(await gl.totalSupply()).to.equal(0n);
    });
  });

  // -------------------------------------------------------------------------
  // 6. Drop-in compatibility with the existing OZ Governor + Timelock.
  // -------------------------------------------------------------------------
  describe("drop-in AereGovernor + TimelockController compatibility", function () {
    it("propose -> vote (govAERE weight) -> queue -> execute flips an owned target", async function () {
      const [deployer] = await ethers.getSigners();
      const gl = await deployGovLock(0);

      // deployer locks capital and self-delegates to obtain real voting weight.
      // (test accounts hold 10,000 ETH each; lock a chunk and keep gas headroom.)
      const LOCKED = E(5000);
      await (await gl.lock(LOCKED, (await now()) + 60 * DAY, { value: LOCKED })).wait();
      await (await gl.delegate(deployer.address)).wait();

      const TL = await ethers.getContractFactory("TimelockController");
      const timelock = await TL.deploy(0, [], [], deployer.address);
      await timelock.waitForDeployment();

      const Gov = await ethers.getContractFactory("AereGovernor");
      const gov = await Gov.deploy(await gl.getAddress(), await timelock.getAddress(), 1, 5, 0, 4);
      await gov.waitForDeployment();

      const PROPOSER = await timelock.PROPOSER_ROLE();
      const EXECUTOR = await timelock.EXECUTOR_ROLE();
      const CANCELLER = await timelock.CANCELLER_ROLE();
      await (await timelock.grantRole(PROPOSER, await gov.getAddress())).wait();
      await (await timelock.grantRole(EXECUTOR, await gov.getAddress())).wait();
      await (await timelock.grantRole(CANCELLER, await gov.getAddress())).wait();

      const Target = await ethers.getContractFactory("GovSelfTestTarget");
      const target = await Target.deploy();
      await target.waitForDeployment();
      await (await target.transferOwnership(await timelock.getAddress())).wait();

      await mine(2);

      const targets = [await target.getAddress()];
      const values = [0n];
      const calldatas = [target.interface.encodeFunctionData("setValue", [42])];
      const description = "govlock setValue(42)";
      const descHash = ethers.id(description);
      const proposalId = await gov.hashProposal(targets, values, calldatas, descHash);

      await (await gov.propose(targets, values, calldatas, description)).wait();
      const snapshot = await gov.proposalSnapshot(proposalId);
      const deadline = await gov.proposalDeadline(proposalId);

      const cur = await ethers.provider.getBlockNumber();
      await mine(Number(snapshot) - cur + 1);
      expect(Number(await gov.state(proposalId))).to.equal(1); // Active

      // quorum is a real fraction of real locked capital (meaningful denominator).
      expect(await gov.quorum(snapshot)).to.equal((LOCKED * 4n) / 100n);

      await (await gov.castVote(proposalId, 1)).wait();

      const cur2 = await ethers.provider.getBlockNumber();
      await mine(Number(deadline) - cur2 + 1);
      expect(Number(await gov.state(proposalId))).to.equal(4); // Succeeded

      await (await gov.queue(targets, values, calldatas, descHash)).wait();
      await mine(1);
      await (await gov.execute(targets, values, calldatas, descHash)).wait();
      expect(Number(await gov.state(proposalId))).to.equal(7); // Executed
      expect(await target.value()).to.equal(42n);
    });
  });
});
