// AereStateChannels tests.

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

const ONE6 = 10n ** 6n;

async function deployStack() {
  const [deployer, alice, bob, mallory] = await ethers.getSigners();
  const ERC20 = await ethers.getContractFactory("MockERC20Lending");
  const token = await ERC20.deploy("USDC.e", "USDC.e", 6);
  await token.waitForDeployment();
  const Channels = await ethers.getContractFactory("AereStateChannels");
  const channels = await Channels.deploy(24 * 3600); // 24h window
  await channels.waitForDeployment();

  // Mint and approve.
  await token.mint(alice.address, 10_000n * ONE6);
  await token.mint(bob.address, 10_000n * ONE6);
  await token.connect(alice).approve(await channels.getAddress(), 10_000n * ONE6);
  await token.connect(bob).approve(await channels.getAddress(), 10_000n * ONE6);
  return { deployer, alice, bob, mallory, token, channels };
}

async function signState(wallet, channels, state) {
  const domain = {
    name: "AereStateChannels", version: "1",
    chainId: (await ethers.provider.getNetwork()).chainId,
    verifyingContract: await channels.getAddress(),
  };
  const types = {
    ChannelState: [
      { name: "channelId",         type: "bytes32" },
      { name: "nonce",             type: "uint256" },
      { name: "balanceA",          type: "uint256" },
      { name: "balanceB",          type: "uint256" },
      { name: "htlcLockedFromA",   type: "uint256" },
      { name: "htlcLockedFromB",   type: "uint256" },
      { name: "lockHash",          type: "bytes32" },
      { name: "lockTimeout",       type: "uint256" },
    ],
  };
  return wallet.signTypedData(domain, types, state);
}

async function openChannel(env, depositA, depositB) {
  // open() now requires the caller to be a channel participant; open as partyA (alice).
  const tx = await env.channels.connect(env.alice).open(env.alice.address, env.bob.address, await env.token.getAddress(), depositA, depositB);
  const rcpt = await tx.wait();
  const id = rcpt.logs.find(l => l.fragment?.name === "Opened").args.channelId;
  return id;
}

