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

import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/security/Pausable.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
import "./interfaces/ISeason1.sol";

/**
 * @title AereRewardDistributor — AERE Season 1 "Altitude" reward claim (SECURITY-CRITICAL)
 * @notice Merkle-rooted claim of NATIVE AERE against a Foundation-published
 *         root, with cliff + linear vesting, pre-claim sybil EXCLUSION,
 *         post-expiry CLAWBACK, an optional locked-sAERE delivery path, and an
 *         emergency PAUSE.
 *
 *  ┌─────────────────────────── SAFETY POSTURE ───────────────────────────┐
 *  │ - This contract DEPLOYS UNFUNDED and holds 0 AERE. No Airdrop Reserve │
 *  │   funds move during the build. Only the founder signs the later       │
 *  │   funding transaction, and ONLY AFTER an external audit.              │
 *  │ - MUST BE EXTERNALLY AUDITED before it is funded. It will custody real│
 *  │   AERE once funded; treat every line as adversarial surface.          │
 *  │ - The owner (Foundation multisig) can pause, exclude sybils before    │
 *  │   claims begin, and claw back ONLY the UNCLAIMED remainder AFTER a     │
 *  │   long expiry. The owner CANNOT redirect a valid, vested claim away    │
 *  │   from its rightful account, and cannot shorten the claim window.     │
 *  └───────────────────────────────────────────────────────────────────────┘
 *
 *  Vesting: for an entitlement `total`, with start = rootPublishedAt,
 *    - t <  start + cliff                 -> 0
 *    - t >= start + duration              -> total
 *    - otherwise                          -> total * (t - start) / duration
 *  (cliff delays first unlock; at cliff a lump of total*cliff/duration is
 *   available, then it accrues linearly to full at start + duration.)
 *
 *  Delivery: liquid native AERE by default, OR (if deliverAsSAERE) the vested
 *  amount is wrapped to WAERE and deposited into the sAERE ERC-4626 vault with
 *  the shares minted straight to the claimant (a staked, drip-redeemable
 *  position instead of liquid AERE).
 *
 *  Leaf = keccak256(bytes.concat(keccak256(abi.encode(account, totalAmount)))),
 *  the OpenZeppelin StandardMerkleTree leaf encoding (second-preimage safe).
 *
 *  DISCLAIMER: Season 1 rewards are discretionary and depend on the network
 *  growing. Altitude Points are not a token and confer no right to value or
 *  returns. There is no listing. Nothing here is a promise of value.
 */
