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

import { IERC20 } from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol";
import { SafeERC20 } from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol";

/// @title PullPaymentsV3
/// @notice Pull-payment credit ledger. Bonds credit users; users withdraw.
///         Never sends value during complex state transitions.
abstract contract PullPaymentsV3 {
    using SafeERC20 for IERC20;

    error NoCredit();
    error TransferFailed();
    error ZeroAmount();
    error Reentrancy();
    error UnsupportedAssetTransfer();

    event CreditedV3(address indexed asset, address indexed to, uint256 amount);
    event WithdrawnV3(address indexed asset, address indexed to, uint256 amount);

    /// @dev credits[asset(0=native)][holder] = amount
    mapping(address => mapping(address => uint256)) internal _credits;
    /// @notice The portion of an asset balance owed through this pull ledger.
    /// @dev Descendants with locked principal must include that principal in
    ///      their own liability view in addition to this value.
    mapping(address => uint256) public creditLiabilities;
    uint256 private _reentrancyStatus = 1;

    modifier nonReentrantPullPayment() {
        if (_reentrancyStatus != 1) revert Reentrancy();
        _reentrancyStatus = 2;
        _;
        _reentrancyStatus = 1;
    }

    function creditOf(address asset, address holder) external view returns (uint256) {
        return _credits[asset][holder];
    }

    function creditAssets(address asset) public view returns (uint256) {
        return asset == address(0) ? address(this).balance : IERC20(asset).balanceOf(address(this));
    }

    function isCreditSolvent(address asset) external view returns (bool) {
        return creditAssets(asset) >= creditLiabilities[asset];
    }

    function _credit(address asset, address to, uint256 amount) internal {
        if (amount == 0) revert ZeroAmount();
        _credits[asset][to] += amount;
        creditLiabilities[asset] += amount;
        emit CreditedV3(asset, to, amount);
    }

    /// @notice Callable at any time, including when the contract is paused.
    ///         Invariant #11 — pause never traps principal.
    function withdrawCredit(address asset) public virtual nonReentrantPullPayment {
        uint256 amt = _credits[asset][msg.sender];
        if (amt == 0) revert NoCredit();
        _credits[asset][msg.sender] = 0;
        creditLiabilities[asset] -= amt;
        _afterCreditWithdrawal(asset, amt);
        if (asset == address(0)) {
            (bool ok,) = payable(msg.sender).call{ value: amt }("");
            if (!ok) revert TransferFailed();
        } else {
            _pushExact(asset, msg.sender, amt);
        }
        emit WithdrawnV3(asset, msg.sender, amt);
    }

    /// @dev Pulls exactly `amount` tokens. A token which reports success but
    ///      delivers less (or nothing) cannot create an unbacked liability.
    function _pullExact(address asset, uint256 amount) internal {
        if (asset == address(0) || amount == 0) revert ZeroAmount();
        // slither-disable-next-line reentrancy-balance
        uint256 beforeBalance = IERC20(asset).balanceOf(address(this));
        IERC20(asset).safeTransferFrom(msg.sender, address(this), amount);
        uint256 afterBalance = IERC20(asset).balanceOf(address(this));
        if (afterBalance < beforeBalance || afterBalance - beforeBalance != amount) {
            revert UnsupportedAssetTransfer();
        }
    }

    /// @dev Sends exactly `amount` tokens out and verifies that the intended
    ///      claimant, rather than a fee collector, received the full amount.
    function _pushExact(address asset, address to, uint256 amount) internal {
        // slither-disable-next-line reentrancy-balance
        uint256 senderBefore = IERC20(asset).balanceOf(address(this));
        // slither-disable-next-line reentrancy-balance
        uint256 recipientBefore = IERC20(asset).balanceOf(to);
        IERC20(asset).safeTransfer(to, amount);
        uint256 senderAfter = IERC20(asset).balanceOf(address(this));
        uint256 recipientAfter = IERC20(asset).balanceOf(to);
        if (
            senderAfter > senderBefore || senderBefore - senderAfter != amount || recipientAfter < recipientBefore
                || recipientAfter - recipientBefore != amount
        ) {
            revert UnsupportedAssetTransfer();
        }
    }

    /// @dev Optional liability-accounting hook for descendants that accept
    ///      third-party donations and therefore expose a surplus-skim path.
    function _afterCreditWithdrawal(address asset, uint256 amount) internal virtual { }
}
