// SPDX-License-Identifier: MIT
pragma solidity 0.8.23;

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

/**
 * @title AereRollupSettlementV2 — bug-fixed per-rollup settlement contract on AERE
 * @notice Bug-fix redeploy of AereRollupSettlement. Deployed PER-ROLLUP by
 *         AereRaaSFactoryV2. Same three roles as V1:
 *           1. State-root commitment ledger (optimistic with N-hour challenge)
 *           2. Revenue collector (sequencer settles fees in DEBT_TOKEN)
 *           3. Revenue splitter (rollupOwner / sinkSplit / sequencerSplit)
 *
 *         ─────────────────────────── WHY V2 ───────────────────────────
 *         V1 (per-rollup template, deployed by AereRaaSFactory 0x8C1b0018…26Cd)
 *         had four confirmed issues around the challenge/finalisation game:
 *
 *         F6 (last-block challenge grief). A challenge placed in the LAST
 *            in-window block could not be defended: resolveChallenge reverted
 *            WindowClosed the moment block.timestamp >= proposedAt+WINDOW, so
 *            only resolveChallengeExpired remained — which REJECTED the (valid)
 *            root and refunded the challenger's bond IN FULL. Griefing a valid
 *            root therefore cost the attacker nothing but gas.
 *            FIX: a DEFENSE_GRACE period after window-end during which the
 *            sequencer can still call resolveChallenge. A defeated / false
 *            challenger's bond is SLASHED to the AereSink (not refunded).
 *            resolveChallengeExpired only becomes callable AFTER the grace.
 *
 *         F7 (rejected epoch freezes finalisation). Once an epoch was rejected,
 *            the contiguous latestFinalisedEpoch tracker stuck forever (the
 *            walk stops at the gap) AND the epoch could never be re-proposed
 *            (strict epoch==latestEpoch+1 continuity). One griefing challenge
 *            permanently froze finalisation.
 *            FIX: proposeStateRoot may REPLACE a rejected epoch (re-propose it),
 *            so the sequencer can heal the gap and finalisation resumes.
 *
 *         isFinalised ancestor safety (MED). V1 isFinalised(epoch) was purely
 *            LOCAL — a descendant of a rejected epoch read as final. A bridge
 *            trusting isFinalised on a post-gap epoch could release funds against
 *            an unfinalised ancestor chain.
 *            FIX: isFinalised(epoch) is now ANCESTOR-AWARE: it is true iff
 *            epoch <= latestFinalisedEpoch, and latestFinalisedEpoch only ever
 *            advances CONTIGUOUSLY through settled, non-rejected epochs. The
 *            purely-local notion is exposed separately as isEpochSettled().
 *
 *         settleRevenue stuck-residual (LOW). V1's sink flush was best-effort
 *            with no recovery if the sink call failed.
 *            FIX: flushResidualToSink() re-pushes any free DEBT_TOKEN balance
 *            (never touching pending challenge bonds) to the sink.
 *
 *         Everything else (immutability model, revenue split maths, governance-
 *         lite sequencer/owner rotation) is behaviourally identical to V1.
 *
 *         IMMUTABILITY at deploy: rollupId, sink, debtToken, challengeWindow,
 *         DEFENSE_GRACE, sinkBps/rollupBps/sequencerBps (sum 10_000),
 *         minChallengeBond.
 */
