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

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

/**
 * @title AereCompliancePoolV2 — Privacy Pools + Travel Rule hybrid (bug-fixed challenge bond)
 * @notice Bug-fix redeploy of AereCompliancePool
 *         (0x79735c31F289F7A4d6Be3E02aaB70B544796D41d, 0 deposits). The
 *         Privacy-Pools construction, on-chain append-only Merkle tree, rolling
 *         root history, SP1 verifier reference, and Travel-Rule deposit path are
 *         BYTE-FOR-BYTE identical to V1. The SAME deployed SP1 verifier is
 *         reused, so the existing off-chain circuit still matches this pool's
 *         tree (TREE_DEPTH, ZERO_VALUE and keccak hashing are unchanged).
 *
 *         ─────────────────────────── WHY V2 ───────────────────────────
 *         F8 (challenge bond unrecoverable). V1's challengeAssociationRoot pulled
 *         a 100-token CHALLENGE_BOND but there was NO path to return it to an
 *         HONEST challenger:
 *           - if the Foundation dismissed the challenge, the bond was BURNED;
 *           - if the Foundation simply never dismissed, the root stayed
 *             "challenged" forever, the bond sat locked in the contract, and no
 *             refund function existed.
 *         So an honest challenger who was RIGHT always lost 100 tokens.
 *
 *         V2 fix. A challenge now resolves one of two ways:
 *           - DISMISSED (provably-bad challenge): the Foundation calls
 *             dismissAssociationRootChallenge WITHIN the dismissal window
 *             (== ASSOCIATION_ROOT_CHALLENGE_WINDOW after the challenge). The
 *             bond is BURNED to dEaD, exactly as V1. This preserves V1's
 *             anti-grief property: griefing a valid root still costs the bond.
 *           - UPHELD (Foundation failed to dismiss in time): after the dismissal
 *             window passes with the challenge still standing, anyone can call
 *             reclaimUpheldChallenge — the bond is REFUNDED to the challenger and
 *             the root is PERMANENTLY REJECTED (it can never publish). An honest
 *             challenger is made whole; a questionable association set is not
 *             published (the safe default for a compliance pool).
 *
 *         The dismissal window is bounded by ASSOCIATION_ROOT_CHALLENGE_WINDOW,
 *         which is now a constructor IMMUTABLE (was a constant) so a deployer-
 *         owned demo twin can exercise the full lifecycle quickly while the
 *         canonical pool keeps the production 24h value. No other behaviour
 *         changes.
 */
interface IPrivacyPoolVerifier {
    /// @param  publicInputs   [poolId, depositRoot, associationRoot, nullifierHash,
    ///                         recipientWord, feeRecipientWord, refundWord, feeWord]
    function verify(bytes calldata proof, uint256[] calldata publicInputs) external view returns (bool);
}

/// @dev Append-only zk-mixer Merkle tree with rolling-history root cache.
/// IDENTICAL to AereCompliancePool V1 (circuit compatibility).
abstract contract MerkleTreeWithHistoryV2 {
    uint32 public constant TREE_DEPTH = 20;
    uint32 public constant ROOT_HISTORY_SIZE = 256;

    bytes32 public constant ZERO_VALUE = keccak256("aere.compliance-pool.v1.empty-leaf");

    bytes32[TREE_DEPTH] public filledSubtrees;
    bytes32[TREE_DEPTH] public zeros;
    bytes32[ROOT_HISTORY_SIZE] public roots;
    uint32 public currentRootIndex;
    uint32 public nextLeafIndex;

    error TreeFull();

    event TreeNearCapacity(uint32 indexed leaves, uint32 capacity);

    constructor() {
        bytes32 z = ZERO_VALUE;
        for (uint32 i = 0; i < TREE_DEPTH; i++) {
            zeros[i] = z;
            filledSubtrees[i] = z;
            z = keccak256(abi.encodePacked(z, z));
        }
        // history intentionally left as zeros.
    }

    function _insert(bytes32 leaf) internal returns (uint32 idx) {
        idx = nextLeafIndex;
        if (idx >= uint32(1 << TREE_DEPTH)) revert TreeFull();
        nextLeafIndex = idx + 1;

        uint32 cur = idx;
        bytes32 left;
        bytes32 right;
        bytes32 node = leaf;
        for (uint32 i = 0; i < TREE_DEPTH; i++) {
            if (cur & 1 == 0) {
                left = node;
                right = zeros[i];
                filledSubtrees[i] = node;
            } else {
                left = filledSubtrees[i];
                right = node;
            }
            node = keccak256(abi.encodePacked(left, right));
            cur >>= 1;
        }

        uint32 newRootIdx = (currentRootIndex + 1) % ROOT_HISTORY_SIZE;
        currentRootIndex = newRootIdx;
        roots[newRootIdx] = node;

        uint32 cap = uint32(1 << TREE_DEPTH);
        uint32 leaves = nextLeafIndex;
        if (leaves == (cap * 90) / 100 || leaves == (cap * 99) / 100) {
            emit TreeNearCapacity(leaves, cap);
        }
    }

    function isKnownRoot(bytes32 root) public view returns (bool) {
        if (root == bytes32(0)) return false;
        uint32 i = currentRootIndex;
        do {
            if (roots[i] == root) return true;
            if (i == 0) i = ROOT_HISTORY_SIZE;
            i--;
        } while (i != currentRootIndex);
        return false;
    }

    function getLastRoot() external view returns (bytes32) {
        return roots[currentRootIndex];
    }
}

