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

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

/**
 * @title AereSink — Immutable 3-bucket protocol revenue router
 * @notice Single entry point for all protocol fee streams that should accrue
 *         to AERE holders. Anything sent in is routed to:
 *           1. BURN_VAULT       — extra burn on top of AereFeeBurnVault's 37.5%
 *           2. BUYBACK-AND-BURN — non-AERE fees → swapped to AERE → burned
 *           3. STAKER-YIELD     → swapped/transferred to sAERE vault, lifting
 *                                  the WAERE/sAERE exchange rate
 *
 * @dev    Locked design 2026-06-06. Per saere_architecture_2026-06-06.md.
 *
 *         Immutability invariants (verified in tests):
 *         - NO owner, NO admin, NO governance role
 *         - NO setBucket, NO setRecipient, NO setRouter, NO pause
 *         - Bucket recipients (BURN_VAULT, sAERE_VAULT) set at deploy, immutable
 *         - Bucket basis-point splits set at deploy, immutable
 *         - Bps must sum to exactly 10000 (constructor reverts if not)
 *         - Router is immutable; if it ever needs replacement, redeploy and
 *           rewire fee sources
 *
 *         Fee-source contracts (AereCoinbaseSplitter, AereFeeMonetization,
 *         AereSettlementHub, etc.) call `flush(token, amount)` to route their
 *         accumulated balances through here. They must approve the sink for
 *         the amount first.
 *
 *         AereSink does NOT custody funds across calls. Every `flush()` fully
 *         dispatches into the three buckets. Residual dust (≤2 wei per call
 *         from integer rounding) accumulates in the contract; an external
 *         `sweepDust()` is provided that re-flushes the dust through the same
 *         buckets — anyone can call it, no permission.
 *
 *         Reentrancy: BURN bucket is a simple transfer (no callback). STAKER
 *         bucket is a simple transfer to the ERC-4626 vault — the vault does
 *         NOT mint shares on raw transfer, so the rate just appreciates.
 *         BUYBACK bucket calls into the router (Uniswap-V2-style) which is
 *         the only external-callback surface; nonReentrant on flush() and
 *         sweepDust() blocks reentry.
 */
interface IAereV2Router {
    /// @notice Uniswap V2-style swap. token-in approval must be set beforehand.
    /// @return amounts Amount of each token in the path swapped.
    function swapExactTokensForTokens(
        uint256 amountIn,
        uint256 amountOutMin,
        address[] calldata path,
        address to,
        uint256 deadline
    ) external returns (uint256[] memory amounts);
}

/// @notice Oracle interface for sandwich-resistant amountOutMin computation.
///         Compatible with AereLendingOracle.getPrice() — returns 18-decimals
///         normalised USD price. Used to compute the swap floor independent
///         of the DEX pool state (which can be sandwich-manipulated).
interface ISwapGuardOracle {
    function getPrice(address asset) external view returns (uint256);
}

/// @notice sAERE vault sync hook. AUDIT FIX R5 (HIGH-1): after we transfer
///         AERE to the staker bucket, ping sync() so the receiving vault
///         immediately recognises the arrival and starts dripping it linearly
///         instead of letting a public-mempool sandwicher race the next tx
///         to extract a step-function pricePerShare jump.
interface ISaereSyncable {
    function sync() external;
}

