//! Tempo charge method for server-side payment verification.
//!
//! This module provides [`ChargeMethod`] which implements the [`ChargeMethod`]
//! trait for **Tempo blockchain** payments using alloy's typed Provider.
//!
//! # Tempo-Specific
//!
//! This verifier is designed specifically for the Tempo network (chain ID 42431).
//! It uses Tempo-specific constants and expects a `TempoNetwork` provider.
//! For other chains (Base, Ethereum mainnet, etc.), use separate method modules.
//!
//! # Example
//!
//! ```ignore
//! use mpp_br::server::{tempo_provider, TempoChargeMethod};
//! use mpp_br::protocol::traits::ChargeMethod as ChargeMethodTrait;
//!
//! let provider = tempo_provider("https://rpc.moderato.tempo.xyz");
//! let method = TempoChargeMethod::new(provider);
//!
//! // In your server handler:
//! let receipt = method.verify(&credential, &request).await?;
//! assert!(receipt.is_success());
//! ```

use alloy::network::ReceiptResponse;
use alloy::primitives::{hex, keccak256, Address, Bytes, TxKind, B256, U256};
use alloy::providers::Provider;
use alloy::sol_types::SolCall;
use std::future::Future;
use std::sync::Arc;
use tempo_alloy::contracts::precompiles::{IStablecoinDEX, ITIP20, STABLECOIN_DEX_ADDRESS};
use tempo_alloy::TempoNetwork;

use crate::protocol::core::{PaymentCredential, Receipt};
use crate::protocol::intents::ChargeRequest;
use crate::protocol::traits::{ChargeMethod as ChargeMethodTrait, VerificationError};
use crate::store::Store;

use super::transfers::{get_request_transfers, Transfer};
use super::{TempoChargeExt, CHAIN_ID, INTENT_CHARGE, METHOD_NAME};

const MAX_FEE_PAYER_GAS_LIMIT: u64 = 1_000_000;

/// TIP-20 Transfer event topic: keccak256("Transfer(address,address,uint256)")
/// TIP-20 is Tempo's token standard (compatible with ERC-20 Transfer events).
const TRANSFER_EVENT_TOPIC: B256 =
    alloy::primitives::b256!("ddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef");

/// TIP-20 TransferWithMemo event topic: keccak256("TransferWithMemo(address,address,uint256,bytes32)")
const TRANSFER_WITH_MEMO_EVENT_TOPIC: B256 =
    alloy::primitives::b256!("57bc7354aa85aed339e000bccffabbc529466af35f0772c8f8ee1145927de7f0");

/// TIP-20 transfer function selector: bytes4(keccak256("transfer(address,uint256)"))
const TRANSFER_SELECTOR: [u8; 4] = [0xa9, 0x05, 0x9c, 0xbb];

/// TIP-20 transferWithMemo function selector: bytes4(keccak256("transferWithMemo(address,uint256,bytes32)"))
const TRANSFER_WITH_MEMO_SELECTOR: [u8; 4] = [0x95, 0x77, 0x7d, 0x59];

fn no_matching_payment_call_error() -> VerificationError {
    VerificationError::new("Invalid transaction: no matching payment call found".to_string())
}

fn disallowed_fee_payer_call_pattern_error() -> VerificationError {
    VerificationError::new("Fee-sponsored transaction contains disallowed call pattern".to_string())
}

fn call_selector(data: &Bytes) -> Option<[u8; 4]> {
    if data.len() < 4 {
        None
    } else {
        data[..4].try_into().ok()
    }
}

fn decode_approve_spender(call: &tempo_primitives::transaction::Call) -> Option<Address> {
    if call_selector(&call.input) != Some(ITIP20::approveCall::SELECTOR) || call.input.len() != 68 {
        return None;
    }

    Some(Address::from_slice(&call.input[16..36]))
}

fn transfer_call_offset(
    calls: &[tempo_primitives::transaction::Call],
) -> Result<usize, VerificationError> {
    let first_selector = calls.first().and_then(|call| call_selector(&call.input));

    if first_selector == Some(ITIP20::approveCall::SELECTOR) {
        let second_selector = calls.get(1).and_then(|call| call_selector(&call.input));
        if second_selector != Some(IStablecoinDEX::swapExactAmountOutCall::SELECTOR) {
            return Err(no_matching_payment_call_error());
        }
        Ok(2)
    } else if first_selector == Some(IStablecoinDEX::swapExactAmountOutCall::SELECTOR) {
        Err(no_matching_payment_call_error())
    } else {
        Ok(0)
    }
}

fn get_transfer_calls(
    calls: &[tempo_primitives::transaction::Call],
) -> Result<&[tempo_primitives::transaction::Call], VerificationError> {
    let offset = transfer_call_offset(calls)?;
    let transfer_calls = &calls[offset..];

    if transfer_calls.is_empty()
        || transfer_calls.iter().any(|call| {
            !matches!(
                call_selector(&call.input),
                Some(TRANSFER_SELECTOR) | Some(TRANSFER_WITH_MEMO_SELECTOR)
            )
        })
    {
        return Err(no_matching_payment_call_error());
    }

    Ok(transfer_calls)
}

fn validate_fee_payer_calls(
    calls: &[tempo_primitives::transaction::Call],
) -> Result<(), VerificationError> {
    if calls.is_empty() {
        return Err(disallowed_fee_payer_call_pattern_error());
    }

    let has_swap_prefix = calls.first().and_then(|call| call_selector(&call.input))
        == Some(ITIP20::approveCall::SELECTOR);

    if has_swap_prefix {
        if calls.get(1).and_then(|call| call_selector(&call.input))
            != Some(IStablecoinDEX::swapExactAmountOutCall::SELECTOR)
        {
            return Err(disallowed_fee_payer_call_pattern_error());
        }
    } else if calls.first().and_then(|call| call_selector(&call.input))
        == Some(IStablecoinDEX::swapExactAmountOutCall::SELECTOR)
    {
        return Err(disallowed_fee_payer_call_pattern_error());
    }

    let transfer_calls = &calls[if has_swap_prefix { 2 } else { 0 }..];
    if transfer_calls.is_empty()
        || transfer_calls.len() > 11
        || transfer_calls.iter().any(|call| {
            !matches!(
                call_selector(&call.input),
                Some(TRANSFER_SELECTOR) | Some(TRANSFER_WITH_MEMO_SELECTOR)
            )
        })
    {
        return Err(disallowed_fee_payer_call_pattern_error());
    }

    if has_swap_prefix {
        let approve_spender = decode_approve_spender(&calls[0])
            .ok_or_else(disallowed_fee_payer_call_pattern_error)?;
        if approve_spender != STABLECOIN_DEX_ADDRESS {
            return Err(VerificationError::new(
                "Fee-sponsored transaction approve spender is not the DEX".to_string(),
            ));
        }

        match &calls[1].to {
            TxKind::Call(address) if *address == STABLECOIN_DEX_ADDRESS => {}
            _ => {
                return Err(VerificationError::new(
                    "Fee-sponsored transaction swap target is not the DEX".to_string(),
                ));
            }
        }
    }

    Ok(())
}

/// Parse a hex string (with or without 0x prefix) into a B256.
fn parse_b256_hex(s: &str) -> Option<B256> {
    let s = s.strip_prefix("0x").unwrap_or(s);
    hex::decode(s).ok().and_then(|bytes| {
        if bytes.len() == 32 {
            Some(B256::from_slice(&bytes))
        } else {
            None
        }
    })
}

