"""
Comprehensive tests for double-spend protection in crypto payment processing.

These tests verify that the system correctly handles:
1. Duplicate payments to confirmed addresses
2. Race conditions between main loop and scanner
3. Multiple rapid payments
4. Wallet drain prevention
"""

import json
import time
import unittest
import unittest.mock
from decimal import Decimal
from unittest.mock import MagicMock, patch, call

from pyramid import testing

from ..models.crypto_payment import CryptoPayment
from ..lib.crypto_watcher import (
    process_payment,
    scan_wallet_for_double_or_late_payments,
    _should_process_late_payment,
    _create_duplicate_payment,
)


class TestDoubleSpendProtection(unittest.TestCase):
    """Unit tests for double-spend protection logic."""

    def setUp(self):
        self.config = testing.setUp()
        self.request = testing.DummyRequest()
        self.request.dbsession = MagicMock()

    def tearDown(self):
        testing.tearDown()

    def _create_test_payment(self, **kwargs):
        """Helper to create a test payment with mock dependencies."""
        defaults = {
            "invoice": MagicMock(id="invoice-123", user=MagicMock()),
            "user": MagicMock(id="user-123"),
            "shop": MagicMock(id="shop-123"),
            "address": "4TestAddress...",
            "account_index": 0,
            "subaddress_index": 1,
            "coin_type": "XMR",
            "expected_amount": 100000000,
            "rate_locked_usd_per_coin": Decimal("150.00"),
            "quote_expires_at_ms": int((time.time() + 900) * 1000),
            "confirmations_required": 10,
            "refund_address": "4RefundAddress...",
            "shop_sweep_to_address": "4ShopAddress...",
        }
        defaults.update(kwargs)

        payment = CryptoPayment(**defaults)
        # Override properties that were set in __init__
        for key, value in kwargs.items():
            if hasattr(payment, key):
                setattr(payment, key, value)
        return payment

    def test_duplicate_payment_to_confirmed_address_rejected_by_main_loop(self):
        """Test that main loop rejects new transactions to already-paid addresses."""
        # Create a confirmed payment that already received funds
        payment = self._create_test_payment()
        payment.id = "test-payment-1"
        payment.status = CryptoPayment.STATUS_CONFIRMED
        payment.received_amount = 100000000  # Already received 0.001 XMR
        payment.expected_amount = 100000000
        payment.tx_hashes = json.dumps(["existing-tx-123"])
        payment.coin_type = "XMR"

        # New transaction arrives (duplicate payment)
        incoming_transfers = [
            {
                "txid": "new-tx-456",  # Different transaction
                "amount": 50000000,  # 0.0005 XMR
                "confirmations": 1,
                "subaddr_index": {"major": 0, "minor": 1},
            }
        ]

        # Mock the database session
        self.request.dbsession.add = MagicMock()

        # Process payment - should detect duplicate and return early
        with patch("make_post_sell.lib.crypto_watcher.log") as mock_log:
            process_payment(self.request, payment, incoming_transfers)

            # Verify duplicate was detected via payment_error
            error_calls = mock_log.payment_error.call_args_list
            duplicate_calls = [
                call
                for call in error_calls
                if "DUPLICATE PAYMENT DETECTED" in str(call[0][1])
            ]
            self.assertTrue(len(duplicate_calls) >= 1)

        # Verify payment was NOT updated with new amount
        self.assertEqual(payment.received_amount, 100000000)  # Unchanged

        # Verify confirmation count was still updated
        self.assertEqual(payment.current_confirmations, 1)

    def test_duplicate_payment_to_received_status_rejected(self):
        """Test that payments in RECEIVED status also reject duplicates."""
        payment = self._create_test_payment()
        payment.id = "test-payment-2"
        payment.status = CryptoPayment.STATUS_RECEIVED
        payment.received_amount = 50000000  # Already received 0.0005 XMR
        payment.expected_amount = 100000000  # Expecting 0.001 XMR
        payment.tx_hashes = json.dumps(["first-tx-789"])
        payment.coin_type = "XMR"

        # Another transaction arrives before confirmation
        incoming_transfers = [
            {
                "txid": "second-tx-abc",
                "amount": 60000000,  # 0.0006 XMR - would overpay
                "confirmations": 2,
            }
        ]

        self.request.dbsession.add = MagicMock()

        with patch("make_post_sell.lib.crypto_watcher.log") as mock_log:
            process_payment(self.request, payment, incoming_transfers)

            # Should detect as duplicate
            error_calls = mock_log.payment_error.call_args_list
            duplicate_calls = [
                call
                for call in error_calls
                if "DUPLICATE PAYMENT DETECTED" in str(call[0][1])
            ]
            self.assertTrue(len(duplicate_calls) >= 1)

    def test_pending_payment_accepts_first_transaction(self):
        """Test that PENDING status correctly accepts the first transaction."""
        payment = self._create_test_payment()
        payment.id = "test-payment-3"
        payment.status = CryptoPayment.STATUS_PENDING
        payment.received_amount = 0  # No funds received yet
        payment.expected_amount = 100000000
        payment.tx_hashes = json.dumps([])
        payment.coin_type = "XMR"
        payment.confirmations_required = 10

        incoming_transfers = [
            {
                "txid": "first-tx-def",
                "amount": 100000000,
                "confirmations": 1,
            }
        ]

        # Mock database and finalization
        self.request.dbsession.add = MagicMock()

        with patch("make_post_sell.lib.crypto_watcher.log") as mock_log:
            process_payment(self.request, payment, incoming_transfers)

            # Should NOT log duplicate warning
            error_calls = mock_log.payment_error.call_args_list
            duplicate_calls = [
                call
                for call in error_calls
                if "DUPLICATE PAYMENT DETECTED" in str(call[0][1])
            ]
            self.assertEqual(len(duplicate_calls), 0)

        # Payment should be updated
        self.assertEqual(payment.received_amount, 100000000)
        self.assertEqual(payment.status, CryptoPayment.STATUS_RECEIVED)

    def test_should_process_late_payment_detects_doubles(self):
        """Test _should_process_late_payment correctly identifies double payments."""
        # Test confirmed payment receiving more funds
        payment = self._create_test_payment()
        payment.id = "test-payment-4"
        payment.status = CryptoPayment.STATUS_CONFIRMED
        payment.received_amount = 100000000
        payment.expected_amount = 100000000

        new_tx = {"amount": 50000000, "confirmations": 5}

        # Should return True - this is a double payment
        result = _should_process_late_payment(payment, new_tx)
        self.assertTrue(result)

        # Test confirmed overpaid payment
        payment.status = CryptoPayment.STATUS_CONFIRMED_OVERPAY
        result = _should_process_late_payment(payment, new_tx)
        self.assertTrue(result)

    def test_create_duplicate_payment_object(self):
        """Test creation of duplicate payment objects for refunds."""
        original = self._create_test_payment()
        original.id = "original-payment"
        original.coin_type = "XMR"
        original.account_index = 0
        original.subaddress_index = 123
        original.address = "4ABC..."
        original.refund_address = "4DEF..."
        original.shop_sweep_to_address = "4GHI..."
        original.confirmations_required = 10
        original.user_id = "user-123"
        original.shop_id = "shop-456"
        original.shop_location_id = "loc-789"

        tx = {"amount": 75000000, "txid": "duplicate-tx-xyz", "confirmations": 3}

        duplicate = _create_duplicate_payment(original, tx, "XMR")

        # Verify duplicate has correct properties
        self.assertNotEqual(duplicate.id, original.id)
        self.assertIsNone(duplicate.invoice_id)  # No invoice for duplicates
        self.assertEqual(duplicate.status, CryptoPayment.STATUS_DOUBLEPAY_REFUNDED)
        self.assertEqual(duplicate.expected_amount, 75000000)
        self.assertEqual(duplicate.received_amount, 75000000)
        self.assertEqual(duplicate.refund_address, original.refund_address)
        self.assertEqual(duplicate.current_confirmations, 3)

    def test_race_condition_protection(self):
        """Test that race conditions don't cause double refunds."""
        # Simulate two processes seeing the same payment
        payment1 = self._create_test_payment()
        payment1.id = "payment-race"
        payment1.status = CryptoPayment.STATUS_CONFIRMED_OVERPAY
        payment1.received_amount = 150000000  # Got 0.0015 XMR
        payment1.expected_amount = 100000000  # Expected 0.001 XMR
        payment1.tx_hashes = json.dumps(["tx1", "tx2"])

        # Both processes see a new transaction
        new_tx = [{"txid": "tx3", "amount": 50000000, "confirmations": 1}]

        # Process 1 handles it
        self.request.dbsession.add = MagicMock()
        with patch("make_post_sell.lib.crypto_watcher.log") as mock_log:
            process_payment(self.request, payment1, new_tx)
            # Should reject as duplicate
            error_calls = mock_log.payment_error.call_args_list
            duplicate_calls = [
                call
                for call in error_calls
                if "DUPLICATE PAYMENT DETECTED" in str(call[0][1])
            ]
            self.assertTrue(len(duplicate_calls) >= 1)

        # Payment amount should NOT increase
        self.assertEqual(payment1.received_amount, 150000000)

    def test_multiple_rapid_payments_scenario(self):
        """Test handling of multiple payments in quick succession."""
        payment = self._create_test_payment()
        payment.id = "rapid-payment"
        payment.status = CryptoPayment.STATUS_PENDING
        payment.received_amount = 0
        payment.expected_amount = 100000000
        payment.tx_hashes = json.dumps([])
        payment.confirmations_required = 10
        payment.coin_type = "XMR"

        # First transaction
        tx1 = [{"txid": "rapid1", "amount": 40000000, "confirmations": 1}]
        self.request.dbsession.add = MagicMock()
        process_payment(self.request, payment, tx1)
        self.assertEqual(payment.received_amount, 40000000)
        self.assertEqual(payment.status, CryptoPayment.STATUS_RECEIVED)

        # Second transaction batch (only new transaction)
        # Any additional transactions should be treated as duplicates
        tx2 = [
            {
                "txid": "rapid2",
                "amount": 60000000,
                "confirmations": 1,
            },  # New transaction (duplicate)
        ]
        process_payment(self.request, payment, tx2)
        # Should NOT add to received amount since this is a duplicate payment
        self.assertEqual(
            payment.received_amount, 40000000
        )  # Still only from original rapid1

        # Third transaction (duplicate - should be rejected)
        # Set to received status so duplicates are detected
        payment.status = CryptoPayment.STATUS_RECEIVED
        payment.tx_hashes = json.dumps(["rapid1", "rapid2"])
        payment.received_amount = 100000000  # Set existing balance

        tx3 = [
            {"txid": "rapid1", "amount": 40000000, "confirmations": 3},
            {"txid": "rapid2", "amount": 60000000, "confirmations": 2},
            {
                "txid": "rapid3",
                "amount": 50000000,
                "confirmations": 1,
            },  # NEW transaction
        ]

        with patch("make_post_sell.lib.crypto_watcher.log") as mock_log:
            process_payment(self.request, payment, tx3)
            # Should detect duplicate
            error_calls = mock_log.payment_error.call_args_list
            duplicate_calls = [
                call
                for call in error_calls
                if "DUPLICATE PAYMENT DETECTED" in str(call[0][1])
            ]
            self.assertTrue(len(duplicate_calls) >= 1)

        # Amount should not change
        self.assertEqual(payment.received_amount, 100000000)

    def test_duplicate_record_singleton_per_transaction_id(self):
        """Test that each transaction ID creates exactly ONE duplicate record, not one per cycle."""
        payment = self._create_test_payment()
        payment.id = "singleton-test"
        payment.status = CryptoPayment.STATUS_RECEIVED
        payment.received_amount = 100000000  # Already received funds
        payment.expected_amount = 100000000
        payment.tx_hashes = json.dumps(["original-tx"])
        payment.coin_type = "XMR"

        # Mock database session to track actual records created
        created_records = []

        def mock_add(record):
            if (
                hasattr(record, "status")
                and record.status == CryptoPayment.STATUS_DOUBLEPAY_REFUNDED
            ):
                created_records.append(record)

        def mock_query(model):
            query_mock = MagicMock()
            filter_mock = MagicMock()
            # Return previously created records with DOUBLEPAY_REFUND status
            filter_mock.all.return_value = [
                r
                for r in created_records
                if r.status == CryptoPayment.STATUS_DOUBLEPAY_REFUNDED
            ]
            query_mock.filter.return_value = filter_mock
            return query_mock

        self.request.dbsession.add = mock_add
        self.request.dbsession.flush = MagicMock()
        self.request.dbsession.query = mock_query

        duplicate_tx = [
            {
                "txid": "ff8d14fa1deef6034f2cd16e7cbcd1c76d5e589f37cd55d718d122148783014c",
                "amount": 50000000,
                "confirmations": 1,
            }
        ]

        # Both the original transaction and duplicate
        all_transfers = [
            {
                "txid": "original-tx",
                "amount": 100000000,
                "confirmations": 10,
            },
            duplicate_tx[0],
        ]

        # Simulate multiple processing cycles (like every 20 seconds)
        with patch("make_post_sell.lib.crypto_watcher.log") as mock_log, patch(
            "make_post_sell.lib.crypto_watcher.send_purchase_email"
        ) as mock_purchase_email, patch(
            "make_post_sell.lib.crypto_watcher.send_sale_email"
        ) as mock_sale_email:
            # Cycle 1: Should create duplicate record
            process_payment(self.request, payment, all_transfers)
            self.assertEqual(len(created_records), 1)

            # Cycle 2: Should NOT create another duplicate record for same txid
            process_payment(self.request, payment, all_transfers)
            self.assertEqual(len(created_records), 1)  # Still only 1 record

            # Cycle 3: Should still NOT create another duplicate record
            process_payment(self.request, payment, all_transfers)
            self.assertEqual(len(created_records), 1)  # Still only 1 record

            # Verify the "skipping" log messages appeared
            info_calls = mock_log.payment_info.call_args_list
            skip_logs = [
                call
                for call in info_calls
                if "Skipping duplicate creation" in str(call[0][1])
            ]
            # We expect at least 1 skip message (from cycle 2)
            # Cycle 3 might not process duplicates if payment is already confirmed
            self.assertGreaterEqual(len(skip_logs), 1)  # At least 1 skip message

    def test_original_payment_moves_to_confirmed_despite_duplicates(self):
        """Test that original payment reaches CONFIRMED status even when duplicates are detected."""
        payment = self._create_test_payment()
        payment.id = "status-test"
        payment.status = CryptoPayment.STATUS_RECEIVED
        payment.received_amount = 100000000  # Already received expected amount
        payment.expected_amount = 100000000
        payment.tx_hashes = json.dumps(["original-tx"])
        payment.coin_type = "XMR"
        payment.confirmations_required = 2

        # Mock database session
        self.request.dbsession.add = MagicMock()
        self.request.dbsession.flush = MagicMock()
        self.request.dbsession.query = MagicMock(return_value=MagicMock())
        self.request.dbsession.query.return_value.filter.return_value.all.return_value = (
            []
        )

        # Incoming transfers: original transaction + duplicate
        incoming_transfers = [
            {
                "txid": "original-tx",
                "amount": 100000000,
                "confirmations": 10,
            },  # Original with enough confirmations
            {
                "txid": "duplicate-tx",
                "amount": 50000000,
                "confirmations": 5,
            },  # Duplicate transaction
        ]

        with patch("make_post_sell.lib.crypto_watcher.log") as mock_log, patch(
            "make_post_sell.lib.crypto_watcher.send_purchase_email"
        ) as mock_purchase, patch(
            "make_post_sell.lib.crypto_watcher.send_sale_email"
        ) as mock_sale:
            process_payment(self.request, payment, incoming_transfers)

            # Verify original payment moved to CONFIRMED status
            self.assertEqual(payment.status, CryptoPayment.STATUS_CONFIRMED)

            # Verify confirmations were updated from legitimate transaction
            self.assertEqual(payment.current_confirmations, 10)

            # Verify duplicate detection was logged
            error_calls = mock_log.payment_error.call_args_list
            duplicate_warnings = [
                call
                for call in error_calls
                if "DUPLICATE PAYMENT DETECTED" in str(call[0][1])
            ]
            self.assertEqual(len(duplicate_warnings), 1)

    def test_confirmed_payment_with_ongoing_duplicates_sends_emails(self):
        """Test that confirmed payments create duplicate records without re-processing."""
        payment = self._create_test_payment()
        payment.id = "confirmed-with-duplicates"
        payment.status = CryptoPayment.STATUS_CONFIRMED  # Already confirmed
        payment.received_amount = 100000000
        payment.expected_amount = 100000000
        payment.tx_hashes = json.dumps(["original-tx"])
        payment.coin_type = "XMR"
        payment.purchase_email_sent = True  # Already sent
        payment.sales_email_sent = True  # Already sent
        payment.shop_sweep_to_address = "4SwepToAddress..."
        payment.swept_tx_hash = "sweep-tx-hash"  # Already swept

        # Mock invoice and shop for email sending
        payment.invoice = MagicMock()
        payment.invoice.user.email = "test@example.com"
        payment.invoice.line_items = [MagicMock()]
        payment.invoice.line_items[0].product = MagicMock()
        payment.invoice.total = 100
        payment.invoice.shop = MagicMock()

        # Mock database session
        self.request.dbsession.add = MagicMock()
        self.request.dbsession.flush = MagicMock()
        self.request.dbsession.query = MagicMock(return_value=MagicMock())
        self.request.dbsession.query.return_value.filter.return_value.all.return_value = (
            []
        )

        # Mock email functions
        with patch(
            "make_post_sell.lib.crypto_watcher.send_purchase_email"
        ) as mock_purchase_email, patch(
            "make_post_sell.lib.crypto_watcher.send_sale_email"
        ) as mock_sale_email, patch(
            "make_post_sell.lib.crypto_watcher.create_shop_context_request"
        ) as mock_context, patch(
            "make_post_sell.lib.crypto_watcher.auto_sweep_payment"
        ) as mock_sweep:

            mock_context.return_value = self.request
            mock_sweep.return_value = True

            # Incoming transfers: original transaction + duplicate
            incoming_transfers = [
                {
                    "txid": "original-tx",
                    "amount": 100000000,
                    "confirmations": 15,
                },  # Original
                {
                    "txid": "duplicate-tx",
                    "amount": 50000000,
                    "confirmations": 5,
                },  # Duplicate
            ]

            # Mock crypto client for sweep
            mock_client = MagicMock()

            with patch("make_post_sell.lib.crypto_watcher.log") as mock_log:
                process_payment(self.request, payment, incoming_transfers, mock_client)

                # Verify emails were NOT sent (already processed)
                mock_purchase_email.assert_not_called()
                mock_sale_email.assert_not_called()

                # Verify sweep was NOT attempted (already swept)
                mock_sweep.assert_not_called()

                # Verify payment status remains confirmed
                self.assertEqual(payment.status, CryptoPayment.STATUS_CONFIRMED)

                # Verify duplicate was logged
                error_calls = mock_log.payment_error.call_args_list
                duplicate_warnings = [
                    call
                    for call in error_calls
                    if "DUPLICATE PAYMENT DETECTED" in str(call[0][1])
                ]
                self.assertGreater(len(duplicate_warnings), 0)

                # Verify duplicate payment creation was logged
                info_calls = mock_log.payment_info.call_args_list
                duplicate_creation_logs = [
                    call
                    for call in info_calls
                    if "Created duplicate payment record" in str(call[0][1])
                ]
                # Should have logs about creating duplicate records
                self.assertGreater(len(duplicate_creation_logs), 0)

    def test_confirmed_payment_skips_emails_if_already_sent(self):
        """Test that confirmed payments don't resend emails if already sent."""
        payment = self._create_test_payment()
        payment.id = "confirmed-emails-sent"
        payment.status = CryptoPayment.STATUS_CONFIRMED
        payment.received_amount = 100000000
        payment.expected_amount = 100000000
        payment.tx_hashes = json.dumps(["original-tx"])
        payment.coin_type = "XMR"
        payment.purchase_email_sent = True  # Already sent
        payment.sales_email_sent = True  # Already sent

        payment.invoice = MagicMock()
        self.request.dbsession.add = MagicMock()
        self.request.dbsession.flush = MagicMock()
        self.request.dbsession.query = MagicMock(return_value=MagicMock())
        self.request.dbsession.query.return_value.filter.return_value.all.return_value = (
            []
        )

        # Mock email functions
        with patch(
            "make_post_sell.lib.crypto_watcher.send_purchase_email"
        ) as mock_purchase_email, patch(
            "make_post_sell.lib.crypto_watcher.send_sale_email"
        ) as mock_sale_email:

            incoming_transfers = [
                {"txid": "original-tx", "amount": 100000000, "confirmations": 15},
                {"txid": "duplicate-tx", "amount": 50000000, "confirmations": 5},
            ]

            process_payment(self.request, payment, incoming_transfers)

            # Verify emails were NOT sent (already sent)
            mock_purchase_email.assert_not_called()
            mock_sale_email.assert_not_called()

    def test_concurrent_transactions_within_processing_window(self):
        """Test multiple transactions arriving within same processing cycle - only first is processed."""
        # Scenario: Customer accidentally sends multiple payments to same quote
        # Both arrive before either is processed (within same 20-second window)
        payment = self._create_test_payment()
        payment.id = "concurrent-payment"
        payment.status = CryptoPayment.STATUS_PENDING
        payment.received_amount = 0
        payment.expected_amount = 100000000  # Expecting 0.001 XMR
        payment.tx_hashes = json.dumps([])
        payment.confirmations_required = 10
        payment.coin_type = "XMR"

        # Both transactions arrive in same batch (realistic scenario)
        # Customer sends 0.0006 XMR then quickly sends another 0.0004 XMR
        concurrent_txs = [
            {
                "txid": "tx1-partial",
                "amount": 60000000,
                "confirmations": 10,
            },  # 0.0006 XMR - enough confirmations
            {
                "txid": "tx2-completion",
                "amount": 40000000,
                "confirmations": 10,
            },  # 0.0004 XMR - enough confirmations
        ]

        self.request.dbsession.add = MagicMock()

        with patch("make_post_sell.lib.crypto_watcher.finalize_invoice"):
            process_payment(self.request, payment, concurrent_txs)

        # Multiple transactions to one quote are treated as duplicates
        # First transaction processed normally, rest become duplicates
        self.assertEqual(
            payment.received_amount, 60000000
        )  # First transaction processed normally
        self.assertEqual(
            payment.status, CryptoPayment.STATUS_RECEIVED
        )  # Status changed to received

        # Should track all transaction IDs for audit trail
        stored_txids = json.loads(payment.tx_hashes)
        self.assertEqual(len(stored_txids), 2)  # All transactions tracked
        self.assertIn("tx1-partial", stored_txids)
        self.assertIn("tx2-completion", stored_txids)

    def test_sequential_overpayment_detection(self):
        """Test sequential transactions where second is detected as duplicate."""
        # Scenario: Customer sends first payment (gets confirmed), then sends second payment
        # The second should be detected as duplicate and handled by scanner, not main loop
        payment = self._create_test_payment()
        payment.id = "sequential-overpay"
        payment.status = CryptoPayment.STATUS_PENDING
        payment.received_amount = 0
        payment.expected_amount = 100000000  # Expecting 0.001 XMR
        payment.tx_hashes = json.dumps([])
        payment.confirmations_required = 10
        payment.coin_type = "XMR"

        # First transaction - legitimate payment
        first_tx = [
            {"txid": "tx1-legitimate", "amount": 100000000, "confirmations": 10}
        ]

        self.request.dbsession.add = MagicMock()

        with patch("make_post_sell.lib.crypto_watcher.finalize_invoice"):
            process_payment(self.request, payment, first_tx)

        # Verify first payment was processed
        self.assertEqual(payment.received_amount, 100000000)
        self.assertEqual(payment.status, CryptoPayment.STATUS_CONFIRMED)

        # Now second transaction arrives (this is the duplicate scenario)
        # Payment is already confirmed, so this should be rejected by main loop
        payment.tx_hashes = json.dumps(["tx1-legitimate"])  # Update stored txids

        second_tx = [
            {
                "txid": "tx1-legitimate",
                "amount": 100000000,
                "confirmations": 15,
            },  # Same TX, more confirmations
            {
                "txid": "tx2-duplicate",
                "amount": 50000000,
                "confirmations": 1,
            },  # NEW duplicate transaction
        ]

        with patch("make_post_sell.lib.crypto_watcher.log") as mock_log:
            process_payment(self.request, payment, second_tx)

            # Should detect duplicate and reject
            error_calls = mock_log.payment_error.call_args_list
            duplicate_calls = [
                call
                for call in error_calls
                if "DUPLICATE PAYMENT DETECTED" in str(call[0][1])
            ]
            self.assertTrue(len(duplicate_calls) >= 1)
            error_msg = str(duplicate_calls[0][0][1])
            self.assertIn("1 new transactions", error_msg)

        # Amount should NOT change (duplicate rejected)
        self.assertEqual(payment.received_amount, 100000000)  # Still 0.001 XMR
        self.assertEqual(
            payment.status, CryptoPayment.STATUS_CONFIRMED
        )  # Still confirmed

        # Should update confirmation count but not add duplicate
        # Note: The duplicate detection creates duplicate payment records and sets
        # new_txids=[] so legitimate transfers may be empty, using min_confs from all transfers
        # which includes the duplicate with 1 confirmation - so min is 1
        self.assertEqual(payment.current_confirmations, 1)

    def test_second_processing_cycle_rejects_duplicates(self):
        """Test that subsequent processing cycles correctly reject already-seen transactions."""
        # Scenario: First cycle processes two concurrent transactions,
        # then 20 seconds later the system processes again and sees the same transactions
        payment = self._create_test_payment()
        payment.id = "cycle-test"
        payment.status = CryptoPayment.STATUS_PENDING
        payment.received_amount = 0
        payment.expected_amount = 100000000
        payment.tx_hashes = json.dumps([])
        payment.confirmations_required = 10
        payment.coin_type = "XMR"

        # First processing cycle - concurrent transactions
        first_cycle_txs = [
            {"txid": "concurrent1", "amount": 50000000, "confirmations": 10},
            {"txid": "concurrent2", "amount": 50000000, "confirmations": 10},
        ]

        self.request.dbsession.add = MagicMock()

        with patch("make_post_sell.lib.crypto_watcher.finalize_invoice"):
            process_payment(self.request, payment, first_cycle_txs)

        # Verify first cycle - first transaction processed, second is duplicate
        self.assertEqual(
            payment.received_amount, 50000000
        )  # First transaction processed
        self.assertEqual(
            payment.status, CryptoPayment.STATUS_RECEIVED
        )  # Changed to received
        stored_txids = json.loads(payment.tx_hashes)
        self.assertEqual(len(stored_txids), 2)  # Both transactions tracked

        # Second processing cycle - same transactions with more confirmations
        # Plus a new transaction (this is the duplicate scenario)
        second_cycle_txs = [
            {
                "txid": "concurrent1",
                "amount": 50000000,
                "confirmations": 15,
            },  # Same as before
            {
                "txid": "concurrent2",
                "amount": 50000000,
                "confirmations": 15,
            },  # Same as before
            {
                "txid": "new-duplicate",
                "amount": 25000000,
                "confirmations": 1,
            },  # NEW transaction
        ]

        # Reset mocks for second cycle
        self.request.dbsession.add.reset_mock()

        with patch("make_post_sell.lib.crypto_watcher.log") as mock_log:
            process_payment(self.request, payment, second_cycle_txs)

            # Should detect multiple transactions as duplicates
            error_calls = mock_log.payment_error.call_args_list
            duplicate_calls = [
                call
                for call in error_calls
                if "DUPLICATE PAYMENT DETECTED" in str(call[0][1])
            ]
            self.assertGreaterEqual(len(duplicate_calls), 1)

        # Amount should remain unchanged (no new transactions processed)
        self.assertEqual(payment.received_amount, 50000000)

        # Confirmation count should remain at initial value since no transactions were processed
        # (Payment starts with 0 confirmations and stays that way when all transactions are duplicates)