contract AereCompliancePoolV2 is MerkleTreeWithHistoryV2, ReentrancyGuard {

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

    IERC20 public immutable TOKEN;
    uint256 public immutable DENOMINATION;
    address public immutable FOUNDATION;
    IPrivacyPoolVerifier public immutable VERIFIER;
    bytes32 public immutable POOL_ID;

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

    /// association roots go through a challenge window.
    mapping(bytes32 => uint64) public associationRootPublishedAt;
    mapping(bytes32 => uint64) public associationRootProposedAt;
    mapping(bytes32 => bool)   public associationRootChallenged;
    /// F8: timestamp of the CURRENT active challenge (dismissal-window anchor).
    mapping(bytes32 => uint64) public associationRootChallengedAt;
    /// F8: a root permanently killed by an UPHELD challenge — can never publish.
    mapping(bytes32 => bool)   public associationRootRejected;
    mapping(bytes32 => uint8)  public associationRootDismissCount;
    mapping(bytes32 => address) public associationRootChallenger;
    uint8   public constant MAX_DISMISSALS_PER_ROOT = 2;
    uint256 public constant CHALLENGE_BOND = 100 ether; // 100 tokens
    address public constant CHALLENGE_BOND_BURN = 0x000000000000000000000000000000000000dEaD;
    IERC20  public immutable BOND_TOKEN; // bond paid in same TOKEN as DENOMINATION
    /// V2: immutable (was a 24h constant) so a demo twin can use a short window.
    uint64  public immutable ASSOCIATION_ROOT_CHALLENGE_WINDOW;

    mapping(bytes32 => bool) public spentNullifiers;
    mapping(bytes32 => bool) public commitmentSeen; // prevents duplicate commitment deposits
    uint256 public depositCount;

    mapping(address => bool) public isComplianceProvider;
    address[] public complianceProviderList;

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

    event Deposited(
        uint256 indexed depositId,
        address indexed sender,
        bytes32 commitment,
        bytes32 travelRuleHash,
        address indexed complianceProvider,
        string  travelRuleUri,
        uint32  leafIndex,
        bytes32 newRoot
    );
    event AssociationRootProposed(bytes32 indexed root, uint64 challengeEndsAt);
    event AssociationRootChallenged(bytes32 indexed root, address indexed challenger, string reason);
    event AssociationRootChallengeDismissed(bytes32 indexed root, address indexed challenger, uint8 dismissCount);
    /// F8: bond refunded to an honest challenger; root permanently rejected.
    event AssociationRootChallengeUpheld(bytes32 indexed root, address indexed challenger, uint256 bondRefunded);
    event AssociationRootRejected(bytes32 indexed root);
    event AssociationRootPublished(bytes32 indexed root);
    event Withdrawn(
        bytes32 indexed nullifierHash,
        address indexed recipient,
        address indexed feeRecipient,
        uint256 fee,
        uint256 refund,
        bytes32 depositRoot,
        bytes32 associationRoot
    );
    event ComplianceProviderSet(address indexed provider, bool allowed);

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

    error NotFoundation();
    error ZeroAddr();
    error NotAComplianceProvider();
    error AlreadySpent();
    error UnknownAssociationRoot();
    error UnknownDepositRoot();
    error InvalidProof();
    error WrongDenomination();
    error TransferFailed();
    error FeeTooLarge();
    error CommitmentReused();
    error ChallengeBondTransferFailed();
    error MaxDismissalsReached();
    error DismissalWindowClosed();  // F8: Foundation dismissed too late (challenge upheld)
    error DismissalWindowOpen();    // F8: reclaim attempted before the window elapsed
    error NotChallenged();          // F8

    modifier onlyFoundation() {
        if (msg.sender != FOUNDATION) revert NotFoundation();
        _;
    }

    constructor(
        IERC20 token,
        uint256 denomination,
        address foundation,
        IPrivacyPoolVerifier verifier,
        uint64 challengeWindow
    ) MerkleTreeWithHistoryV2() {
        if (address(token) == address(0) || foundation == address(0) || address(verifier) == address(0)) {
            revert ZeroAddr();
        }
        require(denomination > 0, "zero-denom");
        require(challengeWindow > 0, "zero-window");
        TOKEN = token;
        BOND_TOKEN = token;
        DENOMINATION = denomination;
        FOUNDATION = foundation;
        VERIFIER = verifier;
        ASSOCIATION_ROOT_CHALLENGE_WINDOW = challengeWindow;
        POOL_ID = keccak256(abi.encodePacked(address(this), block.chainid));
    }

    /* ============================ compliance provider ========================= */

    function setComplianceProvider(address provider, bool allowed) external onlyFoundation {
        if (provider == address(0)) revert ZeroAddr();
        bool prev = isComplianceProvider[provider];
        isComplianceProvider[provider] = allowed;
        if (!prev && allowed) complianceProviderList.push(provider);
        emit ComplianceProviderSet(provider, allowed);
    }

    /* ============================== deposit path ============================= */
    /* IDENTICAL to V1. */

    function deposit(
        bytes32 commitment,
        bytes32 travelRuleHash,
        address complianceProvider,
        string calldata travelRuleUri
    ) external nonReentrant {
        if (!isComplianceProvider[complianceProvider]) revert NotAComplianceProvider();
        if (commitment == bytes32(0) || commitment == ZERO_VALUE) revert CommitmentReused();
        bytes32 actualLeaf = keccak256(abi.encodePacked(commitment, msg.sender));
        if (commitmentSeen[actualLeaf]) revert CommitmentReused();
        commitmentSeen[actualLeaf] = true;

        if (!TOKEN.transferFrom(msg.sender, address(this), DENOMINATION)) revert TransferFailed();

        uint32 leafIndex = _insert(actualLeaf);
        bytes32 newRoot = roots[currentRootIndex];

        uint256 id = depositCount++;
        emit Deposited(id, msg.sender, commitment, travelRuleHash, complianceProvider, travelRuleUri, leafIndex, newRoot);
    }

    /* ========================== association set publish ====================== */

    function proposeAssociationRoot(bytes32 root) external onlyFoundation {
        if (associationRootPublishedAt[root] != 0) return;
        if (associationRootRejected[root]) return; // permanently killed by an upheld challenge
        if (associationRootProposedAt[root] == 0) {
            associationRootProposedAt[root] = uint64(block.timestamp);
            associationRootChallenged[root] = false;
            emit AssociationRootProposed(root, uint64(block.timestamp) + ASSOCIATION_ROOT_CHALLENGE_WINDOW);
        }
    }

    function challengeAssociationRoot(bytes32 root, string calldata reason) external {
        if (associationRootProposedAt[root] == 0) revert UnknownAssociationRoot();
        if (associationRootPublishedAt[root] != 0) revert UnknownAssociationRoot();
        if (associationRootRejected[root]) revert UnknownAssociationRoot();
        if (associationRootChallenged[root]) revert UnknownAssociationRoot(); // already challenged
        if (associationRootDismissCount[root] >= MAX_DISMISSALS_PER_ROOT) revert MaxDismissalsReached();
        if (!BOND_TOKEN.transferFrom(msg.sender, address(this), CHALLENGE_BOND)) revert ChallengeBondTransferFailed();
        associationRootChallenged[root] = true;
        associationRootChallenger[root] = msg.sender;
        associationRootChallengedAt[root] = uint64(block.timestamp);
        emit AssociationRootChallenged(root, msg.sender, reason);
    }

    /// @notice Foundation dismisses a PROVABLY-BAD challenge. Must be called
    ///         within ASSOCIATION_ROOT_CHALLENGE_WINDOW of the challenge; after
    ///         that the challenge is deemed UPHELD and can only be reclaimed.
    ///         Burns the bond (unchanged from V1) and restarts the publish
    ///         window so an honest Foundation can still publish a valid root.
    function dismissAssociationRootChallenge(bytes32 root) external onlyFoundation {
        if (!associationRootChallenged[root]) revert UnknownAssociationRoot();
        if (block.timestamp >= uint256(associationRootChallengedAt[root]) + ASSOCIATION_ROOT_CHALLENGE_WINDOW) {
            revert DismissalWindowClosed();
        }
        associationRootChallenged[root] = false;
        associationRootDismissCount[root] += 1;
        associationRootProposedAt[root] = uint64(block.timestamp); // reset publish window
        address challenger = associationRootChallenger[root];
        delete associationRootChallenger[root];
        delete associationRootChallengedAt[root];
        if (challenger != address(0)) {
            // burn the provably-bad challenger's bond (best-effort)
            BOND_TOKEN.transfer(CHALLENGE_BOND_BURN, CHALLENGE_BOND);
        }
        emit AssociationRootChallengeDismissed(root, challenger, associationRootDismissCount[root]);
        emit AssociationRootProposed(root, uint64(block.timestamp) + ASSOCIATION_ROOT_CHALLENGE_WINDOW);
    }

    /// @notice F8: if the Foundation did NOT dismiss within the window, the
    ///         challenge stands (UPHELD). Anyone can finalise it: the honest
    ///         challenger's bond is REFUNDED and the root is permanently
    ///         rejected (safe default — a questioned set is not published).
    function reclaimUpheldChallenge(bytes32 root) external nonReentrant {
        if (!associationRootChallenged[root]) revert NotChallenged();
        if (block.timestamp < uint256(associationRootChallengedAt[root]) + ASSOCIATION_ROOT_CHALLENGE_WINDOW) {
            revert DismissalWindowOpen();
        }
        address challenger = associationRootChallenger[root];
        associationRootChallenged[root] = false;
        associationRootRejected[root] = true;
        delete associationRootChallenger[root];
        delete associationRootChallengedAt[root];
        if (challenger != address(0)) {
            if (!BOND_TOKEN.transfer(challenger, CHALLENGE_BOND)) revert TransferFailed();
        }
        emit AssociationRootChallengeUpheld(root, challenger, CHALLENGE_BOND);
        emit AssociationRootRejected(root);
    }

    function publishAssociationRoot(bytes32 root) external {
        if (associationRootPublishedAt[root] != 0) return;
        if (associationRootRejected[root]) revert UnknownAssociationRoot();
        uint64 proposed = associationRootProposedAt[root];
        if (proposed == 0) revert UnknownAssociationRoot();
        if (associationRootChallenged[root]) revert UnknownAssociationRoot();
        if (block.timestamp < uint256(proposed) + ASSOCIATION_ROOT_CHALLENGE_WINDOW) {
            revert UnknownAssociationRoot();
        }
        associationRootPublishedAt[root] = uint64(block.timestamp);
        emit AssociationRootPublished(root);
    }

    /* ============================== withdraw path ============================ */
    /* IDENTICAL to V1. */

    function withdraw(
        bytes calldata proof,
        bytes32 depositRoot,
        bytes32 associationRoot,
        bytes32 nullifierHash,
        address recipient,
        address feeRecipient,
        uint256 fee,
        uint256 refund
    ) external nonReentrant {
        if (spentNullifiers[nullifierHash]) revert AlreadySpent();
        if (associationRootPublishedAt[associationRoot] == 0) revert UnknownAssociationRoot();
        if (!isKnownRoot(depositRoot)) revert UnknownDepositRoot();
        if (fee + refund > DENOMINATION) revert FeeTooLarge();

        uint256[] memory pubs = new uint256[](8);
        pubs[0] = uint256(POOL_ID);
        pubs[1] = uint256(depositRoot);
        pubs[2] = uint256(associationRoot);
        pubs[3] = uint256(nullifierHash);
        pubs[4] = uint256(uint160(recipient));
        pubs[5] = uint256(uint160(feeRecipient));
        pubs[6] = refund;
        pubs[7] = fee;

        if (!VERIFIER.verify(proof, pubs)) revert InvalidProof();

        spentNullifiers[nullifierHash] = true;

        uint256 toRecipient = DENOMINATION - fee - refund;
        if (toRecipient > 0 && !TOKEN.transfer(recipient, toRecipient)) revert TransferFailed();
        if (fee > 0 && !TOKEN.transfer(feeRecipient, fee)) revert TransferFailed();
        if (refund > 0 && !TOKEN.transfer(msg.sender, refund)) revert TransferFailed();

        emit Withdrawn(nullifierHash, recipient, feeRecipient, fee, refund, depositRoot, associationRoot);
    }

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

    function complianceProviders() external view returns (address[] memory) {
        return complianceProviderList;
    }

    function isAssociationRootValid(bytes32 root) external view returns (bool) {
        return associationRootPublishedAt[root] != 0;
    }

    function latestDepositRoot() external view returns (bytes32) {
        return roots[currentRootIndex];
    }
}
