import copy
from pathlib import Path

import pytest

import ape
from ape.api.networks import EcosystemAPI
from ape.api.providers import SubprocessProvider
from ape.exceptions import NetworkError, ProviderNotFoundError
from ape.managers.networks import NodeProcessData, NodeProcessMap
from ape.utils.misc import LOCAL_NETWORK_NAME
from ape.utils.os import create_tempdir
from ape.utils.testing import DEFAULT_TEST_CHAIN_ID


class NewChainID:
    chain_id = 12345678987654

    def __call__(self, *args, **kwargs) -> int:
        self.chain_id += 1
        return self.chain_id


chain_id_factory = NewChainID()

DEFAULT_CHOICES = {
    "::node",
    "::test",
    ":sepolia",
    ":sepolia:node",
    ":local",
    ":mainnet",
    ":mainnet:node",
    "ethereum",
    "ethereum::test",
    "ethereum::node",
    "ethereum:sepolia",
    "ethereum:sepolia:node",
    "ethereum:local",
    "ethereum:local:node",
    "ethereum:local:test",
    "ethereum:mainnet",
    "ethereum:mainnet:node",
}


@pytest.fixture
def get_provider_with_unused_chain_id(networks_connected_to_tester):
    networks = networks_connected_to_tester

    def fn(**more_settings):
        chain_id = chain_id_factory()
        settings = {"chain_id": chain_id, **more_settings}
        choice = "ethereum:local:test"
        disconnect_after = settings.pop("disconnect_after", True)
        context = networks.parse_network_choice(
            choice, disconnect_after=disconnect_after, provider_settings=settings
        )
        return context

    return fn


@pytest.fixture
def get_context(networks_connected_to_tester):
    def fn():
        return networks_connected_to_tester.parse_network_choice("ethereum:local:test")

    return fn


@pytest.fixture
def network_with_no_providers(ethereum):
    network = ethereum.get_network("sepolia-fork")
    default_provider = network.default_provider
    providers = network.__dict__["providers"]

    if default_provider or providers:
        # Handle user running tests with forked-network plugins installed
        network._default_provider = None
        network.__dict__["providers"] = []

    yield network

    if default_provider or providers:
        network._default_provider = default_provider.name
        network.__dict__["providers"] = providers
        assert network.default_provider, "Tear-down failed - providers not set."


def test_get_network_choices(networks, ethereum, mocker):
    # Simulate having a provider like foundry installed.
    mock_provider = mocker.MagicMock()
    mock_provider.name = "mock"
    ethereum.networks["mainnet-fork"].providers["mock"] = mock_provider
    ethereum.networks["local"].providers["mock"] = mock_provider

    # Ensure the provider shows up both mainnet fork and local
    # (There was once a bug where it was skipped!)
    expected = {":mainnet-fork:mock", ":local:mock"}

    actual = {c for c in networks.get_network_choices()}
    expected = DEFAULT_CHOICES.union(expected)
    assert expected.issubset(actual)


def test_get_network_choices_filter_ecosystem(networks):
    actual = {c for c in networks.get_network_choices(ecosystem_filter="ethereum")}
    assert DEFAULT_CHOICES.issubset(actual)


def test_get_network_choices_filter_network(networks):
    actual = {c for c in networks.get_network_choices(network_filter="mainnet")}
    mainnet_choices = {
        ":mainnet",
        ":mainnet:node",
        "ethereum",
        "ethereum:mainnet",
        "ethereum:mainnet:node",
    }
    assert mainnet_choices.issubset(actual)


def test_get_network_choices_filter_provider(networks):
    actual = {c for c in networks.get_network_choices(provider_filter="test")}
    expected = {"::test", ":local", "ethereum:local", "ethereum:local:test", "ethereum"}
    assert all(e in actual for e in expected)


def test_get_provider_when_no_default(network_with_no_providers):
    expected = f"No default provider for network '{network_with_no_providers.name}'"
    with pytest.raises(NetworkError, match=expected):
        # Not provider installed out-of-the-box for sepolia-fork network
        provider = network_with_no_providers.get_provider()
        assert not provider, f"Provider should be None but got '{provider.name}'"


def test_repr_connected_to_local(networks_connected_to_tester):
    actual = repr(networks_connected_to_tester)
    expected = f"<NetworkManager active_provider=<Test chain_id={DEFAULT_TEST_CHAIN_ID}>>"
    assert actual == expected

    # Check individual network
    actual = repr(networks_connected_to_tester.provider.network)
    expected = f"<ethereum:local chain_id={DEFAULT_TEST_CHAIN_ID}>"
    assert actual == expected