contract AereRewardDistributor is Ownable, Pausable, ReentrancyGuard {

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

    /// @notice ERC-20 AERE (WAERE) used to feed the sAERE vault on the sAERE path.
    IWaereWrap      public immutable WAERE;
    /// @notice sAERE ERC-4626 vault (asset() must be WAERE).
    ISAEREVault public immutable SAERE;
    /// @notice Optional clawback destination (AereSink).
    address payable public immutable SINK;

    /* --------------------------------- campaign ----------------------------- */

    bytes32 public merkleRoot;
    uint64  public rootPublishedAt;   // vesting start; 0 == not published
    uint64  public cliffDuration;     // seconds after start before any unlock
    uint64  public vestingDuration;   // seconds after start to full unlock (>= cliff)
    uint64  public claimExpiry;       // absolute unix; after this, owner may clawback
    bool    public deliverAsSAERE;    // deliver locked sAERE instead of liquid AERE
    bool    public claimsStarted;     // once true, the root/params are frozen

    uint256 public totalClaimed;      // cumulative AERE (or AERE-equivalent) delivered
    mapping(address => uint256) public claimed;  // per-account cumulative delivered
    mapping(address => bool)    public excluded; // sybil revocation

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

    event Funded(address indexed from, uint256 amount);
    event RootPublished(
        bytes32 indexed merkleRoot,
        uint64 rootPublishedAt,
        uint64 cliffDuration,
        uint64 vestingDuration,
        uint64 claimExpiry,
        bool   deliverAsSAERE
    );
    event ExclusionSet(address indexed account, bool excluded);
    event Claimed(address indexed account, uint256 amount, uint256 cumulative, uint256 total, bool asSAERE);
    event ClaimExpiryExtended(uint64 newExpiry);
    event ClawedBack(address indexed to, uint256 amount);

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

    error NotPublished();
    error RootFrozen();
    error ZeroRoot();
    error BadVesting();
    error BadExpiry();
    error IsExcluded();
    error BadProof();
    error NothingToClaim();
    error ClawbackTooEarly();
    error TransferFailed();
    error ApproveFailed();

    constructor(address waere, address sAERE, address payable sink) {
        require(waere != address(0) && sAERE != address(0) && sink != address(0), "zero-ref");
        // Sanity: sAERE must vault WAERE, else the sAERE path cannot work.
        require(ISAEREVault(sAERE).asset() == waere, "sAERE-asset-mismatch");
        WAERE = IWaereWrap(waere);
        SAERE = ISAEREVault(sAERE);
        SINK = sink;
        // Deploys UNFUNDED: no value accepted in the constructor; balance == 0.
    }

    /// @notice Accept native AERE funding. The founder sends the funding tx
    ///         LATER, after an external audit. Nothing here pulls reserve funds.
    receive() external payable {
        emit Funded(msg.sender, msg.value);
    }

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

    /// @notice Publish (or, before the first claim, re-publish to correct) the
    ///         Merkle root and vesting/expiry parameters. Frozen once the first
    ///         claim lands.
    function publishRoot(
        bytes32 root,
        uint64 _cliffDuration,
        uint64 _vestingDuration,
        uint64 _claimExpiry,
        bool   _deliverAsSAERE
    ) external onlyOwner {
        if (claimsStarted) revert RootFrozen();
        if (root == bytes32(0)) revert ZeroRoot();
        if (_vestingDuration == 0 || _vestingDuration < _cliffDuration) revert BadVesting();
        // Expiry must outlast the vesting schedule so every claimant can claim.
        if (_claimExpiry <= block.timestamp + _vestingDuration) revert BadExpiry();

        merkleRoot = root;
        rootPublishedAt = uint64(block.timestamp);
        cliffDuration = _cliffDuration;
        vestingDuration = _vestingDuration;
        claimExpiry = _claimExpiry;
        deliverAsSAERE = _deliverAsSAERE;

        emit RootPublished(root, rootPublishedAt, _cliffDuration, _vestingDuration, _claimExpiry, _deliverAsSAERE);
    }

    /// @notice Exclude (or re-include) an address. Excluding blocks that
    ///         address from claiming; it can never move a claim to a third
    ///         party. Intended for sybil revocation, ideally before claims
    ///         begin, but permitted anytime as a safety valve.
    function setExcluded(address account, bool value) external onlyOwner {
        excluded[account] = value;
        emit ExclusionSet(account, value);
    }

    /// @notice Push the claim expiry LATER (never earlier). Lets the Foundation
    ///         extend the window; it can never shorten it to strand claimants.
    function extendClaimExpiry(uint64 newExpiry) external onlyOwner {
        if (newExpiry <= claimExpiry) revert BadExpiry();
        claimExpiry = newExpiry;
        emit ClaimExpiryExtended(newExpiry);
    }

    function pause() external onlyOwner { _pause(); }
    function unpause() external onlyOwner { _unpause(); }

    /* --------------------------------- claim -------------------------------- */

    /// @notice Claim the currently-vested, not-yet-claimed portion of `account`'s
    ///         entitlement. Anyone may submit (relayer/paymaster friendly); the
    ///         reward always goes to `account`, which is bound in the leaf.
    function claim(
        address account,
        uint256 totalAmount,
        bytes32[] calldata proof
    ) external nonReentrant whenNotPaused {
        if (merkleRoot == bytes32(0)) revert NotPublished();
        if (excluded[account]) revert IsExcluded();

        bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(account, totalAmount))));
        if (!MerkleProof.verify(proof, merkleRoot, leaf)) revert BadProof();

        uint256 vested = _vested(totalAmount, block.timestamp);
        uint256 already = claimed[account];
        if (vested <= already) revert NothingToClaim();
        uint256 amount = vested - already;

        // Effects.
        claimed[account] = vested;
        totalClaimed += amount;
        if (!claimsStarted) claimsStarted = true;

        // Interaction / delivery.
        if (deliverAsSAERE) {
            _deliverSAERE(account, amount);
        } else {
            (bool ok, ) = payable(account).call{value: amount}("");
            if (!ok) revert TransferFailed();
        }

        emit Claimed(account, amount, vested, totalAmount, deliverAsSAERE);
    }

    /// @dev Wrap native AERE to WAERE and deposit into sAERE, minting shares
    ///      directly to the claimant.
    function _deliverSAERE(address account, uint256 amount) internal {
        WAERE.deposit{value: amount}();
        if (!WAERE.approve(address(SAERE), amount)) revert ApproveFailed();
        SAERE.deposit(amount, account);
    }

    /* ------------------------------- clawback ------------------------------- */

    /// @notice After the (long) claim expiry, recover the UNCLAIMED remainder.
    ///         Destination is the Foundation owner, or AereSink if `toSink`.
    ///         Recovers only whatever native AERE is left; it cannot touch a
    ///         claim that has already been delivered.
    function clawbackUnclaimed(bool toSink) external onlyOwner nonReentrant {
        if (claimExpiry == 0) revert NotPublished();
        if (block.timestamp <= claimExpiry) revert ClawbackTooEarly();
        uint256 bal = address(this).balance;
        address payable dest = toSink ? SINK : payable(owner());
        (bool ok, ) = dest.call{value: bal}("");
        if (!ok) revert TransferFailed();
        emit ClawedBack(dest, bal);
    }

    /* --------------------------------- views -------------------------------- */

    function _vested(uint256 total, uint256 atTime) internal view returns (uint256) {
        if (rootPublishedAt == 0) return 0;
        uint256 start = rootPublishedAt;
        if (atTime < start + cliffDuration) return 0;
        uint256 end = start + vestingDuration;
        if (atTime >= end) return total;
        return (total * (atTime - start)) / vestingDuration;
    }

    /// @notice Vested amount for a given entitlement at a given time.
    function vestedAmount(uint256 total, uint256 atTime) external view returns (uint256) {
        return _vested(total, atTime);
    }

    /// @notice Currently-claimable amount for `account` given their entitlement
    ///         `totalAmount` (caller supplies the total; not proof-checked here).
    function claimableNow(address account, uint256 totalAmount) external view returns (uint256) {
        if (excluded[account]) return 0;
        uint256 vested = _vested(totalAmount, block.timestamp);
        uint256 already = claimed[account];
        return vested > already ? vested - already : 0;
    }

    /// @notice Verify a leaf against the current root without state changes.
    function verifyEntry(address account, uint256 totalAmount, bytes32[] calldata proof)
        external
        view
        returns (bool)
    {
        if (merkleRoot == bytes32(0)) return false;
        bytes32 leaf = keccak256(bytes.concat(keccak256(abi.encode(account, totalAmount))));
        return MerkleProof.verify(proof, merkleRoot, leaf);
    }
}
