"""
Vaultfire x402 Gateway — Python Tests

Tests for the standalone Python x402 modules:
  - x402_gateway: X402Gateway, X402Rule, X402PaymentRequired
  - x402_privacy: verify_x402_wallet, scrub_identity_metadata, TraceBuffer
  - x402_rails: EvmRail, AssembleRail, Ns3Rail
  - x402_receipts: verify_hmac_receipt, canonical message, nonce state
  - x402_dashboard: load_dashboard_entries, aggregate_totals

Run with:
  python -m pytest tests/test_x402_gateway.py -v

Or:
  python -m unittest tests/test_x402_gateway.py
"""

import hashlib
import hmac
import json
import os
import sys
import tempfile
import time
import unittest
from pathlib import Path
from typing import Any
from unittest.mock import patch, MagicMock

# Add parent directory to path so imports work
sys.path.insert(0, str(Path(__file__).parent.parent))

from python.x402_privacy import (
    TraceBuffer,
    verify_x402_wallet,
    scrub_identity_metadata,
    contains_identity_metadata,
    is_pre_authorised,
    codex_redact_x402_trace,
    GHOSTKEY_VERIFIED_WALLETS,
    IDENTITY_METADATA_KEYS,
)
from python.x402_rails import (
    EvmRail,
    AssembleRail,
    Ns3Rail,
    DEFAULT_RAILS,
    X402PaymentReceipt,
)
from python.x402_receipts import (
    verify_hmac_receipt,
    _canonical_receipt_message,
    _hmac_digest,
    ReceiptVerificationResult,
)
from python.x402_gateway import (
    X402Gateway,
    X402Rule,
    X402PaymentRequired,
    X402GatewayOffline,
    get_default_gateway,
    _DEFAULT_RULES,
)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def make_gateway(tmp_path: Path) -> X402Gateway:
    """Create an X402Gateway that writes to a temp directory."""
    return X402Gateway(
        ledger_path=tmp_path / "ledger.jsonl",
        codex_memory_path=tmp_path / "memory.jsonl",
        ghostkey_earnings_path=tmp_path / "earnings.jsonl",
        companion_path=tmp_path / "companion.jsonl",
    )


def sign_payload(payload: dict, secret: str) -> str:
    """Compute HMAC signature for a payload (mirrors partner-webhook.py)."""
    msg = {
        "partner_id": payload.get("partner_id") or payload.get("issuer"),
        "key_id": payload.get("key_id") or payload.get("kid") or "k1",
        "rail": payload.get("rail") or payload.get("provider"),
        "currency": (payload.get("currency") or "").upper(),
        "amount": payload.get("amount"),
        "wallet_address": payload.get("wallet_address") or payload.get("wallet"),
        "tx_ref": payload.get("tx_ref") or payload.get("tx_hash") or payload.get("transaction_hash"),
        "nonce": payload.get("nonce"),
        "timestamp": payload.get("timestamp"),
    }
    message = json.dumps(msg, separators=(",", ":"), sort_keys=True)
    return hmac.new(secret.encode(), message.encode(), hashlib.sha256).hexdigest()


# ---------------------------------------------------------------------------
# TraceBuffer tests
# ---------------------------------------------------------------------------

class TestTraceBuffer(unittest.TestCase):

    def test_append_and_len(self):
        buf = TraceBuffer(retention_seconds=10.0)
        buf.append({"event": "test"})
        self.assertEqual(len(buf), 1)

    def test_clear(self):
        buf = TraceBuffer(retention_seconds=10.0)
        buf.append({"event": "a"})
        buf.append({"event": "b"})
        buf.clear()
        self.assertEqual(len(buf), 0)

    def test_prune_old_entries(self):
        buf = TraceBuffer(retention_seconds=0.01)
        buf.append({"event": "old"})
        time.sleep(0.05)
        buf.append({"event": "new"})
        buf.prune()
        self.assertEqual(len(buf), 1)

    def test_codex_redact_clears_buffer(self):
        buf = TraceBuffer()
        buf.append({"event": "sensitive"})
        self.assertEqual(len(buf), 1)
        codex_redact_x402_trace(buf)
        self.assertEqual(len(buf), 0)


