// 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";
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";

/**
 * @title AereRetroPGFRound1 — Retroactive Public Goods Funding, Round 1
 * @notice 10M AERE pool distributed across 5 categories to projects that have
 *         already shipped on AERE Network. Round 1 is the first of an annual
 *         cadence; later rounds re-deploy this contract with different totals
 *         and category caps.
 *
 *         CATEGORIES (per immediate_build_plan_2026-06-06):
 *           1. Infrastructure       (validator-host operators, indexers, RPC, faucet)
 *           2. Developer tooling    (SDKs, CLIs, AI assistant, examples)
 *           3. Applications         (dApps live on chain 2800)
 *           4. Education            (courses, blog, technical writing)
 *           5. Security             (audit contests, bug-bounty reporters, samczsun-tier review)
 *           plus a 6th "Buffer" category for late-emerging proposals.
 *
 * @dev    PAYOUT MECHANISM: optimistic Merkle-rooted payout list.
 *           1. Foundation Safe (multisig) publishes a Merkle root of
 *              (recipient, category, amount) tuples in `proposePayoutRoot()`.
 *           2. A 14-day CHALLENGE PERIOD begins. Anyone can dispute by calling
 *              `challenge(reason)` — this freezes the round indefinitely
 *              until the multisig either fixes the root or, if the challenge
 *              is malicious, calls `dismissChallenge(challengeId)` after a
 *              public response.
 *           3. After the challenge window elapses without a challenge,
 *              `finalize()` activates the payout list.
 *           4. Recipients call `claim(category, amount, proof)` to receive
 *              their AERE. Claims can be batched off-chain by a relayer
 *              and submitted by a third party via `claimFor`.
 *           5. After 365 days the unclaimed remainder rolls back to the
 *              Ecosystem Reserve via `sweepUnclaimed()`. The reserve
 *              receiver is set at deploy and is immutable.
 *
 *         Per-category caps are immutable at deploy. Foundation cannot
 *         publish a Merkle root whose category sums exceed the cap; on-
 *         chain enforcement happens at claim time (a category counter is
 *         tracked and overruns revert).
 *
 *         IMMUTABILITY:
 *           - totalPool, categoryCaps, fundingToken, ecosystemReserve all immutable
 *           - challengeWindow, sweepWindow constants
 *           - Foundation can only (a) propose / finalize / sweep / dismiss
 *             — never withdraw funds outside the published list
 */