/// Tempo charge method for one-time payment verification.
///
/// This is a **Tempo-specific** payment verifier. It expects:
/// - `method="tempo"` in the credential
/// - Chain ID 42431 (Tempo Moderato) by default
/// - A provider configured for `TempoNetwork`
///
/// For other chains (Base, Ethereum), use or create separate method modules.
///
/// # Verification Flow
///
/// 1. Parse the credential payload (hash or signed transaction)
/// 2. For transaction credentials: validate call data before broadcasting
/// 3. Fetch the transaction receipt from Tempo RPC
/// 4. Verify transfer amount, recipient, and currency match
///
/// # Credential Types
///
/// - `hash`: Client already broadcast the transaction, provides tx hash
/// - `transaction`: Client provides signed transaction for server to broadcast
///
/// # Example
///
/// ```ignore
/// use mpp_br::server::{tempo_provider, TempoChargeMethod};
/// use mpp_br::protocol::traits::ChargeMethod as ChargeMethodTrait;
///
/// let provider = tempo_provider("https://rpc.moderato.tempo.xyz");
/// let method = TempoChargeMethod::new(provider);
///
/// // Verify a payment
/// let receipt = method.verify(&credential, &request).await?;
/// if receipt.is_success() {
///     println!("Payment verified: {}", receipt.reference);
/// }
/// ```
#[derive(Clone)]
pub struct ChargeMethod<P> {
    provider: Arc<P>,
    fee_payer_signer: Option<Arc<dyn alloy::signers::Signer + Send + Sync>>,
    store: Option<Arc<dyn Store>>,
}

