// AereMailboxV3 + AereIGPV3 — fix for the quoteDispatch gap in AERE's
// Hyperlane core (chain 2800).
//
// Diagnosed root cause (on-chain, 2026-07-10):
//   - Live AereMessenger 0xe54c...7325 has NO quoteDispatch selector at all
//     (0x9c42bd18 absent from bytecode) -> empty revert on every call.
//   - Live AereIGP 0x61B4...6837 has zero destination gas configs -> quote 0.
//   - Both are Foundation-owned, so no deployer-side config fix is possible.
//
// This suite proves the V3 replacement:
//   quoteDispatch returns a sane nonzero quote, dispatch enforces + forwards
//   the fee, Dispatch/DispatchId/GasPayment events fire, inbound process
//   verifies validator signatures over the Hyperlane v3 packed message.
//
// Run: npx hardhat test test/hyperlane-mailbox-v3.test.js

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

const AERE_DOMAIN = 2800;
const ETH_DOMAIN = 1;
const BASE_DOMAIN = 8453;
const DEFAULT_GAS_USAGE = 50_000n;

// Documented sane placeholder pricing (owner-tunable):
//   fee = (gasOverhead + gasLimit) * gasPriceAereWei
const ETH_OVERHEAD = 150_000n;
const ETH_PRICE = 30n * 10n ** 9n; // 30 gwei-AERE per unit of Ethereum gas
const BASE_OVERHEAD = 100_000n;
const BASE_PRICE = 2n * 10n ** 9n;

function b32(addr) {
  return ethers.zeroPadValue(addr, 32);
}

// Hyperlane v3 packed message.
function formatMessage(nonce, origin, sender, destination, recipient, body) {
  return ethers.solidityPacked(
    ["uint8", "uint32", "uint32", "bytes32", "uint32", "bytes32", "bytes"],
    [3, nonce, origin, sender, destination, recipient, body]
  );
}

// StandardHookMetadata: variant(2) || msgValue(32) || gasLimit(32) || refundAddress(20)
function hookMetadata(gasLimit, refundAddress) {
  return ethers.solidityPacked(
    ["uint16", "uint256", "uint256", "address"],
    [1, 0, gasLimit, refundAddress]
  );
}

