"""
Connectivity / network-error handling tests for the will-executor client.

Simulates every realistic network failure mode and asserts the plugin handles
each one gracefully (no crash, sane fallback) across all the requests it makes
to will-executor servers and to the welist:

    * offline (no Electrum network instance)
    * connection refused / reset (``OSError`` / ``ConnectionError``)
    * DNS resolution failure (``socket.gaierror``)
    * request timeout (``TimeoutError``) -> retried, then gives up
    * HTTP / server errors (generic ``Exception`` from the transport)
    * malformed / empty response bodies

All tests mock the Electrum transport (``Network.send_http_on_proxy`` /
``Network.get_instance``) so they never touch the real network.
"""

import os
import socket
import sys

import pytest
from unittest.mock import MagicMock, patch

sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))

from bal.core.willexecutors import Willexecutors


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

def _online():
    """Patch ``Network.get_instance`` to look online."""
    return patch(
        "bal.core.willexecutors.Network.get_instance",
        return_value=MagicMock(),
    )


def _transport(side_effect=None, return_value=None):
    """Patch the low-level HTTP transport used by ``send_request``."""
    return patch(
        "bal.core.willexecutors.Network.send_http_on_proxy",
        side_effect=side_effect,
        return_value=return_value,
    )


def _we(url="https://we.bitcoin-after.life"):
    return {
        "url": url,
        "address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7",
        "base_fee": 100000,
        "selected": True,
        "status": "New",
    }


# ------------------------------------------------------------------ #
# Offline (no network instance)
# ------------------------------------------------------------------ #

def test_send_request_offline_raises():
    with patch("bal.core.willexecutors.Network.get_instance", return_value=None):
        with pytest.raises(Exception) as exc:
            Willexecutors.send_request("get", "https://we.example/info")
        assert "offline" in str(exc.value).lower()


def test_get_info_task_offline_marks_ko():
    we = _we()
    with patch("bal.core.willexecutors.Network.get_instance", return_value=None):
        out = Willexecutors.get_info_task(we["url"], we)
    assert out["status"] == "KO"


def test_download_list_offline_returns_empty():
    with patch("bal.core.willexecutors.Network.get_instance", return_value=None):
        out = Willexecutors.download_list({}, "https://welist.bitcoin-after.life/")
    assert out == {}


# ------------------------------------------------------------------ #
# Connection refused / reset
# ------------------------------------------------------------------ #

@pytest.mark.parametrize("exc", [
    ConnectionRefusedError("refused"),
    ConnectionResetError("reset"),
    OSError("network unreachable"),
])
def test_get_info_task_connection_errors(exc):
    we = _we()
    with _online(), _transport(side_effect=exc):
        out = Willexecutors.get_info_task(we["url"], we, max_retries=0)
    assert out["status"] == "KO"


@pytest.mark.parametrize("exc", [
    ConnectionRefusedError("refused"),
    OSError("unreachable"),
])
def test_download_list_connection_errors(exc):
    with _online(), _transport(side_effect=exc):
        out = Willexecutors.download_list({}, "https://welist.bitcoin-after.life")
    assert out == {}


@pytest.mark.parametrize("exc", [
    ConnectionRefusedError("refused"),
    OSError("unreachable"),
])
def test_push_connection_errors_marks_failed(exc):
    we = _we()
    we["txs"] = "rawtxhex\n"
    with _online(), _transport(side_effect=exc):
        ok = Willexecutors.push_transactions_to_willexecutor(
            we, timeout=1, max_retries=0, retry_sleep=0
        )
    assert ok is False
    assert we["broadcast_status"] == "Failed"


def test_check_transaction_connection_error_raises():
    with _online(), _transport(side_effect=ConnectionRefusedError("refused")):
        with pytest.raises(Exception):
            Willexecutors.check_transaction(
                "txid", "https://we.example", timeout=1, max_retries=0,
                retry_sleep=0,
            )


# ------------------------------------------------------------------ #
# DNS resolution failure
# ------------------------------------------------------------------ #

def test_get_info_task_dns_failure():
    we = _we("https://nonexistent.invalid")
    with _online(), _transport(side_effect=socket.gaierror("name resolution")):
        out = Willexecutors.get_info_task(we["url"], we, max_retries=0)
    assert out["status"] == "KO"


def test_download_list_dns_failure():
    with _online(), _transport(side_effect=socket.gaierror("name resolution")):
        out = Willexecutors.download_list({}, "https://nonexistent.invalid")
    assert out == {}


# ------------------------------------------------------------------ #
# Timeout (retried, then handled)
# ------------------------------------------------------------------ #