contract AereRetroPGFRound1 is Ownable, ReentrancyGuard {

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

    /// @notice Token paid out (WAERE).
    IERC20 public immutable FUNDING_TOKEN;
    /// @notice Receiver for unclaimed funds after sweepWindow elapses.
    address public immutable ECOSYSTEM_RESERVE;
    /// @notice Total AERE in the round (10M per spec).
    uint256 public immutable TOTAL_POOL;
    /// @notice 14-day challenge window before payouts go live.
    uint256 public constant CHALLENGE_WINDOW = 14 days;
    /// @notice 365-day claim window. Unclaimed AERE rolls back to Ecosystem Reserve.
    uint256 public constant SWEEP_WINDOW = 365 days;

    uint8 public constant CATEGORIES = 6;
    /// @notice Per-category caps. Length = CATEGORIES. Sum must equal TOTAL_POOL.
    uint256[6] public categoryCap;

    /* ---------------------------- mutable round state -------------------------- */

    enum State { Funding, Proposed, Challenged, Finalized, Swept }
    State public state;

    /// @notice Merkle root of the proposed payout list.
    bytes32 public payoutRoot;
    /// @notice Timestamp at which the proposed root finalises automatically.
    uint256 public earliestFinalizeTs;
    /// @notice Timestamp at which unclaimed funds become sweep-eligible.
    uint256 public earliestSweepTs;

    /// @notice Total AERE claimed per category. Enforces category caps.
    uint256[6] public categoryClaimed;

    /// @notice claim-leaf consumed flag (keccak256 of leaf → claimed).
    mapping(bytes32 => bool) public leafClaimed;

    /// @notice Active challenges. Multiple challenges may exist concurrently;
    ///         each must be dismissed individually OR a new root may be proposed
    ///         after at least one challenge has been dismissed.
    mapping(uint256 => Challenge) public challenges;
    uint256 public nextChallengeId;
    uint256 public openChallengeCount;

    struct Challenge {
        address challenger;
        uint64 timestamp;
        string reason;
        bool dismissed;
    }

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

    event Funded(address indexed funder, uint256 amount);
    event PayoutRootProposed(bytes32 indexed root, uint256 earliestFinalize);
    event Challenged(uint256 indexed id, address indexed challenger, string reason);
    event ChallengeDismissed(uint256 indexed id, string response);
    event Finalized(bytes32 indexed root, uint256 earliestSweep);
    event Claimed(address indexed recipient, uint8 indexed category, uint256 amount);
    event Swept(address indexed receiver, uint256 amount);

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

    error WrongState(State got, State want);
    error CapsMustSumToTotal();
    error ZeroAddress();
    error InsufficientFunding();
    error TimelockNotElapsed(uint256 nowTs, uint256 earliest);
    error AlreadyClaimed();
    error InvalidProof();
    error CategoryOverflow(uint8 category, uint256 wouldBe, uint256 cap);
    error UnknownCategory(uint8 category);
    error OutstandingChallenges(uint256 count);
    error SweepWindowNotElapsed();

    /* ---------------------------------- modifiers ------------------------------- */

    modifier inState(State s) {
        if (state != s) revert WrongState(state, s);
        _;
    }

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

    constructor(
        address fundingToken,
        address ecosystemReserve,
        uint256 totalPool,
        uint256[6] memory _categoryCaps
    ) {
        if (fundingToken == address(0) || ecosystemReserve == address(0)) revert ZeroAddress();
        uint256 sum;
        for (uint8 i = 0; i < 6; i++) sum += _categoryCaps[i];
        if (sum != totalPool) revert CapsMustSumToTotal();

        FUNDING_TOKEN = IERC20(fundingToken);
        ECOSYSTEM_RESERVE = ecosystemReserve;
        TOTAL_POOL = totalPool;
        for (uint8 i = 0; i < 6; i++) categoryCap[i] = _categoryCaps[i];
        state = State.Funding;
    }

    /* ---------------------------------- lifecycle ------------------------------- */

    /// @notice Owner funds the round. Caller must approve TOTAL_POOL first.
    function fund() external onlyOwner inState(State.Funding) {
        bool ok = FUNDING_TOKEN.transferFrom(msg.sender, address(this), TOTAL_POOL);
        if (!ok) revert InsufficientFunding();
        emit Funded(msg.sender, TOTAL_POOL);
    }

    /// @notice Owner publishes a Merkle root. Funds must already be in the
    ///         contract. Starts a 14-day challenge window.
    function proposePayoutRoot(bytes32 root) external onlyOwner {
        if (state != State.Funding && state != State.Challenged) {
            revert WrongState(state, State.Funding);
        }
        if (FUNDING_TOKEN.balanceOf(address(this)) < TOTAL_POOL) revert InsufficientFunding();

        payoutRoot = root;
        earliestFinalizeTs = block.timestamp + CHALLENGE_WINDOW;
        state = State.Proposed;
        // Reset claim flags? No — leafClaimed is keyed by leaf hash, so a
        // new root with different leaves naturally avoids replay.
        emit PayoutRootProposed(root, earliestFinalizeTs);
    }

    /// @notice Anyone can raise a challenge against the proposed root during
    ///         the challenge window. State transitions to Challenged; the
    ///         multisig must dismiss the challenge or propose a new root.
    function challenge(string calldata reason) external inState(State.Proposed) {
        uint256 id = nextChallengeId++;
        challenges[id] = Challenge({
            challenger: msg.sender,
            timestamp: uint64(block.timestamp),
            reason: reason,
            dismissed: false
        });
        openChallengeCount++;
        state = State.Challenged;
        emit Challenged(id, msg.sender, reason);
    }

    /// @notice Owner dismisses a challenge with a public response.
    function dismissChallenge(uint256 id, string calldata response) external onlyOwner {
        Challenge storage c = challenges[id];
        require(c.timestamp != 0 && !c.dismissed, "no challenge");
        c.dismissed = true;
        openChallengeCount--;
        if (openChallengeCount == 0) {
            // Restore to Proposed; the challenge window is NOT reset,
            // earliestFinalizeTs continues from the original timer so
            // dismissals can't be used to stall claimants forever.
            state = State.Proposed;
        }
        emit ChallengeDismissed(id, response);
    }

    /// @notice After challenge window with no open challenges, anyone can
    ///         finalise so claims become possible.
    function finalize() external inState(State.Proposed) {
        if (block.timestamp < earliestFinalizeTs) {
            revert TimelockNotElapsed(block.timestamp, earliestFinalizeTs);
        }
        if (openChallengeCount > 0) revert OutstandingChallenges(openChallengeCount);
        state = State.Finalized;
        earliestSweepTs = block.timestamp + SWEEP_WINDOW;
        emit Finalized(payoutRoot, earliestSweepTs);
    }

    /* ---------------------------------- claims ---------------------------------- */

    /// @notice Recipient (or a relayer) claims a payout by presenting the
    ///         Merkle proof for their (recipient, category, amount) leaf.
    /// @dev    Leaf encoding: keccak256(abi.encode(recipient, category, amount)).
    ///         Category overflow is enforced at claim time.
    function claim(
        address recipient,
        uint8 category,
        uint256 amount,
        bytes32[] calldata proof
    ) external nonReentrant inState(State.Finalized) {
        if (category >= CATEGORIES) revert UnknownCategory(category);

        bytes32 leaf = keccak256(abi.encode(recipient, category, amount));
        if (leafClaimed[leaf]) revert AlreadyClaimed();
        if (!MerkleProof.verify(proof, payoutRoot, leaf)) revert InvalidProof();

        uint256 wouldBe = categoryClaimed[category] + amount;
        if (wouldBe > categoryCap[category]) {
            revert CategoryOverflow(category, wouldBe, categoryCap[category]);
        }

        leafClaimed[leaf] = true;
        categoryClaimed[category] = wouldBe;

        bool ok = FUNDING_TOKEN.transfer(recipient, amount);
        if (!ok) revert InsufficientFunding();

        emit Claimed(recipient, category, amount);
    }

    /* ----------------------------------- sweep ---------------------------------- */

    /// @notice After 365 days post-finalize, anyone can roll unclaimed funds
    ///         back to the Ecosystem Reserve.
    function sweepUnclaimed() external nonReentrant inState(State.Finalized) {
        if (block.timestamp < earliestSweepTs) revert SweepWindowNotElapsed();
        uint256 bal = FUNDING_TOKEN.balanceOf(address(this));
        state = State.Swept;
        if (bal > 0) {
            bool ok = FUNDING_TOKEN.transfer(ECOSYSTEM_RESERVE, bal);
            if (!ok) revert InsufficientFunding();
        }
        emit Swept(ECOSYSTEM_RESERVE, bal);
    }

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

    function categoryRemaining(uint8 cat) external view returns (uint256) {
        return categoryCap[cat] - categoryClaimed[cat];
    }

    function totalRemaining() external view returns (uint256) {
        return FUNDING_TOKEN.balanceOf(address(this));
    }
}