# ---------------------------------------------------------------------------
# verify_x402_wallet tests
# ---------------------------------------------------------------------------

class TestVerifyX402Wallet(unittest.TestCase):

    def test_ghostkey_wallet_with_codex_signature(self):
        ok, cls = verify_x402_wallet("bpow20.cb.id", signature="codex::auto")
        self.assertTrue(ok)
        self.assertEqual(cls, "ghostkey")

    def test_ghostkey_wallet_with_sig_signature(self):
        ok, cls = verify_x402_wallet("bpow20.cb.id", signature="sig:abc123")
        self.assertTrue(ok)
        self.assertEqual(cls, "ghostkey")

    def test_ghostkey_ens_with_codex_signature(self):
        ok, cls = verify_x402_wallet("ghostkey316.eth", signature="codex::test")
        self.assertTrue(ok)
        self.assertEqual(cls, "ghostkey")

    def test_unverified_wallet_fails(self):
        ok, cls = verify_x402_wallet("0xrandom", signature="codex::auto")
        self.assertFalse(ok)
        self.assertEqual(cls, "unverified")

    def test_ghostkey_without_signature_fails(self):
        ok, cls = verify_x402_wallet("bpow20.cb.id", signature=None)
        self.assertFalse(ok)

    def test_zk_wallet_type(self):
        ok, cls = verify_x402_wallet("any-address", wallet_type="zk", signature="codex::auto")
        self.assertTrue(ok)
        self.assertEqual(cls, "zk")

    def test_ephemeral_wallet_type(self):
        ok, cls = verify_x402_wallet("any-address", wallet_type="ephemeral", signature="codex::auto")
        self.assertTrue(ok)
        self.assertEqual(cls, "ephemeral")

    def test_valid_belief_signal(self):
        belief = {"confidence": 0.9, "source": "belief-engine", "verified": True}
        ok, cls = verify_x402_wallet("bpow20.cb.id", belief_signal=belief)
        self.assertTrue(ok)

    def test_low_confidence_belief_signal_fails(self):
        belief = {"confidence": 0.1, "source": "belief-engine", "verified": True}
        ok, cls = verify_x402_wallet("bpow20.cb.id", belief_signal=belief)
        self.assertFalse(ok)

    def test_ephemeral_prefix_in_address(self):
        ok, cls = verify_x402_wallet("ephemeral:0xdeadbeef", signature="codex::auto")
        self.assertTrue(ok)
        self.assertEqual(cls, "ephemeral")


# ---------------------------------------------------------------------------
# scrub_identity_metadata tests
# ---------------------------------------------------------------------------

class TestScrubIdentityMetadata(unittest.TestCase):

    def test_removes_ip(self):
        result = scrub_identity_metadata({"ip": "1.2.3.4", "safe": "value"})
        self.assertNotIn("ip", result)
        self.assertEqual(result["safe"], "value")

    def test_removes_email(self):
        result = scrub_identity_metadata({"email": "user@example.com"})
        self.assertNotIn("email", result)

    def test_removes_user_agent(self):
        result = scrub_identity_metadata({"user_agent": "Mozilla/5.0"})
        self.assertNotIn("user_agent", result)

    def test_removes_source_and_origin(self):
        result = scrub_identity_metadata({"source": "web", "origin": "mobile"})
        self.assertNotIn("source", result)
        self.assertNotIn("origin", result)

    def test_recursive_scrubbing(self):
        nested = {"outer": {"email": "x@y.com", "ok": "yes"}}
        result = scrub_identity_metadata(nested)
        self.assertNotIn("email", result["outer"])
        self.assertEqual(result["outer"]["ok"], "yes")

    def test_contains_identity_metadata_true(self):
        self.assertTrue(contains_identity_metadata({"ip": "1.2.3.4"}))

    def test_contains_identity_metadata_false(self):
        self.assertFalse(contains_identity_metadata({"safe_key": "value"}))

    def test_is_pre_authorised_ghostkey(self):
        self.assertTrue(is_pre_authorised({"authorized_by": "ghostkey316.eth"}))

    def test_is_pre_authorised_test_relay(self):
        self.assertTrue(is_pre_authorised({"relay": "test_environment"}))

    def test_is_pre_authorised_false(self):
        self.assertFalse(is_pre_authorised({"authorized_by": "someone_else"}))


