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

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

interface IAereSinkSettlement {
    function flush(address token, uint256 amount) external;
}

/**
 * @title AereSettlementHubV2 - institutional cross-asset settlement venue
 * @notice Counterparties deposit tokenised collateral (BUIDL, USDY, OUSG,
 *         LBTC, SolvBTC, pumpBTC, USDC.e, etc.) for batched solver-driven
 *         settlement on AERE Chain 2800. The hub enforces:
 *           - per-asset configuration (bond, perTxLimit, paused, decimals)
 *           - solver allowlist (Phase 1) then bonded permissionless (Phase 2)
 *           - 50bps protocol fee, immutable, routed to AereSink
 *           - RWA receiver allowlist for restricted assets
 *           - 6-hour challenge window on settlement claims
 *
 * @dev    CORRECTED FORK of AereSettlementHub (canonical
 *         0x2a02fD80c16293D2B5D8a295F31D1a6E6a582c02). This is a FRESH INERT
 *         redeploy (deployer-owned, holds nothing, not wired live). It fixes
 *         two audited defects in the V1 logic:
 *
 *         F-SWEEP (HIGH griefing) - V1 sweepResidual() was permissionless and
 *           took an arbitrary amount with no residual accounting, so any EOA
 *           could push a live intent's parked settlement funds and posted
 *           solver bonds into the sink, making settle() revert TransferFailed
 *           and permanently bricking the settlement (depositor funds burned,
 *           solver unpaid). V2 tracks a per-asset committed-liabilities total
 *           (parked intent remainders + posted bonds) and gates sweepResidual
 *           to the TRUE residual only: balance minus committed. A permissionless
 *           sweep can therefore only ever take genuine surplus (fees or slashed
 *           bonds that could not reach the sink) and can never touch funds owed
 *           to a solver or a depositor.
 *
 *         F-LOCK (liveness) - V1 had no refund/cancel/timeout, so an unclaimed
 *           deposit was locked forever. V2 records the deposit timestamp and
 *           adds cancelIntent(), a depositor-only refund path for an intent
 *           that no solver ever claimed, callable after REFUND_TIMEOUT. It
 *           returns the parked remainder to the depositor and closes the intent.
 *
 *         AereSink wiring is in the constructor and IMMUTABLE. Fee bps is
 *         constant 50.
 */