impl<P> ChargeMethod<P>
where
    P: Provider<TempoNetwork> + Clone + Send + Sync + 'static,
{
    /// Create a new Tempo charge method with the given alloy Provider.
    ///
    /// The provider must be configured for `TempoNetwork`. Use
    /// [`tempo_provider`](crate::server::tempo_provider) to create one.
    pub fn new(provider: P) -> Self {
        Self {
            provider: Arc::new(provider),
            fee_payer_signer: None,
            store: None,
        }
    }

    /// Configure a store for transaction hash deduplication.
    ///
    /// When set, each verified transaction hash is recorded and subsequent
    /// attempts to replay the same hash are rejected.
    pub fn with_store(mut self, store: Arc<dyn Store>) -> Self {
        self.store = Some(store);
        self
    }

    /// Configure a fee payer signer for sponsoring transaction fees.
    ///
    /// When set, requests with `feePayer: true` will be accepted and
    /// broadcast. Without a fee payer signer, such requests are rejected.
    ///
    /// Accepts any type implementing alloy's [`Signer`](alloy::signers::Signer)
    /// trait — local private keys, KMS-backed signers, hardware wallets, etc.
    pub fn with_fee_payer(
        mut self,
        signer: impl alloy::signers::Signer + Send + Sync + 'static,
    ) -> Self {
        self.fee_payer_signer = Some(Arc::new(signer));
        self
    }

    /// Get a reference to the underlying provider.
    pub fn provider(&self) -> &P {
        &self.provider
    }

    /// Compute the expected transfers from a charge request (primary + splits).
    fn expected_transfers(charge: &ChargeRequest) -> Result<Vec<Transfer>, VerificationError> {
        get_request_transfers(charge)
            .map_err(|e| VerificationError::new(format!("Invalid charge request: {e}")))
    }

    async fn verify_hash(
        &self,
        tx_hash: &str,
        charge: &ChargeRequest,
    ) -> Result<Receipt, VerificationError> {
        let hash = tx_hash
            .parse::<B256>()
            .map_err(|e| VerificationError::new(format!("Invalid transaction hash: {}", e)))?;

        let replay_key = format!("mpp:charge:{:#x}", hash);

        if let Some(store) = &self.store {
            let seen = store
                .get(&replay_key)
                .await
                .map_err(|e| VerificationError::new(format!("Store error: {e}")))?;
            if seen.is_some() {
                return Err(VerificationError::new(
                    "Transaction hash has already been used.",
                ));
            }
        }

        let receipt = self
            .provider
            .get_transaction_receipt(hash)
            .await
            .map_err(|e| {
                VerificationError::network_error(format!("Failed to fetch receipt: {}", e))
            })?
            .ok_or_else(|| {
                VerificationError::pending(format!(
                    "Transaction {} not found or not yet mined",
                    tx_hash
                ))
            })?;

        if !receipt.status() {
            return Err(VerificationError::transaction_failed(format!(
                "Transaction {} reverted",
                tx_hash
            )));
        }

        let currency = charge.currency_address().map_err(|e| {
            VerificationError::new(format!("Invalid currency address in request: {}", e))
        })?;
        let expected = Self::expected_transfers(charge)?;

        // Tempo uses TIP-20 tokens exclusively (no native token transfers)
        self.verify_tip20_transfers(&receipt, currency, &expected)?;

        if let Some(store) = &self.store {
            store
                .put(&replay_key, serde_json::Value::Bool(true))
                .await
                .map_err(|e| VerificationError::new(format!("Failed to record tx hash: {e}")))?;
        }

        Ok(Receipt::success(METHOD_NAME, tx_hash))
    }

    /// Verify that all expected transfers are present in the receipt logs.
    ///
    /// Uses order-insensitive matching: sorts expected transfers by memo-specificity
    /// (transfers with memos matched first) and uses a `used` set to prevent
    /// double-matching.
    fn verify_tip20_transfers(
        &self,
        receipt: &<TempoNetwork as alloy::network::Network>::ReceiptResponse,
        currency: Address,
        expected: &[Transfer],
    ) -> Result<(), VerificationError> {
        let receipt_json = serde_json::to_value(receipt)
            .map_err(|e| VerificationError::new(format!("Failed to serialize receipt: {}", e)))?;

        let tx_sender = receipt.from();

        let logs = receipt_json
            .get("logs")
            .and_then(|v| v.as_array())
            .ok_or_else(|| VerificationError::new("Receipt has no logs".to_string()))?;

        // Sort expected transfers: memo-bearing first for greedy-safe matching
        let mut sorted_expected: Vec<(usize, &Transfer)> = expected.iter().enumerate().collect();
        sorted_expected.sort_by_key(|(_, t)| if t.memo.is_some() { 0 } else { 1 });

        let mut used_logs: Vec<bool> = vec![false; logs.len()];

        for (_, transfer) in &sorted_expected {
            if transfer.amount.is_zero() {
                return Err(VerificationError::new(
                    "Invalid amount: expected_amount must be greater than zero".to_string(),
                ));
            }
            if transfer.recipient.is_zero() {
                return Err(VerificationError::new(
                    "Invalid recipient: expected_recipient cannot be the zero address".to_string(),
                ));
            }

            let mut found = false;
            for (log_idx, log) in logs.iter().enumerate() {
                if used_logs[log_idx] {
                    continue;
                }

                let log_address = log
                    .get("address")
                    .and_then(|v| v.as_str())
                    .and_then(|s| s.parse::<Address>().ok());
                if log_address != Some(currency) {
                    continue;
                }

                let topics: Vec<&str> = log
                    .get("topics")
                    .and_then(|v| v.as_array())
                    .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
                    .unwrap_or_default();
                if topics.is_empty() {
                    continue;
                }

                let topic0 = match topics[0].parse::<B256>() {
                    Ok(t) => t,
                    Err(_) => continue,
                };

                if let Some(exp_memo) = &transfer.memo {
                    let exp_memo = B256::from(*exp_memo);
                    if topic0 == TRANSFER_WITH_MEMO_EVENT_TOPIC && topics.len() >= 3 {
                        let from_address = match topics[1].parse::<B256>() {
                            Ok(b) => Address::from_slice(&b[12..]),
                            Err(_) => continue,
                        };
                        if from_address != tx_sender {
                            continue;
                        }
                        let to_address = match topics[2].parse::<B256>() {
                            Ok(b) => Address::from_slice(&b[12..]),
                            Err(_) => continue,
                        };
                        if to_address != transfer.recipient {
                            continue;
                        }
                        let data = log.get("data").and_then(|v| v.as_str()).unwrap_or("0x");
                        if data.len() >= 130 {
                            let amount = match U256::from_str_radix(&data[2..66], 16) {
                                Ok(a) => a,
                                Err(_) => continue,
                            };
                            let memo_bytes = match parse_b256_hex(&data[66..130]) {
                                Some(m) => m,
                                None => continue,
                            };
                            if amount == transfer.amount && memo_bytes == exp_memo {
                                used_logs[log_idx] = true;
                                found = true;
                                break;
                            }
                        }
                    }
                } else if (topic0 == TRANSFER_EVENT_TOPIC
                    || topic0 == TRANSFER_WITH_MEMO_EVENT_TOPIC)
                    && topics.len() >= 3
                {
                    // No memo expected — accept both Transfer and TransferWithMemo events,
                    // matching only by recipient and amount (consistent with
                    // validate_transaction_transfers).
                    let from_address = match topics[1].parse::<B256>() {
                        Ok(b) => Address::from_slice(&b[12..]),
                        Err(_) => continue,
                    };
                    if from_address != tx_sender {
                        continue;
                    }
                    let to_address = match topics[2].parse::<B256>() {
                        Ok(b) => Address::from_slice(&b[12..]),
                        Err(_) => continue,
                    };
                    if to_address != transfer.recipient {
                        continue;
                    }
                    let data = log.get("data").and_then(|v| v.as_str()).unwrap_or("0x");
                    if data.len() >= 66 {
                        let amount = match U256::from_str_radix(&data[2..66], 16) {
                            Ok(a) => a,
                            Err(_) => continue,
                        };
                        if amount == transfer.amount {
                            used_logs[log_idx] = true;
                            found = true;
                            break;
                        }
                    }
                }
            }

            if !found {
                return Err(VerificationError::new(format!(
                    "No matching transfer event found for {} to {}{}",
                    transfer.amount,
                    transfer.recipient,
                    if transfer.memo.is_some() {
                        " with memo"
                    } else {
                        ""
                    }
                )));
            }
        }

        Ok(())
    }

    /// Validate that a transaction contains all expected payment calls (supports splits).
    ///
    /// Uses order-insensitive matching with memo-specificity sorting.
    fn validate_transaction_transfers(
        &self,
        tx_bytes: &[u8],
        currency: Address,
        expected: &[Transfer],
        expected_chain_id: u64,
        require_exact_calls: bool,
    ) -> Result<(), VerificationError> {
        if currency.is_zero() {
            return Err(VerificationError::new(
                "Invalid currency: currency cannot be the zero address".to_string(),
            ));
        }

        // Skip type byte (0x76) for Tempo transactions
        let tx_data = if !tx_bytes.is_empty()
            && tx_bytes[0] == tempo_primitives::transaction::TEMPO_TX_TYPE_ID
        {
            &tx_bytes[1..]
        } else {
            tx_bytes
        };

        let signed = tempo_primitives::AASigned::rlp_decode(&mut &tx_data[..])
            .map_err(|e| VerificationError::new(format!("Failed to decode transaction: {}", e)))?;
        let tx = signed.tx();

        if tx.chain_id != expected_chain_id {
            return Err(VerificationError::new(format!(
                "Transaction chain_id mismatch: expected {}, got {}",
                expected_chain_id, tx.chain_id
            )));
        }

        if require_exact_calls && tx.gas_limit > MAX_FEE_PAYER_GAS_LIMIT {
            return Err(VerificationError::new(format!(
                "Fee-sponsored transaction gas limit {} exceeds maximum {}",
                tx.gas_limit, MAX_FEE_PAYER_GAS_LIMIT
            )));
        }

        let transfer_calls = get_transfer_calls(&tx.calls)?;

        if require_exact_calls {
            validate_fee_payer_calls(&tx.calls)?;
        }

        // Sort expected transfers: memo-bearing first for greedy-safe matching
        let mut sorted_expected: Vec<(usize, &Transfer)> = expected.iter().enumerate().collect();
        sorted_expected.sort_by_key(|(_, t)| if t.memo.is_some() { 0 } else { 1 });

        let mut used_calls: Vec<bool> = vec![false; transfer_calls.len()];

        if require_exact_calls && transfer_calls.len() != expected.len() {
            return Err(VerificationError::new(format!(
                "Invalid transaction: no matching payment call found (expected {} transfer calls, got {})",
                expected.len(),
                transfer_calls.len()
            )));
        }

        for (_, transfer) in &sorted_expected {
            if transfer.amount.is_zero() {
                return Err(VerificationError::new(
                    "Invalid amount: expected_amount must be greater than zero".to_string(),
                ));
            }
            if transfer.recipient.is_zero() {
                return Err(VerificationError::new(
                    "Invalid recipient: expected_recipient cannot be the zero address".to_string(),
                ));
            }

            let mut found = false;

            for (call_idx, call) in transfer_calls.iter().enumerate() {
                if used_calls[call_idx] {
                    continue;
                }

                let call_to = match &call.to {
                    TxKind::Call(addr) => addr,
                    TxKind::Create => continue,
                };
                if call_to != &currency {
                    continue;
                }

                let data = &call.input;
                if data.len() < 4 {
                    continue;
                }

                let selector: [u8; 4] = data[..4].try_into().unwrap_or([0; 4]);

                if let Some(exp_memo) = &transfer.memo {
                    if selector == TRANSFER_WITH_MEMO_SELECTOR && data.len() == 100 {
                        let to = Address::from_slice(&data[16..36]);
                        let amount = U256::from_be_slice(&data[36..68]);
                        let memo_bytes = B256::from_slice(&data[68..100]);

                        if to == transfer.recipient
                            && amount == transfer.amount
                            && memo_bytes == B256::from(*exp_memo)
                        {
                            used_calls[call_idx] = true;
                            found = true;
                            break;
                        }
                    }
                } else {
                    // No memo — accept transfer or transferWithMemo
                    if selector == TRANSFER_SELECTOR && data.len() == 68 {
                        let to = Address::from_slice(&data[16..36]);
                        let amount = U256::from_be_slice(&data[36..68]);

                        if to == transfer.recipient && amount == transfer.amount {
                            used_calls[call_idx] = true;
                            found = true;
                            break;
                        }
                    }
                    if !found && selector == TRANSFER_WITH_MEMO_SELECTOR && data.len() == 100 {
                        let to = Address::from_slice(&data[16..36]);
                        let amount = U256::from_be_slice(&data[36..68]);

                        if to == transfer.recipient && amount == transfer.amount {
                            used_calls[call_idx] = true;
                            found = true;
                            break;
                        }
                    }
                }
            }

            if !found {
                return Err(VerificationError::new(format!(
                    "Invalid transaction: no matching transfer call found for {} to {}{}",
                    transfer.amount,
                    transfer.recipient,
                    if transfer.memo.is_some() {
                        " with memo"
                    } else {
                        ""
                    }
                )));
            }
        }

        if require_exact_calls && !used_calls.iter().all(|used| *used) {
            return Err(VerificationError::new(
                "Fee-sponsored transaction contains unexpected calls".to_string(),
            ));
        }

        Ok(())
    }

    async fn broadcast_transaction(
        &self,
        signed_tx: &str,
        charge: &ChargeRequest,
        expected_chain_id: u64,
    ) -> Result<B256, VerificationError> {
        let tx_bytes = signed_tx
            .parse::<Bytes>()
            .map_err(|e| VerificationError::new(format!("Invalid transaction bytes: {}", e)))?;

        let currency = charge.currency_address().map_err(|e| {
            VerificationError::new(format!("Invalid currency address in request: {}", e))
        })?;
        let expected = Self::expected_transfers(charge)?;

        // Fee payer co-signing replaces the placeholder fee_payer_signature
        // with a real co-signature and sets the fee_token.
        let final_tx_bytes = if charge.fee_payer() {
            let fee_payer_signer = self.fee_payer_signer.as_ref().ok_or_else(|| {
                VerificationError::new(
                    "feePayer requested but fee sponsorship is not configured on this server"
                        .to_string(),
                )
            })?;

            self.cosign_fee_payer_transaction(&tx_bytes, &**fee_payer_signer, currency)
                .await?
        } else {
            tx_bytes.to_vec()
        };

        self.validate_transaction_transfers(
            &final_tx_bytes,
            currency,
            &expected,
            expected_chain_id,
            charge.fee_payer(),
        )?;

        // Pre-broadcast dedup: hash the final tx bytes (after co-signing/validation)
        // and check/mark in store before broadcasting. Uses a separate namespace
        // from the post-broadcast hash-based dedup in verify_hash.
        if let Some(store) = &self.store {
            let tx_hash_pre = keccak256(&final_tx_bytes);
            let dedup_key = format!("mpp:charge:submission:{:#x}", tx_hash_pre);
            let seen = store
                .get(&dedup_key)
                .await
                .map_err(|e| VerificationError::new(format!("Store error: {e}")))?;
            if seen.is_some() {
                return Err(VerificationError::new(
                    "Transaction has already been submitted.",
                ));
            }
            store
                .put(&dedup_key, serde_json::Value::Bool(true))
                .await
                .map_err(|e| VerificationError::new(format!("Failed to record tx: {e}")))?;
        }

        let pending = self
            .provider
            .send_raw_transaction(&final_tx_bytes)
            .await
            .map_err(|e| VerificationError::network_error(format!("Failed to broadcast: {}", e)))?;

        // Wait for transaction to be mined
        let receipt = pending.get_receipt().await.map_err(|e| {
            VerificationError::network_error(format!("Failed to get receipt: {}", e))
        })?;

        if !receipt.status() {
            return Err(VerificationError::transaction_failed(format!(
                "Transaction {} reverted",
                receipt.transaction_hash()
            )));
        }

        // Verify the receipt contains the expected TIP-20 transfer(s)
        self.verify_tip20_transfers(&receipt, currency, &expected)?;

        // Record the on-chain tx hash for hash-based replay protection
        if let Some(store) = &self.store {
            let replay_key = format!("mpp:charge:{:#x}", receipt.transaction_hash());
            store
                .put(&replay_key, serde_json::Value::Bool(true))
                .await
                .map_err(|e| VerificationError::new(format!("Failed to record tx hash: {e}")))?;
        }

        Ok(receipt.transaction_hash())
    }

    /// Co-sign a fee payer transaction.
    ///
    /// Accepts a `0x78` fee payer envelope, recovers the sender via
    /// ecrecover, validates fee-payer invariants, then co-signs and
    /// returns a complete `0x76` transaction ready for broadcast.
    async fn cosign_fee_payer_transaction(
        &self,
        tx_bytes: &[u8],
        fee_payer_signer: &(dyn alloy::signers::Signer + Send + Sync),
        fee_token: Address,
    ) -> Result<Vec<u8>, VerificationError> {
        use super::fee_payer_envelope::{FeePayerEnvelope78, TEMPO_FEE_PAYER_ENVELOPE_TYPE_ID};
        use alloy::consensus::transaction::SignerRecoverable;
        use alloy::eips::Encodable2718;
        use tempo_primitives::transaction::TEMPO_EXPIRING_NONCE_KEY;

        if tx_bytes.is_empty() {
            return Err(VerificationError::new("Empty transaction bytes"));
        }

        let type_byte = tx_bytes[0];
        if type_byte != TEMPO_FEE_PAYER_ENVELOPE_TYPE_ID {
            return Err(VerificationError::new(format!(
                "Expected fee payer envelope (0x78), got 0x{type_byte:02x}"
            )));
        }

        let env = FeePayerEnvelope78::decode_envelope(tx_bytes)
            .map_err(|e| VerificationError::new(format!("Failed to decode 0x78 envelope: {e}")))?;

        let signed = env.to_recoverable_signed();
        let sender = signed
            .recover_signer()
            .map_err(|e| VerificationError::new(format!("Failed to recover sender: {e}")))?;
        if sender != env.sender {
            return Err(VerificationError::new(format!(
                "Sender mismatch in 0x78 envelope: envelope={:#x} recovered={:#x}",
                env.sender, sender
            )));
        }

        let tx = signed.tx();

        // Validate fee-payer invariants
        if tx.fee_payer_signature.is_none() {
            return Err(VerificationError::new(
                "Transaction must include fee_payer_signature placeholder",
            ));
        }

        if tx.fee_token.is_some() {
            return Err(VerificationError::new(
                "Fee payer transaction must not include fee_token (server sets it)",
            ));
        }

        if tx.nonce_key != TEMPO_EXPIRING_NONCE_KEY {
            return Err(VerificationError::new(
                "Fee payer envelope must use expiring nonce key (U256::MAX)",
            ));
        }

        match tx.valid_before {
            None => {
                return Err(VerificationError::new(
                    "Fee payer envelope must include valid_before",
                ));
            }
            Some(vb) => {
                let now = std::time::SystemTime::now()
                    .duration_since(std::time::UNIX_EPOCH)
                    .map_err(|e| VerificationError::new(format!("System clock error: {e}")))?
                    .as_secs();
                if vb <= now {
                    return Err(VerificationError::new(format!(
                        "Fee payer envelope expired: valid_before ({vb}) is not in the future (now={now})"
                    )));
                }
            }
        }

        // Rebuild the transaction with fee_token set and real fee_payer_signature
        let (tx, client_signature, _hash) = signed.into_parts();
        let mut tx = tx;
        tx.fee_token = Some(fee_token);
        tx.fee_payer_signature = None; // Clear placeholder before computing hash

        // Compute the fee payer signature hash and co-sign
        let fp_hash = tx.fee_payer_signature_hash(sender);
        let fp_sig = fee_payer_signer
            .sign_hash(&fp_hash)
            .await
            .map_err(|e| VerificationError::new(format!("Failed to co-sign transaction: {e}")))?;

        tx.fee_payer_signature = Some(fp_sig);

        let signed_tx = tx.into_signed(client_signature);
        Ok(signed_tx.encoded_2718())
    }
}