# ---------------------------------------------------------------------------
# X402Rule tests
# ---------------------------------------------------------------------------

class TestX402Rule(unittest.TestCase):

    def test_requires_payment_true(self):
        rule = X402Rule(
            endpoint="test.endpoint",
            category="api",
            description="Test",
            minimum_charge=0.001,
            currency="ETH",
        )
        self.assertTrue(rule.requires_payment())

    def test_requires_payment_false(self):
        rule = X402Rule(
            endpoint="test.free",
            category="api",
            description="Free endpoint",
            minimum_charge=0.0,
            currency="ETH",
        )
        self.assertFalse(rule.requires_payment())

    def test_effective_amount_uses_default_amount(self):
        rule = X402Rule(
            endpoint="test",
            category="api",
            description="Test",
            minimum_charge=0.001,
            currency="ETH",
            default_amount=0.002,
        )
        self.assertEqual(rule.effective_amount(), 0.002)

    def test_effective_amount_falls_back_to_minimum(self):
        rule = X402Rule(
            endpoint="test",
            category="api",
            description="Test",
            minimum_charge=0.001,
            currency="ETH",
        )
        self.assertEqual(rule.effective_amount(), 0.001)


# ---------------------------------------------------------------------------
# X402PaymentRequired exception tests
# ---------------------------------------------------------------------------

class TestX402PaymentRequired(unittest.TestCase):

    def test_message_contains_endpoint_and_amount(self):
        rule = X402Rule("ep", "cat", "desc", 0.001, "ETH")
        exc = X402PaymentRequired("ep", rule)
        self.assertIn("ep", str(exc))
        self.assertIn("0.001", str(exc))
        self.assertIn("ETH", str(exc))

    def test_to_dict_structure(self):
        rule = X402Rule("my.endpoint", "api", "My endpoint", 0.0042, "USDC")
        exc = X402PaymentRequired("my.endpoint", rule)
        d = exc.to_dict()
        self.assertEqual(d["status"], 402)
        self.assertEqual(d["endpoint"], "my.endpoint")
        self.assertEqual(d["currency"], "USDC")
        self.assertEqual(d["minimum_charge"], 0.0042)

    def test_user_message_is_string(self):
        rule = X402Rule("ep", "cat", "desc", 0.001, "ETH")
        exc = X402PaymentRequired("ep", rule)
        self.assertIsInstance(exc.user_message(), str)


# ---------------------------------------------------------------------------
# X402Gateway execution tests
# ---------------------------------------------------------------------------