contract AereSink is ReentrancyGuard {
    /* --------------------------- immutable state --------------------------- */

    /// @notice Native protocol token (WAERE — ERC-20 wrapper around AERE).
    address public immutable AERE;

    /// @notice AereFeeBurnVault. Existing deployed, no admin, no withdraw.
    address public immutable BURN_VAULT;

    /// @notice sAERE ERC-4626 receipt vault. Raw transfer here lifts the
    ///         exchange rate (totalAssets goes up, totalShares does not).
    address public immutable SAERE_VAULT;

    /// @notice AereSwap V2-compatible router used for non-AERE → AERE swaps
    ///         (buyback-and-burn + staker-yield buckets when input is not AERE).
    address public immutable DEX_ROUTER;

    /// @notice Basis points (out of 10000) for each bucket. Sum = 10000.
    uint16 public immutable BURN_BPS;
    uint16 public immutable BUYBACK_BPS;
    uint16 public immutable STAKER_YIELD_BPS;

    /// @notice Max slippage tolerance for buyback swaps, in bps. e.g. 200 = 2%.
    ///         Immutable: chosen at deploy. Applied against ORACLE price floor
    ///         to compute amountOutMin, NOT against DEX pool snapshot (which
    ///         is sandwich-able by the same actor reading + swapping in one tx).
    uint16 public immutable MAX_SLIPPAGE_BPS;

    /// @notice External oracle for sandwich-resistant amountOutMin. If set
    ///         (non-zero), every non-AERE → AERE swap computes a floor from
    ///         oracle prices and rejects swaps below that floor. If zero,
    ///         swaps accept ANY non-zero output (test-only mode — production
    ///         deploys MUST set this).
    address public immutable ORACLE;

    /// @notice 18-decimal normalisation factor for oracle prices.
    uint8 public constant ORACLE_DECIMALS = 18;

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

    event Flushed(
        address indexed feeder,
        address indexed token,
        uint256 amount,
        uint256 toBurn,
        uint256 toBuyback,
        uint256 toStakers
    );
    event DustSwept(address indexed token, uint256 amount);
    event SwapFailed(address indexed token, uint256 amountIn, string reason);

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

    error BpsMustSumTo10000(uint16 burn, uint16 buyback, uint16 staker);
    error ZeroAddress();
    error ZeroAmount();
    error UnreachablePath();
    error TransferFailed();

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

    constructor(
        address _aere,
        address _burnVault,
        address _sAereVault,
        address _dexRouter,
        uint16 _burnBps,
        uint16 _buybackBps,
        uint16 _stakerYieldBps,
        uint16 _maxSlippageBps,
        address _oracle
    ) {
        if (_aere == address(0) || _burnVault == address(0) || _sAereVault == address(0) || _dexRouter == address(0)) {
            revert ZeroAddress();
        }
        if (uint256(_burnBps) + uint256(_buybackBps) + uint256(_stakerYieldBps) != 10_000) {
            revert BpsMustSumTo10000(_burnBps, _buybackBps, _stakerYieldBps);
        }

        AERE = _aere;
        BURN_VAULT = _burnVault;
        SAERE_VAULT = _sAereVault;
        DEX_ROUTER = _dexRouter;
        BURN_BPS = _burnBps;
        BUYBACK_BPS = _buybackBps;
        STAKER_YIELD_BPS = _stakerYieldBps;
        MAX_SLIPPAGE_BPS = _maxSlippageBps;
        // _oracle == address(0) is test-mode (sandwich-able). Production
        // deploys MUST pass a real oracle (e.g. AereLendingOracle) so
        // _swapAndForward can compute a sandwich-resistant amountOutMin.
        ORACLE = _oracle;
    }

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

    /**
     * @notice Route `amount` of `token` through the three buckets.
     * @dev    Caller must `IERC20(token).approve(address(this), amount)` first.
     *         If `token == AERE`, all three buckets settle in AERE directly
     *         (no swap needed): BURN + BUYBACK both burn (semantic merge),
     *         STAKER goes to sAERE.
     *
     *         If `token != AERE`, each bucket's portion is swapped via the
     *         DEX_ROUTER along path [token, AERE], then dispatched.
     */
    function flush(address token, uint256 amount) external nonReentrant {
        if (amount == 0) revert ZeroAmount();

        // Pull the tokens.
        bool ok = IERC20(token).transferFrom(msg.sender, address(this), amount);
        if (!ok) revert TransferFailed();

        uint256 burnPart    = amount * BURN_BPS    / 10_000;
        uint256 buybackPart = amount * BUYBACK_BPS / 10_000;
        // Last bucket eats any rounding residue (typically ≤2 wei) — no dust
        // accumulation when input is already AERE.
        uint256 stakerPart  = amount - burnPart - buybackPart;

        if (token == AERE) {
            // BURN + BUYBACK are both "destroy AERE" → one transfer.
            uint256 toBurnTotal = burnPart + buybackPart;
            if (toBurnTotal > 0) {
                _safeTransfer(token, BURN_VAULT, toBurnTotal);
            }
            if (stakerPart > 0) {
                _safeTransfer(token, SAERE_VAULT, stakerPart);
                _pingSaere(); // R5 HIGH-1: trigger drip
            }
        } else {
            uint256 swapForBurnAmt = burnPart + buybackPart;
            if (swapForBurnAmt > 0) {
                _swapAndForward(token, swapForBurnAmt, BURN_VAULT);
            }
            if (stakerPart > 0) {
                _swapAndForward(token, stakerPart, SAERE_VAULT);
                _pingSaere(); // R5 HIGH-1: trigger drip
            }
        }

        emit Flushed(msg.sender, token, amount, burnPart, buybackPart, stakerPart);
    }

    /// AUDIT FIX R5 (HIGH-1): best-effort ping; the sync() entrypoint is
    /// permissionless and idempotent so failure is safe to ignore (the next
    /// caller will re-sync). Wrapped in try-style call so non-syncable vaults
    /// (test mocks, older deployments) do not brick the flush.
    function _pingSaere() internal {
        (bool ok, ) = SAERE_VAULT.call(abi.encodeCall(ISaereSyncable.sync, ()));
        ok; // intentionally unused — best-effort
    }

    /**
     * @notice Re-flush any token dust held by the sink. Permissionless.
     * @dev    Useful for residual amounts left from failed mid-flush swaps,
     *         or anyone who sent tokens here directly.
     */
    function sweepDust(address token) external nonReentrant {
        uint256 bal = IERC20(token).balanceOf(address(this));
        if (bal == 0) revert ZeroAmount();

        uint256 burnPart    = bal * BURN_BPS    / 10_000;
        uint256 buybackPart = bal * BUYBACK_BPS / 10_000;
        uint256 stakerPart  = bal - burnPart - buybackPart;

        if (token == AERE) {
            uint256 toBurnTotal = burnPart + buybackPart;
            if (toBurnTotal > 0) _safeTransfer(token, BURN_VAULT, toBurnTotal);
            if (stakerPart > 0)  { _safeTransfer(token, SAERE_VAULT, stakerPart); _pingSaere(); }
        } else {
            uint256 swapForBurnAmt = burnPart + buybackPart;
            if (swapForBurnAmt > 0) _swapAndForward(token, swapForBurnAmt, BURN_VAULT);
            if (stakerPart > 0)     { _swapAndForward(token, stakerPart, SAERE_VAULT); _pingSaere(); }
        }

        emit DustSwept(token, bal);
    }

    /* -------------------------------- internals -------------------------------- */

    function _safeTransfer(address token, address to, uint256 amount) internal {
        (bool ok, bytes memory ret) = token.call(
            abi.encodeCall(IERC20.transfer, (to, amount))
        );
        if (!ok || (ret.length > 0 && !abi.decode(ret, (bool)))) revert TransferFailed();
    }

    /// @notice Compute the minimum acceptable AERE out for a swap of
    ///         `amountIn` of `tokenIn`, using ORACLE prices. If ORACLE is
    ///         zero (test-mode) returns 1 (legacy behavior). If oracle reverts
    ///         or returns zero for either asset, returns 1 (swap will accept
    ///         any non-zero — same fallback as the dust path).
    ///
    ///         Math: expected_out_in_aere_units
    ///                = amountIn * priceOf(tokenIn) / priceOf(AERE)
    ///         Then apply (1 - MAX_SLIPPAGE_BPS / 10_000) tolerance.
    ///
    ///         Both prices are oracle-normalised to 18 decimals. Assumes both
    ///         tokens are 18-decimal. For non-18-decimal tokens this will
    ///         systematically misprice; production deploys should add a
    ///         decimals-normalising oracle wrapper.
    function _computeOracleFloor(address tokenIn, uint256 amountIn) internal view returns (uint256) {
        if (ORACLE == address(0)) return 1;
        try ISwapGuardOracle(ORACLE).getPrice(tokenIn) returns (uint256 priceIn) {
            if (priceIn == 0) return 1;
            try ISwapGuardOracle(ORACLE).getPrice(AERE) returns (uint256 priceAere) {
                if (priceAere == 0) return 1;
                // expected = amountIn * priceIn / priceAere (both in 1e18 USD)
                uint256 expected = (amountIn * priceIn) / priceAere;
                // Apply max-slippage tolerance.
                uint256 floor = expected * (10_000 - uint256(MAX_SLIPPAGE_BPS)) / 10_000;
                return floor == 0 ? 1 : floor;
            } catch {
                return 1;
            }
        } catch {
            return 1;
        }
    }

    function _swapAndForward(address tokenIn, uint256 amountIn, address recipient) internal {
        // Approve router for this exact amount.
        IERC20(tokenIn).approve(DEX_ROUTER, amountIn);

        address[] memory path = new address[](2);
        path[0] = tokenIn;
        path[1] = AERE;

        // ROUND-3 FIX (HIGH): compute amountOutMin from ORACLE prices, NOT
        // from DEX pool reserves. DEX pool reserves can be sandwich-
        // manipulated by the same actor reading + swapping in one tx.
        // Oracle prices are independent of pool state.
        uint256 amountOutMin = _computeOracleFloor(tokenIn, amountIn);
        // 5-minute deadline in case the tx is held in mempool.
        uint256 deadline = block.timestamp + 300;

        try IAereV2Router(DEX_ROUTER).swapExactTokensForTokens(
            amountIn,
            amountOutMin,
            path,
            recipient,
            deadline
        ) returns (uint256[] memory) {
            // Success — AERE has been forwarded to `recipient`.
        } catch (bytes memory err) {
            // Swap failed (no liquidity, slippage, etc.). The tokens stay in
            // this contract as dust; sweepDust() can be called later when
            // liquidity returns. We emit and continue rather than revert so
            // a single dead pair doesn't lock up the whole flush.
            string memory msg_ = err.length > 0 ? string(err) : "swap-failed";
            emit SwapFailed(tokenIn, amountIn, msg_);
            // Clear the approval we just set, since the swap didn't consume it.
            IERC20(tokenIn).approve(DEX_ROUTER, 0);
        }
    }
}