def test_send_request_timeout_retries_then_gives_up():
    calls = {"n": 0}

    def boom(*a, **k):
        calls["n"] += 1
        raise TimeoutError("timed out")

    with _online(), _transport(side_effect=boom):
        # max_retries=2 + initial attempt = 3 calls, then returns None.
        result = Willexecutors.send_request(
            "get", "https://we.example/info", max_retries=2, retry_sleep=0,
        )
    assert result is None
    assert calls["n"] == 3  # 1 initial + 2 retries


def test_send_request_timeout_then_success():
    seq = [TimeoutError("t1"), "ok"]

    def maybe(*a, **k):
        v = seq.pop(0)
        if isinstance(v, Exception):
            raise v
        return v

    with _online(), _transport(side_effect=maybe):
        result = Willexecutors.send_request(
            "get", "https://we.example/info", max_retries=3, retry_sleep=0,
        )
    assert result == "ok"


def test_get_info_task_timeout_marks_ko():
    we = _we()
    with _online(), _transport(side_effect=TimeoutError("timeout")):
        out = Willexecutors.get_info_task(we["url"], we, max_retries=0)
    assert out["status"] == "KO"


# ------------------------------------------------------------------ #
# HTTP / server errors
# ------------------------------------------------------------------ #

def test_send_request_http_error_propagates():
    with _online(), _transport(side_effect=Exception("HTTP 500")):
        with pytest.raises(Exception):
            Willexecutors.send_request("get", "https://we.example/info")


def test_get_info_task_http_error_marks_ko():
    we = _we()
    with _online(), _transport(side_effect=Exception("HTTP 503")):
        out = Willexecutors.get_info_task(we["url"], we, max_retries=0)
    assert out["status"] == "KO"


# ------------------------------------------------------------------ #
# Malformed / unexpected responses
# ------------------------------------------------------------------ #

def test_get_info_task_non_dict_reply_marks_ko():
    """A non-dict reply (e.g. plain string / empty) marks the server KO."""
    we = _we()
    with _online(), _transport(return_value="not-a-dict"):
        out = Willexecutors.get_info_task(we["url"], we, max_retries=0)
    assert out["status"] == "KO"


def test_get_info_task_empty_reply_marks_ko():
    we = _we()
    with _online(), _transport(return_value=None):
        out = Willexecutors.get_info_task(we["url"], we, max_retries=0)
    assert out["status"] == "KO"


def test_get_info_task_valid_reply_updates_fields():
    """A well-formed info reply updates the will-executor's fee/address/info."""
    we = _we()
    reply = {
        "base_fee": 123456,
        "address": "bcrt1qnewaddressxxxxxxxxxxxxxxxxxxxxxxxxxx",
        "info": "Test Will Executor",
    }
    with _online(), _transport(return_value=reply):
        out = Willexecutors.get_info_task(we["url"], we, max_retries=0)
    assert out["status"] == 200
    assert out["base_fee"] == 123456
    assert out["info"] == "Test Will Executor"


def test_download_list_malformed_json_returns_empty():
    """A reply that is not an iterable mapping is handled without crashing."""
    # send_request returns a bare int -> iterating it raises -> caught -> {}.
    with _online(), _transport(return_value=12345):
        out = Willexecutors.download_list({}, "https://welist.bitcoin-after.life")
    assert out == {}


# ------------------------------------------------------------------ #
# Parallel helpers degrade gracefully under failure
# ------------------------------------------------------------------ #

def test_ping_servers_parallel_all_fail():
    wes = {
        "https://a.example": _we("https://a.example"),
        "https://b.example": _we("https://b.example"),
    }
    with _online(), _transport(side_effect=OSError("down")):
        out = Willexecutors.ping_servers_parallel(wes, timeout=1)
    assert all(v["status"] == "KO" for v in out.values())


def test_check_transactions_parallel_all_fail():
    items = [("wid1", "https://a.example"), ("wid2", "https://b.example")]
    with _online(), _transport(side_effect=ConnectionRefusedError("no")):
        results = Willexecutors.check_transactions_parallel(
            items, deadline=5,
        )
    # Each target reports an exception rather than crashing the batch.
    for wid, (res, exc) in results.items():
        assert res is None
        assert exc is not None


# ------------------------------------------------------------------ #

if __name__ == "__main__":
    import inspect
    for name, obj in sorted(globals().items()):
        if name.startswith("test_") and inspect.isfunction(obj):
            try:
                obj()
                print(f"  [OK] {name}")
            except TypeError:
                # parametrized tests need pytest; skip in __main__ mode.
                print(f"  [SKIP __main__] {name}")
    print("[done] connectivity tests")
