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

const abi = ethers.AbiCoder.defaultAbiCoder();
const Q = (s) => ethers.keccak256(ethers.toUtf8Bytes(s));

/* ----------------------------- merkle helpers ----------------------------- */
// Leaf = keccak256(bytes.concat(keccak256(abi.encode(account, total)))) — OZ
// StandardMerkleTree encoding. Internal nodes use sorted-pair hashing so the
// on-chain OZ MerkleProof.verify matches.
function leafHash(account, total) {
  const inner = ethers.keccak256(abi.encode(["address", "uint256"], [account, total]));
  return ethers.keccak256(inner);
}
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;
}
function makeTree(entries) {
  // entries: [{account, total}] -> {root, proofFor(account)}
  const leaves = entries.map((e) => leafHash(e.account, e.total));
  const layers = buildLayers(leaves);
  const root = layers[layers.length - 1][0];
  const indexOf = {};
  entries.forEach((e, i) => (indexOf[e.account.toLowerCase()] = i));
  return {
    root,
    proofFor: (account) => getProof(layers, indexOf[account.toLowerCase()]),
  };
}

/* ------------------------- EIP-712 ticket signing ------------------------- */
async function signTicket(signer, attestorAddr, chainId, ticket) {
  const domain = {
    name: "AereQuestAttestor",
    version: "1",
    chainId,
    verifyingContract: attestorAddr,
  };
  const types = {
    QuestTicket: [
      { name: "account", type: "address" },
      { name: "questId", type: "bytes32" },
      { name: "nonce", type: "uint256" },
      { name: "validUntil", type: "uint64" },
    ],
  };
  return signer.signTypedData(domain, types, ticket);
}

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

describe("AERE Season 1 — Points Ledger", () => {
  let ledger, owner, attestor, alice, bob;
  beforeEach(async () => {
    [owner, attestor, alice, bob] = await ethers.getSigners();
    ledger = await (await ethers.getContractFactory("AerePointsLedger")).deploy();
    await ledger.setAttestor(attestor.address, true);
  });

  it("credits only via an authorized attestor", async () => {
    await ledger.connect(attestor).credit(alice.address, 100, Q("create-passkey"));
    expect(await ledger.balanceOf(alice.address)).to.equal(100);
    expect(await ledger.totalSupply()).to.equal(100);
    await expect(
      ledger.connect(bob).credit(alice.address, 100, Q("x"))
    ).to.be.revertedWithCustomError(ledger, "NotAttestor");
  });

  it("is NON-transferable: transfer/transferFrom/approve REVERT", async () => {
    await ledger.connect(attestor).credit(alice.address, 100, Q("q"));
    await expect(ledger.connect(alice).transfer(bob.address, 1)).to.be.revertedWithCustomError(
      ledger,
      "NotTransferable"
    );
    await expect(
      ledger.connect(alice).transferFrom(alice.address, bob.address, 1)
    ).to.be.revertedWithCustomError(ledger, "NotTransferable");
    await expect(ledger.connect(alice).approve(bob.address, 1)).to.be.revertedWithCustomError(
      ledger,
      "NotTransferable"
    );
    expect(await ledger.allowance(alice.address, bob.address)).to.equal(0);
  });

  it("has no burn / redeem / conversion path (credit-only)", async () => {
    // The ABI must not expose any burn/redeem/withdraw/mint-to-self function.
    const fns = Object.keys(ledger.interface.fragments
      ? {}
      : {}); // placeholder; explicit checks below
    const names = ledger.interface.fragments
      .filter((f) => f.type === "function")
      .map((f) => f.name);
    for (const bad of ["burn", "redeem", "withdraw", "mint", "convert", "claim"]) {
      expect(names).to.not.include(bad);
    }
  });

  it("rejects zero account / zero amount", async () => {
    await expect(
      ledger.connect(attestor).credit(ethers.ZeroAddress, 1, Q("q"))
    ).to.be.revertedWithCustomError(ledger, "ZeroAccount");
    await expect(
      ledger.connect(attestor).credit(alice.address, 0, Q("q"))
    ).to.be.revertedWithCustomError(ledger, "ZeroAmount");
  });
});

