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

import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

/**
 * @title AereFaucetV2 — testnet faucet with optional anonymous claim path
 * @notice Two claim paths:
 *
 *           1. claim() — per-address cooldown (existing AereFaucet pattern,
 *              preserved for compatibility).
 *
 *           2. claimAnonymous(commitment, recipient, validUntil, sig) —
 *              Foundation off-chain issues a one-time claim ticket. The
 *              ticket commits to a fresh commitment value generated by
 *              the user; the on-chain side burns the commitment after
 *              first use. Different (commitment, recipient) pairs are
 *              independent — a user can claim N times by getting N
 *              tickets, but each ticket is one-shot.
 *
 *              The anonymous path is useful for:
 *                - developer testing with throwaway addresses
 *                - integration tests in CI
 *                - hackathon onboarding where the founder doesn't want
 *                  to expose participant wallet trails
 *
 *         CIRCULATION CONTROL: anonymous claim drains the same balance
 *         as regular claim; both share `dripAmount`. The Foundation gates
 *         issuance volume by deciding how many tickets to mint off-chain.
 *
 *         IMMUTABILITY: SIGNER is set at deploy and cannot be replaced.
 *         To rotate, redeploy. (Faucets are cheap; live ones can be
 *         retired by setting dripAmount to 0.)
 *
 *         GOTCHAS:
 *           - Tickets are ECDSA-signed (EIP-191) by SIGNER.
 *           - validUntil prevents indefinite ticket hoarding.
 *           - Commitment is the unique replay key; collisions are
 *             improbable with 32-byte randomness.
 *           - recipient is bound in the signed message — a ticket cannot
 *             be redirected after signing.
 */
contract AereFaucetV2 is Ownable, ReentrancyGuard {

    /* ================================ config ================================ */

    /// @notice Off-chain ticket signer for anonymous-mode claims.
    address public immutable SIGNER;

    uint256 public dripAmount = 0.05 ether;
    uint64  public cooldown   = 1 days;

    /* ================================= state ================================ */

    mapping(address => uint64) public lastClaim;
    /// @notice commitment → spent. One-shot per commitment.
    mapping(bytes32 => bool) public spentCommitments;

    /* ================================= events ================================ */

    event Dripped(address indexed to, uint256 amount);
    event AnonymousDripped(address indexed to, bytes32 indexed commitment, uint256 amount);
    event Funded(address indexed from, uint256 amount);
    event ConfigUpdated(uint256 dripAmount, uint64 cooldown);

    /* ================================= errors ================================ */

    error InCooldown();
    error Dry();
    error TransferFailed();
    error TicketExpired();
    error TicketSignerMismatch();
    error CommitmentSpent();
    error BadParams();

    /* ============================== constructor ============================= */

    constructor(address signer_) {
        require(signer_ != address(0), "zero-signer");
        SIGNER = signer_;
    }

    receive() external payable { emit Funded(msg.sender, msg.value); }

    /* ============================== regular claim ============================ */

    function claim() external nonReentrant {
        if (block.timestamp - lastClaim[msg.sender] < cooldown) revert InCooldown();
        if (address(this).balance < dripAmount) revert Dry();
        lastClaim[msg.sender] = uint64(block.timestamp);
        (bool ok, ) = msg.sender.call{value: dripAmount}("");
        if (!ok) revert TransferFailed();
        emit Dripped(msg.sender, dripAmount);
    }

    /* =========================== anonymous claim ============================= */

    /// @notice Burn a one-time ticket and drip to `recipient`. The ticket
    ///         is an EIP-191 signature by SIGNER over
    ///           keccak256(abi.encode("AereFaucetV2", chainid, address(this),
    ///                                commitment, recipient, validUntil))
    ///         msg.sender doesn't matter for permission — anyone can submit
    ///         (typical relayer pattern). Useful for gasless onboarding.
    function claimAnonymous(
        bytes32 commitment,
        address recipient,
        uint64 validUntil,
        bytes calldata sig
    ) external nonReentrant {
        if (recipient == address(0) || commitment == bytes32(0)) revert BadParams();
        if (block.timestamp > validUntil) revert TicketExpired();
        if (spentCommitments[commitment]) revert CommitmentSpent();
        if (address(this).balance < dripAmount) revert Dry();

        bytes32 inner = keccak256(abi.encode(
            "AereFaucetV2", block.chainid, address(this), commitment, recipient, validUntil
        ));
        bytes32 ethSigned = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", inner));
        address recovered = _recover(ethSigned, sig);
        if (recovered != SIGNER) revert TicketSignerMismatch();

        spentCommitments[commitment] = true;
        (bool ok, ) = recipient.call{value: dripAmount}("");
        if (!ok) revert TransferFailed();
        emit AnonymousDripped(recipient, commitment, dripAmount);
    }

    /* ============================== owner ops ============================== */

    function setConfig(uint256 _drip, uint64 _cooldown) external onlyOwner {
        if (_cooldown < 1 hours) revert BadParams();
        dripAmount = _drip;
        cooldown = _cooldown;
        emit ConfigUpdated(_drip, _cooldown);
    }

    /* ================================ views ================================ */

    function nextClaimAt(address user) external view returns (uint64) {
        return lastClaim[user] + cooldown;
    }

    /* ================================ internal ============================== */

    function _recover(bytes32 hash, bytes calldata sig) internal pure returns (address) {
        if (sig.length != 65) return address(0);
        bytes32 r; bytes32 s; uint8 v;
        assembly {
            r := calldataload(sig.offset)
            s := calldataload(add(sig.offset, 0x20))
            v := byte(0, calldataload(add(sig.offset, 0x40)))
        }
        if (v < 27) v += 27;
        return ecrecover(hash, v, r, s);
    }
}