class TestX402Gateway(unittest.TestCase):

    def setUp(self):
        self.tmp = tempfile.mkdtemp()
        self.gateway = make_gateway(Path(self.tmp))

    def _valid_kwargs(self):
        return {
            "wallet_address": "bpow20.cb.id",
            "signature": "codex::test",
        }

    def test_execute_free_endpoint_succeeds(self):
        """Execute a non-billable callback successfully."""
        result = self.gateway.execute(
            "api.vaultfire.query",
            lambda: "free_result",
            amount=0.00042,  # meets minimum
            **self._valid_kwargs(),
        )
        self.assertEqual(result, "free_result")

    def test_execute_raises_x402_when_underpaying(self):
        """Underpaying a registered rule raises X402PaymentRequired."""
        with self.assertRaises(X402PaymentRequired) as ctx:
            self.gateway.execute(
                "api.vaultfire.query",
                lambda: None,
                amount=0.0,  # below minimum
                **self._valid_kwargs(),
            )
        self.assertEqual(ctx.exception.endpoint, "api.vaultfire.query")

    def test_execute_raises_permission_error_for_unverified_wallet(self):
        """Unverified wallets are blocked."""
        with self.assertRaises(PermissionError):
            self.gateway.execute(
                "api.vaultfire.query",
                lambda: None,
                wallet_address="0xunverified",
                signature=None,
            )

    def test_execute_writes_to_ledger(self):
        """Successful billable execution writes a JSONL entry."""
        self.gateway.execute(
            "api.vaultfire.query",
            lambda: "ok",
            amount=0.00042,
            **self._valid_kwargs(),
        )
        ledger_lines = (Path(self.tmp) / "ledger.jsonl").read_text().splitlines()
        self.assertGreater(len(ledger_lines), 0)
        entry = json.loads(ledger_lines[0])
        self.assertEqual(entry["event"], "payment")
        self.assertEqual(entry["endpoint"], "api.vaultfire.query")

    def test_register_and_get_rule(self):
        rule = X402Rule("custom.ep", "custom", "Custom endpoint", 0.001, "USDC")
        self.gateway.register_rule(rule)
        retrieved = self.gateway.get_rule("custom.ep")
        self.assertEqual(retrieved, rule)

    def test_unregister_rule(self):
        rule = X402Rule("temp.ep", "temp", "Temp", 0.001, "ETH")
        self.gateway.register_rule(rule)
        result = self.gateway.unregister_rule("temp.ep")
        self.assertTrue(result)
        self.assertIsNone(self.gateway.get_rule("temp.ep"))

    def test_describe_rules_returns_dict(self):
        rules = self.gateway.describe_rules()
        self.assertIsInstance(rules, dict)
        self.assertIn("api.vaultfire.query", rules)

    def test_denial_count_increments(self):
        for _ in range(2):
            try:
                self.gateway.execute(
                    "api.vaultfire.query",
                    lambda: None,
                    amount=0.0,
                    **self._valid_kwargs(),
                )
            except X402PaymentRequired:
                pass
        self.assertEqual(self.gateway.get_denial_count("api.vaultfire.query"), 2)

    def test_denial_count_resets_on_success(self):
        # Cause a denial
        try:
            self.gateway.execute("api.vaultfire.query", lambda: None, amount=0.0, **self._valid_kwargs())
        except X402PaymentRequired:
            pass
        # Now succeed
        self.gateway.execute("api.vaultfire.query", lambda: "ok", amount=0.00042, **self._valid_kwargs())
        self.assertEqual(self.gateway.get_denial_count("api.vaultfire.query"), 0)

    def test_record_external_event(self):
        entry = self.gateway.record_external_event(
            event_type="payment",
            status="completed",
            amount=0.001,
            currency="USDC",
            **self._valid_kwargs(),
        )
        self.assertIn("timestamp", entry)
        self.assertEqual(entry["event"], "webhook")
        self.assertEqual(entry["event_type"], "payment")

    def test_default_rules_are_loaded(self):
        rule_endpoints = set(self.gateway.describe_rules().keys())
        expected = {r.endpoint for r in _DEFAULT_RULES}
        self.assertEqual(rule_endpoints, expected)

    def test_identity_metadata_raises_permission_error(self):
        with self.assertRaises(PermissionError):
            self.gateway.execute(
                "api.vaultfire.query",
                lambda: None,
                amount=0.00042,
                metadata={"ip": "1.2.3.4"},  # identity metadata
                **self._valid_kwargs(),
            )


# ---------------------------------------------------------------------------
# Rail adapter tests
# ---------------------------------------------------------------------------

class TestEvmRail(unittest.TestCase):

    def setUp(self):
        self.rail = EvmRail()

    def test_supports_eth(self):
        self.assertTrue(self.rail.supports_currency("ETH"))

    def test_supports_usdc(self):
        self.assertTrue(self.rail.supports_currency("USDC"))

    def test_does_not_support_asm(self):
        self.assertFalse(self.rail.supports_currency("ASM"))

    def test_parse_webhook(self):
        payload = {
            "currency": "USDC",
            "amount": 1.5,
            "tx_hash": "0xabc123",
            "from": "0xsender",
            "chain": "base",
        }
        receipt = self.rail.parse_webhook(payload)
        self.assertIsInstance(receipt, X402PaymentReceipt)
        self.assertEqual(receipt.currency, "USDC")
        self.assertEqual(receipt.amount, 1.5)
        self.assertEqual(receipt.tx_ref, "0xabc123")
        self.assertEqual(receipt.payer, "0xsender")