describe("AERE Season 1 — Quest Registry", () => {
  let reg, owner, alice;
  beforeEach(async () => {
    [owner, alice] = await ethers.getSigners();
    reg = await (await ethers.getContractFactory("AereQuestRegistry")).deploy();
  });

  it("creates and reads a quest; only owner", async () => {
    const now = await time.latest();
    await reg.createQuest(Q("follow-x"), 10, now, now + 1000, 10, false);
    const q = await reg.getQuest(Q("follow-x"));
    expect(q.weight).to.equal(10);
    expect(q.active).to.equal(true);
    expect(await reg.isOpen(Q("follow-x"), now + 500)).to.equal(true);
    expect(await reg.isOpen(Q("follow-x"), now + 2000)).to.equal(false);
    await expect(
      reg.connect(alice).createQuest(Q("z"), 1, now, 0, 1, false)
    ).to.be.revertedWith("Ownable: caller is not the owner");
  });

  it("enforces validation and duplicate protection", async () => {
    const now = await time.latest();
    await expect(reg.createQuest(Q("a"), 0, now, 0, 5, false)).to.be.revertedWithCustomError(reg, "BadWeight");
    await expect(reg.createQuest(Q("a"), 10, now, 0, 5, false)).to.be.revertedWithCustomError(reg, "BadWeight"); // cap<weight
    await expect(reg.createQuest(Q("a"), 10, now, now - 1, 10, false)).to.be.revertedWithCustomError(reg, "BadWindow");
    await reg.createQuest(Q("a"), 10, now, 0, 10, false);
    await expect(reg.createQuest(Q("a"), 10, now, 0, 10, false)).to.be.revertedWithCustomError(reg, "QuestExists");
  });

  it("is tunable: update + active toggle without redeploy", async () => {
    const now = await time.latest();
    await reg.createQuest(Q("a"), 10, now, 0, 10, false);
    await reg.updateQuest(Q("a"), 20, now, 0, 40, true);
    const q = await reg.getQuest(Q("a"));
    expect(q.weight).to.equal(20);
    expect(q.perUserCap).to.equal(40);
    expect(q.requiresVerifiedHuman).to.equal(true);
    await reg.setQuestActive(Q("a"), false);
    expect(await reg.isOpen(Q("a"), now)).to.equal(false);
  });
});

