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

describe("AereAgentBond + AereAIReputation", () => {
  let foundation, op1, op2, attestor1, outsider;
  let aere, sink, bond, rep;
  const AGENT_A = ethers.keccak256(ethers.toUtf8Bytes("agent-A"));
  const AGENT_B = ethers.keccak256(ethers.toUtf8Bytes("agent-B"));
  const ONE_AERE = ethers.parseUnits("1", 18);
  const TEN_AERE = ethers.parseUnits("10", 18);

  beforeEach(async () => {
    [foundation, op1, op2, attestor1, outsider] = await ethers.getSigners();
    const T = await ethers.getContractFactory("MockERC20Lending");
    aere = await T.deploy("AERE", "AERE", 18);
    await aere.waitForDeployment();
    const S = await ethers.getContractFactory("MockSinkSimple");
    sink = await S.deploy();
    await sink.waitForDeployment();
    const B = await ethers.getContractFactory("AereAgentBond");
    bond = await B.deploy(await aere.getAddress(), await sink.getAddress(), [foundation.address]);
    await bond.waitForDeployment();
    const R = await ethers.getContractFactory("AereAIReputation");
    rep = await R.deploy(await bond.getAddress(), [attestor1.address]);
    await rep.waitForDeployment();

    // Seed operators with AERE balance.
    await aere.mint(op1.address, ethers.parseUnits("100", 18));
    await aere.mint(op2.address, ethers.parseUnits("100", 18));
    await aere.connect(op1).approve(await bond.getAddress(), ethers.MaxUint256);
    await aere.connect(op2).approve(await bond.getAddress(), ethers.MaxUint256);
  });

  describe("AgentBond", () => {
    it("postBond pulls AERE + sets state + emits", async () => {
      await expect(bond.connect(op1).postBond(AGENT_A, TEN_AERE))
        .to.emit(bond, "BondPosted").withArgs(op1.address, AGENT_A, TEN_AERE, TEN_AERE);
      const b = await bond.bondOf(op1.address, AGENT_A);
      expect(b.amount).to.equal(TEN_AERE);
      expect(b.exists).to.equal(true);
      expect(b.requestedAt).to.equal(0n);
    });

    it("top-up emits BondToppedUp + cancels in-flight withdrawal", async () => {
      await bond.connect(op1).postBond(AGENT_A, TEN_AERE);
      await bond.connect(op1).requestWithdraw(AGENT_A);
      expect((await bond.bondOf(op1.address, AGENT_A)).requestedAt).to.be.gt(0n);
      await expect(bond.connect(op1).postBond(AGENT_A, TEN_AERE))
        .to.emit(bond, "BondToppedUp");
      expect((await bond.bondOf(op1.address, AGENT_A)).requestedAt).to.equal(0n);
      expect((await bond.bondOf(op1.address, AGENT_A)).amount).to.equal(TEN_AERE * 2n);
    });

    it("requestWithdraw + withdraw after cooldown", async () => {
      await bond.connect(op1).postBond(AGENT_A, TEN_AERE);
      await bond.connect(op1).requestWithdraw(AGENT_A);
      await expect(bond.connect(op1).requestWithdraw(AGENT_A))
        .to.be.revertedWithCustomError(bond, "WithdrawalAlreadyRequested");
      await expect(bond.connect(outsider).withdraw(op1.address, AGENT_A))
        .to.be.revertedWithCustomError(bond, "WithdrawalCooldownOpen");
      await ethers.provider.send("evm_increaseTime", [7 * 24 * 3600 + 1]);
      await ethers.provider.send("evm_mine", []);
      const balBefore = await aere.balanceOf(op1.address);
      await expect(bond.connect(outsider).withdraw(op1.address, AGENT_A))
        .to.emit(bond, "Withdrawn");
      const balAfter = await aere.balanceOf(op1.address);
      expect(balAfter - balBefore).to.equal(TEN_AERE);
    });

    it("withdraw without request reverts", async () => {
      await bond.connect(op1).postBond(AGENT_A, TEN_AERE);
      await expect(bond.connect(op1).withdraw(op1.address, AGENT_A))
        .to.be.revertedWithCustomError(bond, "WithdrawalNotRequested");
    });

    it("slash only from oracle; forwards to sink; reduces amount; bumps lifetimeSlashed", async () => {
      await bond.connect(op1).postBond(AGENT_A, TEN_AERE);
      await expect(
        bond.connect(outsider).slash(op1.address, AGENT_A, ONE_AERE, ethers.ZeroHash, "")
      ).to.be.revertedWithCustomError(bond, "NotOracle");
      const reason = ethers.keccak256(ethers.toUtf8Bytes("aifraud-001"));
      // AUDIT FIX (HIGH #18): 25% cap per call → max 2.5 AERE on 10 AERE bond.
      const slashAmt = ONE_AERE * 2n;
      await expect(
        bond.connect(foundation).slash(op1.address, AGENT_A, slashAmt, reason, "ipfs://evidence")
      ).to.emit(bond, "Slashed").withArgs(op1.address, AGENT_A, foundation.address, slashAmt, reason, "ipfs://evidence");

      const b = await bond.bondOf(op1.address, AGENT_A);
      expect(b.amount).to.equal(TEN_AERE - slashAmt);
      expect(b.lifetimeSlashed).to.equal(slashAmt);
      expect(await sink.lastFlushAmount()).to.equal(slashAmt);
      expect(await bond.totalSlashedToSink()).to.equal(slashAmt);
    });

    it("slash beyond available bond reverts", async () => {
      await bond.connect(op1).postBond(AGENT_A, ONE_AERE);
      await expect(
        bond.connect(foundation).slash(op1.address, AGENT_A, TEN_AERE, ethers.ZeroHash, "")
      ).to.be.revertedWithCustomError(bond, "SlashAmountExceedsBond");
    });

    it("slash beyond 25% per-call cap reverts (audit fix HIGH #18)", async () => {
      await bond.connect(op1).postBond(AGENT_A, TEN_AERE);
      // 30% of 10 AERE = 3 AERE > 25% cap (2.5 AERE)
      await expect(
        bond.connect(foundation).slash(op1.address, AGENT_A, ONE_AERE * 3n, ethers.ZeroHash, "")
      ).to.be.revertedWithCustomError(bond, "SlashExceedsCallCap");
    });

    it("slash cooldown 24h (audit fix HIGH #18)", async () => {
      await bond.connect(op1).postBond(AGENT_A, TEN_AERE);
      await bond.connect(foundation).slash(op1.address, AGENT_A, ONE_AERE, ethers.ZeroHash, "");
      // Second slash within 24h reverts.
      await expect(
        bond.connect(foundation).slash(op1.address, AGENT_A, ONE_AERE, ethers.ZeroHash, "")
      ).to.be.revertedWithCustomError(bond, "SlashCooldownActive");
      // After 24h+ second slash succeeds.
      await ethers.provider.send("evm_increaseTime", [24 * 3600 + 1]);
      await ethers.provider.send("evm_mine", []);
      await bond.connect(foundation).slash(op1.address, AGENT_A, ONE_AERE, ethers.ZeroHash, "");
    });

    it("constructor rejects empty oracles array (audit fix MED #33)", async () => {
      const F = await ethers.getContractFactory("AereAgentBond");
      await expect(F.deploy(await aere.getAddress(), await sink.getAddress(), [])).to.be.revertedWith("no-oracles");
    });

    it("postBond top-up emits WithdrawalCancelled (audit fix MED #36)", async () => {
      await bond.connect(op1).postBond(AGENT_A, ONE_AERE);
      await bond.connect(op1).requestWithdraw(AGENT_A);
      await expect(bond.connect(op1).postBond(AGENT_A, ONE_AERE)).to.emit(bond, "WithdrawalCancelled");
    });

    it("isBonded threshold + withdraw-flag semantics", async () => {
      await bond.connect(op1).postBond(AGENT_A, TEN_AERE);
      expect(await bond.isBonded(op1.address, AGENT_A, TEN_AERE)).to.equal(true);
      expect(await bond.isBonded(op1.address, AGENT_A, TEN_AERE + 1n)).to.equal(false);
      await bond.connect(op1).requestWithdraw(AGENT_A);
      // After withdraw request, isBonded false even though amount is still there.
      expect(await bond.isBonded(op1.address, AGENT_A, ONE_AERE)).to.equal(false);
    });
  });

  describe("AIReputation", () => {
    it("attest +1 / -1 / 0 are only callable by attestor; record stats", async () => {
      // AUDIT FIX (HIGH #20): each evidenceHash can only be used once per attestor.
      const ev1 = ethers.keccak256(ethers.toUtf8Bytes("ev-1"));
      const ev2 = ethers.keccak256(ethers.toUtf8Bytes("ev-2"));
      const ev3 = ethers.keccak256(ethers.toUtf8Bytes("ev-3"));
      const ev4 = ethers.keccak256(ethers.toUtf8Bytes("ev-4"));
      await expect(rep.connect(outsider).attest(op1.address, AGENT_A, 1, ev1, ""))
        .to.be.revertedWithCustomError(rep, "NotAttestor");
      await rep.connect(attestor1).attest(op1.address, AGENT_A, 1, ev1, "");
      await rep.connect(attestor1).attest(op1.address, AGENT_A, 1, ev2, "");
      await rep.connect(attestor1).attest(op1.address, AGENT_A, -1, ev3, "");
      await rep.connect(attestor1).attest(op1.address, AGENT_A, 0, ev4, "ipfs://memo");

      const s = await rep.statsOf(op1.address, AGENT_A);
      expect(s.positiveCount).to.equal(2n);
      expect(s.disputeCount).to.equal(1n);

      // Replay rejected.
      await expect(rep.connect(attestor1).attest(op1.address, AGENT_A, 1, ev1, ""))
        .to.be.revertedWithCustomError(rep, "EvidenceReplay");
    });

    it("attest rejects out-of-range delta + zero evidence hash", async () => {
      const ev = ethers.keccak256(ethers.toUtf8Bytes("ev-1"));
      await expect(rep.connect(attestor1).attest(op1.address, AGENT_A, 2, ev, ""))
        .to.be.revertedWithCustomError(rep, "InvalidDelta");
      await expect(rep.connect(attestor1).attest(op1.address, AGENT_A, 1, ethers.ZeroHash, ""))
        .to.be.revertedWithCustomError(rep, "EmptyEvidenceHash");
    });

    it("score formula: positive +10, slash -10/AERE, dispute -20; clamped", async () => {
      // Setup: post bond + slash 2 AERE (under 25% cap on 10 AERE); +5 positives; 1 dispute.
      await bond.connect(op1).postBond(AGENT_A, TEN_AERE);
      await bond.connect(foundation).slash(op1.address, AGENT_A, ONE_AERE * 2n, ethers.keccak256(ethers.toUtf8Bytes("s1")), "");
      for (let i = 0; i < 5; i++) {
        const ev = ethers.keccak256(ethers.toUtf8Bytes(`ev-pos-${i}`));
        await rep.connect(attestor1).attest(op1.address, AGENT_A, 1, ev, "");
      }
      await rep.connect(attestor1).attest(op1.address, AGENT_A, -1, ethers.keccak256(ethers.toUtf8Bytes("ev-dispute")), "");

      // raw = 5*10 - 2*10 - 1*20 = 50 - 20 - 20 = 10
      expect(await rep.scoreOf(op1.address, AGENT_A)).to.equal(10n);
    });

    it("score clamps at zero when malus exceeds positives", async () => {
      // Post 1 AERE bond, slash 0.2 AERE (= 20% < 25% cap). No positives → score 0.
      await bond.connect(op1).postBond(AGENT_A, ONE_AERE);
      await bond.connect(foundation).slash(op1.address, AGENT_A, ONE_AERE / 5n, ethers.keccak256(ethers.toUtf8Bytes("s")), "");
      expect(await rep.scoreOf(op1.address, AGENT_A)).to.equal(0n);
    });

    it("score reflects slashed bond even after operator withdraws clean", async () => {
      // Post + slash 2 AERE (20% of 10, under cap) + request withdraw + cooldown + withdraw.
      await bond.connect(op1).postBond(AGENT_A, TEN_AERE);
      await bond.connect(foundation).slash(op1.address, AGENT_A, ONE_AERE * 2n, ethers.keccak256(ethers.toUtf8Bytes("s2")), "");
      await bond.connect(op1).requestWithdraw(AGENT_A);
      await ethers.provider.send("evm_increaseTime", [7 * 24 * 3600 + 1]);
      await ethers.provider.send("evm_mine", []);
      await bond.connect(op1).withdraw(op1.address, AGENT_A);
      // lifetimeSlashed persists; score still reflects -20 from slash.
      const s = await rep.statsOf(op1.address, AGENT_A);
      expect(s.lifetimeSlashed).to.equal(ONE_AERE * 2n);
    });
  });
});