def test_repr_disconnected(networks_disconnected):
    assert repr(networks_disconnected) == "<NetworkManager>"
    assert repr(networks_disconnected.ethereum) == "<ethereum>"
    assert repr(networks_disconnected.ethereum.local) == "<ethereum:local>"
    assert repr(networks_disconnected.ethereum.sepolia) == "<ethereum:sepolia chain_id=11155111>"


def test_get_provider_from_choice_custom_provider(networks_connected_to_tester):
    uri = "https://geth:1234567890abcdef@geth.foo.bar/"
    provider = networks_connected_to_tester.get_provider_from_choice(f"ethereum:local:{uri}")
    assert uri in provider.connection_id
    assert provider.name == "node"
    assert provider.uri == uri
    assert provider.network.name == "local"  # Network was specified to be local!
    assert provider.network.ecosystem.name == "ethereum"


def test_get_provider_from_choice_custom_adhoc_ecosystem(networks_connected_to_tester):
    uri = "https://geth:1234567890abcdef@geth.foo.bar/"
    provider = networks_connected_to_tester.get_provider_from_choice(uri)
    assert provider.name == "node"
    assert provider.uri == uri
    assert provider.network.name == "custom"
    assert provider.network.ecosystem.name == "ethereum"


def test_parse_network_choice_same_provider(chain, networks_connected_to_tester, get_context):
    context = get_context()
    start_count = len(context.connected_providers)
    original_block_number = chain.blocks.height
    object_id = id(chain.provider)

    with context:
        assert id(chain.provider) == object_id
        count = len(context.connected_providers)

        # Does not create a new provider since it is the same chain ID
        assert count == start_count

    assert id(chain.provider) == object_id
    assert len(context.connected_providers) == start_count
    assert chain.blocks.height == original_block_number

    for provider in context.connected_providers.values():
        assert provider._web3 is not None


@pytest.mark.xdist_group(name="multiple-eth-testers")
def test_parse_network_choice_new_chain_id(get_provider_with_unused_chain_id, get_context):
    start_count = len(get_context().connected_providers)
    context = get_provider_with_unused_chain_id()
    with context:
        count = len(context.connected_providers)

        assert context._provider.chain_id != DEFAULT_TEST_CHAIN_ID

        # Creates new provider since it has a new chain ID
        assert count == start_count + 1

    for provider in context.connected_providers.values():
        assert provider._web3 is not None


@pytest.mark.xdist_group(name="multiple-eth-testers")
def test_parse_network_choice_disconnect_after(get_provider_with_unused_chain_id):
    context = get_provider_with_unused_chain_id()
    with context as provider:
        connection_id = provider.connection_id
        assert connection_id in context.connected_providers

    assert connection_id not in context.connected_providers


@pytest.mark.xdist_group(name="multiple-eth-testers")
def test_parse_network_choice_multiple_contexts(
    eth_tester_provider, get_provider_with_unused_chain_id
):
    first_context = get_provider_with_unused_chain_id()
    assert eth_tester_provider.chain_id == DEFAULT_TEST_CHAIN_ID, (
        "Test setup failed - expecting to start on default chain ID"
    )
    assert eth_tester_provider.make_request("eth_chainId") == DEFAULT_TEST_CHAIN_ID

    with first_context:
        start_count = len(first_context.connected_providers)
        expected_next_count = start_count + 1
        second_context = get_provider_with_unused_chain_id()
        with second_context:
            # Second context should already know about connected providers
            assert len(first_context.connected_providers) == expected_next_count
            assert len(second_context.connected_providers) == expected_next_count

    assert eth_tester_provider.chain_id == DEFAULT_TEST_CHAIN_ID
    assert eth_tester_provider.make_request("eth_chainId") == DEFAULT_TEST_CHAIN_ID


def test_getattr_ecosystem_with_hyphenated_name(networks, ethereum):
    networks._plugin_ecosystems["hyphen-in-name"] = networks.ecosystems["ethereum"]
    assert networks.hyphen_in_name  # Make sure does not raise AttributeError
    del networks.ecosystems["hyphen-in-name"]


def test_getattr_custom_ecosystem(networks, custom_networks_config_dict, project):
    data = copy.deepcopy(custom_networks_config_dict)
    data["networks"]["custom"][0]["ecosystem"] = "custom-ecosystem"

    with project.temp_config(**data):
        actual = networks.custom_ecosystem
        assert isinstance(actual, EcosystemAPI)


def test_getattr_plugin_ecosystem_same_as_evm_chains(mocker, networks):
    """
    Simulated having a plugin ecosystem installed.
    """
    optimismy = mocker.MagicMock()
    networks._plugin_ecosystems["optimismy"] = optimismy
    actual = networks.optimismy
    del networks._plugin_ecosystems["optimismy"]
    assert actual == optimismy


