"""
Tests for BitcoinPurple (BTCP) network support.

Covers:
  - Network constants against the technical specification
  - 120-block difficulty adjustment logic in blockchain.py
  - Address encoding under the BTCP network parameters
"""
import os
from unittest.mock import patch

from electrum import bitcoin, blockchain, constants, segwit_addr
from electrum.bitcoin import (
    address_to_script,
    is_address,
    is_b58_address,
    is_segwit_address,
    public_key_to_p2pkh,
    serialize_privkey,
    deserialize_privkey,
)
from electrum.blockchain import Blockchain, InvalidHeader
from electrum.constants import BitcoinPurple, BitcoinPurpleTestnet
from electrum.simple_config import SimpleConfig
from electrum.util import make_dir

from . import ElectrumTestCase


# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

class TestBitcoinPurpleConstants(ElectrumTestCase):
    """Verify all BTCP network constants against the technical specification."""

    # --- identity ---

    def test_net_names(self):
        self.assertEqual("bitcoinpurple", BitcoinPurple.NET_NAME)
        self.assertEqual("bitcoinpurple_testnet", BitcoinPurpleTestnet.NET_NAME)

    def test_testnet_flags(self):
        self.assertFalse(BitcoinPurple.TESTNET)
        self.assertTrue(BitcoinPurpleTestnet.TESTNET)

    def test_cli_flags_and_datadir(self):
        self.assertEqual("bitcoinpurple", BitcoinPurple.cli_flag())
        self.assertEqual("bitcoinpurple", BitcoinPurple.datadir_subdir())
        self.assertEqual("bitcoinpurple_testnet", BitcoinPurpleTestnet.cli_flag())
        self.assertEqual("bitcoinpurple_testnet", BitcoinPurpleTestnet.datadir_subdir())

    # --- address encoding ---

    def test_address_prefixes_mainnet(self):
        self.assertEqual(56, BitcoinPurple.ADDRTYPE_P2PKH)   # 0x38
        self.assertEqual(55, BitcoinPurple.ADDRTYPE_P2SH)    # 0x37
        self.assertEqual(0xb7, BitcoinPurple.WIF_PREFIX)     # 183

    def test_address_prefixes_testnet(self):
        # BTCP testnet keeps the same prefixes as mainnet
        self.assertEqual(56, BitcoinPurpleTestnet.ADDRTYPE_P2PKH)
        self.assertEqual(55, BitcoinPurpleTestnet.ADDRTYPE_P2SH)
        self.assertEqual(0xb7, BitcoinPurpleTestnet.WIF_PREFIX)

    def test_segwit_hrp(self):
        self.assertEqual("btcp", BitcoinPurple.SEGWIT_HRP)
        self.assertEqual("tbtcp", BitcoinPurpleTestnet.SEGWIT_HRP)

    def test_bolt11_hrp(self):
        self.assertEqual("btcp", BitcoinPurple.BOLT11_HRP)
        self.assertEqual("tbtcp", BitcoinPurpleTestnet.BOLT11_HRP)

    # --- genesis ---

    def test_genesis_mainnet(self):
        self.assertEqual(
            "000003823fbf82ea4906cbe214617ce7a70a5da29c19ecb1d65618bcf04ec015",
            BitcoinPurple.GENESIS,
        )

    def test_genesis_testnet(self):
        self.assertEqual(
            "000002fdc3921c1ad368816fcc587f499698d42b42ab5a5d94ee67882ef9d998",
            BitcoinPurpleTestnet.GENESIS,
        )

    def test_rev_genesis_bytes_mainnet(self):
        # Wire-order (reversed) bytes used in LN chain_hash
        expected_wire = "15c04ef0bc1856d6b1ec199ca25d0aa7e77c6114e2cb0649ea82bf3f82030000"
        self.assertEqual(expected_wire, BitcoinPurple.rev_genesis_bytes().hex())

    def test_rev_genesis_bytes_testnet(self):
        expected_wire = "98d9f92e8867ee945d5aab422bd49896497f58cc6f8168d31a1c92c3fd020000"
        self.assertEqual(expected_wire, BitcoinPurpleTestnet.rev_genesis_bytes().hex())

    # --- ports ---

    def test_default_ports_mainnet(self):
        self.assertEqual({'t': '50001', 's': '50002'}, BitcoinPurple.DEFAULT_PORTS)

    def test_default_ports_testnet(self):
        self.assertEqual({'t': '60001', 's': '60002'}, BitcoinPurpleTestnet.DEFAULT_PORTS)

    # --- PoW constants ---

    def test_pow_adjustment_interval(self):
        self.assertEqual(120, BitcoinPurple.DIFFICULTY_ADJUSTMENT_INTERVAL)
        # testnet inherits the same value
        self.assertEqual(120, BitcoinPurpleTestnet.DIFFICULTY_ADJUSTMENT_INTERVAL)

    def test_pow_target_timespan(self):
        self.assertEqual(7200, BitcoinPurple.POW_TARGET_TIMESPAN)   # 120 * 60 s

    def test_pow_max_adjustment_factor(self):
        self.assertEqual(4, BitcoinPurple.MAX_ADJUSTMENT_FACTOR)

    def test_pow_max_target_exceeds_bitcoin(self):
        # BTCP starts with easier proof-of-work than Bitcoin
        self.assertGreater(BitcoinPurple.MAX_TARGET, constants.BitcoinMainnet.MAX_TARGET)

    def test_pow_max_target_value(self):
        # compact 0x1e0ffff0 maps to a target just under BTCP MAX_TARGET
        genesis_bits = 0x1e0ffff0
        genesis_target = Blockchain.bits_to_target(genesis_bits)
        self.assertLessEqual(genesis_target, BitcoinPurple.MAX_TARGET)

    # --- HD key headers ---

    def test_xpub_headers_mainnet_match_bitcoin(self):
        # BTCP mainnet reuses Bitcoin's BIP32 root bytes (0488B21E / 0488ADE4)
        for script_type in ('standard', 'p2wpkh-p2sh', 'p2wpkh', 'p2wsh-p2sh', 'p2wsh'):
            with self.subTest(script_type=script_type):
                self.assertEqual(
                    constants.BitcoinMainnet.XPUB_HEADERS[script_type],
                    BitcoinPurple.XPUB_HEADERS[script_type],
                )
                self.assertEqual(
                    constants.BitcoinMainnet.XPRV_HEADERS[script_type],
                    BitcoinPurple.XPRV_HEADERS[script_type],
                )

    def test_xpub_headers_testnet_match_bitcoin_testnet(self):
        for script_type in ('standard', 'p2wpkh-p2sh', 'p2wpkh', 'p2wsh-p2sh', 'p2wsh'):
            with self.subTest(script_type=script_type):
                self.assertEqual(
                    constants.BitcoinTestnet.XPUB_HEADERS[script_type],
                    BitcoinPurpleTestnet.XPUB_HEADERS[script_type],
                )
                self.assertEqual(
                    constants.BitcoinTestnet.XPRV_HEADERS[script_type],
                    BitcoinPurpleTestnet.XPRV_HEADERS[script_type],
                )

    def test_xpub_inv_headers_are_inverses(self):
        for hdr, hdr_inv in (
            (BitcoinPurple.XPUB_HEADERS, BitcoinPurple.XPUB_HEADERS_INV),
            (BitcoinPurple.XPRV_HEADERS, BitcoinPurple.XPRV_HEADERS_INV),
        ):
            for k, v in hdr.items():
                self.assertEqual(k, hdr_inv[v])

    # --- Lightning ---

    def test_ln_realm_byte(self):
        self.assertEqual(0, BitcoinPurple.LN_REALM_BYTE)
        self.assertEqual(1, BitcoinPurpleTestnet.LN_REALM_BYTE)

    def test_ln_dns_seeds_empty(self):
        self.assertEqual([], BitcoinPurple.LN_DNS_SEEDS)
        self.assertEqual([], BitcoinPurpleTestnet.LN_DNS_SEEDS)

    def test_bip44_coin_type(self):
        self.assertEqual(13496, BitcoinPurple.BIP44_COIN_TYPE)
        self.assertEqual(1, BitcoinPurpleTestnet.BIP44_COIN_TYPE)

    # --- NETS_LIST integrity ---

    def test_nets_list_contains_btcp(self):
        net_names = [c.NET_NAME for c in constants.NETS_LIST]
        self.assertIn("bitcoinpurple", net_names)
        self.assertIn("bitcoinpurple_testnet", net_names)

    def test_nets_list_unique(self):
        net_names = [c.NET_NAME for c in constants.NETS_LIST]
        self.assertEqual(len(net_names), len(set(net_names)))

    # --- inheritance: BitcoinPurple must not inherit from any Bitcoin class ---

    def test_btcp_independent_from_bitcoin_mainnet(self):
        self.assertFalse(issubclass(BitcoinPurple, constants.BitcoinMainnet))

    def test_btcp_independent_from_bitcoin_testnet(self):
        self.assertFalse(issubclass(BitcoinPurple, constants.BitcoinTestnet))

    def test_btcp_testnet_inherits_from_btcp(self):
        self.assertTrue(issubclass(BitcoinPurpleTestnet, BitcoinPurple))