class TestWalletDrainPrevention(unittest.TestCase):
    """Test that the system prevents wallet drain scenarios."""

    def setUp(self):
        self.config = testing.setUp()
        self.request = testing.DummyRequest()
        self.request.dbsession = MagicMock()
        self.request.registry = MagicMock()
        self.request.registry.settings = {
            "crypto.xmr.wallet_rpc_uri": "http://localhost:18082",
            "crypto.xmr.wallet_rpc_user": "test",
            "crypto.xmr.wallet_rpc_password": "test",
        }

    def tearDown(self):
        testing.tearDown()

    def _create_test_payment(self, **kwargs):
        """Helper to create a test payment with mock dependencies."""
        defaults = {
            "invoice": MagicMock(id="invoice-123", user=MagicMock()),
            "user": MagicMock(id="user-123"),
            "shop": MagicMock(id="shop-123"),
            "address": "4TestAddress...",
            "account_index": 0,
            "subaddress_index": 1,
            "coin_type": "XMR",
            "expected_amount": 100000000,
            "rate_locked_usd_per_coin": Decimal("150.00"),
            "quote_expires_at_ms": int((time.time() + 900) * 1000),
            "confirmations_required": 10,
            "refund_address": "4RefundAddress...",
            "shop_sweep_to_address": "4ShopAddress...",
        }
        defaults.update(kwargs)

        payment = CryptoPayment(**defaults)
        # Override properties that were set in __init__
        for key, value in kwargs.items():
            if hasattr(payment, key):
                setattr(payment, key, value)
        return payment

    @patch("make_post_sell.lib.crypto_watcher.get_crypto_client")
    def test_sweep_all_not_called_multiple_times(self, mock_get_client):
        """Verify sweep_all is not called multiple times for same payment."""
        # Mock crypto client
        mock_client = MagicMock()
        mock_get_client.return_value = mock_client

        # Create a payment ready for sweep
        payment = self._create_test_payment()
        payment.id = "sweep-test"
        payment.status = CryptoPayment.STATUS_CONFIRMED
        payment.received_amount = 100000000
        payment.shop_sweep_to_address = "4ABC..."
        payment.account_index = 0
        payment.subaddress_index = 99
        payment.coin_type = "XMR"
        payment.invoice = MagicMock()
        payment.swept_tx_hash = None

        # Mock sweep_all response
        mock_client._call.return_value = {
            "tx_hash_list": ["sweep-tx-123"],
            "amount_list": [95000000],
            "fee_list": [5000000],
        }

        # Test logic - sweep should only happen once per payment
        # First sweep
        payment.swept_tx_hash = None

        # Simulate sweep logic
        if not payment.swept_tx_hash:
            payment.swept_tx_hash = "sweep-tx-123"
            call_count = 1
        else:
            call_count = 0

        # Second attempt should be skipped
        if not payment.swept_tx_hash:  # Already set, so this won't execute
            call_count += 1

        # Should only sweep once
        self.assertEqual(call_count, 1)
        self.assertEqual(payment.swept_tx_hash, "sweep-tx-123")

    def test_wallet_scan_creates_separate_duplicate_payments(self):
        """Test that scanner creates separate payment objects for duplicates."""
        # Mock database query
        mock_payment = self._create_test_payment()
        mock_payment.id = "original"
        mock_payment.status = CryptoPayment.STATUS_CONFIRMED
        mock_payment.received_amount = 100000000
        mock_payment.coin_type = "XMR"
        mock_payment.account_index = 0
        mock_payment.subaddress_index = 50

        self.request.dbsession.query.return_value.filter.return_value.first.return_value = (
            mock_payment
        )
        self.request.dbsession.add = MagicMock()
        self.request.dbsession.flush = MagicMock()

        # Mock duplicate payment creation
        with patch(
            "make_post_sell.lib.crypto_watcher._create_duplicate_payment"
        ) as mock_create:
            mock_duplicate = self._create_test_payment()
            mock_duplicate.id = "duplicate-1"
            mock_duplicate.status = CryptoPayment.STATUS_DOUBLEPAY_REFUNDED
            mock_create.return_value = mock_duplicate

            with patch(
                "make_post_sell.lib.crypto_watcher.process_payment"
            ) as mock_process:
                # Simulate finding duplicate payment in scan
                tx = {"amount": 50000000, "confirmations": 5}

                # Test the duplicate detection logic
                should_process = _should_process_late_payment(mock_payment, tx)
                self.assertTrue(should_process)

                # Test duplicate creation
                duplicate = _create_duplicate_payment(mock_payment, tx, "XMR")
                self.assertEqual(
                    duplicate.status, CryptoPayment.STATUS_DOUBLEPAY_REFUNDED
                )
                self.assertEqual(duplicate.received_amount, 50000000)

                # Verify duplicate properties
                self.assertEqual(duplicate.coin_type, "XMR")
                self.assertEqual(duplicate.account_index, mock_payment.account_index)
                self.assertEqual(
                    duplicate.subaddress_index, mock_payment.subaddress_index
                )