#[allow(clippy::manual_async_fn)]
impl<P> crate::protocol::traits::SessionMethod for ChargeMethod<P>
where
    P: Provider<TempoNetwork> + Clone + Send + Sync + 'static,
{
    fn method(&self) -> &str {
        METHOD_NAME
    }

    fn verify_session(
        &self,
        _credential: &PaymentCredential,
        _request: &crate::protocol::intents::SessionRequest,
    ) -> impl Future<Output = Result<Receipt, VerificationError>> + Send {
        async {
            Err(VerificationError::new(
                "Session verification not yet implemented — requires on-chain channel state lookup",
            ))
        }
    }
}

impl<P> ChargeMethodTrait for ChargeMethod<P>
where
    P: Provider<TempoNetwork> + Clone + Send + Sync + 'static,
{
    fn method(&self) -> &str {
        METHOD_NAME
    }

    fn verify(
        &self,
        credential: &PaymentCredential,
        request: &ChargeRequest,
    ) -> impl Future<Output = Result<Receipt, VerificationError>> + Send {
        let credential = credential.clone();
        let request = request.clone();
        let provider = Arc::clone(&self.provider);
        let fee_payer_signer = self.fee_payer_signer.clone();
        let store = self.store.clone();

        async move {
            let this = ChargeMethod {
                provider,
                fee_payer_signer,
                store,
            };

            if credential.challenge.method.as_str() != METHOD_NAME {
                return Err(VerificationError::credential_mismatch(format!(
                    "Method mismatch: expected {}, got {}",
                    METHOD_NAME, credential.challenge.method
                )));
            }
            if credential.challenge.intent.as_str() != INTENT_CHARGE {
                return Err(VerificationError::credential_mismatch(format!(
                    "Intent mismatch: expected {}, got {}",
                    INTENT_CHARGE, credential.challenge.intent
                )));
            }

            let expected_chain_id = request.chain_id().unwrap_or(CHAIN_ID);
            let actual_chain_id = this.provider.get_chain_id().await.map_err(|e| {
                VerificationError::network_error(format!("Failed to fetch chain ID: {}", e))
            })?;

            if actual_chain_id != expected_chain_id {
                return Err(VerificationError::chain_id_mismatch(format!(
                    "Chain ID mismatch: expected {}, got {}",
                    expected_chain_id, actual_chain_id
                )));
            }

            let charge_payload = credential.charge_payload().map_err(|e| {
                VerificationError::with_code(
                    format!("Expected charge payload: {}", e),
                    crate::protocol::traits::ErrorCode::InvalidCredential,
                )
            })?;

            if charge_payload.is_hash() {
                // Client already broadcast the transaction, verify by hash
                this.verify_hash(charge_payload.tx_hash().unwrap(), &request)
                    .await
            } else {
                // Client sent signed transaction, validate and broadcast it.
                // broadcast_transaction already does pre-broadcast dedup and
                // validates the receipt, so we do NOT call verify_hash here
                // (which would self-reject since the tx hash is already marked).
                let tx_hash = this
                    .broadcast_transaction(
                        charge_payload.signed_tx().unwrap(),
                        &request,
                        expected_chain_id,
                    )
                    .await?;
                Ok(Receipt::success(METHOD_NAME, format!("{:#x}", tx_hash)))
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::{super::MODERATO_CHAIN_ID, *};

    #[test]
    fn test_transfer_selector() {
        // transfer(address,uint256) = 0xa9059cbb
        assert_eq!(TRANSFER_SELECTOR, [0xa9, 0x05, 0x9c, 0xbb]);
    }

    #[test]
    fn test_transfer_with_memo_selector() {
        // transferWithMemo(address,uint256,bytes32) = 0x95777d59
        assert_eq!(TRANSFER_WITH_MEMO_SELECTOR, [0x95, 0x77, 0x7d, 0x59]);
    }

    #[test]
    fn test_parse_b256_hex_valid() {
        let valid = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
        let result = parse_b256_hex(valid);
        assert!(result.is_some());

        // Without 0x prefix
        let valid_no_prefix = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
        let result = parse_b256_hex(valid_no_prefix);
        assert!(result.is_some());
    }

    #[test]
    fn test_parse_b256_hex_invalid_length() {
        // Too short (only 3 bytes)
        let too_short = "0xabcdef";
        assert!(parse_b256_hex(too_short).is_none());

        // Too long (33 bytes)
        let too_long = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef00";
        assert!(parse_b256_hex(too_long).is_none());

        // Empty
        assert!(parse_b256_hex("").is_none());
        assert!(parse_b256_hex("0x").is_none());
    }

    #[test]
    fn test_parse_b256_hex_invalid_chars() {
        // Invalid hex characters
        let invalid = "0xgggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg";
        assert!(parse_b256_hex(invalid).is_none());
    }

    #[test]
    fn test_event_topics() {
        // Verify event topic constants match keccak256 of event signatures
        // Transfer(address,address,uint256)
        assert_eq!(
            TRANSFER_EVENT_TOPIC,
            alloy::primitives::b256!(
                "ddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
            )
        );

        // TransferWithMemo(address,address,uint256,bytes32)
        assert_eq!(
            TRANSFER_WITH_MEMO_EVENT_TOPIC,
            alloy::primitives::b256!(
                "57bc7354aa85aed339e000bccffabbc529466af35f0772c8f8ee1145927de7f0"
            )
        );
    }

    #[test]
    fn test_parse_b256_hex_case_insensitive() {
        // Test case insensitivity - both should parse to the same value
        let lower = "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
        let upper = "0xABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890";
        let mixed = "0xAbCdEf1234567890AbCdEf1234567890AbCdEf1234567890AbCdEf1234567890";

        let lower_result = parse_b256_hex(lower).unwrap();
        let upper_result = parse_b256_hex(upper).unwrap();
        let mixed_result = parse_b256_hex(mixed).unwrap();

        assert_eq!(lower_result, upper_result);
        assert_eq!(lower_result, mixed_result);
    }

    #[test]
    fn test_calldata_length_constants() {
        // Verify the expected calldata lengths match ABI encoding
        // transfer(address,uint256): 4 + 32 + 32 = 68
        // transferWithMemo(address,uint256,bytes32): 4 + 32 + 32 + 32 = 100
        const TRANSFER_CALLDATA_LEN: usize = 4 + 32 + 32;
        const TRANSFER_WITH_MEMO_CALLDATA_LEN: usize = 4 + 32 + 32 + 32;

        assert_eq!(TRANSFER_CALLDATA_LEN, 68);
        assert_eq!(TRANSFER_WITH_MEMO_CALLDATA_LEN, 100);
    }

    #[test]
    fn test_selector_parsing_short_input() {
        // Ensure short inputs don't panic - test with various short lengths
        let short_inputs: Vec<&[u8]> = vec![&[], &[0xa9], &[0xa9, 0x05], &[0xa9, 0x05, 0x9c]];

        for input in short_inputs {
            // This mimics the parsing logic - should not panic
            if input.len() >= 4 {
                let _selector: [u8; 4] = input[..4].try_into().unwrap_or([0; 4]);
            }
        }
    }

    #[test]
    fn test_zero_amount_rejected() {
        // Zero amounts should be rejected to prevent parse-failure bypasses
        let zero = U256::ZERO;
        assert!(zero.is_zero());

        let non_zero = U256::from(1u64);
        assert!(!non_zero.is_zero());
    }

    #[test]
    fn test_zero_address_detection() {
        // Zero addresses should be rejected to prevent parse-failure bypasses
        let zero_addr = Address::ZERO;
        assert!(zero_addr.is_zero());

        let valid_addr: Address = "0x742d35Cc6634C0532925a3b844Bc9e7595f3bB77"
            .parse()
            .unwrap();
        assert!(!valid_addr.is_zero());
    }

    #[test]
    fn test_chain_id_constant() {
        // Verify the Tempo mainnet chain ID constant
        assert_eq!(CHAIN_ID, 4217);
        // Verify the Tempo Moderato testnet chain ID constant
        assert_eq!(MODERATO_CHAIN_ID, 42431);
    }

    #[test]
    fn test_fee_payer_not_configured() {
        // When fee_payer_signer is None, the error message should indicate
        // that fee sponsorship is not configured.
        let error = VerificationError::new(
            "feePayer requested but fee sponsorship is not configured on this server",
        );
        assert!(error
            .to_string()
            .contains("fee sponsorship is not configured"));
    }

    // ==================== Fee payer co-sign unit tests ====================

    /// Helper: build a valid TempoTransaction for fee payer tests.
    fn make_fee_payer_tx(valid_before_secs_from_now: u64) -> tempo_primitives::TempoTransaction {
        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_secs();

        tempo_primitives::TempoTransaction {
            chain_id: CHAIN_ID,
            nonce: 0,
            nonce_key: U256::MAX,
            gas_limit: 1_000_000,
            max_fee_per_gas: 1_000_000_000,
            max_priority_fee_per_gas: 1_000_000_000,
            fee_token: None,
            fee_payer_signature: Some(alloy::primitives::Signature::new(
                U256::ZERO,
                U256::ZERO,
                false,
            )),
            valid_before: Some(now + valid_before_secs_from_now),
            valid_after: None,
            calls: vec![tempo_primitives::transaction::Call {
                to: TxKind::Call(Address::repeat_byte(0x20)),
                value: U256::ZERO,
                input: Bytes::from(vec![0xa9, 0x05, 0x9c, 0xbb]), // transfer selector
            }],
            access_list: Default::default(),
            tempo_authorization_list: vec![],
            key_authorization: None,
        }
    }

    fn make_transfer_input(recipient: Address, amount: U256) -> Bytes {
        let mut data = Vec::with_capacity(68);
        data.extend_from_slice(&TRANSFER_SELECTOR);
        data.extend_from_slice(&[0u8; 12]);
        data.extend_from_slice(recipient.as_slice());

        let mut amount_bytes = [0u8; 32];
        amount.to_be_bytes::<32>().clone_into(&mut amount_bytes);
        data.extend_from_slice(&amount_bytes);
        Bytes::from(data)
    }

    fn make_approve_input(spender: Address, amount: U256) -> Bytes {
        Bytes::from(ITIP20::approveCall { spender, amount }.abi_encode())
    }

    fn make_swap_input(token_in: Address, token_out: Address, amount_out: u128) -> Bytes {
        Bytes::from(
            IStablecoinDEX::swapExactAmountOutCall {
                tokenIn: token_in,
                tokenOut: token_out,
                amountOut: amount_out,
                maxAmountIn: amount_out,
            }
            .abi_encode(),
        )
    }

    fn encode_signed_tx(
        calls: Vec<tempo_primitives::transaction::Call>,
        gas_limit: u64,
    ) -> Vec<u8> {
        use alloy::eips::Encodable2718;
        use alloy::signers::SignerSync;

        let signer = alloy::signers::local::PrivateKeySigner::random();
        let tx = tempo_primitives::TempoTransaction {
            chain_id: CHAIN_ID,
            nonce: 0,
            nonce_key: U256::MAX,
            gas_limit,
            max_fee_per_gas: 1_000_000_000,
            max_priority_fee_per_gas: 1_000_000_000,
            fee_token: Some(Address::repeat_byte(0x20)),
            fee_payer_signature: None,
            valid_before: None,
            valid_after: None,
            calls,
            access_list: Default::default(),
            tempo_authorization_list: vec![],
            key_authorization: None,
        };

        let signature: tempo_primitives::transaction::TempoSignature =
            signer.sign_hash_sync(&tx.signature_hash()).unwrap().into();

        tx.into_signed(signature).encoded_2718()
    }

    /// Helper: sign a tx and encode as a 0x78 fee payer envelope.
    fn sign_and_encode_0x78(
        tx: tempo_primitives::TempoTransaction,
        signer: &alloy::signers::local::PrivateKeySigner,
    ) -> Vec<u8> {
        use super::super::{FeePayerEnvelope78, TEMPO_FEE_PAYER_ENVELOPE_TYPE_ID};
        use alloy::signers::SignerSync;

        let sig_hash = tx.signature_hash();
        let sig = signer.sign_hash_sync(&sig_hash).unwrap();
        let signature: tempo_primitives::transaction::TempoSignature = sig.into();
        let encoded =
            FeePayerEnvelope78::from_signing_tx(tx, signer.address(), signature).encoded_envelope();
        assert_eq!(encoded[0], TEMPO_FEE_PAYER_ENVELOPE_TYPE_ID);
        encoded
    }

    /// Round-trip: sign 0x78 envelope → cosign_fee_payer_transaction
    /// succeeds and produces a valid co-signed 0x76 transaction.
    #[tokio::test]
    async fn test_fee_payer_round_trip_0x78_envelope() {
        use super::super::{FeePayerEnvelope78, TEMPO_FEE_PAYER_ENVELOPE_TYPE_ID};
        use alloy::eips::Decodable2718;
        use alloy::signers::SignerSync;

        let client_signer = alloy::signers::local::PrivateKeySigner::random();
        let fee_payer_signer = alloy::signers::local::PrivateKeySigner::random();
        let fee_token: Address = "0x20c0000000000000000000000000000000000000"
            .parse()
            .unwrap();

        let tx = make_fee_payer_tx(60);

        // Encode as a 0x78 fee payer envelope (sender address in the fee_payer slot).
        let sig_hash = tx.signature_hash();
        let sig = client_signer.sign_hash_sync(&sig_hash).unwrap();
        let signature: tempo_primitives::transaction::TempoSignature = sig.into();

        let encoded = FeePayerEnvelope78::from_signing_tx(tx, client_signer.address(), signature)
            .encoded_envelope();
        assert_eq!(encoded[0], TEMPO_FEE_PAYER_ENVELOPE_TYPE_ID);

        let provider =
            alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
                .connect_http("http://127.0.0.1:1".parse().unwrap());

        let method = ChargeMethod::new(provider).with_fee_payer(fee_payer_signer);

        let result = method.cosign_fee_payer_transaction(
            &encoded,
            &**method.fee_payer_signer.as_ref().unwrap(),
            fee_token,
        );

        let co_signed = result
            .await
            .expect("cosign should succeed for valid 0x78 envelope");

        // Result should be a valid 0x76 transaction
        assert_eq!(
            co_signed[0],
            tempo_primitives::transaction::TEMPO_TX_TYPE_ID,
            "co-signed output should be 0x76"
        );

        // It should be decodable by AASigned
        let signed = tempo_primitives::AASigned::decode_2718(&mut &co_signed[..])
            .expect("co-signed tx should be decodable as AASigned");

        let decoded_tx = signed.tx();
        assert_eq!(decoded_tx.chain_id, CHAIN_ID);
        assert_eq!(decoded_tx.nonce_key, U256::MAX);
        assert_eq!(decoded_tx.fee_token, Some(fee_token));
        assert!(decoded_tx.fee_payer_signature.is_some());
        assert!(decoded_tx.valid_before.is_some());
    }

    #[test]
    fn test_validate_transaction_transfers_rejects_unexpected_fee_payer_calls() {
        let provider =
            alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
                .connect_http("http://127.0.0.1:1".parse().unwrap());
        let method = ChargeMethod::new(provider);

        let currency = Address::repeat_byte(0x20);
        let recipient = Address::repeat_byte(0x33);
        let expected = vec![Transfer {
            amount: U256::from(100u64),
            recipient,
            memo: None,
        }];

        let tx_bytes = encode_signed_tx(
            vec![
                tempo_primitives::transaction::Call {
                    to: TxKind::Call(currency),
                    value: U256::ZERO,
                    input: make_transfer_input(recipient, U256::from(100u64)),
                },
                tempo_primitives::transaction::Call {
                    to: TxKind::Call(Address::repeat_byte(0x44)),
                    value: U256::ZERO,
                    input: Bytes::from(vec![0u8; 4]),
                },
            ],
            MAX_FEE_PAYER_GAS_LIMIT,
        );

        let error = method
            .validate_transaction_transfers(&tx_bytes, currency, &expected, CHAIN_ID, true)
            .unwrap_err();

        assert!(
            error.to_string().contains("disallowed call pattern")
                || error.to_string().contains("no matching payment call")
        );
    }

    #[test]
    fn test_validate_transaction_transfers_accepts_fee_payer_approve_swap_prefix() {
        let provider =
            alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
                .connect_http("http://127.0.0.1:1".parse().unwrap());
        let method = ChargeMethod::new(provider);

        let currency = Address::repeat_byte(0x20);
        let recipient = Address::repeat_byte(0x33);
        let token_in = Address::repeat_byte(0x11);
        let expected = vec![Transfer {
            amount: U256::from(100u64),
            recipient,
            memo: None,
        }];

        let tx_bytes = encode_signed_tx(
            vec![
                tempo_primitives::transaction::Call {
                    to: TxKind::Call(token_in),
                    value: U256::ZERO,
                    input: make_approve_input(STABLECOIN_DEX_ADDRESS, U256::from(100u64)),
                },
                tempo_primitives::transaction::Call {
                    to: TxKind::Call(STABLECOIN_DEX_ADDRESS),
                    value: U256::ZERO,
                    input: make_swap_input(token_in, currency, 100),
                },
                tempo_primitives::transaction::Call {
                    to: TxKind::Call(currency),
                    value: U256::ZERO,
                    input: make_transfer_input(recipient, U256::from(100u64)),
                },
            ],
            MAX_FEE_PAYER_GAS_LIMIT,
        );

        method
            .validate_transaction_transfers(&tx_bytes, currency, &expected, CHAIN_ID, true)
            .unwrap();
    }

    #[test]
    fn test_validate_transaction_transfers_accepts_fee_payer_approve_swap_prefix_with_splits() {
        let provider =
            alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
                .connect_http("http://127.0.0.1:1".parse().unwrap());
        let method = ChargeMethod::new(provider);

        let currency = Address::repeat_byte(0x20);
        let primary_recipient = Address::repeat_byte(0x33);
        let split_recipient = Address::repeat_byte(0x34);
        let token_in = Address::repeat_byte(0x11);
        let expected = vec![
            Transfer {
                amount: U256::from(90u64),
                recipient: primary_recipient,
                memo: None,
            },
            Transfer {
                amount: U256::from(10u64),
                recipient: split_recipient,
                memo: None,
            },
        ];

        let tx_bytes = encode_signed_tx(
            vec![
                tempo_primitives::transaction::Call {
                    to: TxKind::Call(token_in),
                    value: U256::ZERO,
                    input: make_approve_input(STABLECOIN_DEX_ADDRESS, U256::from(100u64)),
                },
                tempo_primitives::transaction::Call {
                    to: TxKind::Call(STABLECOIN_DEX_ADDRESS),
                    value: U256::ZERO,
                    input: make_swap_input(token_in, currency, 100),
                },
                tempo_primitives::transaction::Call {
                    to: TxKind::Call(currency),
                    value: U256::ZERO,
                    input: make_transfer_input(primary_recipient, U256::from(90u64)),
                },
                tempo_primitives::transaction::Call {
                    to: TxKind::Call(currency),
                    value: U256::ZERO,
                    input: make_transfer_input(split_recipient, U256::from(10u64)),
                },
            ],
            MAX_FEE_PAYER_GAS_LIMIT,
        );

        method
            .validate_transaction_transfers(&tx_bytes, currency, &expected, CHAIN_ID, true)
            .unwrap();
    }

    #[test]
    fn test_validate_transaction_transfers_rejects_fee_payer_swap_without_approve() {
        let provider =
            alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
                .connect_http("http://127.0.0.1:1".parse().unwrap());
        let method = ChargeMethod::new(provider);

        let currency = Address::repeat_byte(0x20);
        let recipient = Address::repeat_byte(0x33);
        let token_in = Address::repeat_byte(0x11);
        let expected = vec![Transfer {
            amount: U256::from(100u64),
            recipient,
            memo: None,
        }];

        let tx_bytes = encode_signed_tx(
            vec![
                tempo_primitives::transaction::Call {
                    to: TxKind::Call(STABLECOIN_DEX_ADDRESS),
                    value: U256::ZERO,
                    input: make_swap_input(token_in, currency, 100),
                },
                tempo_primitives::transaction::Call {
                    to: TxKind::Call(currency),
                    value: U256::ZERO,
                    input: make_transfer_input(recipient, U256::from(100u64)),
                },
            ],
            MAX_FEE_PAYER_GAS_LIMIT,
        );

        let error = method
            .validate_transaction_transfers(&tx_bytes, currency, &expected, CHAIN_ID, true)
            .unwrap_err();

        assert!(
            error.to_string().contains("disallowed call pattern")
                || error.to_string().contains("no matching payment call")
        );
    }

    #[test]
    fn test_validate_transaction_transfers_rejects_fee_payer_wrong_approve_spender() {
        let provider =
            alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
                .connect_http("http://127.0.0.1:1".parse().unwrap());
        let method = ChargeMethod::new(provider);

        let currency = Address::repeat_byte(0x20);
        let recipient = Address::repeat_byte(0x33);
        let token_in = Address::repeat_byte(0x11);
        let expected = vec![Transfer {
            amount: U256::from(100u64),
            recipient,
            memo: None,
        }];

        let tx_bytes = encode_signed_tx(
            vec![
                tempo_primitives::transaction::Call {
                    to: TxKind::Call(token_in),
                    value: U256::ZERO,
                    input: make_approve_input(Address::repeat_byte(0x99), U256::from(100u64)),
                },
                tempo_primitives::transaction::Call {
                    to: TxKind::Call(STABLECOIN_DEX_ADDRESS),
                    value: U256::ZERO,
                    input: make_swap_input(token_in, currency, 100),
                },
                tempo_primitives::transaction::Call {
                    to: TxKind::Call(currency),
                    value: U256::ZERO,
                    input: make_transfer_input(recipient, U256::from(100u64)),
                },
            ],
            MAX_FEE_PAYER_GAS_LIMIT,
        );

        let error = method
            .validate_transaction_transfers(&tx_bytes, currency, &expected, CHAIN_ID, true)
            .unwrap_err();

        assert!(error.to_string().contains("approve spender is not the DEX"));
    }

    #[test]
    fn test_validate_transaction_transfers_rejects_fee_payer_wrong_swap_target() {
        let provider =
            alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
                .connect_http("http://127.0.0.1:1".parse().unwrap());
        let method = ChargeMethod::new(provider);

        let currency = Address::repeat_byte(0x20);
        let recipient = Address::repeat_byte(0x33);
        let token_in = Address::repeat_byte(0x11);
        let expected = vec![Transfer {
            amount: U256::from(100u64),
            recipient,
            memo: None,
        }];

        let tx_bytes = encode_signed_tx(
            vec![
                tempo_primitives::transaction::Call {
                    to: TxKind::Call(token_in),
                    value: U256::ZERO,
                    input: make_approve_input(STABLECOIN_DEX_ADDRESS, U256::from(100u64)),
                },
                tempo_primitives::transaction::Call {
                    to: TxKind::Call(Address::repeat_byte(0x98)),
                    value: U256::ZERO,
                    input: make_swap_input(token_in, currency, 100),
                },
                tempo_primitives::transaction::Call {
                    to: TxKind::Call(currency),
                    value: U256::ZERO,
                    input: make_transfer_input(recipient, U256::from(100u64)),
                },
            ],
            MAX_FEE_PAYER_GAS_LIMIT,
        );

        let error = method
            .validate_transaction_transfers(&tx_bytes, currency, &expected, CHAIN_ID, true)
            .unwrap_err();

        assert!(error.to_string().contains("swap target is not the DEX"));
    }

    #[test]
    fn test_validate_transaction_transfers_rejects_fee_payer_gas_limit_above_max() {
        let provider =
            alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
                .connect_http("http://127.0.0.1:1".parse().unwrap());
        let method = ChargeMethod::new(provider);

        let currency = Address::repeat_byte(0x20);
        let recipient = Address::repeat_byte(0x33);
        let expected = vec![Transfer {
            amount: U256::from(100u64),
            recipient,
            memo: None,
        }];

        let tx_bytes = encode_signed_tx(
            vec![tempo_primitives::transaction::Call {
                to: TxKind::Call(currency),
                value: U256::ZERO,
                input: make_transfer_input(recipient, U256::from(100u64)),
            }],
            MAX_FEE_PAYER_GAS_LIMIT + 1,
        );

        let error = method
            .validate_transaction_transfers(&tx_bytes, currency, &expected, CHAIN_ID, true)
            .unwrap_err();

        assert!(error.to_string().contains("exceeds maximum"));
    }

    /// cosign_fee_payer_transaction rejects txs with wrong nonce_key.
    #[tokio::test]
    async fn test_cosign_rejects_wrong_nonce_key() {
        let client_signer = alloy::signers::local::PrivateKeySigner::random();
        let fee_payer_signer = alloy::signers::local::PrivateKeySigner::random();
        let fee_token: Address = "0x20c0000000000000000000000000000000000000"
            .parse()
            .unwrap();

        let mut tx = make_fee_payer_tx(60);
        tx.nonce_key = U256::ZERO; // Wrong — should be U256::MAX

        let encoded = sign_and_encode_0x78(tx, &client_signer);

        let provider =
            alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
                .connect_http("http://127.0.0.1:1".parse().unwrap());

        let method = ChargeMethod::new(provider).with_fee_payer(fee_payer_signer);

        let result = method.cosign_fee_payer_transaction(
            &encoded,
            &**method.fee_payer_signer.as_ref().unwrap(),
            fee_token,
        );

        let err = result.await.expect_err("should reject wrong nonce_key");
        assert!(
            err.to_string().contains("expiring nonce key"),
            "error should mention expiring nonce key, got: {err}"
        );
    }

    /// cosign_fee_payer_transaction rejects txs without valid_before.
    #[tokio::test]
    async fn test_cosign_rejects_missing_valid_before() {
        let client_signer = alloy::signers::local::PrivateKeySigner::random();
        let fee_payer_signer = alloy::signers::local::PrivateKeySigner::random();
        let fee_token: Address = "0x20c0000000000000000000000000000000000000"
            .parse()
            .unwrap();

        let mut tx = make_fee_payer_tx(60);
        tx.valid_before = None; // Missing

        let encoded = sign_and_encode_0x78(tx, &client_signer);

        let provider =
            alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
                .connect_http("http://127.0.0.1:1".parse().unwrap());

        let method = ChargeMethod::new(provider).with_fee_payer(fee_payer_signer);

        let result = method.cosign_fee_payer_transaction(
            &encoded,
            &**method.fee_payer_signer.as_ref().unwrap(),
            fee_token,
        );

        let err = result
            .await
            .expect_err("should reject missing valid_before");
        assert!(
            err.to_string().contains("must include valid_before"),
            "error should mention valid_before, got: {err}"
        );
    }

    /// cosign_fee_payer_transaction rejects txs with expired valid_before.
    #[tokio::test]
    async fn test_cosign_rejects_expired_valid_before() {
        let client_signer = alloy::signers::local::PrivateKeySigner::random();
        let fee_payer_signer = alloy::signers::local::PrivateKeySigner::random();
        let fee_token: Address = "0x20c0000000000000000000000000000000000000"
            .parse()
            .unwrap();

        // Build a tx with valid_before in the past
        let past = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_secs()
            - 10;

        let mut tx = make_fee_payer_tx(60);
        tx.valid_before = Some(past);

        let encoded = sign_and_encode_0x78(tx, &client_signer);

        let provider =
            alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
                .connect_http("http://127.0.0.1:1".parse().unwrap());

        let method = ChargeMethod::new(provider).with_fee_payer(fee_payer_signer);

        let result = method.cosign_fee_payer_transaction(
            &encoded,
            &**method.fee_payer_signer.as_ref().unwrap(),
            fee_token,
        );

        let err = result
            .await
            .expect_err("should reject expired valid_before");
        assert!(
            err.to_string().contains("expired"),
            "error should mention expiration, got: {err}"
        );
    }

    /// cosign_fee_payer_transaction rejects empty input.
    #[tokio::test]
    async fn test_cosign_rejects_empty_input() {
        let fee_payer_signer = alloy::signers::local::PrivateKeySigner::random();
        let fee_token: Address = "0x20c0000000000000000000000000000000000000"
            .parse()
            .unwrap();

        let provider =
            alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
                .connect_http("http://127.0.0.1:1".parse().unwrap());

        let method = ChargeMethod::new(provider).with_fee_payer(fee_payer_signer);

        let result = method.cosign_fee_payer_transaction(
            &[],
            &**method.fee_payer_signer.as_ref().unwrap(),
            fee_token,
        );

        let err = result.await.expect_err("should reject empty input");
        assert!(
            err.to_string().contains("Empty transaction bytes"),
            "error should mention empty, got: {err}"
        );
    }

    /// cosign_fee_payer_transaction rejects non-0x78 type byte.
    #[tokio::test]
    async fn test_cosign_rejects_wrong_type_byte() {
        let fee_payer_signer = alloy::signers::local::PrivateKeySigner::random();
        let fee_token: Address = "0x20c0000000000000000000000000000000000000"
            .parse()
            .unwrap();

        let provider =
            alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
                .connect_http("http://127.0.0.1:1".parse().unwrap());

        let method = ChargeMethod::new(provider).with_fee_payer(fee_payer_signer);

        let result = method.cosign_fee_payer_transaction(
            &[0x79, 0xc0], // wrong type byte
            &**method.fee_payer_signer.as_ref().unwrap(),
            fee_token,
        );

        let err = result.await.expect_err("should reject wrong type");
        assert!(
            err.to_string()
                .contains("Expected fee payer envelope (0x78)"),
            "error should mention 0x78, got: {err}"
        );
    }

    #[tokio::test]
    async fn test_store_rejects_replayed_hash() {
        use crate::store::{MemoryStore, Store};

        let store = Arc::new(MemoryStore::new());
        let hash = "0xabc123def456";

        // Simulate first successful verification: record the hash
        let key = format!("mpp:charge:{hash}");
        store
            .put(&key, serde_json::Value::Bool(true))
            .await
            .unwrap();

        // Verify the hash is now in the store
        let seen = store.get(&key).await.unwrap();
        assert!(seen.is_some(), "hash should be recorded after first use");

        // A second lookup should find it (replay detected)
        let seen_again = store.get(&key).await.unwrap();
        assert!(
            seen_again.is_some(),
            "replayed hash should be detected via store"
        );
    }

    #[tokio::test]
    async fn test_store_allows_unseen_hash() {
        use crate::store::{MemoryStore, Store};

        let store = Arc::new(MemoryStore::new());

        // A hash that was never recorded should not be found
        let key = "mpp:charge:0xnever_seen";
        let seen = store.get(key).await.unwrap();
        assert!(seen.is_none(), "unseen hash should not be in store");
    }

    #[tokio::test]
    async fn test_store_dedup_case_insensitive() {
        use crate::store::{MemoryStore, Store};

        let store = Arc::new(MemoryStore::new());

        // Simulate the canonical key construction used by verify_hash:
        // parse to B256, then format with {:#x} for canonical lowercase 0x-prefixed output.
        let mixed_case = "0xABCdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
        let hash = mixed_case.parse::<B256>().unwrap();
        let key1 = format!("mpp:charge:{:#x}", hash);
        store
            .put(&key1, serde_json::Value::Bool(true))
            .await
            .unwrap();

        // Same hash submitted with different casing produces same canonical key
        let lower_case = "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
        let hash2 = lower_case.parse::<B256>().unwrap();
        let key2 = format!("mpp:charge:{:#x}", hash2);
        let seen = store.get(&key2).await.unwrap();
        assert!(
            seen.is_some(),
            "same hash with different case should be detected as replay"
        );

        // Without 0x prefix should also parse to the same canonical key
        let no_prefix = "ABCdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
        let hash3 = no_prefix.parse::<B256>().unwrap();
        let key3 = format!("mpp:charge:{:#x}", hash3);
        assert_eq!(
            key1, key3,
            "0x-prefixed and unprefixed should produce same key"
        );
    }

    #[tokio::test]
    async fn test_store_dedup_different_hashes_independent() {
        use crate::store::{MemoryStore, Store};

        let store = Arc::new(MemoryStore::new());

        // Record one hash
        store
            .put("mpp:charge:0xhash_a", serde_json::Value::Bool(true))
            .await
            .unwrap();

        // Different hash should not be affected
        let seen = store.get("mpp:charge:0xhash_b").await.unwrap();
        assert!(seen.is_none(), "different hash should not be blocked");

        // Original hash should still be blocked
        let seen = store.get("mpp:charge:0xhash_a").await.unwrap();
        assert!(seen.is_some(), "original hash should still be recorded");
    }
}