def test_getattr_evm_chains_ecosystem(networks):
    """
    Show we can getattr evm-chains only ecosystems.
    """
    actual = networks.moonbeam
    assert actual.name == "moonbeam"


def test_getattr_plugin_ecosystem_same_name_as_evm_chains(mocker, networks):
    """
    Show when an ecosystem is both in evm-chains and an installed plugin
    that Ape prefers the installed plugin.
    """
    moonbeam_plugin = mocker.MagicMock()
    networks._plugin_ecosystems["moonbeam"] = moonbeam_plugin
    actual = networks.moonbeam
    del networks._plugin_ecosystems["moonbeam"]
    assert actual == moonbeam_plugin
    assert actual != networks._evmchains_ecosystems["moonbeam"]


@pytest.mark.parametrize("scheme", ("http", "https"))
def test_create_custom_provider_http(networks, scheme):
    provider = networks.create_custom_provider(f"{scheme}://example.com")
    assert provider.uri == f"{scheme}://example.com"


@pytest.mark.parametrize("scheme", ("ws", "wss"))
def test_create_custom_provider_ws(networks, scheme):
    with pytest.raises(NetworkError):
        networks.create_custom_provider(f"{scheme}://example.com")


def test_create_custom_provider_ipc(networks):
    provider = networks.create_custom_provider("path/to/geth.ipc")
    assert provider.ipc_path == Path("path/to/geth.ipc")
    assert provider.uri == f"{provider.ipc_path}"


def test_ecosystems(networks):
    actual = networks.ecosystems
    assert "ethereum" in actual
    assert actual["ethereum"].name == "ethereum"


def test_ecosystems_include_custom(networks, custom_networks_config_dict, project):
    data = copy.deepcopy(custom_networks_config_dict)
    data["networks"]["custom"][0]["ecosystem"] = "custom-ecosystem"
    with project.temp_config(**data):
        actual = networks.ecosystems

    assert "custom-ecosystem" in actual


def test_ecosystems_when_custom_has_bad_base_ecosystem(
    networks, custom_networks_config_dict, project
):
    data = copy.deepcopy(custom_networks_config_dict)
    eco_name = "custom-ecosystem-bad"
    eco_plugin_name = "plugin-ecosystem-not-installed"
    # Only hits error if ecosystem is custom because it is unknown.
    data["networks"]["custom"][0]["ecosystem"] = eco_name
    # The plugin base is for the class-implementation. If it is not a real plugin,
    # it should fail nicely.
    data["networks"]["custom"][0]["base_ecosystem_plugin"] = eco_plugin_name
    expected = (
        rf"Custom network '{eco_name}:apenet' specified unknown "
        rf"base-ecosystem class '{eco_plugin_name}'\. "
        rf"Are you missing plugin 'ape-{eco_plugin_name}'\?"
    )
    with project.temp_config(**data):
        with pytest.raises(NetworkError, match=expected):
            _ = networks.ecosystems


def test_fork_network_not_forkable(networks, eth_tester_provider):
    """
    Show correct failure when trying to fork the local network.
    """
    expected = "Unable to fork network 'local'."
    with pytest.raises(NetworkError, match=expected):
        with networks.fork():
            pass


def test_fork_no_providers(networks, mock_sepolia, disable_fork_providers):
    """
    Show correct failure when trying to fork without
    ape-hardhat or ape-foundry installed.
    """
    expected = "No providers for network 'sepolia-fork'."
    with pytest.raises(NetworkError, match=expected):
        with networks.fork():
            pass


def test_fork_use_non_existing_provider(networks, mock_sepolia):
    """
    Show correct failure when specifying a non-existing provider.
    """
    expected = "No provider named 'NOT_EXISTS' in network 'sepolia-fork' in ecosystem 'ethereum'.*"
    with pytest.raises(ProviderNotFoundError, match=expected):
        with networks.fork(provider_name="NOT_EXISTS"):
            pass


def test_fork(networks, mock_sepolia, mock_fork_provider):
    """
    Happy-path fork test.
    """
    ctx = networks.fork()
    assert ctx._disconnect_after is True
    with ctx as provider:
        assert provider.name == "mock"
        assert provider.network.name == "sepolia-fork"
        # Fork the fork.
        ctx2 = networks.fork()
        with ctx2 as provider2:
            assert provider2.partial_call[1]["provider_settings"]["host"] == "auto"
            assert provider2.name == "mock"
            assert provider2.network.name == "sepolia-fork"


def test_fork_specify_provider(networks, mock_sepolia, mock_fork_provider):
    """
    Happy-path fork test when specifying the provider.
    """
    ctx = networks.fork(provider_name="mock")
    assert ctx._disconnect_after is True
    with ctx as provider:
        assert provider.name == "mock"
        assert provider.network.name == "sepolia-fork"