contract AereRollupSettlementV2 is ReentrancyGuard {

    /* ------------------------------- immutable ------------------------------- */

    bytes32 public immutable ROLLUP_ID;
    address public immutable SINK;
    address public immutable DEBT_TOKEN;
    uint64  public immutable CHALLENGE_WINDOW;
    uint64  public immutable DEFENSE_GRACE;   // F6: sequencer defense grace after window-end
    uint16  public immutable SINK_BPS;
    uint16  public immutable ROLLUP_BPS;
    uint16  public immutable SEQUENCER_BPS;
    uint256 public immutable MIN_CHALLENGE_BOND;

    /* --------------------------------- state -------------------------------- */

    address public rollupOwner;
    address public sequencer;

    struct StateRoot {
        bytes32 root;
        bytes32 l2BlockHash;
        uint64 proposedAt;
        bool challenged;
        bool resolvedRejected;
        address challenger;
        uint256 challengeBond;
    }

    /// @notice epoch → state root commitment
    mapping(uint256 => StateRoot) public roots;
    uint256 public latestEpoch;
    uint256 public latestFinalisedEpoch;

    /// @notice DEBT_TOKEN currently escrowed against pending challenges. The
    ///         residual-flush recovery never touches this, so a live challenge
    ///         bond can never be swept to the sink out from under a challenger.
    uint256 public activeBondEscrow;

    uint256 public totalRevenueSettled;

    /* --------------------------------- events ------------------------------- */

    event StateRootProposed(uint256 indexed epoch, bytes32 root, bytes32 l2BlockHash);
    event StateRootReplaced(uint256 indexed epoch, bytes32 root); // F7: re-proposed a rejected epoch
    event StateRootFinalised(uint256 indexed epoch, bytes32 root);
    event Challenged(uint256 indexed epoch, address indexed challenger, uint256 bond, bytes32 reasonHash);
    event ChallengeResolved(uint256 indexed epoch, bool accepted, address indexed bondRecipient);
    event ChallengerSlashed(uint256 indexed epoch, address indexed challenger, uint256 bond, address sink); // F6
    event RevenueSettled(uint256 amount, uint256 toSink, uint256 toRollup, uint256 toSequencer);
    event ResidualFlushed(uint256 amount); // LOW: sink recovery
    event SequencerRotated(address oldSequencer, address newSequencer);
    event OwnerTransferred(address oldOwner, address newOwner);

    /* --------------------------------- errors ------------------------------- */

    error NotSequencer();
    error NotOwner();
    error EpochOutOfOrder();
    error WindowOpen();        // grace not yet expired (expired path called too early)
    error WindowClosed();      // defense grace already expired (sequencer resolve too late)
    error UnknownEpoch();
    error AlreadyChallenged();
    error NotChallenged();
    error AlreadyFinalised();  // challenge window already closed / epoch already final
    error EpochRejected();     // cannot challenge an already-rejected epoch
    error ZeroAddress();
    error ZeroAmount();
    error BondTooSmall();
    error TransferFailed();
    error InvalidSplits();

    /* ----------------------------- constructor ------------------------------ */

    struct Config {
        bytes32 rollupId;
        address rollupOwner;
        address sequencer;
        address sink;
        address debtToken;
        uint64 challengeWindow;
        uint64 defenseGrace;
        uint16 sinkBps;
        uint16 rollupBps;
        uint16 sequencerBps;
        uint256 minChallengeBond;
    }

    constructor(Config memory c) {
        if (c.rollupOwner == address(0) || c.sequencer == address(0)) revert ZeroAddress();
        if (c.sink == address(0) || c.debtToken == address(0)) revert ZeroAddress();
        if (uint256(c.sinkBps) + uint256(c.rollupBps) + uint256(c.sequencerBps) != 10_000) revert InvalidSplits();
        ROLLUP_ID = c.rollupId;
        rollupOwner = c.rollupOwner;
        sequencer = c.sequencer;
        SINK = c.sink;
        DEBT_TOKEN = c.debtToken;
        CHALLENGE_WINDOW = c.challengeWindow;
        DEFENSE_GRACE = c.defenseGrace;
        SINK_BPS = c.sinkBps;
        ROLLUP_BPS = c.rollupBps;
        SEQUENCER_BPS = c.sequencerBps;
        MIN_CHALLENGE_BOND = c.minChallengeBond;
    }

    /* ----------------------------- state roots ------------------------------ */

    /// @notice Propose a fresh epoch (epoch == latestEpoch+1) OR re-propose a
    ///         previously-REJECTED epoch to heal a finalisation gap (F7). A
    ///         rejected epoch is replaced with a fresh commitment that restarts
    ///         its own challenge window; latestEpoch is unchanged on replacement.
    function proposeStateRoot(uint256 epoch, bytes32 root, bytes32 l2BlockHash) external {
        if (msg.sender != sequencer) revert NotSequencer();

        StateRoot storage existing = roots[epoch];
        bool isNew = (epoch == latestEpoch + 1) && existing.proposedAt == 0;
        // F7: allow replacing an epoch that was rejected and is not yet part of
        // the contiguous finalised prefix.
        bool isReplacement = existing.proposedAt != 0 && existing.resolvedRejected && epoch > latestFinalisedEpoch;

        if (!isNew && !isReplacement) revert EpochOutOfOrder();

        roots[epoch] = StateRoot({
            root: root,
            l2BlockHash: l2BlockHash,
            proposedAt: uint64(block.timestamp),
            challenged: false,
            resolvedRejected: false,
            challenger: address(0),
            challengeBond: 0
        });

        if (isNew) {
            latestEpoch = epoch;
            emit StateRootProposed(epoch, root, l2BlockHash);
        } else {
            emit StateRootReplaced(epoch, root);
            emit StateRootProposed(epoch, root, l2BlockHash);
        }
    }

    /// @notice LOCAL settlement notion: this epoch's own challenge window has
    ///         passed and it was neither challenged nor rejected. Does NOT imply
    ///         its ancestors are settled — use isFinalised() for that.
    function isEpochSettled(uint256 epoch) public view returns (bool) {
        StateRoot memory s = roots[epoch];
        if (s.proposedAt == 0) return false;
        if (s.resolvedRejected) return false;
        if (s.challenged) return false;
        return block.timestamp >= uint256(s.proposedAt) + CHALLENGE_WINDOW;
    }

    /// @notice ANCESTOR-AWARE finality (bridge-safe). True iff epoch is within
    ///         the contiguous finalised prefix [1..latestFinalisedEpoch]. Because
    ///         latestFinalisedEpoch only advances through settled, non-rejected
    ///         epochs, a descendant of a rejected/unsettled epoch can NEVER read
    ///         as final. Call advanceFinalisation() to extend the prefix first.
    function isFinalised(uint256 epoch) public view returns (bool) {
        if (epoch == 0) return false;
        return epoch <= latestFinalisedEpoch;
    }

    /// @notice Extend the contiguous finalised prefix. Permissionless. Walks from
    ///         latestFinalisedEpoch+1 while each epoch isEpochSettled(); stops at
    ///         the first challenged / rejected / still-in-window / re-proposed
    ///         epoch. A rejected epoch that is later re-proposed and settles will
    ///         let a subsequent call continue past it (F7 healing).
    function advanceFinalisation() external {
        uint256 i = latestFinalisedEpoch + 1;
        while (i <= latestEpoch && isEpochSettled(i)) {
            emit StateRootFinalised(i, roots[i].root);
            unchecked { i++; }
        }
        unchecked { latestFinalisedEpoch = i - 1; }
    }

    /* ----------------------------- challenges ------------------------------- */

    function challenge(uint256 epoch, uint256 bond, bytes32 reasonHash) external nonReentrant {
        StateRoot storage s = roots[epoch];
        if (s.proposedAt == 0) revert UnknownEpoch();
        if (s.resolvedRejected) revert EpochRejected();
        if (block.timestamp >= uint256(s.proposedAt) + CHALLENGE_WINDOW) revert AlreadyFinalised();
        // one challenge per (re)proposal: challenger is only reset on re-propose.
        if (s.challenger != address(0)) revert AlreadyChallenged();
        if (bond < MIN_CHALLENGE_BOND) revert BondTooSmall();
        if (!IERC20(DEBT_TOKEN).transferFrom(msg.sender, address(this), bond)) revert TransferFailed();
        s.challenged = true;
        s.challenger = msg.sender;
        s.challengeBond = bond;
        activeBondEscrow += bond;
        emit Challenged(epoch, msg.sender, bond, reasonHash);
    }

    /// @notice Phase-1 sequencer-side resolution. Callable within the challenge
    ///         window AND for DEFENSE_GRACE seconds after it (F6): a challenge
    ///         landed in the last in-window block can still be defended.
    ///         accepted=true  → sequencer concedes the root is fraudulent: root
    ///                          rejected, honest challenger's bond REFUNDED.
    ///         accepted=false → sequencer defends a valid root: challenge
    ///                          defeated, root survives, challenger's bond
    ///                          SLASHED to the AereSink (grief is now costly).
    function resolveChallenge(uint256 epoch, bool accepted) external nonReentrant {
        if (msg.sender != sequencer) revert NotSequencer();
        StateRoot storage s = roots[epoch];
        if (!s.challenged) revert NotChallenged();
        if (block.timestamp >= uint256(s.proposedAt) + CHALLENGE_WINDOW + DEFENSE_GRACE) revert WindowClosed();

        uint256 bond = s.challengeBond;
        address challenger = s.challenger;
        s.challengeBond = 0;
        s.challenged = false;
        if (bond > 0) activeBondEscrow -= bond;

        if (accepted) {
            s.resolvedRejected = true;
            if (bond > 0) {
                if (!IERC20(DEBT_TOKEN).transfer(challenger, bond)) revert TransferFailed();
            }
            emit ChallengeResolved(epoch, true, challenger);
        } else {
            // valid root defended: slash the defeated challenger's bond to the sink.
            if (bond > 0) {
                _pushToSink(bond);
                emit ChallengerSlashed(epoch, challenger, bond, SINK);
            }
            emit ChallengeResolved(epoch, false, SINK);
        }
    }

    /// @notice Permissionless fallback: only AFTER the defense grace has fully
    ///         expired without the sequencer resolving. The root is rejected by
    ///         default (sequencer failed to defend) and the challenger's bond is
    ///         refunded — the grief protection is the grace window above, so a
    ///         false challenger is slashed there; an absent sequencer is the one
    ///         penalised here (its root is dropped).
    function resolveChallengeExpired(uint256 epoch) external nonReentrant {
        StateRoot storage s = roots[epoch];
        if (!s.challenged) revert NotChallenged();
        if (block.timestamp < uint256(s.proposedAt) + CHALLENGE_WINDOW + DEFENSE_GRACE) revert WindowOpen();
        address challenger = s.challenger;
        uint256 bond = s.challengeBond;
        s.challengeBond = 0;
        s.challenged = false;
        s.resolvedRejected = true;
        if (bond > 0) {
            activeBondEscrow -= bond;
            if (!IERC20(DEBT_TOKEN).transfer(challenger, bond)) revert TransferFailed();
        }
        emit ChallengeResolved(epoch, true, challenger);
    }

    /* ----------------------------- revenue ---------------------------------- */

    function settleRevenue(uint256 amount) external nonReentrant {
        if (msg.sender != rollupOwner) revert NotOwner();
        if (amount == 0) revert ZeroAmount();
        if (!IERC20(DEBT_TOKEN).transferFrom(msg.sender, address(this), amount)) revert TransferFailed();

        uint256 toSink = (amount * SINK_BPS) / 10_000;
        uint256 toSeq  = (amount * SEQUENCER_BPS) / 10_000;
        uint256 toRol  = amount - toSink - toSeq; // remainder (handles rounding)

        if (toSink > 0) {
            _pushToSink(toSink); // best-effort; residual recoverable via flushResidualToSink
        }
        if (toSeq > 0) {
            if (!IERC20(DEBT_TOKEN).transfer(sequencer, toSeq)) revert TransferFailed();
        }
        if (toRol > 0) {
            if (!IERC20(DEBT_TOKEN).transfer(rollupOwner, toRol)) revert TransferFailed();
        }
        totalRevenueSettled += amount;
        emit RevenueSettled(amount, toSink, toRol, toSeq);
    }

    /// @notice LOW-fix recovery: re-push any FREE DEBT_TOKEN balance (a stuck
    ///         sink-flush residual, or a slashed bond whose sink push failed) to
    ///         the sink. Never touches DEBT_TOKEN escrowed against a pending
    ///         challenge (activeBondEscrow). Permissionless.
    function flushResidualToSink() external nonReentrant {
        uint256 bal = IERC20(DEBT_TOKEN).balanceOf(address(this));
        uint256 locked = activeBondEscrow;
        if (bal <= locked) revert ZeroAmount();
        uint256 free = bal - locked;
        _pushToSink(free);
        emit ResidualFlushed(free);
    }

    function _pushToSink(uint256 amount) internal {
        IERC20(DEBT_TOKEN).approve(SINK, amount);
        (bool ok, ) = SINK.call(abi.encodeWithSignature("flush(address,uint256)", DEBT_TOKEN, amount));
        if (!ok) {
            // clear the dangling allowance; funds stay here, recoverable later.
            IERC20(DEBT_TOKEN).approve(SINK, 0);
        }
    }

    /* ----------------------------- governance-lite -------------------------- */

    function setSequencer(address newSeq) external {
        if (msg.sender != rollupOwner) revert NotOwner();
        if (newSeq == address(0)) revert ZeroAddress();
        address old = sequencer;
        sequencer = newSeq;
        emit SequencerRotated(old, newSeq);
    }

    function transferOwner(address newOwner) external {
        if (msg.sender != rollupOwner) revert NotOwner();
        if (newOwner == address(0)) revert ZeroAddress();
        address old = rollupOwner;
        rollupOwner = newOwner;
        emit OwnerTransferred(old, newOwner);
    }
}