contract AereSettlementHubV2 is Ownable, ReentrancyGuard {

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

    address public immutable SINK;          // AereSink (immutable)
    uint16 public constant PROTOCOL_FEE_BPS = 50;
    uint256 public constant CHALLENGE_WINDOW = 6 hours;

    /// @notice How long after deposit an unclaimed intent may be refunded by
    ///         its depositor. Generous so a legitimate in-flight settlement is
    ///         never at risk; a solver claims long before this elapses.
    uint256 public constant REFUND_TIMEOUT = 7 days;

    /* --------------------------------- structs ------------------------------ */

    struct AssetConfig {
        bool listed;
        bool paused;
        uint8 decimals;
        uint256 minBond;        // bond required to claim this asset
        uint256 perTxLimit;     // max single-deposit amount, 0 = no cap
        bool restrictedRecipient;  // if true, recipient must be on allowlist
    }

    struct Intent {
        address depositor;
        address asset;
        uint256 amount;         // post-fee solverPortion (parked remainder)
        address recipient;
        bool open;              // true while the intent is live; false once terminal
        bool settled;           // true once released to the solver
        address solver;
        uint64 claimedAt;
        uint64 createdAt;       // deposit timestamp, drives the refund timeout
        bytes fillEvidence;
        uint256 bondLocked;     // solver bond reserved at claim, released at settle
    }

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

    /// @notice asset then config
    mapping(address => AssetConfig) public assetConfig;

    /// @notice asset then allowed recipient then true
    mapping(address => mapping(address => bool)) public recipientAllowed;

    /// @notice intentId then Intent
    mapping(bytes32 => Intent) public intents;

    /// @notice solver allowlist per asset (Phase-1). Phase-2 will switch
    ///         to bond-only permissionless gating.
    mapping(address => mapping(address => bool)) public solverAllowed; // asset then solver then bool

    /// @notice solver bonds, per (asset, solver).
    mapping(address => mapping(address => uint256)) public solverBond;

    /// @notice Portion of a solver's bond currently RESERVED to back its own
    ///         outstanding claimed-but-unsettled intents, per (asset, solver).
    ///         At claim() we lock the asset's minBond; at settle() we release the
    ///         exact amount locked for that intent. A solver may only withdraw the
    ///         FREE remainder (solverBond - lockedBond), so a claimed intent's
    ///         backing stays slashable through settlement and a solver can never
    ///         withdraw to dodge an imminent slash.
    mapping(address => mapping(address => uint256)) public lockedBond;

    /// @notice Per-asset committed liabilities: the exact amount the hub OWES,
    ///         equal to the sum of every open intent's parked remainder plus
    ///         every posted solver bond for that asset. Maintained in lockstep
    ///         with deposit / bond / settle / cancel / slash. The genuine
    ///         residual sweepResidual may touch is balanceOf(hub) minus this.
    mapping(address => uint256) public committedLiabilities;

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

    event AssetListed(address indexed asset, uint8 decimals, uint256 minBond, uint256 perTxLimit, bool restrictedRecipient);
    event AssetPausedSet(address indexed asset, bool paused);
    event RecipientAllowedSet(address indexed asset, address indexed recipient, bool allowed);
    event SolverAllowedSet(address indexed asset, address indexed solver, bool allowed);

    event Deposited(bytes32 indexed intentId, address indexed depositor, address indexed asset, uint256 amountAfterFee, uint256 feeAmount, address recipient);
    event Claimed(bytes32 indexed intentId, address indexed solver, uint64 claimedAt);
    event Settled(bytes32 indexed intentId, address indexed solver, uint256 amount);
    event Challenged(bytes32 indexed intentId, address indexed challenger, string reason);
    event Refunded(bytes32 indexed intentId, address indexed depositor, uint256 amount);
    event SolverBondDeposited(address indexed asset, address indexed solver, uint256 amount);
    event SolverBondWithdrawn(address indexed asset, address indexed solver, uint256 amount);
    event SolverBondSlashed(address indexed asset, address indexed solver, uint256 amount);
    event ResidualSwept(address indexed asset, uint256 amount, address indexed caller);

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

    error ZeroAddress();
    error ZeroAmount();
    error AssetNotListed();
    error AssetPausedErr();
    error PerTxLimitExceeded(uint256 amount, uint256 limit);
    error RecipientNotAllowed();
    error NotAllowedSolver();
    error InsufficientBond(uint256 have, uint256 need);
    error InsufficientFreeBond(uint256 free, uint256 requested);
    error IntentUnknown();
    error IntentAlreadyClaimed();
    error IntentAlreadySettled();
    error WithinChallengeWindow();
    error ChallengeWindowElapsed();
    error TransferFailed();
    error DuplicateIntent();
    error NotDepositor();
    error RefundTimeoutNotReached(uint256 nowTs, uint256 unlockTs);
    error ExceedsResidual(uint256 amount, uint256 residual);

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

    constructor(address sink) {
        if (sink == address(0)) revert ZeroAddress();
        SINK = sink;
    }

    /* ------------------------------- asset admin ---------------------------- */

    function listAsset(
        address asset,
        uint8 decimals,
        uint256 minBond,
        uint256 perTxLimit,
        bool restrictedRecipient
    ) external onlyOwner {
        if (asset == address(0)) revert ZeroAddress();
        assetConfig[asset] = AssetConfig({
            listed: true,
            paused: false,
            decimals: decimals,
            minBond: minBond,
            perTxLimit: perTxLimit,
            restrictedRecipient: restrictedRecipient
        });
        emit AssetListed(asset, decimals, minBond, perTxLimit, restrictedRecipient);
    }

    function setAssetPaused(address asset, bool paused) external onlyOwner {
        if (!assetConfig[asset].listed) revert AssetNotListed();
        assetConfig[asset].paused = paused;
        emit AssetPausedSet(asset, paused);
    }

    function setRecipientAllowed(address asset, address recipient, bool allowed) external onlyOwner {
        if (!assetConfig[asset].listed) revert AssetNotListed();
        recipientAllowed[asset][recipient] = allowed;
        emit RecipientAllowedSet(asset, recipient, allowed);
    }

    /* ------------------------------ solver admin ---------------------------- */

    function setSolverAllowed(address asset, address solver, bool allowed) external onlyOwner {
        if (!assetConfig[asset].listed) revert AssetNotListed();
        if (solver == address(0)) revert ZeroAddress();
        solverAllowed[asset][solver] = allowed;
        emit SolverAllowedSet(asset, solver, allowed);
    }

    /// @notice Permissionless solver bond top-up. The posted bond becomes a
    ///         committed liability so sweepResidual can never touch it.
    function depositSolverBond(address asset, address solver, uint256 amount) external nonReentrant {
        if (!assetConfig[asset].listed) revert AssetNotListed();
        if (amount == 0) revert ZeroAmount();
        bool ok = IERC20(asset).transferFrom(msg.sender, address(this), amount);
        if (!ok) revert TransferFailed();
        solverBond[asset][solver] += amount;
        committedLiabilities[asset] += amount;
        emit SolverBondDeposited(asset, solver, amount);
    }

    /// @notice Solver-callable withdrawal of its OWN FREE bond. This is the F-BOND
    ///         fix: V2 previously had no path to return an honest bond, so a
    ///         solver's posted bond was permanently locked (only slashSolverBond,
    ///         owner-only, could ever reduce it, and that sends to the sink not the
    ///         solver). Now a solver reclaims the portion of its bond that is NOT
    ///         reserved to back an outstanding claimed-but-unsettled intent.
    ///
    /// @dev    Guarantees:
    ///           - only msg.sender's own bond is ever touched (ownership);
    ///           - only the FREE bond (solverBond - lockedBond) is withdrawable, so
    ///             a claimed intent's minBond backing stays slashable until settle
    ///             and a solver cannot dodge an imminent slash;
    ///           - solverBond and committedLiabilities decrement in lockstep with
    ///             the outbound transfer, preserving the sweep-reserve invariant
    ///             (balance >= committed) so sweepResidual can never reach a bond;
    ///           - nonReentrant.
    function withdrawSolverBond(address asset, uint256 amount) external nonReentrant {
        if (amount == 0) revert ZeroAmount();
        uint256 bonded = solverBond[asset][msg.sender];
        uint256 locked = lockedBond[asset][msg.sender];
        uint256 free = bonded > locked ? bonded - locked : 0;
        if (amount > free) revert InsufficientFreeBond(free, amount);

        // Decrement bookkeeping BEFORE the transfer; both drop in lockstep so the
        // committed reserve stays exactly balance-backed.
        solverBond[asset][msg.sender] = bonded - amount;
        committedLiabilities[asset] -= amount;

        bool ok = IERC20(asset).transfer(msg.sender, amount);
        if (!ok) revert TransferFailed();
        emit SolverBondWithdrawn(asset, msg.sender, amount);
    }

    function slashSolverBond(address asset, address solver, uint256 amount) external onlyOwner nonReentrant {
        uint256 cur = solverBond[asset][solver];
        if (cur < amount) revert InsufficientBond(cur, amount);
        solverBond[asset][solver] = cur - amount;
        // The slashed bond is no longer owed to the solver, so it stops being a
        // committed liability. It is destined for the sink.
        committedLiabilities[asset] -= amount;
        // Slash then AereSink to feed the burn flywheel.
        IERC20(asset).approve(SINK, amount);
        (bool ok, ) = SINK.call(abi.encodeWithSignature("flush(address,uint256)", asset, amount));
        if (!ok) {
            // Sink couldn't accept (e.g. no swap route for an exotic asset). The
            // tokens remain in the hub as GENUINE residual (no longer committed),
            // which permissionless sweepResidual can later re-flush. Clear the
            // dangling approval so it cannot be reused.
            IERC20(asset).approve(SINK, 0);
        }
        emit SolverBondSlashed(asset, solver, amount);
    }

    /* --------------------------------- core --------------------------------- */

    function deposit(
        address asset,
        uint256 amount,
        bytes32 intentId,
        address recipient
    ) external nonReentrant returns (uint256 amountAfterFee) {
        AssetConfig memory cfg = assetConfig[asset];
        if (!cfg.listed) revert AssetNotListed();
        if (cfg.paused) revert AssetPausedErr();
        if (amount == 0) revert ZeroAmount();
        if (recipient == address(0)) revert ZeroAddress();
        if (cfg.perTxLimit != 0 && amount > cfg.perTxLimit) revert PerTxLimitExceeded(amount, cfg.perTxLimit);
        if (cfg.restrictedRecipient && !recipientAllowed[asset][recipient]) revert RecipientNotAllowed();
        if (intents[intentId].depositor != address(0)) revert DuplicateIntent();

        uint256 fee = (amount * PROTOCOL_FEE_BPS) / 10_000;
        amountAfterFee = amount - fee;

        // Pull full amount.
        bool pulled = IERC20(asset).transferFrom(msg.sender, address(this), amount);
        if (!pulled) revert TransferFailed();

        // Route the fee to the sink. If the sink rejects (unsupported asset),
        // the fee stays in the contract as GENUINE residual (never committed)
        // and sweepResidual handles it.
        if (fee > 0) {
            IERC20(asset).approve(SINK, fee);
            (bool sinkOk, ) = SINK.call(abi.encodeWithSignature("flush(address,uint256)", asset, fee));
            if (!sinkOk) {
                // Clear the dangling approval; the fee is now genuine residual.
                IERC20(asset).approve(SINK, 0);
            }
        }

        // The parked remainder is owed to whichever solver settles this intent
        // (or refunded to the depositor on timeout): it is a committed liability.
        committedLiabilities[asset] += amountAfterFee;

        intents[intentId] = Intent({
            depositor: msg.sender,
            asset: asset,
            amount: amountAfterFee,
            recipient: recipient,
            open: true,
            settled: false,
            solver: address(0),
            claimedAt: 0,
            createdAt: uint64(block.timestamp),
            fillEvidence: "",
            bondLocked: 0
        });

        emit Deposited(intentId, msg.sender, asset, amountAfterFee, fee, recipient);
    }

    function claim(bytes32 intentId, bytes calldata fillEvidence) external nonReentrant {
        Intent storage i = intents[intentId];
        if (!i.open) revert IntentUnknown();
        if (i.settled) revert IntentAlreadySettled();
        if (i.claimedAt != 0) revert IntentAlreadyClaimed();
        if (!solverAllowed[i.asset][msg.sender]) revert NotAllowedSolver();

        AssetConfig memory cfg = assetConfig[i.asset];
        uint256 bond = solverBond[i.asset][msg.sender];
        // The solver must have at least minBond FREE (not already reserved to back
        // another of its outstanding claimed intents). Reserving minBond here keeps
        // this intent's backing slashable until it settles, so a solver cannot
        // withdrawSolverBond to escape a slash of a claimed-but-unsettled intent.
        uint256 lockedNow = lockedBond[i.asset][msg.sender];
        // Defensive against a prior slash having pushed the bond below the locked
        // amount; treat a shortfall as zero free bond rather than underflowing.
        uint256 free = bond > lockedNow ? bond - lockedNow : 0;
        if (free < cfg.minBond) revert InsufficientBond(free, cfg.minBond);

        lockedBond[i.asset][msg.sender] += cfg.minBond;
        i.bondLocked = cfg.minBond;
        i.solver = msg.sender;
        i.claimedAt = uint64(block.timestamp);
        i.fillEvidence = fillEvidence;
        emit Claimed(intentId, msg.sender, uint64(block.timestamp));
    }

    function challenge(bytes32 intentId, string calldata reason) external {
        Intent storage i = intents[intentId];
        if (!i.open) revert IntentUnknown();
        if (i.settled) revert IntentAlreadySettled();
        if (i.claimedAt == 0) revert IntentUnknown();
        if (block.timestamp >= uint256(i.claimedAt) + CHALLENGE_WINDOW) revert ChallengeWindowElapsed();
        emit Challenged(intentId, msg.sender, reason);
    }

    function settle(bytes32 intentId) external nonReentrant {
        Intent storage i = intents[intentId];
        if (!i.open) revert IntentUnknown();
        if (i.settled) revert IntentAlreadySettled();
        if (i.claimedAt == 0) revert IntentUnknown();
        if (block.timestamp < uint256(i.claimedAt) + CHALLENGE_WINDOW) revert WithinChallengeWindow();

        i.settled = true;
        uint256 amt = i.amount;
        // The parked remainder leaves to the solver, so it is no longer committed.
        committedLiabilities[i.asset] -= amt;
        // Release the bond this intent reserved at claim: it is now free to withdraw
        // (or to keep backing other claims). Guard against underflow if the bond was
        // slashed below the locked amount in the interim.
        uint256 bl = i.bondLocked;
        if (bl > 0) {
            i.bondLocked = 0;
            uint256 lb = lockedBond[i.asset][i.solver];
            lockedBond[i.asset][i.solver] = lb > bl ? lb - bl : 0;
        }
        bool ok = IERC20(i.asset).transfer(i.solver, amt);
        if (!ok) revert TransferFailed();
        emit Settled(intentId, i.solver, amt);
    }

    /// @notice Depositor refund for an intent that no solver ever claimed.
    ///         Callable only by the depositor, only once REFUND_TIMEOUT has
    ///         elapsed since deposit, and only while the intent is unclaimed and
    ///         unsettled. Returns the parked remainder to the depositor and
    ///         closes the intent. This is the F-LOCK liveness fix: a never-claimed
    ///         deposit can no longer be trapped forever.
    function cancelIntent(bytes32 intentId) external nonReentrant {
        Intent storage i = intents[intentId];
        if (!i.open) revert IntentUnknown();
        if (i.settled) revert IntentAlreadySettled();
        if (i.claimedAt != 0) revert IntentAlreadyClaimed();
        if (msg.sender != i.depositor) revert NotDepositor();
        uint256 unlockTs = uint256(i.createdAt) + REFUND_TIMEOUT;
        if (block.timestamp < unlockTs) revert RefundTimeoutNotReached(block.timestamp, unlockTs);

        i.open = false;
        uint256 amt = i.amount;
        // The parked remainder returns to the depositor, so it is no longer committed.
        committedLiabilities[i.asset] -= amt;
        bool ok = IERC20(i.asset).transfer(i.depositor, amt);
        if (!ok) revert TransferFailed();
        emit Refunded(intentId, i.depositor, amt);
    }

    /* ----------------------------- dust / residual -------------------------- */

    /// @notice Genuine residual for an asset: hub balance minus committed
    ///         liabilities (parked intent remainders + posted bonds). This is
    ///         the only value sweepResidual may ever move.
    function residualOf(address asset) public view returns (uint256) {
        uint256 bal = IERC20(asset).balanceOf(address(this));
        uint256 committed = committedLiabilities[asset];
        // committed can never exceed the balance under correct accounting; guard
        // against underflow defensively.
        return bal > committed ? bal - committed : 0;
    }

    /// @notice Anyone may sweep GENUINE residual balances (fees or slashed bonds
    ///         that could not reach the sink on their first try) by re-attempting
    ///         the flush. Permissionless, but strictly bounded to residualOf(asset)
    ///         so it can NEVER touch a live intent's parked funds or a posted
    ///         solver bond. This is the F-SWEEP griefing fix.
    function sweepResidual(address asset, uint256 amount) external nonReentrant {
        if (amount == 0) revert ZeroAmount();
        uint256 residual = residualOf(asset);
        if (amount > residual) revert ExceedsResidual(amount, residual);
        IERC20(asset).approve(SINK, amount);
        (bool ok, ) = SINK.call(abi.encodeWithSignature("flush(address,uint256)", asset, amount));
        if (!ok) {
            IERC20(asset).approve(SINK, 0);
            revert TransferFailed();
        }
        emit ResidualSwept(asset, amount, msg.sender);
    }
}