class TestAssembleRail(unittest.TestCase):

    def setUp(self):
        self.rail = AssembleRail()

    def test_supports_asm(self):
        self.assertTrue(self.rail.supports_currency("ASM"))

    def test_does_not_support_eth(self):
        self.assertFalse(self.rail.supports_currency("ETH"))

    def test_parse_webhook(self):
        payload = {
            "amount": 0.00021,
            "wallet": "bpow20.cb.id",
            "receipt_id": "asm_receipt_001",
        }
        receipt = self.rail.parse_webhook(payload)
        self.assertEqual(receipt.rail, "assemble")
        self.assertEqual(receipt.currency, "ASM")
        self.assertEqual(receipt.amount, 0.00021)
        self.assertEqual(receipt.payer, "bpow20.cb.id")


class TestNs3Rail(unittest.TestCase):

    def setUp(self):
        self.rail = Ns3Rail()

    def test_supports_multiple_currencies(self):
        self.assertTrue(self.rail.supports_currency("ASM"))
        self.assertTrue(self.rail.supports_currency("ETH"))
        self.assertTrue(self.rail.supports_currency("USDC"))

    def test_parse_webhook(self):
        payload = {
            "currency": "ETH",
            "amount": 0.00031,
            "wallet": "0xpayer",
            "session_id": "sess_abc",
        }
        receipt = self.rail.parse_webhook(payload)
        self.assertEqual(receipt.rail, "ns3")
        self.assertEqual(receipt.currency, "ETH")
        self.assertEqual(receipt.tx_ref, "sess_abc")


class TestDefaultRails(unittest.TestCase):

    def test_default_rails_has_three_entries(self):
        self.assertEqual(len(DEFAULT_RAILS), 3)

    def test_default_rails_contains_expected_keys(self):
        self.assertIn("evm", DEFAULT_RAILS)
        self.assertIn("assemble", DEFAULT_RAILS)
        self.assertIn("ns3", DEFAULT_RAILS)


# ---------------------------------------------------------------------------
# Receipt verification tests
# ---------------------------------------------------------------------------

class TestReceiptVerification(unittest.TestCase):

    SECRET = "test-partner-secret-12345"
    PARTNER_ID = "assemble"
    KEY_ID = "k1"

    def _make_valid_payload(self) -> dict:
        nonce = "nonce_" + str(int(time.time() * 1000))
        payload = {
            "partner_id": self.PARTNER_ID,
            "key_id": self.KEY_ID,
            "rail": "assemble",
            "currency": "ASM",
            "amount": 0.00021,
            "wallet_address": "bpow20.cb.id",
            "tx_ref": "asm_001",
            "nonce": nonce,
            "timestamp": time.time(),
        }
        payload["signature"] = sign_payload(payload, self.SECRET)
        return payload

    def test_valid_payload_passes(self):
        payload = self._make_valid_payload()
        result = verify_hmac_receipt(payload, secret=self.SECRET)
        self.assertTrue(result.ok)
        self.assertIsNone(result.reason)

    def test_missing_signature_fails(self):
        payload = self._make_valid_payload()
        del payload["signature"]
        result = verify_hmac_receipt(payload, secret=self.SECRET)
        self.assertFalse(result.ok)
        self.assertEqual(result.reason, "missing signature")

    def test_wrong_signature_fails(self):
        payload = self._make_valid_payload()
        payload["signature"] = "a" * 64  # wrong but correct length
        result = verify_hmac_receipt(payload, secret=self.SECRET)
        self.assertFalse(result.ok)
        self.assertEqual(result.reason, "signature mismatch")

    def test_missing_nonce_fails(self):
        payload = self._make_valid_payload()
        del payload["nonce"]
        payload["signature"] = sign_payload(payload, self.SECRET)
        result = verify_hmac_receipt(payload, secret=self.SECRET)
        self.assertFalse(result.ok)
        self.assertEqual(result.reason, "missing nonce")

    def test_stale_timestamp_fails(self):
        payload = self._make_valid_payload()
        payload["timestamp"] = time.time() - 400  # more than 5 minutes ago
        payload["signature"] = sign_payload(payload, self.SECRET)
        result = verify_hmac_receipt(payload, secret=self.SECRET, max_skew_seconds=300)
        self.assertFalse(result.ok)
        self.assertEqual(result.reason, "timestamp skew too large")

    def test_wrong_secret_fails(self):
        payload = self._make_valid_payload()
        result = verify_hmac_receipt(payload, secret="wrong-secret")
        self.assertFalse(result.ok)

    def test_missing_partner_id_fails_when_required(self):
        payload = self._make_valid_payload()
        del payload["partner_id"]
        payload["signature"] = sign_payload(payload, self.SECRET)
        result = verify_hmac_receipt(payload, secret=self.SECRET, require_partner=True)
        self.assertFalse(result.ok)
        self.assertEqual(result.reason, "missing partner_id")

    def test_no_partner_required_passes_without_partner_id(self):
        payload = self._make_valid_payload()
        del payload["partner_id"]
        payload["signature"] = sign_payload(payload, self.SECRET)
        result = verify_hmac_receipt(payload, secret=self.SECRET, require_partner=False)
        # May still fail due to signature mismatch since partner_id affects canonical msg
        # This is expected — canonical message includes partner_id
        self.assertIsInstance(result, ReceiptVerificationResult)

    def test_canonical_message_is_deterministic(self):
        payload = {"partner_id": "test", "rail": "evm", "currency": "USDC", "amount": 1.0,
                   "nonce": "abc", "timestamp": 1234567890.0}
        msg1 = _canonical_receipt_message(payload)
        msg2 = _canonical_receipt_message(payload)
        self.assertEqual(msg1, msg2)

    def test_tampered_amount_fails(self):
        payload = self._make_valid_payload()
        # Sign valid, then change amount
        payload["amount"] = 9999.0
        result = verify_hmac_receipt(payload, secret=self.SECRET)
        self.assertFalse(result.ok)