describe("AereStateChannels", function () {
  it("opens a channel + pulls deposits from both parties", async function () {
    const env = await deployStack();
    const id = await openChannel(env, 1000n * ONE6, 1000n * ONE6);
    const c = await env.channels.channels(id);
    expect(c.status).to.equal(1); // Open
    expect(c.depositA).to.equal(1000n * ONE6);
    expect(c.depositB).to.equal(1000n * ONE6);
    expect(await env.token.balanceOf(await env.channels.getAddress())).to.equal(2000n * ONE6);
  });

  it("cooperative close: both sign + funds released on withdraw", async function () {
    const env = await deployStack();
    const id = await openChannel(env, 1000n * ONE6, 500n * ONE6);
    const state = {
      channelId: id, nonce: 5n,
      balanceA: 800n * ONE6, balanceB: 700n * ONE6,
      htlcLockedFromA: 0n, htlcLockedFromB: 0n,
      lockHash: ethers.ZeroHash, lockTimeout: 0n,
    };
    const sigA = await signState(env.alice, env.channels, state);
    const sigB = await signState(env.bob, env.channels, state);
    await env.channels.cooperativeClose(state, sigA, sigB);
    const c = await env.channels.channels(id);
    expect(c.status).to.equal(3); // Finalised
    expect(c.latestBalanceA).to.equal(800n * ONE6);
    expect(c.latestBalanceB).to.equal(700n * ONE6);
    // Withdraw.
    const aBefore = await env.token.balanceOf(env.alice.address);
    await env.channels.connect(env.alice).withdraw(id);
    expect(await env.token.balanceOf(env.alice.address) - aBefore).to.equal(800n * ONE6);
  });

  it("challenge close: override with higher nonce; finalize after window", async function () {
    const env = await deployStack();
    const id = await openChannel(env, 1000n * ONE6, 1000n * ONE6);
    // Alice submits an OLD state (nonce 3).
    const old = {
      channelId: id, nonce: 3n,
      balanceA: 1500n * ONE6, balanceB: 500n * ONE6,
      htlcLockedFromA: 0n, htlcLockedFromB: 0n,
      lockHash: ethers.ZeroHash, lockTimeout: 0n,
    };
    const oldA = await signState(env.alice, env.channels, old);
    const oldB = await signState(env.bob, env.channels, old);
    await env.channels.connect(env.alice).startChallenge(old, oldA, oldB);
    // Bob counters with newer state (nonce 7).
    const newer = {
      channelId: id, nonce: 7n,
      balanceA: 300n * ONE6, balanceB: 1700n * ONE6,
      htlcLockedFromA: 0n, htlcLockedFromB: 0n,
      lockHash: ethers.ZeroHash, lockTimeout: 0n,
    };
    const nA = await signState(env.alice, env.channels, newer);
    const nB = await signState(env.bob, env.channels, newer);
    await env.channels.connect(env.bob).overrideChallenge(newer, nA, nB);
    // Advance past window + finalize.
    await ethers.provider.send("evm_increaseTime", [24 * 3600 + 10]);
    await ethers.provider.send("evm_mine", []);
    await env.channels.finalize(id);
    const c = await env.channels.channels(id);
    expect(c.latestBalanceA).to.equal(300n * ONE6);
    expect(c.latestBalanceB).to.equal(1700n * ONE6);
  });

  it("rejects override with same or lower nonce", async function () {
    const env = await deployStack();
    const id = await openChannel(env, 1000n * ONE6, 1000n * ONE6);
    const s1 = { channelId: id, nonce: 5n, balanceA: 800n * ONE6, balanceB: 1200n * ONE6, htlcLockedFromA: 0n, htlcLockedFromB: 0n, lockHash: ethers.ZeroHash, lockTimeout: 0n };
    const sigA1 = await signState(env.alice, env.channels, s1);
    const sigB1 = await signState(env.bob, env.channels, s1);
    await env.channels.connect(env.alice).startChallenge(s1, sigA1, sigB1);
    // Try override with same nonce.
    const s2 = { ...s1, balanceA: 100n * ONE6, balanceB: 1900n * ONE6 };
    const sigA2 = await signState(env.alice, env.channels, s2);
    const sigB2 = await signState(env.bob, env.channels, s2);
    await expect(env.channels.connect(env.bob).overrideChallenge(s2, sigA2, sigB2))
      .to.be.revertedWithCustomError(env.channels, "NonceNotHigher");
  });

  it("rejects balances that don't sum to deposits", async function () {
    const env = await deployStack();
    const id = await openChannel(env, 1000n * ONE6, 1000n * ONE6);
    const bad = { channelId: id, nonce: 1n, balanceA: 500n * ONE6, balanceB: 100n * ONE6, htlcLockedFromA: 0n, htlcLockedFromB: 0n, lockHash: ethers.ZeroHash, lockTimeout: 0n };
    const sigA = await signState(env.alice, env.channels, bad);
    const sigB = await signState(env.bob, env.channels, bad);
    await expect(env.channels.cooperativeClose(bad, sigA, sigB))
      .to.be.revertedWithCustomError(env.channels, "BalancesMismatch");
  });

  it("rejects bad signatures (non-party signer)", async function () {
    const env = await deployStack();
    const id = await openChannel(env, 1000n * ONE6, 1000n * ONE6);
    const s = { channelId: id, nonce: 1n, balanceA: 1000n * ONE6, balanceB: 1000n * ONE6, htlcLockedFromA: 0n, htlcLockedFromB: 0n, lockHash: ethers.ZeroHash, lockTimeout: 0n };
    const malSig = await signState(env.mallory, env.channels, s);
    const goodB = await signState(env.bob, env.channels, s);
    await expect(env.channels.cooperativeClose(s, malSig, goodB))
      .to.be.revertedWithCustomError(env.channels, "BadSignature");
  });

  it("HTLC: settle within timeout → funds flow to receiver", async function () {
    const env = await deployStack();
    const id = await openChannel(env, 1000n * ONE6, 1000n * ONE6);

    const preimage = ethers.toUtf8Bytes("the-secret");
    const lockHash = ethers.keccak256(preimage);
    const lockTimeout = (await ethers.provider.getBlock("latest")).timestamp + 7200;
    // State: A locked 200 USDC.e in an HTLC; payment will route to B if revealed.
    const s = {
      channelId: id, nonce: 1n,
      balanceA: 800n * ONE6, balanceB: 1000n * ONE6,
      htlcLockedFromA: 200n * ONE6, htlcLockedFromB: 0n,
      lockHash, lockTimeout: BigInt(lockTimeout),
    };
    const sigA = await signState(env.alice, env.channels, s);
    const sigB = await signState(env.bob, env.channels, s);
    await env.channels.startChallenge(s, sigA, sigB);
    // Bob reveals the preimage in time.
    await env.channels.connect(env.bob).settleHtlc(id, preimage);
    // Advance past window.
    await ethers.provider.send("evm_increaseTime", [24 * 3600 + 10]);
    await ethers.provider.send("evm_mine", []);
    await env.channels.finalize(id);
    const c = await env.channels.channels(id);
    expect(c.latestBalanceB).to.equal(1200n * ONE6); // received the 200 HTLC
    expect(c.latestBalanceA).to.equal(800n * ONE6);
  });

  it("HTLC: timeout expires unrevealed → funds refund to funder", async function () {
    const env = await deployStack();
    const id = await openChannel(env, 1000n * ONE6, 1000n * ONE6);
    const preimage = ethers.toUtf8Bytes("the-secret");
    const lockHash = ethers.keccak256(preimage);
    const ts = (await ethers.provider.getBlock("latest")).timestamp;
    const s = {
      channelId: id, nonce: 1n,
      balanceA: 800n * ONE6, balanceB: 1000n * ONE6,
      htlcLockedFromA: 200n * ONE6, htlcLockedFromB: 0n,
      lockHash, lockTimeout: BigInt(ts + 60),  // short lock
    };
    const sigA = await signState(env.alice, env.channels, s);
    const sigB = await signState(env.bob, env.channels, s);
    await env.channels.startChallenge(s, sigA, sigB);
    // Advance past lock timeout AND challenge window.
    await ethers.provider.send("evm_increaseTime", [24 * 3600 + 100]);
    await ethers.provider.send("evm_mine", []);
    // settleHtlc now reverts (lock expired).
    await expect(env.channels.connect(env.bob).settleHtlc(id, preimage))
      .to.be.revertedWithCustomError(env.channels, "LockExpired");
    await env.channels.finalize(id);
    const c = await env.channels.channels(id);
    expect(c.latestBalanceA).to.equal(1000n * ONE6); // refunded
    expect(c.latestBalanceB).to.equal(1000n * ONE6);
  });

  it("finalize before window expires reverts", async function () {
    const env = await deployStack();
    const id = await openChannel(env, 1000n * ONE6, 1000n * ONE6);
    const s = { channelId: id, nonce: 1n, balanceA: 1000n * ONE6, balanceB: 1000n * ONE6, htlcLockedFromA: 0n, htlcLockedFromB: 0n, lockHash: ethers.ZeroHash, lockTimeout: 0n };
    const sigA = await signState(env.alice, env.channels, s);
    const sigB = await signState(env.bob, env.channels, s);
    await env.channels.startChallenge(s, sigA, sigB);
    await expect(env.channels.finalize(id))
      .to.be.revertedWithCustomError(env.channels, "WindowOpen");
  });

  it("non-participant cannot withdraw", async function () {
    const env = await deployStack();
    const id = await openChannel(env, 1000n * ONE6, 1000n * ONE6);
    const s = { channelId: id, nonce: 1n, balanceA: 1000n * ONE6, balanceB: 1000n * ONE6, htlcLockedFromA: 0n, htlcLockedFromB: 0n, lockHash: ethers.ZeroHash, lockTimeout: 0n };
    const sigA = await signState(env.alice, env.channels, s);
    const sigB = await signState(env.bob, env.channels, s);
    await env.channels.cooperativeClose(s, sigA, sigB);
    await expect(env.channels.connect(env.mallory).withdraw(id))
      .to.be.revertedWithCustomError(env.channels, "NotParticipant");
  });

  /* --------------------------- open-timeout safety valve -------------------------- */
  // HIGH fix: every close path needs BOTH signatures, so a counterparty who never
  // co-signs any state could permanently freeze the other party's deposit. The
  // open-timeout lets a solvent party ALWAYS eventually reclaim funds unilaterally.

  it("one-directional channel (depositB=0): A reclaims full deposit after open timeout when B never signs", async function () {
    const env = await deployStack();
    // B stakes nothing — griefing A would otherwise be free and permanent.
    const id = await openChannel(env, 1000n * ONE6, 0n);
    const timeout = Number(await env.channels.CHANNEL_OPEN_TIMEOUT());

    // Nothing was ever co-signed. Advance past the generous open timeout.
    await ethers.provider.send("evm_increaseTime", [timeout + 10]);
    await ethers.provider.send("evm_mine", []);

    const aBefore = await env.token.balanceOf(env.alice.address);
    // Either party can trigger; A finalises and withdraws its original deposit.
    await env.channels.connect(env.alice).closeOpenTimeout(id);
    const c = await env.channels.channels(id);
    expect(c.status).to.equal(3); // Finalised
    expect(c.latestBalanceA).to.equal(1000n * ONE6);
    expect(c.latestBalanceB).to.equal(0n);
    await env.channels.connect(env.alice).withdraw(id);
    expect(await env.token.balanceOf(env.alice.address) - aBefore).to.equal(1000n * ONE6);
    // Contract fully drained for this channel.
    expect(await env.token.balanceOf(await env.channels.getAddress())).to.equal(0n);
  });

  it("open timeout cannot be called before the deadline", async function () {
    const env = await deployStack();
    const id = await openChannel(env, 1000n * ONE6, 500n * ONE6);
    // Immediately after open — well before the deadline.
    await expect(env.channels.connect(env.alice).closeOpenTimeout(id))
      .to.be.revertedWithCustomError(env.channels, "OpenTimeoutNotReached");
    // Even close to (but before) the deadline it stays locked.
    const timeout = Number(await env.channels.CHANNEL_OPEN_TIMEOUT());
    await ethers.provider.send("evm_increaseTime", [timeout - 60]);
    await ethers.provider.send("evm_mine", []);
    await expect(env.channels.connect(env.bob).closeOpenTimeout(id))
      .to.be.revertedWithCustomError(env.channels, "OpenTimeoutNotReached");
  });

  it("open timeout cannot override a channel already moved to challenge/finalised", async function () {
    const env = await deployStack();
    const id = await openChannel(env, 1000n * ONE6, 1000n * ONE6);
    // A co-signed state is brought on-chain -> channel leaves Status.Open.
    const s = { channelId: id, nonce: 4n, balanceA: 200n * ONE6, balanceB: 1800n * ONE6, htlcLockedFromA: 0n, htlcLockedFromB: 0n, lockHash: ethers.ZeroHash, lockTimeout: 0n };
    const sigA = await signState(env.alice, env.channels, s);
    const sigB = await signState(env.bob, env.channels, s);
    await env.channels.connect(env.alice).startChallenge(s, sigA, sigB);

    // Now blow past the open timeout: it must NOT be usable to bypass the challenge.
    const timeout = Number(await env.channels.CHANNEL_OPEN_TIMEOUT());
    await ethers.provider.send("evm_increaseTime", [timeout + 10]);
    await ethers.provider.send("evm_mine", []);
    await expect(env.channels.connect(env.alice).closeOpenTimeout(id))
      .to.be.revertedWithCustomError(env.channels, "ChannelNotOpen");

    // The legitimate challenge/finalize path still governs the real balances.
    await env.channels.finalize(id);
    const c = await env.channels.channels(id);
    expect(c.latestBalanceA).to.equal(200n * ONE6);
    expect(c.latestBalanceB).to.equal(1800n * ONE6);

    // And a finalised channel likewise rejects the timeout path.
    await expect(env.channels.connect(env.alice).closeOpenTimeout(id))
      .to.be.revertedWithCustomError(env.channels, "ChannelNotOpen");
  });

  it("open timeout is restricted to channel participants", async function () {
    const env = await deployStack();
    const id = await openChannel(env, 1000n * ONE6, 500n * ONE6);
    const timeout = Number(await env.channels.CHANNEL_OPEN_TIMEOUT());
    await ethers.provider.send("evm_increaseTime", [timeout + 10]);
    await ethers.provider.send("evm_mine", []);
    await expect(env.channels.connect(env.mallory).closeOpenTimeout(id))
      .to.be.revertedWithCustomError(env.channels, "NotParticipant");
  });

  it("open() rejects a non-participant caller (blocks approval-griefing)", async function () {
    const env = await deployStack();
    // Mallory tries to force alice's & bob's approved tokens into a channel.
    await expect(
      env.channels.connect(env.mallory).open(
        env.alice.address, env.bob.address, await env.token.getAddress(), 1000n * ONE6, 500n * ONE6
      )
    ).to.be.revertedWithCustomError(env.channels, "NotParticipant");
  });
});
