// 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 AereRewardDistributorV2 - 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.
 *
 *  AUDIT FIX (FINDING 2, medium) - part 1: the V1 setExcluded had no guard, so
 *  the owner could exclude an account that had ALREADY partially claimed. Its
 *  claim() then reverted IsExcluded and clawbackUnclaimed swept the rightful
 *  account's vested-but-unclaimed AERE to owner()/SINK after expiry,
 *  contradicting the advertised invariant. V2 blocks excluding any account that
 *  has already claimed (claimed[account] > 0), mirroring the RootFrozen freeze
 *  pattern. A claimant who has begun claiming can therefore always finish
 *  claiming their vested entitlement, and exclusion can never
 *  freeze-then-confiscate it.
 *
 *  AUDIT FIX (FINDING 2, medium) - part 2 (RESIDUAL): part 1 still left a second
 *  road to the same confiscation. claim() is whenNotPaused but clawbackUnclaimed
 *  was NOT, and clawback swept the entire address(this).balance with no
 *  accounting of what claimants were still owed. So the owner could pause(),
 *  wait past claimExpiry, and sweep an in-progress claimant's vested-but-
 *  unclaimed AERE anyway. V2 closes this on two fronts:
 *    (a) clawbackUnclaimed is now whenNotPaused, so the owner can never clawback
 *        while the campaign is paused; and
 *    (b) the campaign's totalAllocated (the owner-DECLARED sum of every published
 *        leaf entitlement) is declared at publishRoot time and frozen with the
 *        root, and clawbackUnclaimed can recover ONLY the balance in excess of
 *        the reserve, i.e. balance - (totalAllocated - totalClaimed). AS LONG AS
 *        totalAllocated is declared honestly (see the RESERVE HONESTY DEPENDENCY
 *        in SAFETY POSTURE below), every leaf's full entitlement stays reserved
 *        and claimable forever, so clawback touches only overfunded dust that no
 *        leaf was ever entitled to, never a claimant's still-owed AERE. This
 *        guard is an accounting bound against overfunding, NOT an on-chain check
 *        on the owner: the contract cannot verify totalAllocated against the root
 *        (the root commits the leaves but hides their sum).
 *
 *  ------------------------------ 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 that have NOT
 *    yet claimed, and claw back only balance beyond the declared reserve ONLY
 *    AFTER a long expiry and ONLY while not paused. The owner CANNOT exclude an
 *    account that has already claimed, CANNOT redirect or freeze a claim that is
 *    already in progress, cannot move a delivered claim, and cannot shorten the
 *    claim window. The clawback bound on a claimant's reserved entitlement holds
 *    subject to the RESERVE HONESTY DEPENDENCY below.
 *  - RESERVE HONESTY DEPENDENCY (read before trusting the clawback bound). The
 *    reserve clawbackUnclaimed must leave behind is totalAllocated - totalClaimed,
 *    and totalAllocated is an owner-supplied publishRoot argument. The contract
 *    CANNOT verify it against the Merkle root, because the root commits to the
 *    individual leaves but HIDES their sum. So "the owner cannot clawback a
 *    claimant's reserved entitlement" holds ONLY IF the owner publishes an honest
 *    totalAllocated equal to the true sum of all leaf entitlements. An owner who
 *    UNDER-declares totalAllocated could still sweep genuinely-entitled AERE after
 *    expiry. This does NOT widen the trust boundary: the same owner already
 *    defines every entitlement by choosing the root, so a dishonest owner could
 *    just as well have published a root that omitted or shrank those leaves. The
 *    reserve is an accounting guard against overfunding, not a constraint on an
 *    already-fully-trusted owner. OPERATIONAL MITIGATION: fund only against a
 *    clean post-filter root and set totalAllocated to the exact sum of the
 *    surviving (real) claimant leaves, so the reserve equals real outstanding
 *    entitlements and no entitled AERE is ever clawbackable.
 *  - EXCLUDING A NOT-YET-CLAIMED ACCOUNT LOCKS ITS ALLOCATION. setExcluded only
 *    blocks accounts that have not claimed, and it does NOT reduce totalAllocated,
 *    so clawbackUnclaimed keeps reserving that leaf's full entitlement. An
 *    excluded, never-claimed allocation therefore becomes permanently locked in
 *    the contract: neither claimable (the account is excluded) nor clawbackable
 *    (it stays inside the reserve). This is conservative-safe. It strands that
 *    balance in the contract rather than confiscating any claimant, and it is the
 *    intended, no-loss outcome for a revoked sybil.
 *  -------------------------------------------------------------------------
 *
 *  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 AereRewardDistributorV2 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

    /// @notice Owner-DECLARED sum of every published leaf entitlement, supplied at
    ///         publishRoot time and frozen with the root. clawbackUnclaimed reserves
    ///         (totalAllocated - totalClaimed) so that, WHEN this value is declared
    ///         honestly, no claimant's owed AERE can be swept and only balance beyond
    ///         the reserve (overfunded dust) is recoverable. It is NOT verifiable
    ///         on-chain against the root (the root commits the leaves but hides their
    ///         sum); see the RESERVE HONESTY DEPENDENCY in the contract NatSpec.
    uint256 public totalAllocated;

    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,
        uint256 totalAllocated
    );
    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 BadAllocation();
    error NothingToClawback();
    error IsExcluded();
    error BadProof();
    error NothingToClaim();
    error ClawbackTooEarly();
    error TransferFailed();
    error ApproveFailed();
    error AlreadyClaimed();

    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.
    /// @param _totalAllocated the sum of every leaf entitlement under `root`.
    ///        It is frozen with the root and pins the reserve that
    ///        clawbackUnclaimed must always leave behind, so overfunded dust can
    ///        be recovered while no claimant's owed AERE ever can. It cannot be
    ///        zero (an all-zero campaign has nothing to distribute).
    function publishRoot(
        bytes32 root,
        uint64 _cliffDuration,
        uint64 _vestingDuration,
        uint64 _claimExpiry,
        bool   _deliverAsSAERE,
        uint256 _totalAllocated
    ) external onlyOwner {
        if (claimsStarted) revert RootFrozen();
        if (root == bytes32(0)) revert ZeroRoot();
        if (_totalAllocated == 0) revert BadAllocation();
        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;
        totalAllocated = _totalAllocated;

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

    /// @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 before an entry has drawn anything.
    ///
    ///         FINDING 2 FIX: an account that has already claimed
    ///         (claimed[account] > 0) can NOT be excluded. This removes the
    ///         freeze-then-clawback confiscation vector, so a claimant who has
    ///         begun claiming can always finish claiming their vested amount.
    ///         Re-inclusion (value == false) is always permitted.
    function setExcluded(address account, bool value) external onlyOwner {
        if (value && claimed[account] > 0) revert AlreadyClaimed();
        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 ONLY the native AERE balance
    ///         in excess of the reserve, reserve = totalAllocated - totalClaimed.
    ///         Destination is the Foundation owner, or AereSink if `toSink`.
    ///
    ///         PROVIDED totalAllocated was declared honestly at publishRoot (a value
    ///         this contract cannot verify against the root; see the RESERVE HONESTY
    ///         DEPENDENCY in the contract NatSpec), this cannot touch a claimant's
    ///         vested-but-unclaimed AERE: every published leaf's full entitlement
    ///         stays reserved and claimable forever, even past expiry, and clawback
    ///         recovers only overfunded dust beyond that reserve. It is also
    ///         whenNotPaused, so the owner cannot pause the campaign and clawback
    ///         around a claim that is in progress. Reverts NothingToClawback when
    ///         the balance is fully reserved.
    function clawbackUnclaimed(bool toSink) external onlyOwner nonReentrant whenNotPaused {
        if (claimExpiry == 0) revert NotPublished();
        if (block.timestamp <= claimExpiry) revert ClawbackTooEarly();

        uint256 reserved = totalAllocated > totalClaimed ? totalAllocated - totalClaimed : 0;
        uint256 bal = address(this).balance;
        if (bal <= reserved) revert NothingToClawback();
        uint256 amount = bal - reserved;

        address payable dest = toSink ? SINK : payable(owner());
        (bool ok, ) = dest.call{value: amount}("");
        if (!ok) revert TransferFailed();
        emit ClawedBack(dest, amount);
    }

    /// @notice The amount clawbackUnclaimed could recover right now (0 before
    ///         expiry, while paused, or when the balance is fully reserved).
    function clawableNow() external view returns (uint256) {
        if (claimExpiry == 0 || block.timestamp <= claimExpiry || paused()) return 0;
        uint256 reserved = totalAllocated > totalClaimed ? totalAllocated - totalClaimed : 0;
        uint256 bal = address(this).balance;
        return bal > reserved ? bal - reserved : 0;
    }

    /* --------------------------------- 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);
    }
}