describe("AERE Season 1 — Quest Attestor (EIP-712 one-shot)", () => {
  let ledger, reg, attestor, humanity, owner, signer, alice, bob, relayer;
  let chainId;
  beforeEach(async () => {
    [owner, signer, alice, bob, relayer] = await ethers.getSigners();
    chainId = (await ethers.provider.getNetwork()).chainId;
    ledger = await (await ethers.getContractFactory("AerePointsLedger")).deploy();
    reg = await (await ethers.getContractFactory("AereQuestRegistry")).deploy();
    attestor = await (await ethers.getContractFactory("AereQuestAttestor")).deploy(
      await ledger.getAddress(),
      await reg.getAddress(),
      signer.address
    );
    humanity = await (await ethers.getContractFactory("MockHumanityOracle")).deploy();
    await ledger.setAttestor(await attestor.getAddress(), true);
    const now = await time.latest();
    // open quest, weight 10, cap 10 (one-shot), no human required
    await reg.createQuest(Q("follow-x"), 10, now, now + 100000, 10, false);
    // repeatable quest, weight 10, cap 30 (up to 3 completions)
    await reg.createQuest(Q("daily"), 10, now, now + 100000, 30, false);
    // human-gated quest
    await reg.createQuest(Q("kyc"), 25, now, now + 100000, 25, true);
  });

  it("credits the ledger exactly once per (account,questId,nonce)", async () => {
    const validUntil = (await time.latest()) + 3600;
    const sig = await signTicket(signer, await attestor.getAddress(), chainId, {
      account: alice.address,
      questId: Q("follow-x"),
      nonce: 1,
      validUntil,
    });
    // anyone (relayer) may submit
    await attestor.connect(relayer).redeem(alice.address, Q("follow-x"), 1, validUntil, sig);
    expect(await ledger.balanceOf(alice.address)).to.equal(10);
    expect(await attestor.distinctQuestCount(alice.address)).to.equal(1);
    // replay reverts
    await expect(
      attestor.connect(relayer).redeem(alice.address, Q("follow-x"), 1, validUntil, sig)
    ).to.be.revertedWithCustomError(attestor, "TicketAlreadyUsed");
  });

  it("rejects a ticket from an unauthorized signer", async () => {
    const validUntil = (await time.latest()) + 3600;
    const badSig = await signTicket(bob, await attestor.getAddress(), chainId, {
      account: alice.address,
      questId: Q("follow-x"),
      nonce: 2,
      validUntil,
    });
    await expect(
      attestor.redeem(alice.address, Q("follow-x"), 2, validUntil, badSig)
    ).to.be.revertedWithCustomError(attestor, "BadSigner");
  });

  it("rejects an expired ticket", async () => {
    const validUntil = (await time.latest()) + 100;
    const sig = await signTicket(signer, await attestor.getAddress(), chainId, {
      account: alice.address,
      questId: Q("follow-x"),
      nonce: 3,
      validUntil,
    });
    await time.increase(200);
    await expect(
      attestor.redeem(alice.address, Q("follow-x"), 3, validUntil, sig)
    ).to.be.revertedWithCustomError(attestor, "TicketExpired");
  });

  it("enforces the quest window (closed / inactive)", async () => {
    const now = await time.latest();
    await reg.createQuest(Q("future"), 10, now + 5000, now + 100000, 10, false);
    const validUntil = now + 100000;
    const sig = await signTicket(signer, await attestor.getAddress(), chainId, {
      account: alice.address,
      questId: Q("future"),
      nonce: 1,
      validUntil,
    });
    await expect(
      attestor.redeem(alice.address, Q("future"), 1, validUntil, sig)
    ).to.be.revertedWithCustomError(attestor, "QuestClosed");
    // deactivate an open quest -> closed
    await reg.setQuestActive(Q("follow-x"), false);
    const sig2 = await signTicket(signer, await attestor.getAddress(), chainId, {
      account: alice.address,
      questId: Q("follow-x"),
      nonce: 9,
      validUntil,
    });
    await expect(
      attestor.redeem(alice.address, Q("follow-x"), 9, validUntil, sig2)
    ).to.be.revertedWithCustomError(attestor, "QuestClosed");
  });

  it("enforces the per-user cap across multiple nonces", async () => {
    const validUntil = (await time.latest()) + 100000;
    // daily cap 30 -> 3 completions ok, 4th reverts
    for (let n = 1; n <= 3; n++) {
      const sig = await signTicket(signer, await attestor.getAddress(), chainId, {
        account: alice.address,
        questId: Q("daily"),
        nonce: n,
        validUntil,
      });
      await attestor.redeem(alice.address, Q("daily"), n, validUntil, sig);
    }
    expect(await ledger.balanceOf(alice.address)).to.equal(30);
    expect(await attestor.distinctQuestCount(alice.address)).to.equal(1); // same quest
    const sig4 = await signTicket(signer, await attestor.getAddress(), chainId, {
      account: alice.address,
      questId: Q("daily"),
      nonce: 4,
      validUntil,
    });
    await expect(
      attestor.redeem(alice.address, Q("daily"), 4, validUntil, sig4)
    ).to.be.revertedWithCustomError(attestor, "PerUserCapReached");
  });

  it("enforces requiresVerifiedHuman via the humanity oracle", async () => {
    const validUntil = (await time.latest()) + 100000;
    const mk = async (nonce) =>
      signTicket(signer, await attestor.getAddress(), chainId, {
        account: alice.address,
        questId: Q("kyc"),
        nonce,
        validUntil,
      });
    // oracle unset -> revert
    let sig = await mk(1);
    await expect(attestor.redeem(alice.address, Q("kyc"), 1, validUntil, sig)).to.be.revertedWithCustomError(
      attestor,
      "HumanityOracleUnset"
    );
    // set oracle, alice not human -> revert
    await attestor.setHumanityOracle(await humanity.getAddress());
    sig = await mk(1);
    await expect(attestor.redeem(alice.address, Q("kyc"), 1, validUntil, sig)).to.be.revertedWithCustomError(
      attestor,
      "NotVerifiedHuman"
    );
    // mark human -> ok
    await humanity.set(alice.address, true);
    sig = await mk(1);
    await attestor.redeem(alice.address, Q("kyc"), 1, validUntil, sig);
    expect(await ledger.balanceOf(alice.address)).to.equal(25);
  });

  it("tracks distinct quest count across different quests", async () => {
    const validUntil = (await time.latest()) + 100000;
    for (const [q, n] of [[Q("follow-x"), 1], [Q("daily"), 1]]) {
      const sig = await signTicket(signer, await attestor.getAddress(), chainId, {
        account: alice.address,
        questId: q,
        nonce: n,
        validUntil,
      });
      await attestor.redeem(alice.address, q, n, validUntil, sig);
    }
    expect(await attestor.distinctQuestCount(alice.address)).to.equal(2);
  });
});

