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

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

/**
 * @title AereQuestAttestor — EIP-712 one-shot quest ticket redeemer
 * @notice Clones the AereFaucetV2 one-shot-ticket pattern (EIP-712 flavoured):
 *         an authorized off-chain signer issues a signed ticket
 *         (account, questId, nonce, validUntil). Redeeming the ticket credits
 *         the points ledger with the quest's weight EXACTLY ONCE per
 *         (account, questId, nonce). Replays revert.
 *
 *   Design notes / why it is safe:
 *     - The ticket does NOT carry an amount. The credited amount is the quest
 *       `weight` read LIVE from the registry, so a leaked signer key cannot
 *       over-credit beyond registry config, and the Foundation can retune
 *       weights without re-issuing tickets.
 *     - The quest WINDOW and PER-USER CAP are enforced on-chain from the
 *       registry, independent of what the signer attests.
 *     - `requiresVerifiedHuman` quests additionally require the (settable)
 *       humanity oracle to return true for `account`.
 *     - Replay key = keccak256(account, questId, nonce). One-shot.
 *     - Anyone may submit the redeem tx (relayer / paymaster friendly); the
 *       signature, not msg.sender, authorizes the credit.
 *
 *   This contract must be added as an authorized attestor on the ledger by the
 *   Foundation post-deploy. Owner = Foundation multisig.
 */
contract AereQuestAttestor is Ownable, ReentrancyGuard, EIP712, IAereQuestAttestor {
    using ECDSA for bytes32;

    // keccak256("QuestTicket(address account,bytes32 questId,uint256 nonce,uint64 validUntil)")
    bytes32 public constant TICKET_TYPEHASH =
        keccak256("QuestTicket(address account,bytes32 questId,uint256 nonce,uint64 validUntil)");

    IAerePointsLedger  public immutable LEDGER;
    IAereQuestRegistry public immutable REGISTRY;

    /// @notice Foundation-managed set of authorized ticket signers.
    mapping(address => bool) public isSigner;

    /// @notice Settable humanity oracle for `requiresVerifiedHuman` quests.
    IHumanityOracle public humanityOracle;

    /// @notice One-shot replay guard. keccak256(account, questId, nonce) => used.
    mapping(bytes32 => bool) public ticketUsed;

    /// @notice Cumulative points an account has earned from a given quest
    ///         (for per-user-cap enforcement).
    mapping(address => mapping(bytes32 => uint256)) public earned;

    /// @notice Number of DISTINCT quests an account has been credited for at
    ///         least once (read by the referral registry for activation).
    mapping(address => uint256) public distinctQuestCount;

    event SignerSet(address indexed signer, bool allowed);
    event HumanityOracleSet(address indexed oracle);
    event QuestAttested(
        address indexed account,
        bytes32 indexed questId,
        uint256 nonce,
        uint256 weight
    );

    error BadSigner();
    error TicketExpired();
    error TicketAlreadyUsed();
    error QuestClosed();
    error PerUserCapReached();
    error NotVerifiedHuman();
    error HumanityOracleUnset();

    constructor(address ledger, address registry, address initialSigner)
        EIP712("AereQuestAttestor", "1")
    {
        require(ledger != address(0) && registry != address(0), "zero-ref");
        require(initialSigner != address(0), "zero-signer");
        LEDGER = IAerePointsLedger(ledger);
        REGISTRY = IAereQuestRegistry(registry);
        isSigner[initialSigner] = true;
        emit SignerSet(initialSigner, true);
    }

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

    function setSigner(address signer, bool allowed) external onlyOwner {
        require(signer != address(0), "zero-signer");
        isSigner[signer] = allowed;
        emit SignerSet(signer, allowed);
    }

    function setHumanityOracle(address oracle) external onlyOwner {
        humanityOracle = IHumanityOracle(oracle);
        emit HumanityOracleSet(oracle);
    }

    /* ------------------------------- redeem --------------------------------- */

    /// @notice Redeem a one-shot quest ticket, crediting `account` with the
    ///         quest weight. Callable by anyone (relayer/paymaster friendly).
    function redeem(
        address account,
        bytes32 questId,
        uint256 nonce,
        uint64  validUntil,
        bytes calldata signature
    ) external nonReentrant {
        if (block.timestamp > validUntil) revert TicketExpired();

        bytes32 replayKey = keccak256(abi.encodePacked(account, questId, nonce));
        if (ticketUsed[replayKey]) revert TicketAlreadyUsed();

        // Verify EIP-712 signature by an authorized signer.
        bytes32 structHash = keccak256(abi.encode(TICKET_TYPEHASH, account, questId, nonce, validUntil));
        address recovered = _hashTypedDataV4(structHash).recover(signature);
        if (!isSigner[recovered]) revert BadSigner();

        // Read live quest config.
        IAereQuestRegistry.Quest memory q = REGISTRY.getQuest(questId);
        if (!q.active) revert QuestClosed();
        if (block.timestamp < q.startTime) revert QuestClosed();
        if (q.endTime != 0 && block.timestamp > q.endTime) revert QuestClosed();

        if (q.requiresVerifiedHuman) {
            if (address(humanityOracle) == address(0)) revert HumanityOracleUnset();
            if (!humanityOracle.isVerifiedHuman(account)) revert NotVerifiedHuman();
        }

        // Enforce per-user cap.
        uint256 already = earned[account][questId];
        if (already + q.weight > q.perUserCap) revert PerUserCapReached();

        // Effects: burn the ticket, update accounting.
        ticketUsed[replayKey] = true;
        if (already == 0) {
            distinctQuestCount[account] += 1;
        }
        earned[account][questId] = already + q.weight;

        // Interaction: credit the ledger.
        LEDGER.credit(account, q.weight, questId);

        emit QuestAttested(account, questId, nonce, q.weight);
    }

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

    function domainSeparator() external view returns (bytes32) {
        return _domainSeparatorV4();
    }

    function isTicketUsed(address account, bytes32 questId, uint256 nonce) external view returns (bool) {
        return ticketUsed[keccak256(abi.encodePacked(account, questId, nonce))];
    }
}