describe("AereMailboxV3 + AereIGPV3 — quoteDispatch fix", function () {
  let deployer, validator, alice, foundation;
  let mailbox, igp, recipient;

  beforeEach(async function () {
    [deployer, validator, alice, foundation] = await ethers.getSigners();

    igp = await (await ethers.getContractFactory("AereIGPV3")).deploy(foundation.address);
    await igp.waitForDeployment();

    mailbox = await (await ethers.getContractFactory("AereMailboxV3")).deploy(AERE_DOMAIN);
    await mailbox.waitForDeployment();

    await (await mailbox.setDefaultHook(await igp.getAddress())).wait();
    await (await mailbox.addValidator(validator.address)).wait();

    await (
      await igp.setDestinationGasConfigs([
        { remoteDomain: ETH_DOMAIN, config: { gasOverhead: ETH_OVERHEAD, gasPriceAereWei: ETH_PRICE } },
        { remoteDomain: BASE_DOMAIN, config: { gasOverhead: BASE_OVERHEAD, gasPriceAereWei: BASE_PRICE } },
      ])
    ).wait();

    recipient = await (await ethers.getContractFactory("TestRecipientV3")).deploy();
    await recipient.waitForDeployment();
  });

  describe("quoteDispatch (the missing function)", function () {
    it("returns a sane nonzero quote for a configured domain", async function () {
      const body = ethers.toUtf8Bytes("hello ethereum");
      const quote = await mailbox["quoteDispatch(uint32,bytes32,bytes)"](ETH_DOMAIN, b32(alice.address), body);
      // (150k overhead + 50k default gas) * 30 gwei = 0.006 AERE
      expect(quote).to.equal((ETH_OVERHEAD + DEFAULT_GAS_USAGE) * ETH_PRICE);
      expect(quote).to.equal(ethers.parseEther("0.006"));
    });

    it("prices different domains differently", async function () {
      const body = ethers.toUtf8Bytes("hello base");
      const quote = await mailbox["quoteDispatch(uint32,bytes32,bytes)"](BASE_DOMAIN, b32(alice.address), body);
      expect(quote).to.equal((BASE_OVERHEAD + DEFAULT_GAS_USAGE) * BASE_PRICE);
    });

    it("honours a custom gasLimit via StandardHookMetadata", async function () {
      const body = ethers.toUtf8Bytes("big message");
      const md = hookMetadata(300_000n, ethers.ZeroAddress);
      const quote = await mailbox["quoteDispatch(uint32,bytes32,bytes,bytes)"](
        ETH_DOMAIN, b32(alice.address), body, md
      );
      expect(quote).to.equal((ETH_OVERHEAD + 300_000n) * ETH_PRICE);
    });

    it("reverts with a CLEAR error (not empty data) for an unconfigured domain", async function () {
      const body = ethers.toUtf8Bytes("hello nowhere");
      await expect(
        mailbox["quoteDispatch(uint32,bytes32,bytes)"](424242, b32(alice.address), body)
      ).to.be.revertedWith("AereIGPV3: domain not configured");
    });

    it("quotes zero when no hooks are set (fee-free mode)", async function () {
      const bare = await (await ethers.getContractFactory("AereMailboxV3")).deploy(AERE_DOMAIN);
      await bare.waitForDeployment();
      const q = await bare["quoteDispatch(uint32,bytes32,bytes)"](ETH_DOMAIN, b32(alice.address), "0x");
      expect(q).to.equal(0n);
    });
  });

  describe("dispatch pays the quote", function () {
    it("reverts if msg.value < quote", async function () {
      const body = ethers.toUtf8Bytes("underpaid");
      const quote = await mailbox["quoteDispatch(uint32,bytes32,bytes)"](ETH_DOMAIN, b32(alice.address), body);
      await expect(
        mailbox.connect(alice)["dispatch(uint32,bytes32,bytes)"](ETH_DOMAIN, b32(alice.address), body, {
          value: quote - 1n,
        })
      ).to.be.revertedWith("AereMailboxV3: insufficient hook payment");
    });

    it("dispatch at exactly the quote emits Dispatch + DispatchId + GasPayment and funds the IGP", async function () {
      const body = ethers.toUtf8Bytes("paid in full");
      const quote = await mailbox["quoteDispatch(uint32,bytes32,bytes)"](ETH_DOMAIN, b32(alice.address), body);

      const expectedMessage = formatMessage(
        0, AERE_DOMAIN, b32(alice.address), ETH_DOMAIN, b32(alice.address), body
      );
      const expectedId = ethers.keccak256(expectedMessage);

      const tx = mailbox.connect(alice)["dispatch(uint32,bytes32,bytes)"](
        ETH_DOMAIN, b32(alice.address), body, { value: quote }
      );

      await expect(tx)
        .to.emit(mailbox, "Dispatch").withArgs(alice.address, ETH_DOMAIN, b32(alice.address), expectedMessage)
        .and.to.emit(mailbox, "DispatchId").withArgs(expectedId)
        .and.to.emit(igp, "GasPayment").withArgs(expectedId, ETH_DOMAIN, DEFAULT_GAS_USAGE, quote);

      expect(await ethers.provider.getBalance(await igp.getAddress())).to.equal(quote);
      expect(await mailbox.nonce()).to.equal(1);
      expect(await mailbox.latestDispatchedId()).to.equal(expectedId);
    });

    it("refunds overpayment to the sender", async function () {
      const body = ethers.toUtf8Bytes("overpaid");
      const quote = await mailbox["quoteDispatch(uint32,bytes32,bytes)"](ETH_DOMAIN, b32(alice.address), body);
      const balBefore = await ethers.provider.getBalance(alice.address);

      const tx = await mailbox.connect(alice)["dispatch(uint32,bytes32,bytes)"](
        ETH_DOMAIN, b32(alice.address), body, { value: quote + ethers.parseEther("1") }
      );
      const rc = await tx.wait();
      const gasCost = rc.gasUsed * rc.gasPrice;

      // Alice net spend = quote + tx gas (the extra 1 AERE came back).
      const balAfter = await ethers.provider.getBalance(alice.address);
      expect(balBefore - balAfter).to.equal(quote + gasCost);
      // IGP kept only the quote.
      expect(await ethers.provider.getBalance(await igp.getAddress())).to.equal(quote);
    });

    it("dispatch to an unconfigured domain reverts clearly", async function () {
      await expect(
        mailbox.connect(alice)["dispatch(uint32,bytes32,bytes)"](424242, b32(alice.address), "0x", {
          value: ethers.parseEther("1"),
        })
      ).to.be.revertedWith("AereIGPV3: domain not configured");
    });
  });

  describe("inbound process (Hyperlane v3 packed message + validator multisig)", function () {
    async function signedInbound(body) {
      const message = formatMessage(
        7, ETH_DOMAIN, b32(alice.address), AERE_DOMAIN, b32(await recipient.getAddress()), body
      );
      const messageId = ethers.keccak256(message);
      const sig = await validator.signMessage(ethers.getBytes(messageId));
      return { message, messageId, sig };
    }

    it("delivers a validator-signed message to the recipient", async function () {
      const body = ethers.toUtf8Bytes("inbound payload");
      const { message, messageId, sig } = await signedInbound(body);

      await expect(mailbox.process(sig, message))
        .to.emit(mailbox, "Process").withArgs(ETH_DOMAIN, b32(alice.address), await recipient.getAddress())
        .and.to.emit(mailbox, "ProcessId").withArgs(messageId)
        .and.to.emit(recipient, "Handled");

      expect(await recipient.lastOrigin()).to.equal(ETH_DOMAIN);
      expect(await recipient.lastSender()).to.equal(b32(alice.address));
      expect(ethers.toUtf8String(await recipient.lastBody())).to.equal("inbound payload");
      expect(await mailbox.delivered(messageId)).to.equal(true);
    });

    it("rejects replay", async function () {
      const { message, sig } = await signedInbound(ethers.toUtf8Bytes("once only"));
      await (await mailbox.process(sig, message)).wait();
      await expect(mailbox.process(sig, message)).to.be.revertedWith("AereMailboxV3: already delivered");
    });

    it("rejects a non-validator signature", async function () {
      const body = ethers.toUtf8Bytes("forged");
      const message = formatMessage(
        8, ETH_DOMAIN, b32(alice.address), AERE_DOMAIN, b32(await recipient.getAddress()), body
      );
      const badSig = await alice.signMessage(ethers.getBytes(ethers.keccak256(message)));
      await expect(mailbox.process(badSig, message)).to.be.revertedWith("AereMailboxV3: not a validator");
    });

    it("rejects a message addressed to another domain", async function () {
      const body = ethers.toUtf8Bytes("misrouted");
      const message = formatMessage(9, ETH_DOMAIN, b32(alice.address), 9999, b32(await recipient.getAddress()), body);
      const sig = await validator.signMessage(ethers.getBytes(ethers.keccak256(message)));
      await expect(mailbox.process(sig, message)).to.be.revertedWith("AereMailboxV3: wrong destination");
    });
  });

  describe("IGP hook surface + payout", function () {
    it("reports Hyperlane hookType INTERCHAIN_GAS_PAYMASTER (4)", async function () {
      expect(await igp.hookType()).to.equal(4);
      expect(await igp.supportsMetadata("0x")).to.equal(true);
      expect(await igp.supportsMetadata(hookMetadata(1n, ethers.ZeroAddress))).to.equal(true);
    });

    it("claim() pays the whole balance to the beneficiary", async function () {
      const body = ethers.toUtf8Bytes("fund the igp");
      const quote = await mailbox["quoteDispatch(uint32,bytes32,bytes)"](ETH_DOMAIN, b32(alice.address), body);
      await (
        await mailbox.connect(alice)["dispatch(uint32,bytes32,bytes)"](ETH_DOMAIN, b32(alice.address), body, {
          value: quote,
        })
      ).wait();

      const before = await ethers.provider.getBalance(foundation.address);
      await (await igp.connect(alice).claim()).wait(); // permissionless, pays beneficiary only
      const after = await ethers.provider.getBalance(foundation.address);
      expect(after - before).to.equal(quote);
      expect(await ethers.provider.getBalance(await igp.getAddress())).to.equal(0n);
    });

    it("only the owner can set gas configs or beneficiary", async function () {
      await expect(
        igp.connect(alice).setDestinationGasConfigs([
          { remoteDomain: 1, config: { gasOverhead: 1n, gasPriceAereWei: 1n } },
        ])
      ).to.be.revertedWith("Ownable: caller is not the owner");
      await expect(igp.connect(alice).setBeneficiary(alice.address)).to.be.revertedWith(
        "Ownable: caller is not the owner"
      );
      await expect(mailbox.connect(alice).setDefaultHook(alice.address)).to.be.revertedWith(
        "Ownable: caller is not the owner"
      );
    });

    it("rejects zero gas price configs", async function () {
      await expect(
        igp.setDestinationGasConfigs([{ remoteDomain: 1, config: { gasOverhead: 1n, gasPriceAereWei: 0n } }])
      ).to.be.revertedWith("AereIGPV3: zero gas price");
    });
  });
});