describe("AERE Season 1 — Check-In (soulbound First Flight)", () => {
  let checkin, owner, alice, bob;
  beforeEach(async () => {
    [owner, alice, bob] = await ethers.getSigners();
    checkin = await (await ethers.getContractFactory("AereQuestCheckIn")).deploy();
  });

  it("mints once per account and is queryable", async () => {
    await checkin.connect(alice).checkIn();
    expect(await checkin.hasCheckedIn(alice.address)).to.equal(true);
    expect(await checkin.balanceOf(alice.address)).to.equal(1);
    const tokenId = BigInt(alice.address);
    expect(await checkin.ownerOf(tokenId)).to.equal(alice.address);
    expect(await checkin.totalCheckedIn()).to.equal(1);
    await expect(checkin.connect(alice).checkIn()).to.be.revertedWithCustomError(
      checkin,
      "AlreadyCheckedIn"
    );
  });

  it("is soulbound: all transfer/approval entrypoints REVERT", async () => {
    await checkin.connect(alice).checkIn();
    const tokenId = BigInt(alice.address);
    await expect(
      checkin.connect(alice).transferFrom(alice.address, bob.address, tokenId)
    ).to.be.revertedWithCustomError(checkin, "Soulbound");
    await expect(
      checkin.connect(alice)["safeTransferFrom(address,address,uint256)"](alice.address, bob.address, tokenId)
    ).to.be.revertedWithCustomError(checkin, "Soulbound");
    await expect(checkin.connect(alice).approve(bob.address, tokenId)).to.be.revertedWithCustomError(
      checkin,
      "Soulbound"
    );
    await expect(
      checkin.connect(alice).setApprovalForAll(bob.address, true)
    ).to.be.revertedWithCustomError(checkin, "Soulbound");
  });

  it("supports ERC-721 interface ids and returns tokenURI", async () => {
    await checkin.connect(alice).checkIn();
    expect(await checkin.supportsInterface("0x80ac58cd")).to.equal(true); // ERC721
    expect(await checkin.supportsInterface("0x5b5e139f")).to.equal(true); // metadata
    const uri = await checkin.tokenURI(BigInt(alice.address));
    expect(uri).to.contain("data:application/json;base64,");
  });
});