def test_fork_with_provider_settings(networks, mock_sepolia, mock_fork_provider):
    """
    Show it uses the given provider settings.
    """
    settings = {"fork": {"ethereum": {"sepolia": {"block_number": 123}}}}
    with networks.fork(provider_settings=settings):
        actual = mock_fork_provider.partial_call
        assert actual[1]["provider_settings"] == settings


def test_fork_with_positive_block_number(networks, mock_sepolia, mock_fork_provider):
    block_id = 123
    with networks.fork(block_number=block_id):
        call = mock_fork_provider.partial_call

    settings = call[1]["provider_settings"]["fork"]["ethereum"]["sepolia"]
    actual = settings["block_number"]
    assert actual == block_id


def test_fork_with_negative_block_number(
    networks, mock_sepolia, mock_fork_provider, eth_tester_provider
):
    # Mine so we are past genesis.
    block_id = -1
    block = eth_tester_provider.get_block("latest")
    mock_fork_provider.get_block.return_value = block

    with networks.fork(block_number=block_id):
        call = mock_fork_provider.partial_call

    actual = call[1]["provider_settings"]["fork"]["ethereum"]["sepolia"]["block_number"]
    expected = block.number - 1  # Relative to genesis!
    assert actual == expected


def test_fork_past_genesis(networks, mock_sepolia, mock_fork_provider, eth_tester_provider):
    block_id = -10_000_000_000
    with pytest.raises(NetworkError, match="Unable to fork past genesis block."):
        with networks.fork(block_number=block_id):
            pass


def test_getitem(networks):
    ethereum = networks["ethereum"]
    assert ethereum.name == "ethereum"
    assert isinstance(ethereum, EcosystemAPI)


def test_network_names(networks, custom_networks_config_dict, project):
    data = copy.deepcopy(custom_networks_config_dict)
    with project.temp_config(**data):
        actual = networks.network_names
        assert LOCAL_NETWORK_NAME in actual
        assert "mainnet" in actual  # Normal
        assert "mainnet-fork" in actual  # Forked
        assert "apenet" in actual  # Custom
        assert "apenet-fork" in actual  # Custom forked


def test_custom_networks_defined_in_non_local_project(custom_networks_config_dict):
    """
    Showing we can read and use custom networks that are not defined
    in the local project.
    """
    # Ensure we are using a name that is not used anywhere else, for accurte testing.
    net_name = "customnetdefinedinnonlocalproj"
    eco_name = "customecosystemnotdefinedyet"
    custom_networks = copy.deepcopy(custom_networks_config_dict)
    custom_networks["networks"]["custom"][0]["name"] = net_name
    custom_networks["networks"]["custom"][0]["ecosystem"] = eco_name

    with ape.Project.create_temporary_project(config_override=custom_networks) as temp_project:
        nm = temp_project.network_manager

        # Tests `.get_ecosystem()` for custom networks.
        ecosystem = nm.get_ecosystem(eco_name)
        assert ecosystem.name == eco_name

        network = ecosystem.get_network(net_name)
        assert network.name == net_name


def test_get_ecosystem(networks):
    ethereum = networks.get_ecosystem("ethereum")
    assert isinstance(ethereum, EcosystemAPI)
    assert ethereum.name == "ethereum"


def test_get_ecosystem_from_evmchains(networks):
    """
    Show we can call `.get_ecosystem()` for an ecosystem only
    defined in evmchains.
    """
    moonbeam = networks.get_ecosystem("moonbeam")
    assert isinstance(moonbeam, EcosystemAPI)
    assert moonbeam.name == "moonbeam"


class TestNodeProcessData:
    def test_matches_provider(self, eth_tester_provider):
        data = NodeProcessData(
            network_choice=f"{eth_tester_provider.network.choice}:{eth_tester_provider.name}",
            ipc_path="test.ipc",
        )
        assert not data.matches_provider(eth_tester_provider)
        data.ipc_path = None
        assert data.matches_provider(eth_tester_provider)


class TestNodeProcessMap:
    def test_cache_and_remove_provider(self, mocker, eth_tester_provider):
        mock_process = mocker.MagicMock()
        mock_process.pid = 12345678901234567890

        class MyFakeProvider(SubprocessProvider):
            @property
            def is_connected(self):
                return True

        # Hack to allow abstract methods.
        MyFakeProvider.__abstractmethods__ = set()  # type: ignore
        provider = MyFakeProvider(name="fake", network=eth_tester_provider.network)  # type: ignore
        provider.process = mock_process

        with create_tempdir() as tmp:
            file = tmp / "networks.json"
            mapping = NodeProcessMap(path=file)
            mapping.cache_provider(provider)
            assert provider in mapping

            # Not test removing it.
            mapping.remove_provider(provider)
            assert provider not in mapping