# ---------------------------------------------------------------------------
# Difficulty adjustment (120-block retarget)
# ---------------------------------------------------------------------------

class TestBitcoinPurpleDifficultyAdjustment(ElectrumTestCase):
    """
    Test the 120-block BTCP difficulty retarget logic in blockchain.get_target().

    Uses BitcoinPurple (mainnet) so that get_target() executes the real retarget
    formula.  (Testnet always returns 0, which prevents testing the formula.)
    For can_connect() tests we either rely on the ordering of checks inside
    can_connect() or patch the parts that are not under test.
    """

    GENESIS_BITS = 0x1e0ffff0  # nBits from BTCP genesis block

    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        constants.BitcoinPurple.set_as_network()

    @classmethod
    def tearDownClass(cls):
        super().tearDownClass()
        constants.BitcoinMainnet.set_as_network()

    def setUp(self):
        super().setUp()
        make_dir(os.path.join(self.electrum_path, 'forks'))
        self.config = SimpleConfig({'electrum_path': self.electrum_path})
        blockchain.blockchains = {}

    def _make_chain(self) -> Blockchain:
        chain = Blockchain(
            config=self.config, forkpoint=0, parent=None,
            forkpoint_hash=constants.net.GENESIS, prev_hash=None)
        open(chain.path(), 'w+').close()
        blockchain.blockchains[constants.net.GENESIS] = chain
        return chain

    def _fake_headers(self, first_ts: int, last_ts: int, bits: int = GENESIS_BITS):
        """Return a read_header side_effect that yields controlled timestamps/bits."""
        adj = constants.net.DIFFICULTY_ADJUSTMENT_INTERVAL

        def read_header(height: int):
            if height % adj == 0:
                return {'bits': bits, 'timestamp': first_ts}
            if height % adj == adj - 1:
                return {'bits': bits, 'timestamp': last_ts}
            return {'bits': bits, 'timestamp': first_ts + (last_ts - first_ts) * (height % adj) // (adj - 1)}

        return read_header

    # --- index -1 ---

    def test_get_target_genesis_index_returns_genesis_target(self):
        # Index -1 must return the genesis-era target (derived from POW_GENESIS_BITS
        # 0x1e0ffff0), which is slightly harder than MAX_TARGET (0x1e0fffff).
        chain = self._make_chain()
        got = chain.get_target(-1)
        expected = Blockchain.bits_to_target(BitcoinPurple.POW_GENESIS_BITS)
        self.assertEqual(expected, got)
        # Genesis target must be at or below powLimit
        self.assertLessEqual(got, BitcoinPurple.MAX_TARGET)
        # Differs from Bitcoin mainnet's MAX_TARGET
        self.assertNotEqual(constants.BitcoinMainnet.MAX_TARGET, got)

    # --- retarget formula ---

    def test_get_target_on_time(self):
        """actual == target_timespan → target unchanged."""
        ts = constants.net.POW_TARGET_TIMESPAN   # 7200
        initial_target = Blockchain.bits_to_target(self.GENESIS_BITS)
        chain = self._make_chain()
        with patch.object(chain, 'read_header', side_effect=self._fake_headers(0, ts)):
            new_target = chain.get_target(0)
        expected = Blockchain.bits_to_target(Blockchain.target_to_bits(initial_target))
        self.assertEqual(expected, new_target)

    def test_get_target_fast_blocks_increases_difficulty(self):
        """Blocks mined twice as fast → target halves (difficulty doubles)."""
        ts = constants.net.POW_TARGET_TIMESPAN   # 7200
        actual = ts // 2                          # 3600, above the 1800-floor clamp

        initial_target = Blockchain.bits_to_target(self.GENESIS_BITS)
        expected_target = Blockchain.bits_to_target(
            Blockchain.target_to_bits(initial_target * actual // ts)
        )
        chain = self._make_chain()
        with patch.object(chain, 'read_header', side_effect=self._fake_headers(0, actual)):
            new_target = chain.get_target(0)

        self.assertEqual(expected_target, new_target)
        self.assertLess(new_target, initial_target)  # harder

    def test_get_target_slow_blocks_decreases_difficulty(self):
        """Blocks mined twice as slow → target doubles (difficulty halves)."""
        ts = constants.net.POW_TARGET_TIMESPAN   # 7200
        actual = ts * 2                           # 14400, below the 28800-ceiling clamp

        initial_target = Blockchain.bits_to_target(self.GENESIS_BITS)
        expected_target = Blockchain.bits_to_target(
            Blockchain.target_to_bits(min(BitcoinPurple.MAX_TARGET, initial_target * actual // ts))
        )
        chain = self._make_chain()
        with patch.object(chain, 'read_header', side_effect=self._fake_headers(0, actual)):
            new_target = chain.get_target(0)

        self.assertEqual(expected_target, new_target)
        self.assertGreater(new_target, initial_target)  # easier

    def test_get_target_clamp_lower(self):
        """actual < target/4 → clamped to target/4 (max 4× harder per retarget)."""
        ts = constants.net.POW_TARGET_TIMESPAN           # 7200
        floor = ts // constants.net.MAX_ADJUSTMENT_FACTOR  # 1800
        # Use actual timespan far below the floor
        actual = 100

        initial_target = Blockchain.bits_to_target(self.GENESIS_BITS)
        expected_target = Blockchain.bits_to_target(
            Blockchain.target_to_bits(initial_target * floor // ts)
        )
        chain = self._make_chain()
        with patch.object(chain, 'read_header', side_effect=self._fake_headers(0, actual)):
            new_target = chain.get_target(0)

        self.assertEqual(expected_target, new_target)

    def test_get_target_clamp_upper(self):
        """actual > target*4 → clamped to target*4 (max 4× easier per retarget)."""
        ts = constants.net.POW_TARGET_TIMESPAN                     # 7200
        ceiling = ts * constants.net.MAX_ADJUSTMENT_FACTOR        # 28800
        # Use actual timespan far above the ceiling
        actual = 999_999

        initial_target = Blockchain.bits_to_target(self.GENESIS_BITS)
        raw = initial_target * ceiling // ts
        expected_target = Blockchain.bits_to_target(
            Blockchain.target_to_bits(min(BitcoinPurple.MAX_TARGET, raw))
        )
        chain = self._make_chain()
        with patch.object(chain, 'read_header', side_effect=self._fake_headers(0, actual)):
            new_target = chain.get_target(0)

        self.assertEqual(expected_target, new_target)

    # --- period index computation ---

    def test_adj_interval_is_120_not_2016(self):
        """adj_interval from constants must equal 120 when BTCP network is active."""
        self.assertEqual(120, constants.net.DIFFICULTY_ADJUSTMENT_INTERVAL)

    def test_get_target_uses_120_block_window(self):
        """get_target(1) reads headers at heights 120 and 239, not 2016 and 4031."""
        ts = constants.net.POW_TARGET_TIMESPAN
        read_calls = []

        def tracking_read_header(height):
            read_calls.append(height)
            return {'bits': self.GENESIS_BITS, 'timestamp': height * 60}

        chain = self._make_chain()
        with patch.object(chain, 'read_header', side_effect=tracking_read_header):
            chain.get_target(1)

        # Must have read exactly headers 120 and 239 (period 1 = [120, 239])
        self.assertIn(120, read_calls)
        self.assertIn(239, read_calls)
        # Must NOT have touched 2016 or 4031 (Bitcoin-style chunk boundary)
        self.assertNotIn(2016, read_calls)
        self.assertNotIn(4031, read_calls)

    def test_can_connect_uses_120_block_period(self):
        """
        can_connect should look up target at height // 120 - 1, not height // 2016 - 1.
        Verify by checking that get_target is called with the correct period index.
        """
        chain = self._make_chain()
        get_target_calls = []
        original_get_target = chain.get_target

        def tracking_get_target(index):
            get_target_calls.append(index)
            return original_get_target(index)

        # Height 1 (period 0): can_connect calls get_target(1 // 120 - 1) = get_target(-1)
        # before verify_header.  Even though verify_header rejects the PoW (nonce=0),
        # get_target is called first so the index is recorded.
        dummy_header = {
            'block_height': 1,
            'prev_block_hash': constants.net.GENESIS,
            'version': 1,
            'merkle_root': '00' * 32,
            'timestamp': 1691126892,
            'bits': self.GENESIS_BITS,
            'nonce': 0,
        }
        with patch.object(chain, 'get_target', side_effect=tracking_get_target):
            chain.can_connect(dummy_header, check_height=False)
        self.assertIn(-1, get_target_calls)

        # Height 121 (first block of period 1): get_target(121 // 120 - 1) = get_target(0).
        # get_hash(120) would raise MissingHeader (header not on disk) before get_target
        # is reached, so we patch get_hash to return a fake prev hash.
        get_target_calls.clear()
        fake_prev = 'ab' * 32
        dummy_header['block_height'] = 121
        dummy_header['prev_block_hash'] = fake_prev
        with patch.object(chain, 'get_hash', return_value=fake_prev):
            with patch.object(chain, 'get_target', side_effect=tracking_get_target):
                chain.can_connect(dummy_header, check_height=False)
        self.assertIn(0, get_target_calls)


# ---------------------------------------------------------------------------
# Address encoding under BTCP network parameters
# ---------------------------------------------------------------------------

class TestBitcoinPurpleAddress(ElectrumTestCase):
    """P2PKH, P2SH, Bech32, and WIF encoding under BitcoinPurple network."""

    # compressed pubkey for a known private key (k=1, secp256k1)
    PUBKEY_HEX = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"

    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        constants.BitcoinPurple.set_as_network()

    @classmethod
    def tearDownClass(cls):
        super().tearDownClass()
        constants.BitcoinMainnet.set_as_network()

    def test_p2pkh_address_starts_with_P(self):
        # P2PKH prefix 56 (0x38) encodes to Base58 addresses starting with 'P'
        pubkey = bytes.fromhex(self.PUBKEY_HEX)
        addr = public_key_to_p2pkh(pubkey)
        self.assertTrue(addr.startswith('P'), f"Expected 'P...' address, got: {addr}")

    def test_p2pkh_address_is_valid(self):
        pubkey = bytes.fromhex(self.PUBKEY_HEX)
        addr = public_key_to_p2pkh(pubkey)
        self.assertTrue(is_address(addr))
        self.assertTrue(is_b58_address(addr))

    def test_p2pkh_not_valid_on_bitcoin_mainnet(self):
        # BTCP address must be rejected on Bitcoin mainnet (different prefix)
        pubkey = bytes.fromhex(self.PUBKEY_HEX)
        addr = public_key_to_p2pkh(pubkey)
        self.assertFalse(is_address(addr, net=constants.BitcoinMainnet))

    def test_bech32_hrp_is_btcp(self):
        # A native SegWit witness-v0 address encoded with HRP 'btcp' must be
        # accepted as valid by the BTCP network and rejected by Bitcoin mainnet.
        witness_program = bytes(20)   # all-zero 20-byte hash (P2WPKH)
        addr = segwit_addr.encode_segwit_address(BitcoinPurple.SEGWIT_HRP, 0, witness_program)
        self.assertIsNotNone(addr)
        self.assertTrue(is_segwit_address(addr))
        self.assertFalse(is_segwit_address(addr, net=constants.BitcoinMainnet))

    def test_bech32_address_to_script(self):
        # address_to_script must produce a valid P2WPKH scriptPubKey for a BTCP bech32 addr
        witness_program = bytes(20)
        addr = segwit_addr.encode_segwit_address(BitcoinPurple.SEGWIT_HRP, 0, witness_program)
        script = address_to_script(addr)
        # P2WPKH scriptPubKey: OP_0 <20-byte-hash> = 0x0014 + 20 zero bytes
        self.assertEqual('0014' + '00' * 20, script.hex())

    def test_wif_roundtrip(self):
        # serialize_privkey / deserialize_privkey must round-trip under BTCP prefix 0xb7
        raw_key = bytes(31) + bytes([1])   # 32-byte privkey with value 1
        wif = serialize_privkey(raw_key, True, 'p2pkh')
        txin_type, got_key, compressed = deserialize_privkey(wif)
        self.assertEqual(raw_key, got_key)
        self.assertTrue(compressed)

    def test_bitcoin_address_not_valid_on_btcp(self):
        # A Bitcoin mainnet P2PKH address ('1...') must not pass is_address on BTCP
        btc_addr = "1A1zP1eP5QGefi2DMPTfTL5SLmv7Divf"  # Bitcoin genesis coinbase
        self.assertFalse(is_address(btc_addr))

    def test_bitcoin_bech32_not_valid_on_btcp(self):
        # A Bitcoin mainnet bech32 address (HRP 'bc') must be rejected on BTCP
        btc_bech32 = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"
        self.assertFalse(is_segwit_address(btc_bech32))
