// Branch-focused coverage suite for AereSink (CANONICAL immutable 3-bucket
// router, live at 0x69581B86…1676). Every AERE that the splitter routes into
// the sAERE flywheel passes through here, and the contract has NO admin and NO
// upgrade path, so its branches are the whole of its behaviour.
//
// Measured round-2 baseline: 74.07% branch (the weak axis flagged as the next
// candidate at the end of the round-1 report), with uncovered line 286.
//
// This file targets exactly the gaps the four existing sink suites leave:
//   * line 286 — the INNER oracle catch (the oracle answers for tokenIn but
//     reverts on the AERE leg). The shared MockPriceOracle can only revert
//     GLOBALLY, which reaches the OUTER catch; a selective-revert oracle is the
//     only way in.
//   * priceIn == 0 (the existing suite only zeroes the AERE leg)
//   * the `floor == 0 ? 1 : floor` TRUE side
//   * every constructor guard (four zero-address legs + the bps sum)
//   * the toBurnTotal == 0 and stakerPart == 0 bucket skips
//   * _safeTransfer's TransferFailed branch (a token whose transfer returns
//     false without reverting)
//
// Run: npx hardhat test test/aere-sink-branch-coverage.test.js

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

const ONE = 10n ** 18n;

/// Same shape as the existing sink-oracle-floor helper, but with the bucket bps
/// and the oracle both parameterised so the zero-bucket branches are reachable.
async function deploySink({
  oracleAddr = ethers.ZeroAddress,
  burnBps = 1500,
  buybackBps = 4000,
  stakerBps = 4500,
  slippageBps = 500,
  aereOverride = null,
} = {}) {
  const [deployer] = await ethers.getSigners();

  const WAERE = await (
    await ethers.getContractFactory("contracts/WAERE.sol:WAERE")
  ).deploy();
  await WAERE.waitForDeployment();

  const FeeToken = await (
    await ethers.getContractFactory("contracts/WAERE.sol:WAERE")
  ).deploy();
  await FeeToken.waitForDeployment();

  const burnVault = await (await ethers.getContractFactory("AereFeeBurnVault")).deploy();
  await burnVault.waitForDeployment();

  const router = await (await ethers.getContractFactory("MockSinkRouter")).deploy();
  await router.waitForDeployment();
  await router.setOutToken(await WAERE.getAddress());

  const aere = aereOverride ?? (await WAERE.getAddress());

  const sink = await (await ethers.getContractFactory("AereSink")).deploy(
    aere,
    await burnVault.getAddress(),
    await WAERE.getAddress(), // sAERE stub
    await router.getAddress(),
    burnBps,
    buybackBps,
    stakerBps,
    slippageBps,
    oracleAddr
  );
  await sink.waitForDeployment();

  return { deployer, WAERE, FeeToken, burnVault, router, sink };
}

/// Wrap AERE into both tokens and approve the sink, so flush() can pull.
async function fundAndApprove(ctx, amount) {
  await ctx.deployer.sendTransaction({
    to: await ctx.WAERE.getAddress(),
    value: 1000n * ONE,
  });
  await ctx.WAERE.transfer(await ctx.router.getAddress(), 500n * ONE);
  await ctx.deployer.sendTransaction({
    to: await ctx.FeeToken.getAddress(),
    value: amount,
  });
  await ctx.FeeToken.approve(await ctx.sink.getAddress(), amount);
}