class TestEdgeCasesAndErrorHandling(unittest.TestCase):
    """Test edge cases and error scenarios."""

    def setUp(self):
        self.config = testing.setUp()
        self.request = testing.DummyRequest()
        self.request.dbsession = MagicMock()

    def tearDown(self):
        testing.tearDown()

    def _create_test_payment(self, **kwargs):
        """Helper to create a test payment with mock dependencies."""
        defaults = {
            "invoice": MagicMock(id="invoice-123", user=MagicMock()),
            "user": MagicMock(id="user-123"),
            "shop": MagicMock(id="shop-123"),
            "address": "4TestAddress...",
            "account_index": 0,
            "subaddress_index": 1,
            "coin_type": "XMR",
            "expected_amount": 100000000,
            "rate_locked_usd_per_coin": Decimal("150.00"),
            "quote_expires_at_ms": int((time.time() + 900) * 1000),
            "confirmations_required": 10,
            "refund_address": "4RefundAddress...",
            "shop_sweep_to_address": "4ShopAddress...",
        }
        defaults.update(kwargs)

        payment = CryptoPayment(**defaults)
        # Override properties that were set in __init__
        for key, value in kwargs.items():
            if hasattr(payment, key):
                setattr(payment, key, value)
        return payment

    def test_malformed_tx_hashes_json(self):
        """Test handling of corrupted tx_hashes JSON."""
        payment = self._create_test_payment()
        payment.id = "corrupt-json"
        payment.status = CryptoPayment.STATUS_PENDING  # Start with pending
        payment.received_amount = 0  # No funds yet
        payment.tx_hashes = "not-valid-json{["  # Corrupted
        payment.expected_amount = 100000000
        payment.coin_type = "XMR"
        payment.confirmations_required = 10

        incoming = [{"txid": "new-tx", "amount": 50000000, "confirmations": 1}]

        # Should handle gracefully and treat as empty list
        self.request.dbsession.add = MagicMock()

        with patch("make_post_sell.lib.crypto_watcher.finalize_invoice"):
            process_payment(self.request, payment, incoming)

        # Should process normally since corrupt JSON is treated as []
        self.assertEqual(payment.received_amount, 50000000)

    def test_payment_with_no_txid(self):
        """Test handling of transactions without txid."""
        payment = self._create_test_payment()
        payment.id = "no-txid"
        payment.status = CryptoPayment.STATUS_PENDING
        payment.received_amount = 0
        payment.tx_hashes = json.dumps([])
        payment.expected_amount = 100000000
        payment.coin_type = "DOGE"
        payment.confirmations_required = 3

        # Transaction missing txid - this won't be processed because
        # transactions without txid can't be tracked for deduplication
        incoming = [
            {"amount": 100000000, "confirmations": 3}
        ]  # No txid, meets confirmation requirement

        self.request.dbsession.add = MagicMock()

        with patch("make_post_sell.lib.crypto_watcher.finalize_invoice"):
            process_payment(self.request, payment, incoming)

        # Should NOT process because txid is required for tracking
        self.assertEqual(payment.received_amount, 0)  # No change
        self.assertEqual(json.loads(payment.tx_hashes), [])

    def test_zero_amount_transactions(self):
        """Test handling of zero-amount transactions."""
        payment = self._create_test_payment()
        payment.id = "zero-amount"
        payment.status = CryptoPayment.STATUS_PENDING
        payment.received_amount = 0
        payment.tx_hashes = json.dumps([])
        payment.expected_amount = 100000000
        payment.coin_type = "XMR"

        incoming = [
            {"txid": "zero-tx", "amount": 0, "confirmations": 10},
            {"txid": "real-tx", "amount": 100000000, "confirmations": 10},
        ]

        self.request.dbsession.add = MagicMock()

        with patch("make_post_sell.lib.crypto_watcher.finalize_invoice"):
            process_payment(self.request, payment, incoming)

        # Should process only the first transaction (zero amount)
        self.assertEqual(payment.received_amount, 0)  # First transaction was zero

    def test_payment_status_change_during_processing(self):
        """Test handling when payment status changes mid-processing."""
        payment = self._create_test_payment()
        payment.id = "status-change"
        payment.status = CryptoPayment.STATUS_RECEIVED
        payment.received_amount = 100000000
        payment.expected_amount = 100000000
        payment.tx_hashes = json.dumps(["tx1"])

        # Mock refresh to simulate status change
        def mock_refresh(obj):
            obj.status = CryptoPayment.STATUS_CANCELLED

        self.request.dbsession.refresh = mock_refresh
        self.request.dbsession.add = MagicMock()

        # This would be caught in the main processing loop
        # The payment would be skipped due to status change

    def test_create_duplicate_payment_inherits_saved_refund_address(self):
        """Test that duplicate payments inherit user's saved refund address when original has none."""
        from make_post_sell.lib.crypto_watcher import _create_duplicate_payment
        from make_post_sell.models.user_crypto_refund_address import (
            UserCryptoRefundAddress,
        )
        from unittest.mock import MagicMock

        original = self._create_test_payment()
        original.refund_address = None  # No refund address on original
        original.user_id = "test-user-123"

        # Mock user and shop
        mock_user = MagicMock()
        mock_user.id = "test-user-123"
        mock_shop = MagicMock()
        mock_shop.id = "test-shop-456"

        # Create saved refund address for user
        saved_address = UserCryptoRefundAddress(
            user=mock_user,
            shop=mock_shop,
            coin_type="XMR",
            address="saved_xmr_refund_address_12345",
        )
        saved_address.user_id = "test-user-123"  # Set ID directly for query

        # Mock the query to return our saved address
        mock_query = MagicMock()
        mock_filter = MagicMock()
        mock_first = MagicMock(return_value=saved_address)

        self.request.dbsession.query = MagicMock(return_value=mock_query)
        mock_query.filter = MagicMock(return_value=mock_filter)
        mock_filter.first = mock_first

        tx = {
            "txid": "duplicate-tx-456",
            "amount": 75000000,  # 0.075 XMR
            "confirmations": 3,
        }

        duplicate = _create_duplicate_payment(
            original, tx, "XMR", self.request.dbsession
        )

        # Verify duplicate inherits saved refund address
        self.assertEqual(duplicate.refund_address, "saved_xmr_refund_address_12345")
        self.assertEqual(duplicate.status, CryptoPayment.STATUS_DOUBLEPAY_REFUNDED)
        self.assertEqual(duplicate.received_amount, 75000000)

    def test_duplicate_payment_refund_status_transitions(self):
        """Test that duplicate payment transitions to proper status after refund attempts."""
        # This is a simplified test that verifies the status transition logic
        # Since the duplicate payment processing is complex, we test the core logic directly

        # Test successful refund transition
        duplicate_success = self._create_test_payment()
        duplicate_success.status = CryptoPayment.STATUS_DOUBLEPAY_REFUNDED
        duplicate_success.received_amount = 50000000
        duplicate_success.refund_address = "test_address"

        # Simulate successful refund by setting the status directly (as crypto_watcher.py does)
        duplicate_success.status = CryptoPayment.STATUS_DOUBLEPAY_REFUNDED_COMPLETE
        duplicate_success.refund_tx_hash = "refund-tx-hash-123"
        duplicate_success.refund_amount = 4550000000  # 0.0455 XMR in atomic units
        duplicate_success.refund_reason = "Duplicate payment refund"

        # Verify successful transition
        self.assertEqual(
            duplicate_success.status, CryptoPayment.STATUS_DOUBLEPAY_REFUNDED_COMPLETE
        )
        self.assertIsNotNone(duplicate_success.refund_tx_hash)
        self.assertIsNotNone(duplicate_success.refund_amount)

        # Verify STATUS_DOUBLEPAY_REFUNDED_COMPLETE is in TERMINAL_STATUSES (stops processing)
        self.assertIn(
            CryptoPayment.STATUS_DOUBLEPAY_REFUNDED_COMPLETE,
            CryptoPayment.TERMINAL_STATUSES,
        )

        # Test failed refund (status remains for retry)
        duplicate_fail = self._create_test_payment()
        duplicate_fail.status = CryptoPayment.STATUS_DOUBLEPAY_REFUNDED
        duplicate_fail.received_amount = 50000000
        duplicate_fail.refund_address = "test_address"

        # Failed refund keeps the same status for retry (as crypto_watcher.py does)
        # No status change, no refund details saved
        self.assertEqual(duplicate_fail.status, CryptoPayment.STATUS_DOUBLEPAY_REFUNDED)
        self.assertIsNone(duplicate_fail.refund_tx_hash)
        self.assertIsNone(duplicate_fail.refund_amount)

        # Verify STATUS_DOUBLEPAY_REFUNDED is in ACTIVE_STATUSES (continues processing)
        self.assertIn(
            CryptoPayment.STATUS_DOUBLEPAY_REFUNDED, CryptoPayment.ACTIVE_STATUSES
        )


if __name__ == "__main__":
    unittest.main()