# ---------------------------------------------------------------------------
# get_default_gateway singleton test
# ---------------------------------------------------------------------------

class TestGetDefaultGateway(unittest.TestCase):

    def test_returns_same_instance(self):
        gw1 = get_default_gateway()
        gw2 = get_default_gateway()
        self.assertIs(gw1, gw2)

    def test_is_x402_gateway_instance(self):
        self.assertIsInstance(get_default_gateway(), X402Gateway)


# ---------------------------------------------------------------------------
# Dashboard tests
# ---------------------------------------------------------------------------

class TestDashboard(unittest.TestCase):

    def setUp(self):
        self.tmp = tempfile.mkdtemp()
        self.gateway = make_gateway(Path(self.tmp))

    def test_load_dashboard_entries_empty(self):
        from python.x402_dashboard import load_dashboard_entries
        entries = load_dashboard_entries(gateway=self.gateway)
        self.assertEqual(entries, [])

    def test_load_dashboard_entries_after_payment(self):
        from python.x402_dashboard import load_dashboard_entries, aggregate_totals, count_total_events

        self.gateway.execute(
            "api.vaultfire.query",
            lambda: "ok",
            amount=0.00042,
            currency="ETH",
            wallet_address="bpow20.cb.id",
            signature="codex::test",
        )

        entries = load_dashboard_entries(gateway=self.gateway)
        self.assertGreater(len(entries), 0)
        self.assertIn("timestamp", entries[0])
        self.assertIn("action_type", entries[0])
        self.assertIn("token_payout", entries[0])

        count = count_total_events(gateway=self.gateway)
        self.assertGreater(count, 0)

        totals = aggregate_totals(entries)
        self.assertIn("ETH", totals)
        self.assertAlmostEqual(totals["ETH"], 0.00042, places=8)

    def test_export_dashboard_json(self):
        from python.x402_dashboard import export_dashboard

        entries = [{"timestamp": "2024-01-01T00:00:00+00:00", "action_type": "Codex",
                    "endpoint": "test", "token_payout": {"amount": 0.001, "currency": "ETH"},
                    "status": "recorded", "tx_hash": None, "metadata": {}}]
        dest = Path(self.tmp) / "export.json"
        result = export_dashboard(entries, dest, format="json")
        self.assertTrue(result.exists())
        loaded = json.loads(dest.read_text())
        self.assertEqual(len(loaded), 1)

    def test_export_dashboard_vaultledger(self):
        from python.x402_dashboard import export_dashboard

        entries = [{"key": "value"}]
        dest = Path(self.tmp) / "export.vaultledger"
        result = export_dashboard(entries, dest, format="vaultledger")
        self.assertTrue(result.exists())
        content = dest.read_text()
        self.assertIn("Vaultfire x402 Earnings Ledger", content)


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

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