describe("AERE Season 1 — Referral Registry", () => {
  let ledger, reg, attestor, checkin, referral, passkey, humanity;
  let owner, signer, referrer, invitee, other;
  let chainId;

  async function creditQuest(account, questId, nonce) {
    const validUntil = (await time.latest()) + 1000000;
    const sig = await signTicket(signer, await attestor.getAddress(), chainId, {
      account,
      questId,
      nonce,
      validUntil,
    });
    await attestor.redeem(account, questId, nonce, validUntil, sig);
  }

  beforeEach(async () => {
    [owner, signer, referrer, invitee, other] = await ethers.getSigners();
    chainId = (await ethers.provider.getNetwork()).chainId;
    ledger = await (await ethers.getContractFactory("AerePointsLedger")).deploy();
    reg = await (await ethers.getContractFactory("AereQuestRegistry")).deploy();
    attestor = await (await ethers.getContractFactory("AereQuestAttestor")).deploy(
      await ledger.getAddress(),
      await reg.getAddress(),
      signer.address
    );
    checkin = await (await ethers.getContractFactory("AereQuestCheckIn")).deploy();
    referral = await (await ethers.getContractFactory("AereReferralRegistry")).deploy(
      await ledger.getAddress(),
      await checkin.getAddress(),
      await attestor.getAddress()
    );
    passkey = await (await ethers.getContractFactory("MockAccountFlag")).deploy();
    await referral.setPasskeyFlag(await passkey.getAddress());
    // authorize both the attestor and the referral registry to credit
    await ledger.setAttestor(await attestor.getAddress(), true);
    await ledger.setAttestor(await referral.getAddress(), true);
    const now = await time.latest();
    await reg.createQuest(Q("q1"), 10, now, now + 1000000, 10, false);
    await reg.createQuest(Q("q2"), 10, now, now + 1000000, 10, false);
  });

  async function activate(account) {
    await passkey.set(account.address, true);
    await checkin.connect(account).checkIn();
    await creditQuest(account.address, Q("q1"), 1);
    await creditQuest(account.address, Q("q2"), 1);
  }

  it("binds codes/referrers and rejects self-referral & duplicates", async () => {
    await referral.connect(referrer).registerCode(Q("CODE"));
    await expect(referral.connect(other).registerCode(Q("CODE"))).to.be.revertedWithCustomError(
      referral,
      "CodeTaken"
    );
    await expect(referral.connect(referrer).bindReferrer(Q("CODE"))).to.be.revertedWithCustomError(
      referral,
      "SelfReferral"
    );
    await referral.connect(invitee).bindReferrer(Q("CODE"));
    expect(await referral.referredBy(invitee.address)).to.equal(referrer.address);
    await expect(referral.connect(invitee).bindReferrer(Q("CODE"))).to.be.revertedWithCustomError(
      referral,
      "AlreadyBound"
    );
  });

  it("pays the referrer ONLY on invitee activation", async () => {
    await referral.connect(referrer).registerCode(Q("CODE"));
    await referral.connect(invitee).bindReferrer(Q("CODE"));

    // not activated yet -> revert
    await expect(referral.claimReferral(invitee.address)).to.be.revertedWithCustomError(
      referral,
      "NotActivated"
    );

    // partial activation: passkey + checkin but only 1 quest -> still not activated
    await passkey.set(invitee.address, true);
    await checkin.connect(invitee).checkIn();
    await creditQuest(invitee.address, Q("q1"), 1);
    expect(await referral.isActivated(invitee.address)).to.equal(false);
    await expect(referral.claimReferral(invitee.address)).to.be.revertedWithCustomError(
      referral,
      "NotActivated"
    );

    // second distinct quest completes activation
    await creditQuest(invitee.address, Q("q2"), 1);
    expect(await referral.isActivated(invitee.address)).to.equal(true);

    const before = await ledger.balanceOf(referrer.address);
    await referral.claimReferral(invitee.address);
    const after = await ledger.balanceOf(referrer.address);
    expect(after - before).to.equal(50n); // baseReward

    // double-claim reverts
    await expect(referral.claimReferral(invitee.address)).to.be.revertedWithCustomError(
      referral,
      "AlreadyCredited"
    );
  });

  it("applies diminishing returns and the tier-raised cap", async () => {
    await referral.connect(referrer).registerCode(Q("CODE"));
    // tighten params so the cap binds quickly:
    // baseReward 50, halvingEvery 2, minReward 5, tierSize 3, capBase 90, capStep 60
    await referral.setReferralParams(2, 50, 2, 5, 3, 90, 60);

    const signers = await ethers.getSigners();
    // use a batch of fresh invitees
    const invitees = signers.slice(5, 12);
    const rewards = [];
    for (let i = 0; i < invitees.length; i++) {
      const inv = invitees[i];
      await referral.connect(inv).bindReferrer(Q("CODE"));
      await activate(inv);
      const before = await ledger.balanceOf(referrer.address);
      await referral.claimReferral(inv.address);
      const after = await ledger.balanceOf(referrer.address);
      rewards.push(after - before);
    }
    // diminishing: n=0,1 ->50 ; n=2,3 ->25 ; n=4,5 ->12 ; n=6 ->6 (50>>k, floor 5)
    // but capped by tier: tier=n/3 -> cap=90 + tier*60. Cumulative earned clamps.
    // Check monotonic non-increasing per-referral reward within a tier and cap respected.
    const stats = await referral.referralStats(referrer.address);
    const totalEarned = rewards.reduce((a, b) => a + b, 0n);
    expect(stats.pointsEarned).to.equal(totalEarned);
    // cap for the final count must be >= earned
    expect(stats.currentCap).to.be.gte(totalEarned);
    // first reward is the full base, a later one is strictly smaller (diminished or clamped)
    expect(rewards[0]).to.equal(50n);
    expect(rewards[rewards.length - 1]).to.be.lt(50n);
  });

  it("clamps a single reward to the remaining tier cap", async () => {
    await referral.connect(referrer).registerCode(Q("CODE"));
    // cap base 30, so the very first reward (base 50) clamps to 30
    await referral.setReferralParams(2, 50, 10, 5, 100, 30, 100);
    await referral.connect(invitee).bindReferrer(Q("CODE"));
    await activate(invitee);
    const before = await ledger.balanceOf(referrer.address);
    await referral.claimReferral(invitee.address);
    const after = await ledger.balanceOf(referrer.address);
    expect(after - before).to.equal(30n); // clamped to cap
  });

  it("returns false activation when passkey oracle unset", async () => {
    const fresh = await (await ethers.getContractFactory("AereReferralRegistry")).deploy(
      await ledger.getAddress(),
      await checkin.getAddress(),
      await attestor.getAddress()
    );
    expect(await fresh.isActivated(invitee.address)).to.equal(false);
  });

  // FINDING 5 (info): referral code squatting / front-running.
  // Codes are non-semantic, first-come, and front-runnable BY DESIGN in this
  // version: registerCode lets anyone claim any unused bytes32 with no reservation
  // for branded strings. There is no fund loss (AP is non-transferable and has no
  // monetary value) and no branded-code reservation is in scope for this version.
  // This test characterizes and pins that accepted behavior. It is documentation
  // that lives as an assertion: a squatter can take a "branded" code before the
  // brand owner, and the brand owner is then blocked with CodeTaken. If a future
  // version ever adds owner-gated reservation or sender-derived codes, this test
  // is the intended tripwire to force an explicit, reviewed change.
  it("codes are first-come and front-runnable by design (FINDING 5, accepted)", async () => {
    const BRANDED = Q("aere-official"); // a string a brand would want

    // A squatter (here: `other`) front-runs and claims the branded code first.
    await referral.connect(other).registerCode(BRANDED);
    expect(await referral.codeOwner(BRANDED)).to.equal(other.address);

    // The party who "should" own the brand cannot take it: first-come wins.
    await expect(
      referral.connect(referrer).registerCode(BRANDED)
    ).to.be.revertedWithCustomError(referral, "CodeTaken");

    // Codes are non-semantic: the contract attaches no meaning to the bytes32,
    // and an invitee who binds the branded code is credited to the squatter,
    // exactly as with any other code. No special-casing exists (or is intended).
    await referral.connect(invitee).bindReferrer(BRANDED);
    expect(await referral.referredBy(invitee.address)).to.equal(other.address);
  });
});