describe("AereSink — branch coverage (constructor guards, oracle legs, bucket skips)", function () {
  // Hermetic: snapshot before each test and revert after, so this file leaves
  // signer balances and the block timestamp exactly as it found them. Without
  // it, the AERE these tests wrap into WAERE / lock in vaults stays gone and
  // later test FILES in the same mocha process can run the default signer dry.
  let __snap;
  beforeEach(async () => {
    __snap = await ethers.provider.send("evm_snapshot", []);
  });
  afterEach(async () => {
    await ethers.provider.send("evm_revert", [__snap]);
  });

  describe("constructor — every guard leg", function () {
    let good;
    beforeEach(async () => {
      const [d] = await ethers.getSigners();
      const W = await (
        await ethers.getContractFactory("contracts/WAERE.sol:WAERE")
      ).deploy();
      await W.waitForDeployment();
      good = await W.getAddress();
    });

    async function tryDeploy(args) {
      const F = await ethers.getContractFactory("AereSink");
      return F.deploy(...args);
    }

    it("rejects a zero AERE address", async () => {
      await expect(
        tryDeploy([ethers.ZeroAddress, good, good, good, 1500, 4000, 4500, 500, ethers.ZeroAddress])
      ).to.be.reverted;
    });

    it("rejects a zero burn vault", async () => {
      await expect(
        tryDeploy([good, ethers.ZeroAddress, good, good, 1500, 4000, 4500, 500, ethers.ZeroAddress])
      ).to.be.reverted;
    });

    it("rejects a zero sAERE vault", async () => {
      await expect(
        tryDeploy([good, good, ethers.ZeroAddress, good, 1500, 4000, 4500, 500, ethers.ZeroAddress])
      ).to.be.reverted;
    });

    it("rejects a zero DEX router", async () => {
      await expect(
        tryDeploy([good, good, good, ethers.ZeroAddress, 1500, 4000, 4500, 500, ethers.ZeroAddress])
      ).to.be.reverted;
    });

    it("rejects bps that do not sum to 10000 (under and over)", async () => {
      await expect(
        tryDeploy([good, good, good, good, 1500, 4000, 4499, 500, ethers.ZeroAddress])
      ).to.be.reverted;
      await expect(
        tryDeploy([good, good, good, good, 1500, 4000, 4501, 500, ethers.ZeroAddress])
      ).to.be.reverted;
    });

    it("accepts a valid deploy and exposes the immutables", async () => {
      const ctx = await deploySink({});
      expect(await ctx.sink.BURN_BPS()).to.equal(1500);
      expect(await ctx.sink.BUYBACK_BPS()).to.equal(4000);
      expect(await ctx.sink.STAKER_YIELD_BPS()).to.equal(4500);
      expect(await ctx.sink.MAX_SLIPPAGE_BPS()).to.equal(500);
      expect(await ctx.sink.ORACLE()).to.equal(ethers.ZeroAddress);
      expect(await ctx.sink.ORACLE_DECIMALS()).to.equal(18);
    });
  });

  describe("_computeOracleFloor — the nested try/catch arms", function () {
    it("INNER CATCH (line 286): oracle answers for tokenIn but REVERTS on the AERE leg", async () => {
      // The shared MockPriceOracle reverts globally, so it can only reach the
      // OUTER catch. SelectiveRevertOracle reverts for ONE asset only, which is
      // the only way to enter the inner try and then fail inside it.
      const oracle = await (
        await ethers.getContractFactory("SelectiveRevertOracle")
      ).deploy();
      await oracle.waitForDeployment();

      const ctx = await deploySink({ oracleAddr: await oracle.getAddress() });
      const feeAddr = await ctx.FeeToken.getAddress();
      const aereAddr = await ctx.WAERE.getAddress();

      await oracle.setPrice(feeAddr, ONE);       // tokenIn leg answers
      await oracle.setRevertFor(aereAddr, true); // AERE leg reverts

      await fundAndApprove(ctx, 10n * ONE);
      await ctx.sink.flush(feeAddr, 10n * ONE);

      // Inner catch returns 1 (accept any non-zero output).
      expect(await ctx.router.lastAmountOutMin()).to.equal(1n);
    });

    it("priceIn == 0: the tokenIn leg answers ZERO (the existing suite only zeroes AERE)", async () => {
      const oracle = await (await ethers.getContractFactory("MockPriceOracle")).deploy();
      await oracle.waitForDeployment();

      const ctx = await deploySink({ oracleAddr: await oracle.getAddress() });
      // AERE priced, FeeToken deliberately left at 0.
      await oracle.setPrice(await ctx.WAERE.getAddress(), ONE);

      await fundAndApprove(ctx, 10n * ONE);
      await ctx.sink.flush(await ctx.FeeToken.getAddress(), 10n * ONE);

      expect(await ctx.router.lastAmountOutMin()).to.equal(1n);
    });

    it("floor == 0 -> returns 1: a tokenIn worth far less than AERE rounds the floor away", async () => {
      const oracle = await (await ethers.getContractFactory("MockPriceOracle")).deploy();
      await oracle.waitForDeployment();

      const ctx = await deploySink({ oracleAddr: await oracle.getAddress() });
      const feeAddr = await ctx.FeeToken.getAddress();
      const aereAddr = await ctx.WAERE.getAddress();

      // priceIn = 1 wei of USD, priceAere = 1e18. expected = amountIn/1e18,
      // which for a small amountIn floors to 0, so `floor == 0 ? 1 : floor`
      // takes its TRUE side.
      await oracle.setPrice(feeAddr, 1n);
      await oracle.setPrice(aereAddr, ONE);

      await fundAndApprove(ctx, 1000n);
      await ctx.sink.flush(feeAddr, 1000n);

      expect(await ctx.router.lastAmountOutMin()).to.equal(1n);
    });

    it("both legs priced: the floor is the real slippage-adjusted expectation (non-fallback)", async () => {
      const oracle = await (await ethers.getContractFactory("MockPriceOracle")).deploy();
      await oracle.waitForDeployment();

      const ctx = await deploySink({ oracleAddr: await oracle.getAddress() });
      const feeAddr = await ctx.FeeToken.getAddress();
      await oracle.setPrice(feeAddr, ONE);
      await oracle.setPrice(await ctx.WAERE.getAddress(), ONE);

      await fundAndApprove(ctx, 10n * ONE);
      await ctx.sink.flush(feeAddr, 10n * ONE);

      // Last swap is the staker bucket: 45% of 10 AERE, minus 5% slippage.
      const expectedStaker = (10n * ONE * 4500n) / 10000n;
      expect(await ctx.router.lastAmountOutMin()).to.equal(
        (expectedStaker * 9500n) / 10000n
      );
    });
  });

  describe("flush — pull failure and the bucket-skip branches", function () {
    it("reverts ZeroAmount on a zero flush", async () => {
      const ctx = await deploySink({});
      await expect(
        ctx.sink.flush(await ctx.FeeToken.getAddress(), 0)
      ).to.be.revertedWithCustomError(ctx.sink, "ZeroAmount");
    });

    it("reverts TransferFailed when the pull's transferFrom returns FALSE", async () => {
      // A token that returns false without reverting: `bool ok = transferFrom(...)`
      // is false, so the `if (!ok)` TRUE branch fires.
      const bad = await (
        await ethers.getContractFactory("MockReturnFalseERC20")
      ).deploy();
      await bad.waitForDeployment();

      const ctx = await deploySink({});
      await bad.mint(ctx.deployer.address, 10n * ONE);

      await expect(
        ctx.sink.flush(await bad.getAddress(), 10n * ONE)
      ).to.be.revertedWithCustomError(ctx.sink, "TransferFailed");
    });

    it("SKIPS the burn transfer when BURN + BUYBACK are both 0 (toBurnTotal == 0)", async () => {
      const ctx = await deploySink({ burnBps: 0, buybackBps: 0, stakerBps: 10000 });
      const vaultAddr = await ctx.burnVault.getAddress();
      const aereAddr = await ctx.WAERE.getAddress();

      await ctx.deployer.sendTransaction({ to: aereAddr, value: 100n * ONE });
      await ctx.WAERE.approve(await ctx.sink.getAddress(), 10n * ONE);

      const before = await ctx.WAERE.balanceOf(vaultAddr);
      await ctx.sink.flush(aereAddr, 10n * ONE);

      // Nothing reached the burn vault; the whole flush went to the sAERE leg.
      expect(await ctx.WAERE.balanceOf(vaultAddr)).to.equal(before);
    });

    it("SKIPS the sAERE transfer when the staker bucket rounds to 0 (stakerPart == 0)", async () => {
      // burn 5000 + buyback 5000 + staker 0: stakerPart = amount - burn - buyback
      // is exactly 0 for an even amount, so the `stakerPart > 0` FALSE branch runs.
      const ctx = await deploySink({ burnBps: 5000, buybackBps: 5000, stakerBps: 0 });
      const aereAddr = await ctx.WAERE.getAddress();

      await ctx.deployer.sendTransaction({ to: aereAddr, value: 100n * ONE });
      await ctx.WAERE.approve(await ctx.sink.getAddress(), 10n * ONE);

      await ctx.sink.flush(aereAddr, 10n * ONE);

      // Everything landed in the burn vault.
      expect(await ctx.WAERE.balanceOf(await ctx.burnVault.getAddress())).to.equal(
        10n * ONE
      );
    });

    it("SKIPS the staker SWAP on a NON-AERE flush when the staker bucket is 0", async () => {
      // The AERE-input case is covered above; this is the swap-side twin, which
      // is a separate branch because flush forks on `token == AERE` first.
      const ctx = await deploySink({ burnBps: 5000, buybackBps: 5000, stakerBps: 0 });
      await fundAndApprove(ctx, 10n * ONE);

      await expect(ctx.sink.flush(await ctx.FeeToken.getAddress(), 10n * ONE)).to.emit(
        ctx.sink,
        "Flushed"
      );
      // Exactly ONE swap happened (the burn bundle); the staker leg was skipped.
      expect(await ctx.router.lastAmountOutMin()).to.equal(1n);
    });

    it("SKIPS the burn SWAP on a NON-AERE flush when BURN + BUYBACK are 0", async () => {
      const ctx = await deploySink({ burnBps: 0, buybackBps: 0, stakerBps: 10000 });
      await fundAndApprove(ctx, 10n * ONE);

      await expect(ctx.sink.flush(await ctx.FeeToken.getAddress(), 10n * ONE)).to.emit(
        ctx.sink,
        "Flushed"
      );
    });

    it("emits Flushed with the exact bucket split and retains nothing", async () => {
      const ctx = await deploySink({});
      const aereAddr = await ctx.WAERE.getAddress();
      await ctx.deployer.sendTransaction({ to: aereAddr, value: 100n * ONE });
      await ctx.WAERE.approve(await ctx.sink.getAddress(), 10n * ONE);

      const amount = 10n * ONE;
      const burnPart = (amount * 1500n) / 10000n;
      const buybackPart = (amount * 4000n) / 10000n;
      const stakerPart = amount - burnPart - buybackPart;

      await expect(ctx.sink.flush(aereAddr, amount))
        .to.emit(ctx.sink, "Flushed")
        .withArgs(
          ctx.deployer.address,
          aereAddr,
          amount,
          burnPart,
          buybackPart,
          stakerPart
        );

      expect(await ctx.WAERE.balanceOf(await ctx.sink.getAddress())).to.equal(0n);
    });
  });

  describe("_safeTransfer — the return-false revert branch", function () {
    it("reverts TransferFailed when the token's transfer returns FALSE", async () => {
      // Deploy a sink whose AERE IS the return-false token, then sweepDust: that
      // path reads balanceOf (non-zero) and goes straight to _safeTransfer
      // WITHOUT a transferFrom pull, so it is the only way to reach
      // `ret.length > 0 && !abi.decode(ret,(bool))`.
      const bad = await (
        await ethers.getContractFactory("MockReturnFalseERC20")
      ).deploy();
      await bad.waitForDeployment();
      const badAddr = await bad.getAddress();

      const ctx = await deploySink({ aereOverride: badAddr });
      await bad.mint(await ctx.sink.getAddress(), 10n * ONE);

      await expect(ctx.sink.sweepDust(badAddr)).to.be.revertedWithCustomError(
        ctx.sink,
        "TransferFailed"
      );
    });
  });

  describe("_safeTransfer — the `!ok` (call itself reverted) leg", function () {
    it("reverts TransferFailed when the token's transfer REVERTS outright", async () => {
      // The return-false token above drives the `ret.length > 0 && !decode` leg.
      // A token that actually reverts is the only way into the `!ok` leg.
      const bad = await (
        await ethers.getContractFactory("RevertingTransferToken")
      ).deploy();
      await bad.waitForDeployment();
      const badAddr = await bad.getAddress();

      const ctx = await deploySink({ aereOverride: badAddr });
      await bad.mint(await ctx.sink.getAddress(), 10n * ONE);

      await expect(ctx.sink.sweepDust(badAddr)).to.be.revertedWithCustomError(
        ctx.sink,
        "TransferFailed"
      );
    });
  });

  describe("_swapAndForward — the swap-failure catch and its empty-reason leg", function () {
    async function sinkWithRouter(routerAddr) {
      const [deployer] = await ethers.getSigners();
      const WAERE = await (
        await ethers.getContractFactory("contracts/WAERE.sol:WAERE")
      ).deploy();
      await WAERE.waitForDeployment();
      const FeeToken = await (
        await ethers.getContractFactory("contracts/WAERE.sol:WAERE")
      ).deploy();
      await FeeToken.waitForDeployment();
      const burnVault = await (await ethers.getContractFactory("AereFeeBurnVault")).deploy();
      await burnVault.waitForDeployment();

      const sink = await (await ethers.getContractFactory("AereSink")).deploy(
        await WAERE.getAddress(),
        await burnVault.getAddress(),
        await WAERE.getAddress(),
        routerAddr,
        1500,
        4000,
        4500,
        500,
        ethers.ZeroAddress
      );
      await sink.waitForDeployment();

      await deployer.sendTransaction({ to: await FeeToken.getAddress(), value: 10n * ONE });
      await FeeToken.approve(await sink.getAddress(), 10n * ONE);
      return { deployer, sink, FeeToken };
    }

    it("emits SwapFailed with the literal \"swap-failed\" when the router reverts with EMPTY data", async () => {
      const router = await (await ethers.getContractFactory("EmptyRevertRouter")).deploy();
      await router.waitForDeployment();
      const { sink, FeeToken } = await sinkWithRouter(await router.getAddress());
      const feeAddr = await FeeToken.getAddress();

      // The flush does NOT revert: a dead pair leaves the tokens as dust and the
      // sink continues. Both swap legs fail, so two SwapFailed events fire.
      const tx = await sink.flush(feeAddr, 10n * ONE);
      const rc = await tx.wait();

      const evts = rc.logs
        .map((l) => {
          try {
            return sink.interface.parseLog(l);
          } catch {
            return null;
          }
        })
        .filter((e) => e && e.name === "SwapFailed");

      expect(evts.length).to.be.greaterThan(0);
      for (const e of evts) expect(e.args[2]).to.equal("swap-failed");

      // The tokens really did stay on the sink as recoverable dust.
      expect(await FeeToken.balanceOf(await sink.getAddress())).to.equal(10n * ONE);
    });
  });

  describe("sweepDust — the non-AERE swap path", function () {
    it("reverts ZeroAmount when the sink holds none of that token", async () => {
      const ctx = await deploySink({});
      await expect(
        ctx.sink.sweepDust(await ctx.FeeToken.getAddress())
      ).to.be.revertedWithCustomError(ctx.sink, "ZeroAmount");
    });

    it("re-routes a NON-AERE dust balance through both swap legs", async () => {
      const ctx = await deploySink({});
      const feeAddr = await ctx.FeeToken.getAddress();

      // Someone sends the fee token straight to the sink (no flush).
      await ctx.deployer.sendTransaction({ to: feeAddr, value: 10n * ONE });
      await ctx.WAERE.connect(ctx.deployer);
      await ctx.deployer.sendTransaction({
        to: await ctx.WAERE.getAddress(),
        value: 500n * ONE,
      });
      await ctx.WAERE.transfer(await ctx.router.getAddress(), 400n * ONE);
      await ctx.FeeToken.transfer(await ctx.sink.getAddress(), 10n * ONE);

      await expect(ctx.sink.sweepDust(feeAddr))
        .to.emit(ctx.sink, "DustSwept")
        .withArgs(feeAddr, 10n * ONE);
    });

    it("SKIPS the burn SWAP leg on a NON-AERE dust sweep when BURN + BUYBACK are 0", async () => {
      const ctx = await deploySink({ burnBps: 0, buybackBps: 0, stakerBps: 10000 });
      const feeAddr = await ctx.FeeToken.getAddress();

      await ctx.deployer.sendTransaction({
        to: await ctx.WAERE.getAddress(),
        value: 500n * ONE,
      });
      await ctx.WAERE.transfer(await ctx.router.getAddress(), 400n * ONE);
      await ctx.deployer.sendTransaction({ to: feeAddr, value: 10n * ONE });
      await ctx.FeeToken.transfer(await ctx.sink.getAddress(), 10n * ONE);

      await expect(ctx.sink.sweepDust(feeAddr)).to.emit(ctx.sink, "DustSwept");
    });

    it("SKIPS the staker SWAP leg on a NON-AERE dust sweep when the staker bucket is 0", async () => {
      const ctx = await deploySink({ burnBps: 5000, buybackBps: 5000, stakerBps: 0 });
      const feeAddr = await ctx.FeeToken.getAddress();

      await ctx.deployer.sendTransaction({
        to: await ctx.WAERE.getAddress(),
        value: 500n * ONE,
      });
      await ctx.WAERE.transfer(await ctx.router.getAddress(), 400n * ONE);
      await ctx.deployer.sendTransaction({ to: feeAddr, value: 10n * ONE });
      await ctx.FeeToken.transfer(await ctx.sink.getAddress(), 10n * ONE);

      await expect(ctx.sink.sweepDust(feeAddr)).to.emit(ctx.sink, "DustSwept");
    });

    it("SKIPS the burn leg on a dust sweep when BURN + BUYBACK are 0", async () => {
      const ctx = await deploySink({ burnBps: 0, buybackBps: 0, stakerBps: 10000 });
      const aereAddr = await ctx.WAERE.getAddress();

      await ctx.deployer.sendTransaction({ to: aereAddr, value: 100n * ONE });
      await ctx.WAERE.transfer(await ctx.sink.getAddress(), 5n * ONE);

      const before = await ctx.WAERE.balanceOf(await ctx.burnVault.getAddress());
      await ctx.sink.sweepDust(aereAddr);
      expect(await ctx.WAERE.balanceOf(await ctx.burnVault.getAddress())).to.equal(before);
    });

    it("SKIPS the sAERE leg on a dust sweep when the staker bucket is 0", async () => {
      const ctx = await deploySink({ burnBps: 5000, buybackBps: 5000, stakerBps: 0 });
      const aereAddr = await ctx.WAERE.getAddress();

      await ctx.deployer.sendTransaction({ to: aereAddr, value: 100n * ONE });
      await ctx.WAERE.transfer(await ctx.sink.getAddress(), 4n * ONE);

      await ctx.sink.sweepDust(aereAddr);
      expect(await ctx.WAERE.balanceOf(await ctx.burnVault.getAddress())).to.equal(4n * ONE);
      expect(await ctx.WAERE.balanceOf(await ctx.sink.getAddress())).to.equal(0n);
    });
  });
});