describe("AERE Season 1 — Reward Distributor (SECURITY-CRITICAL)", () => {
  let dist, waere, saere, sink, owner, alice, bob, carol, relayer;
  const E = ethers.parseEther;

  beforeEach(async () => {
    [owner, alice, bob, carol, relayer] = await ethers.getSigners();
    waere = await (await ethers.getContractFactory("WAERE")).deploy();
    saere = await (await ethers.getContractFactory("MockSAERE")).deploy(await waere.getAddress());
    sink = await (await ethers.getContractFactory("MockSink")).deploy();
    dist = await (await ethers.getContractFactory("AereRewardDistributor")).deploy(
      await waere.getAddress(),
      await saere.getAddress(),
      await sink.getAddress()
    );
  });

  it("deploys UNFUNDED and holds 0 AERE", async () => {
    expect(await ethers.provider.getBalance(await dist.getAddress())).to.equal(0);
    expect(await dist.merkleRoot()).to.equal(ethers.ZeroHash);
    expect(await dist.claimsStarted()).to.equal(false);
  });

  it("valid Merkle proof claims with correct cliff+linear vesting math", async () => {
    const entries = [
      { account: alice.address, total: E("100") },
      { account: bob.address, total: E("50") },
      { account: carol.address, total: E("25") },
    ];
    const tree = makeTree(entries);
    const now = await time.latest();
    const cliff = 100;
    const duration = 1000;
    const expiry = now + 10_000_000;
    await dist.publishRoot(tree.root, cliff, duration, expiry, false);
    // fund with native AERE (simulating the founder's LATER funding tx)
    await owner.sendTransaction({ to: await dist.getAddress(), value: E("175") });

    const proofA = tree.proofFor(alice.address);

    // before cliff -> nothing to claim
    await expect(dist.claim(alice.address, E("100"), proofA)).to.be.revertedWithCustomError(
      dist,
      "NothingToClaim"
    );

    // mine the claim at EXACTLY start + duration/2 (500s). vested = 100*500/1000 = 50.
    const start = Number(await dist.rootPublishedAt());
    await time.setNextBlockTimestamp(start + 500);
    const balBefore = await ethers.provider.getBalance(alice.address);
    await dist.connect(relayer).claim(alice.address, E("100"), proofA); // relayer submits, alice receives
    const balAfter = await ethers.provider.getBalance(alice.address);
    // alice pays no gas (relayer did); received exactly 50
    expect(balAfter - balBefore).to.equal(E("50"));
    expect(await dist.claimsStarted()).to.equal(true);

    // mine the next claim past full vest -> claim the remainder (exactly 50)
    await time.setNextBlockTimestamp(start + duration + 10);
    const b2 = await ethers.provider.getBalance(alice.address);
    await dist.connect(relayer).claim(alice.address, E("100"), proofA);
    const b3 = await ethers.provider.getBalance(alice.address);
    expect(b3 - b2).to.equal(E("50"));
    expect(await dist.claimed(alice.address)).to.equal(E("100"));

    // fully vested, nothing left
    await expect(dist.claim(alice.address, E("100"), proofA)).to.be.revertedWithCustomError(
      dist,
      "NothingToClaim"
    );
  });

  it("rejects a bad proof / wrong amount", async () => {
    const entries = [{ account: alice.address, total: E("100") }, { account: bob.address, total: E("50") }];
    const tree = makeTree(entries);
    const now = await time.latest();
    await dist.publishRoot(tree.root, 0, 1000, now + 10_000_000, false);
    await owner.sendTransaction({ to: await dist.getAddress(), value: E("150") });
    const proofA = tree.proofFor(alice.address);
    // wrong amount with alice's proof
    await expect(dist.claim(alice.address, E("999"), proofA)).to.be.revertedWithCustomError(dist, "BadProof");
    // bob's proof used for alice
    await expect(dist.claim(alice.address, E("100"), tree.proofFor(bob.address))).to.be.revertedWithCustomError(
      dist,
      "BadProof"
    );
  });

  it("EXCLUDED address cannot claim (sybil revocation)", async () => {
    const entries = [{ account: alice.address, total: E("100") }, { account: bob.address, total: E("50") }];
    const tree = makeTree(entries);
    const now = await time.latest();
    await dist.publishRoot(tree.root, 0, 10, now + 10_000_000, false);
    await owner.sendTransaction({ to: await dist.getAddress(), value: E("150") });
    await dist.setExcluded(alice.address, true);
    await time.increase(20);
    await expect(dist.claim(alice.address, E("100"), tree.proofFor(alice.address))).to.be.revertedWithCustomError(
      dist,
      "IsExcluded"
    );
    // bob unaffected
    await dist.claim(bob.address, E("50"), tree.proofFor(bob.address));
    expect(await dist.claimed(bob.address)).to.equal(E("50"));
  });

  it("double-claim of the same vested tranche reverts (NothingToClaim)", async () => {
    const entries = [{ account: alice.address, total: E("100") }];
    const tree = makeTree(entries);
    const now = await time.latest();
    await dist.publishRoot(tree.root, 0, 10, now + 10_000_000, false);
    await owner.sendTransaction({ to: await dist.getAddress(), value: E("100") });
    await time.increase(20); // fully vested
    await dist.claim(alice.address, E("100"), tree.proofFor(alice.address));
    await expect(dist.claim(alice.address, E("100"), tree.proofFor(alice.address))).to.be.revertedWithCustomError(
      dist,
      "NothingToClaim"
    );
  });

  it("clawback of unclaimed only AFTER expiry; to owner or sink", async () => {
    const entries = [{ account: alice.address, total: E("100") }];
    const tree = makeTree(entries);
    const now = await time.latest();
    const expiry = now + 5000;
    await dist.publishRoot(tree.root, 0, 1000, expiry, false);
    await owner.sendTransaction({ to: await dist.getAddress(), value: E("100") });
    // too early
    await expect(dist.clawbackUnclaimed(false)).to.be.revertedWithCustomError(dist, "ClawbackTooEarly");
    // alice claims part
    await time.increaseTo(now + 500);
    await dist.claim(alice.address, E("100"), tree.proofFor(alice.address));
    // past expiry -> clawback remainder to sink
    await time.increaseTo(expiry + 1);
    const sinkBefore = await ethers.provider.getBalance(await sink.getAddress());
    const remaining = await ethers.provider.getBalance(await dist.getAddress());
    await dist.clawbackUnclaimed(true);
    const sinkAfter = await ethers.provider.getBalance(await sink.getAddress());
    expect(sinkAfter - sinkBefore).to.equal(remaining);
    expect(await ethers.provider.getBalance(await dist.getAddress())).to.equal(0);
  });

  it("sAERE-delivery path: mints sAERE shares to the claimant, no liquid AERE", async () => {
    const entries = [{ account: alice.address, total: E("100") }];
    const tree = makeTree(entries);
    const now = await time.latest();
    await dist.publishRoot(tree.root, 0, 10, now + 10_000_000, true); // deliverAsSAERE
    await owner.sendTransaction({ to: await dist.getAddress(), value: E("100") });
    await time.increase(20); // fully vested

    const aereBefore = await ethers.provider.getBalance(alice.address);
    await dist.connect(relayer).claim(alice.address, E("100"), tree.proofFor(alice.address));
    const aereAfter = await ethers.provider.getBalance(alice.address);

    // alice received sAERE shares, not liquid AERE
    expect(aereAfter).to.equal(aereBefore); // relayer paid gas, alice got no native
    const shares = await saere.balanceOf(alice.address);
    expect(shares).to.be.gt(0);
    // the vault holds the 100 WAERE now
    expect(await waere.balanceOf(await saere.getAddress())).to.equal(E("100"));
    // distributor holds no residual WAERE
    expect(await waere.balanceOf(await dist.getAddress())).to.equal(0);
    // redeemable value ~ 100 WAERE
    expect(await saere.previewRedeem(shares)).to.be.closeTo(E("100"), E("0.001"));
  });

  it("emergency PAUSE blocks claims; unpause restores", async () => {
    const entries = [{ account: alice.address, total: E("100") }];
    const tree = makeTree(entries);
    const now = await time.latest();
    await dist.publishRoot(tree.root, 0, 10, now + 10_000_000, false);
    await owner.sendTransaction({ to: await dist.getAddress(), value: E("100") });
    await time.increase(20);
    await dist.pause();
    await expect(dist.claim(alice.address, E("100"), tree.proofFor(alice.address))).to.be.revertedWith(
      "Pausable: paused"
    );
    await dist.unpause();
    await dist.claim(alice.address, E("100"), tree.proofFor(alice.address));
    expect(await dist.claimed(alice.address)).to.equal(E("100"));
  });

  it("root is re-publishable before first claim, frozen after", async () => {
    const t1 = makeTree([{ account: alice.address, total: E("100") }]);
    const now = await time.latest();
    await dist.publishRoot(t1.root, 0, 1000, now + 10_000_000, false);
    // re-publish (fix) allowed pre-claim
    const t2 = makeTree([{ account: alice.address, total: E("80") }, { account: bob.address, total: E("20") }]);
    await dist.publishRoot(t2.root, 0, 10, now + 10_000_000, false);
    await owner.sendTransaction({ to: await dist.getAddress(), value: E("100") });
    await time.increase(20);
    await dist.claim(alice.address, E("80"), t2.proofFor(alice.address));
    // now frozen
    await expect(dist.publishRoot(t1.root, 0, 1000, now + 10_000_000, false)).to.be.revertedWithCustomError(
      dist,
      "RootFrozen"
    );
  });

  it("publishRoot input validation", async () => {
    const t = makeTree([{ account: alice.address, total: E("1") }]);
    const now = await time.latest();
    await expect(dist.publishRoot(ethers.ZeroHash, 0, 10, now + 1000, false)).to.be.revertedWithCustomError(dist, "ZeroRoot");
    await expect(dist.publishRoot(t.root, 10, 5, now + 1000, false)).to.be.revertedWithCustomError(dist, "BadVesting"); // cliff>duration
    await expect(dist.publishRoot(t.root, 0, 0, now + 1000, false)).to.be.revertedWithCustomError(dist, "BadVesting"); // zero duration
    await expect(dist.publishRoot(t.root, 0, 1000, now + 500, false)).to.be.revertedWithCustomError(dist, "BadExpiry"); // expiry<start+duration
  });

  it("only owner can publish / exclude / pause / clawback", async () => {
    const t = makeTree([{ account: alice.address, total: E("1") }]);
    const now = await time.latest();
    await expect(dist.connect(alice).publishRoot(t.root, 0, 10, now + 1000, false)).to.be.revertedWith(
      "Ownable: caller is not the owner"
    );
    await expect(dist.connect(alice).setExcluded(bob.address, true)).to.be.revertedWith(
      "Ownable: caller is not the owner"
    );
    await expect(dist.connect(alice).pause()).to.be.revertedWith("Ownable: caller is not the owner");
    await expect(dist.connect(alice).clawbackUnclaimed(false)).to.be.revertedWith(
      "Ownable: caller is not the owner"
    );
  });
});
