"""
bal.core.willexecutors
=======================

Client logic for talking to *will-executor* servers.

A will-executor is an optional third-party service that, for a small fee,
stores the signed inheritance transactions off-line and broadcasts them once
their locktime expires (acting as a dead-man's switch backup).

This module only contains the networking / data-shaping logic (downloading the
server list, pinging servers for their fee and address, pushing transactions,
checking whether a tx is already stored).  It is GUI-free: all user
interaction is handled by the Qt layer.
"""

import json
import time
from datetime import datetime
from typing import Any

from aiohttp import ClientResponse
from electrum import bitcoin, constants
from electrum.bitcoin import COIN, TOTAL_COIN_SUPPLY_LIMIT_IN_BTC
from electrum.i18n import _
from electrum.logging import get_logger
from electrum.network import Network

from .plugin_base import BalPlugin, get_version

# Per-request timeout (seconds) for interactive operations (ping / info /
# list download).  These fail fast (no retries) so a dead server does not
# block the UI.
DEFAULT_TIMEOUT = 5

# Single, shared wall-clock deadline (seconds) for ALL network waits the user
# can watch in the GUI: the parallel broadcast (pushtxs), the parallel check
# (searchtx), the will-executor ping and the will-executor list download.
# Having ONE constant (instead of several scattered 30s/45s values) keeps the
# experience consistent and makes it trivial to tune. Requested by the user
# (reduced from 30s/45s to 20s, unified into one variable).
NETWORK_DEADLINE = 20

# Broadcast (pushtxs) timeouts.  Broadcasting a will is important, so we keep a
# couple of quick retries to survive a transient hiccup -- but far from the old
# 10s x 10 retries + 30s sleeps (~140s) that froze the wizard on a dead server.
# Worst case per server is now ~ PUSH_TIMEOUT * (1 + PUSH_MAX_RETRIES)
# + PUSH_RETRY_SLEEP * PUSH_MAX_RETRIES = 8 * 3 + 1 * 2 = ~26s, and the wizard
# also enforces a global deadline on top of this (see push_transactions_parallel).
PUSH_TIMEOUT = 8
PUSH_MAX_RETRIES = 2
PUSH_RETRY_SLEEP = 1

# Global wall-clock deadline (seconds) for the whole parallel broadcast.  Once
# it elapses we stop waiting for the still-pending servers, mark them as
# "Timeout" and let the wizard proceed instead of appearing stuck.
# Derived from the single shared NETWORK_DEADLINE constant above.
PUSH_GLOBAL_DEADLINE = NETWORK_DEADLINE

# Check (searchtx) timeouts.  Used when the user presses "Check" to verify that
# each will-executor still holds the transaction.  Like the broadcast path, the
# old defaults (10s x 10 retries + 30s sleeps ~= 140s per server) froze the
# "checking transaction" dialog on a single dead server.  Fail fast with one
# quick retry, and cap the whole batch with a global deadline.
CHECK_TIMEOUT = 8
CHECK_MAX_RETRIES = 1
CHECK_RETRY_SLEEP = 1
# Derived from the single shared NETWORK_DEADLINE constant above.
CHECK_GLOBAL_DEADLINE = NETWORK_DEADLINE

_logger = get_logger(__name__)


# --------------------------------------------------------------------------- #
# Tor / .onion helpers
# --------------------------------------------------------------------------- #
# A .onion will-executor is only reachable when Electrum routes traffic through
# Tor. There is no point downloading .onion servers from welist when Electrum is
# not on Tor: they would only waste time (unreachable). We therefore drop them
# from the downloaded list unless Tor is confirmed active. Electrum itself gates
# .onion the same way (`if host.endswith('.onion') and not self.is_proxy_tor`).


def is_onion_url(url):
    """True if the URL points to a Tor hidden service (.onion host)."""
    if not url:
        return False
    try:
        u = url.lower()
        if "://" in u:
            u = u.split("://", 1)[1]
        host = u.split("/", 1)[0].split(":", 1)[0]
        return host.endswith(".onion")
    except Exception:
        return False


def is_tor_active():
    """True only if Electrum is CONFIRMED connected through Tor.

    Reads Network.is_proxy_tor defensively (network may be None in offline
    mode). It is TRI-STATE (True / False / None); we require True, so False and
    None (undetermined) both mean "Tor not active". Available in Electrum 4.7.2
    and 4.8.0.
    """
    try:
        network = Network.get_instance()
        if network is None:
            return False
        return network.is_proxy_tor is True
    except Exception:
        return False


chainname = BalPlugin.chainname


class Willexecutors:

    class NoServersForChainError(Exception):
        """Raised when the welist server responds but returns no data for the
        requested chain, indicating no active servers for that network."""

        def __init__(self, chain, url=None, reason=None):
            self.chain = chain
            self.url = url
            self.reason = reason
            super().__init__(
                f"NoServersForChainError: chain={chain} url={url} reason={reason}"
            )

    # Expose the networking constants as class attributes so the GUI layer can
    # reference them (e.g. to show the "Xs / DEADLINEs" countdown) without
    # importing module-level names.  Single source of truth: the module
    # constants defined above.
    DEFAULT_TIMEOUT = DEFAULT_TIMEOUT
    NETWORK_DEADLINE = NETWORK_DEADLINE
    PUSH_TIMEOUT = PUSH_TIMEOUT
    PUSH_MAX_RETRIES = PUSH_MAX_RETRIES
    PUSH_RETRY_SLEEP = PUSH_RETRY_SLEEP
    PUSH_GLOBAL_DEADLINE = PUSH_GLOBAL_DEADLINE
    CHECK_TIMEOUT = CHECK_TIMEOUT
    CHECK_MAX_RETRIES = CHECK_MAX_RETRIES
    CHECK_RETRY_SLEEP = CHECK_RETRY_SLEEP
    CHECK_GLOBAL_DEADLINE = CHECK_GLOBAL_DEADLINE

    @staticmethod
    def save(bal_plugin, willexecutors):
        _logger.debug(f"save {willexecutors},{chainname}")
        aw = bal_plugin.WILLEXECUTORS.get()
        aw[chainname] = willexecutors
        bal_plugin.WILLEXECUTORS.set(aw)
        _logger.debug(f"saved: {aw}")
        # bal_plugin.WILLEXECUTORS.set(willexecutors)

    @staticmethod
    def get_willexecutors(
        bal_plugin, update=False, bal_window: Any = None, force=False, task=True
    ):
        willexecutors = bal_plugin.WILLEXECUTORS.get()
        willexecutors = willexecutors.get(chainname, {})
        to_del = []
        for w in willexecutors:
            if not isinstance(willexecutors[w], dict):
                to_del.append(w)
                continue
            Willexecutors.initialize_willexecutor(willexecutors[w], w)
        for w in to_del:
            _logger.error(
                "error Willexecutor to delete type:{} {}".format(
                    type(willexecutors[w]), w
                )
            )
            del willexecutors[w]
        bal = bal_plugin.WILLEXECUTORS.default.get(chainname, {})
        for bal_url, bal_executor in bal.items():
            if bal_url not in willexecutors:
                _logger.debug(f"force add {bal_url} willexecutor")
                willexecutors[bal_url] = bal_executor
        # if update:
        #    found = False
        #    for url, we in willexecutors.items():
        #        if Willexecutors.is_selected(we):
        #            found = True
        #    if found or force:
        #        if bal_plugin.PING_WILLEXECUTORS.get() or force:
        #            ping_willexecutors = True
        #            if bal_plugin.ASK_PING_WILLEXECUTORS.get() and not force:
        #                if bal_window:
        #                    ping_willexecutors = bal_window.window.question(
        #                        _(
        #                            "Contact willexecutors servers to update payment informations?"
        #                        )
        #                    )

        #            if ping_willexecutors:
        #                if task:
        #                    bal_window.ping_willexecutors(willexecutors, task)
        #                else:
        #                    bal_window.ping_willexecutors_task(willexecutors)
        w_sorted = dict(
            sorted(
                willexecutors.items(), key=lambda w: w[1].get("sort", 0), reverse=True
            )
        )
        return w_sorted

    @staticmethod
    def is_selected(willexecutor, value=None):
        if not willexecutor:
            return False
        if value is not None:
            willexecutor["selected"] = value
        try:
            return willexecutor["selected"]
        except Exception:
            willexecutor["selected"] = False
            return False

    @staticmethod
    def is_valid(willexecutor, max_fee=None, dust=None):
        if not willexecutor:
            return False
        address = willexecutor.get("address", "")
        if not address or not bitcoin.is_address(address, net=constants.net):
            return False
        base_fee = int(willexecutor.get("base_fee", 0))
        if dust is not None and base_fee < dust:
            return False
        if max_fee is not None and base_fee > max_fee:
            return False
        return True

    @staticmethod
    def get_willexecutor_transactions(will, force=False):
        willexecutors = {}
        for wid, willitem in will.items():
            if willitem.get_status("VALID"):
                if willitem.get_status("COMPLETE"):
                    if not willitem.get_status("PUSHED") or force:
                        if willexecutor := willitem.we:
                            url = willexecutor["url"]
                            if willexecutor and Willexecutors.is_selected(willexecutor):
                                if url not in willexecutors:
                                    willexecutor["txs"] = ""
                                    willexecutor["txsids"] = []
                                    willexecutor["broadcast_status"] = _("Waiting...")
                                    willexecutors[url] = willexecutor
                                willexecutors[url]["txs"] += str(willitem.tx) + "\n"
                                willexecutors[url]["txsids"].append(wid)

        return willexecutors

    # def only_selected_list(willexecutors):
    #    out = {}
    #    for url, v in willexecutors.items():
    #        if Willexecutors.is_selected(url):
    #            out[url] = v

    # def push_transactions_to_willexecutors(will):
    #    willexecutors = Willexecutors.get_transactions_to_be_pushed()
    #    for url in willexecutors:
    #        willexecutor = willexecutors[url]
    #        if Willexecutors.is_selected(willexecutor):
    #            if "txs" in willexecutor:
    #                Willexecutors.push_transactions_to_willexecutor(
    #                    willexecutors[url]["txs"], url
    #                )

    @staticmethod
    def send_request(
        method, url, data=None, *, timeout=10, handle_response=None, count_reply=0,
        max_retries=10, retry_sleep=3,
    ):
        """Send an HTTP request to a will-executor server.

        ``max_retries`` / ``retry_sleep`` control the timeout-retry behaviour:

        * For *critical* operations (pushing inheritance transactions) the
          historical default of up to 10 retries with a 3s back-off is kept, so
          a transient network hiccup does not lose a transaction.
        * For *interactive* operations (ping / info / list download) callers
          should pass ``max_retries=0`` so a dead server fails fast (one short
          timeout) instead of blocking the UI for minutes.  See
          :meth:`ping_servers_parallel`.
        """
        network = Network.get_instance()
        if not network:
            raise Exception("You are offline.")
        _logger.debug(f"<-- {method} {url} {data}")
        headers = {}
        headers["user-agent"] = f"BalPlugin v:{get_version()}"
        headers["Content-Type"] = "text/plain"
        if not handle_response:
            handle_response = Willexecutors.handle_response
        attempts = max_retries + 1
        for attempt in range(attempts):
            try:
                if method == "get":
                    response = Network.send_http_on_proxy(
                        method,
                        url,
                        params=data,
                        headers=headers,
                        on_finish=handle_response,
                        timeout=timeout,
                    )
                elif method == "post":
                    response = Network.send_http_on_proxy(
                        method,
                        url,
                        body=data,
                        headers=headers,
                        on_finish=handle_response,
                        timeout=timeout,
                    )
                else:
                    raise Exception(f"unexpected {method=!r}")
                _logger.debug(f"--> {response}")
                return response
            except TimeoutError:
                if attempt < max_retries:
                    _logger.debug(
                        f"timeout({attempt}) error: "
                        f"retry in {retry_sleep} sec..."
                    )
                    if retry_sleep:
                        time.sleep(retry_sleep)
                else:
                    _logger.debug(f"Too many timeouts: {attempt}")
            except Exception as e:
                raise e
        return None

    @staticmethod
    def get_we_url_from_response(resp):
        url_slices = str(resp.url).split("/")
        if len(url_slices) > 2:
            url_slices = url_slices[:-2]
        return "/".join(url_slices)

    @staticmethod
    async def handle_response(resp: ClientResponse):
        resp.raise_for_status()
        r = await resp.text()
        try:
            r = json.loads(r)
        except json.JSONDecodeError:
            pass
        return r

    @staticmethod
    class AlreadyPresentException(Exception):
        pass

    @staticmethod
    def push_transactions_to_willexecutor(
        willexecutor, *, timeout=PUSH_TIMEOUT, max_retries=PUSH_MAX_RETRIES,
        retry_sleep=PUSH_RETRY_SLEEP,
    ):
        # ``timeout`` / ``max_retries`` / ``retry_sleep`` are forwarded to
        # send_request so the broadcast fails fast on a dead/slow server instead
        # of hanging for ~140s (the old default was 10s timeout x 10 retries +
        # 30s of sleeps).  A small number of quick retries still protects
        # against a transient hiccup without freezing the wizard.
        out = True
        try:
            _logger.debug(f"{willexecutor['url']}: {willexecutor['txs']}")
            if w := Willexecutors.send_request(
                "post",
                willexecutor["url"] + "/" + chainname + "/pushtxs",
                data=willexecutor["txs"].encode("ascii"),
                timeout=timeout,
                max_retries=max_retries,
                retry_sleep=retry_sleep,
            ):
                _logger.debug(f"pushed: {w}")
                if w != "thx":
                    _logger.debug(f"error: {w}")
                    raise Exception(w)
                willexecutor["broadcast_status"] = _("Success")
            else:
                raise Exception(
                    f"empty reply from:{willexecutor['url']}"
                )
        except Exception as e:
            _logger.debug(f"error:{e}")
            if str(e) == "already present":
                raise Willexecutors.AlreadyPresentException() from None
            out = False
            willexecutor["broadcast_status"] = _("Failed")

        return out

    @staticmethod
    def ping_servers(willexecutors):
        for url, we in willexecutors.items():
            Willexecutors.get_info_task(url, we)

    @staticmethod
    def get_info_task(url, willexecutor, *, timeout=DEFAULT_TIMEOUT,
                      max_retries=0, retry_sleep=0):
        w = None
        try:
            _logger.info("GETINFO_WILLEXECUTOR")
            _logger.debug(url)
            # Fast-fail by default (max_retries=0): a dead server returns after a
            # single short timeout instead of retrying 10x with sleeps, which
            # used to freeze the UI for minutes per unreachable server.
            w = Willexecutors.send_request(
                "get", url + "/" + chainname + "/info",
                timeout=timeout, max_retries=max_retries, retry_sleep=retry_sleep,
            )
            if isinstance(w, dict):
                address = w.get("address")
                if not isinstance(address, str) or not bitcoin.is_address(
                    address, net=constants.net
                ):
                    _logger.warning(
                        f"invalid address from {url}: {address!r}"
                    )
                    willexecutor["status"] = "KO"
                else:
                    base_fee = w.get("base_fee")
                    try:
                        base_fee = int(base_fee or 0)
                        if base_fee < 0:
                            raise ValueError("negative fee")
                        if base_fee > TOTAL_COIN_SUPPLY_LIMIT_IN_BTC * COIN:
                            raise ValueError("fee exceeds total coin supply")
                    except (TypeError, ValueError) as e:
                        _logger.warning(
                            f"invalid base_fee from {url}: "
                            f"{w.get('base_fee')!r} ({e})"
                        )
                        willexecutor["status"] = "KO"
                    else:
                        willexecutor["url"] = url
                        willexecutor["status"] = 200
                        willexecutor["base_fee"] = base_fee
                        willexecutor["address"] = address
                        willexecutor["info"] = w.get("info", "")
            else:
                # No dict reply (timeout / empty) -> mark as unreachable.
                willexecutor["status"] = "KO"
            _logger.debug(f"response_data {w}")
        except Exception as e:
            _logger.error(f"error {e} contacting {url}: {w}")
            willexecutor["status"] = "KO"

        willexecutor["last_update"] = datetime.now().timestamp()
        return willexecutor

    @staticmethod
    def ping_servers_parallel(willexecutors, *, on_each=None, max_workers=8,
                              timeout=DEFAULT_TIMEOUT, on_tick=None,
                              tick_interval=1.0):
        """Ping every will-executor concurrently and report results as they
        arrive.

        Network requests run in a thread pool: each ``send_http_on_proxy`` call
        schedules its coroutine on Electrum's shared asyncio loop and blocks
        only its *own* worker thread, so the total wall-clock time is roughly
        that of the slowest server rather than the *sum* of all of them.  A
        single dead server can no longer stall the whole batch.

        Args:
            willexecutors: ``{url: we_dict}`` mapping (mutated in place with the
                ping result, exactly like the old sequential ``ping_servers``).
            on_each: optional ``callback(url, we_dict, ok: bool)`` invoked from a
                worker thread each time a server answers (or fails), so the GUI
                can update its list live.  Must be thread-safe / marshalled to
                the GUI thread by the caller.
            max_workers: maximum number of concurrent pings.
            timeout: per-request timeout in seconds (fast-fail, no retries).

            on_tick: optional ``callback()`` invoked periodically (every
                ``tick_interval`` seconds) **from the calling thread** while
                waiting for servers, so a Qt caller can refresh an elapsed-time
                counter from the same thread that drives ``on_each``.

        Returns:
            The same ``willexecutors`` mapping, updated in place.
        """
        from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait

        items = list(willexecutors.items())
        if not items:
            return willexecutors

        def _ping_one(url, we):
            we = Willexecutors.get_info_task(
                url, we, timeout=timeout, max_retries=0, retry_sleep=0
            )
            ok = we.get("status") == 200
            return url, we, ok

        def _fire_tick():
            if on_tick is not None:
                try:
                    on_tick()
                except Exception as cb_err:
                    _logger.error(f"ping on_tick callback error: {cb_err}")

        workers = max(1, min(max_workers, len(items)))
        # Manual pool (no ``with``) so we can poll futures in short slices and
        # drive ``on_tick`` from THIS thread between waits (reliable Qt repaint).
        pool = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="bal-ping")
        futures = {pool.submit(_ping_one, url, we) for url, we in items}
        try:
            pending = set(futures)
            while pending:
                done, pending = wait(
                    pending, timeout=tick_interval, return_when=FIRST_COMPLETED
                )
                for fut in done:
                    try:
                        url, we, ok = fut.result()
                    except Exception as e:  # defensive: one server never crashes all
                        _logger.error(f"ping_servers_parallel worker error: {e}")
                        continue
                    willexecutors[url] = we
                    if on_each is not None:
                        try:
                            on_each(url, we, ok)
                        except Exception as cb_err:
                            _logger.error(f"ping on_each callback error: {cb_err}")
                # Drive the elapsed-time counter from the calling thread.
                _fire_tick()
        finally:
            try:
                pool.shutdown(wait=False, cancel_futures=True)
            except TypeError:
                pool.shutdown(wait=False)
        return willexecutors

    @staticmethod
    def push_transactions_parallel(willexecutors, *, on_each=None, max_workers=8,
                                   deadline=PUSH_GLOBAL_DEADLINE, on_timeout=None,
                                   on_tick=None, tick_interval=1.0):
        """Push transactions to multiple will-executors concurrently.

        Like :meth:`ping_servers_parallel` but for the ``pushtxs`` operation.
        Each server keeps a short retry behaviour
        (:meth:`push_transactions_to_willexecutor`) so a real transaction is not
        lost to a transient hiccup, but servers are contacted in parallel and
        results are reported via ``on_each(url, we_dict, ok, exc)`` as they
        complete.

        A global wall-clock ``deadline`` (seconds) caps the whole operation: if
        some servers are still pending when it elapses, we stop waiting, mark
        them via ``on_timeout(url, we_dict)`` and return, so the caller (the
        wizard) is never stuck behind one unresponsive server.  Pass
        ``deadline=None`` to wait indefinitely (old behaviour).

        ``on_tick()`` is invoked periodically (every ``tick_interval`` seconds)
        **from the calling thread** while waiting for workers.  This lets a Qt
        caller refresh an elapsed-time counter from the same thread that drives
        ``on_each`` (so its pyqtSignal repaints reliably), instead of relying on
        a separate heartbeat thread whose signal emissions are not marshalled.

        Returns ``{url: (ok, exception_or_None)}`` for the servers that
        answered in time (timed-out servers are reported via ``on_timeout``).
        """
        from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait

        targets = [(url, we) for url, we in willexecutors.items() if "txs" in we]
        results = {}
        if not targets:
            return results

        def _push_one(url, we):
            try:
                ok = Willexecutors.push_transactions_to_willexecutor(we)
                return url, we, ok, None
            except Willexecutors.AlreadyPresentException as ape:
                return url, we, False, ape
            except Exception as e:
                return url, we, False, e

        def _fire_tick():
            if on_tick is not None:
                try:
                    on_tick()
                except Exception as cb_err:
                    _logger.error(f"push on_tick callback error: {cb_err}")

        workers = max(1, min(max_workers, len(targets)))
        # NOTE: we do not use ``with ThreadPoolExecutor(...)`` here because its
        # __exit__ calls shutdown(wait=True), which would block on a hung worker
        # and defeat the whole point of the global deadline.  We shut the pool
        # down without waiting once the deadline elapses; the daemon worker(s)
        # stuck on a dead socket will be torn down when their request finally
        # times out (PUSH_TIMEOUT), without holding up the wizard.
        pool = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="bal-push")
        fut_to_url = {pool.submit(_push_one, url, we): (url, we)
                      for url, we in targets}
        start = time.time()
        try:
            # Poll the futures in short slices so we can call ``on_tick`` from
            # THIS thread between waits.  ``wait(..., timeout=tick_interval)``
            # returns as soon as a future completes OR the slice elapses,
            # whichever comes first, so the counter advances ~once per second
            # while the parallel push runs.
            pending = set(fut_to_url.keys())
            while pending:
                if deadline is not None and (time.time() - start) >= deadline:
                    break
                slice_timeout = tick_interval
                if deadline is not None:
                    remaining = deadline - (time.time() - start)
                    slice_timeout = max(0.0, min(tick_interval, remaining))
                done, pending = wait(
                    pending, timeout=slice_timeout, return_when=FIRST_COMPLETED
                )
                for fut in done:
                    try:
                        url, we, ok, exc = fut.result()
                    except Exception as e:
                        _logger.error(
                            f"push_transactions_parallel worker error: {e}"
                        )
                        continue
                    results[url] = (ok, exc)
                    if on_each is not None:
                        try:
                            on_each(url, we, ok, exc)
                        except Exception as cb_err:
                            _logger.error(f"push on_each callback error: {cb_err}")
                # Drive the elapsed-time counter from the calling thread.
                _fire_tick()
            # Any server still pending here hit the global deadline.
            if pending:
                elapsed = time.time() - start
                _logger.warning(
                    f"push global deadline ({deadline}s) reached after "
                    f"{elapsed:.1f}s; {len(pending)} server(s) "
                    f"did not answer in time"
                )
                for fut in pending:
                    url, we = fut_to_url[fut]
                    if url in results:
                        continue
                    if on_timeout is not None:
                        try:
                            on_timeout(url, we)
                        except Exception as cb_err:
                            _logger.error(
                                f"push on_timeout callback error: {cb_err}"
                            )
        finally:
            # Do not block on still-running workers (Python 3.9+: cancel queued).
            try:
                pool.shutdown(wait=False, cancel_futures=True)
            except TypeError:
                pool.shutdown(wait=False)
        return results

    @staticmethod
    def check_transactions_parallel(items, *, on_each=None, max_workers=8,
                                    deadline=CHECK_GLOBAL_DEADLINE,
                                    on_timeout=None, on_tick=None,
                                    tick_interval=1.0):
        """Check (searchtx) several will-executors concurrently.

        Same design as :meth:`push_transactions_parallel`, but for the "Check"
        operation: it verifies that each will-executor still holds its
        transaction.  ``items`` is an iterable of ``(wid, url)`` pairs (one per
        will-item that has a will-executor).

        Each server is contacted in parallel with a short fail-fast retry
        (:meth:`check_transaction`), results are reported via
        ``on_each(wid, url, result_or_None, exc)`` as they arrive, ``on_tick()``
        is called periodically from the calling thread to refresh a counter, and
        a global ``deadline`` guarantees the dialog never freezes behind one
        unresponsive server (pending servers are reported via
        ``on_timeout(wid, url)``).

        Returns ``{wid: (result_or_None, exception_or_None)}`` for the servers
        that answered in time.
        """
        from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait

        targets = [(wid, url) for wid, url in items if url]
        results = {}
        if not targets:
            return results

        def _check_one(wid, url):
            try:
                res = Willexecutors.check_transaction(wid, url)
                return wid, url, res, None
            except Exception as e:
                return wid, url, None, e

        def _fire_tick():
            if on_tick is not None:
                try:
                    on_tick()
                except Exception as cb_err:
                    _logger.error(f"check on_tick callback error: {cb_err}")

        workers = max(1, min(max_workers, len(targets)))
        # Manual pool (no ``with``): we must not block on a hung worker when the
        # global deadline elapses (see push_transactions_parallel for details).
        pool = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="bal-check")
        fut_to_target = {pool.submit(_check_one, wid, url): (wid, url)
                         for wid, url in targets}
        start = time.time()
        try:
            pending = set(fut_to_target.keys())
            while pending:
                if deadline is not None and (time.time() - start) >= deadline:
                    break
                slice_timeout = tick_interval
                if deadline is not None:
                    remaining = deadline - (time.time() - start)
                    slice_timeout = max(0.0, min(tick_interval, remaining))
                done, pending = wait(
                    pending, timeout=slice_timeout, return_when=FIRST_COMPLETED
                )
                for fut in done:
                    try:
                        wid, url, res, exc = fut.result()
                    except Exception as e:
                        _logger.error(
                            f"check_transactions_parallel worker error: {e}"
                        )
                        continue
                    results[wid] = (res, exc)
                    if on_each is not None:
                        try:
                            on_each(wid, url, res, exc)
                        except Exception as cb_err:
                            _logger.error(f"check on_each callback error: {cb_err}")
                # Drive the elapsed-time counter from the calling thread.
                _fire_tick()
            # Any server still pending here hit the global deadline.
            if pending:
                elapsed = time.time() - start
                _logger.warning(
                    f"check global deadline ({deadline}s) reached after "
                    f"{elapsed:.1f}s; {len(pending)} server(s) "
                    f"did not answer in time"
                )
                for fut in pending:
                    wid, url = fut_to_target[fut]
                    if wid in results:
                        continue
                    if on_timeout is not None:
                        try:
                            on_timeout(wid, url)
                        except Exception as cb_err:
                            _logger.error(
                                f"check on_timeout callback error: {cb_err}"
                            )
        finally:
            try:
                pool.shutdown(wait=False, cancel_futures=True)
            except TypeError:
                pool.shutdown(wait=False)
        return results

    @staticmethod
    def initialize_willexecutor(willexecutor, url, status=None, old_willexecutor=None):
        old_willexecutor=old_willexecutor if old_willexecutor is not None else {}
        willexecutor["url"] = url
        if status is not None:
            willexecutor["status"] = status
        else:
            willexecutor["status"] = old_willexecutor.get("status",willexecutor.get("status","Ko"))
        willexecutor["selected"]=Willexecutors.is_selected(old_willexecutor) or willexecutor.get("selected",False)
        address = old_willexecutor.get("address", willexecutor.get("address", ""))
        if address and not bitcoin.is_address(address, net=constants.net):
            _logger.warning(
                f"invalid address {address!r} for executor {url}, "
                f"falling back to empty"
            )
            address = ""
        willexecutor["address"] = address
        willexecutor["promo_code"]=old_willexecutor.get("promo_code",willexecutor.get("promo_code"))



    @staticmethod
    def download_list(old_willexecutors,welist_server):
        try:
            welist_server = welist_server if welist_server[-1] == '/' else welist_server+'/'
            willexecutors = Willexecutors.send_request(
                "get",
                f"{welist_server}data/{chainname}?page=0&limit=100",
            )
            if not isinstance(willexecutors, dict):
                _logger.warning(
                    f"unexpected download_list response type: "
                    f"{type(willexecutors).__name__}"
                )
                return {}
            for w in willexecutors:
                if w not in ("status", "url"):
                    if not isinstance(willexecutors.get(w), dict):
                        _logger.warning(
                            f"malformed entry {w!r} in executor list, "
                            f"type={type(willexecutors.get(w)).__name__}"
                        )
                        continue
                    Willexecutors.initialize_willexecutor(
                        willexecutors[w], w, None, old_willexecutors.get(w,None)
                    )
            return willexecutors

        except Exception as e:
            _logger.error(f"Failed to download willexecutors list: {e}")
            return {}

    @staticmethod
    def get_willexecutors_list_from_json():
        try:
            with open("willexecutors.json") as f:
                willexecutors = json.load(f)
                for w in willexecutors:
                    willexecutor = willexecutors[w]
                    Willexecutors.initialize_willexecutor(willexecutor, w, "New", False)
                # bal_plugin.WILLEXECUTORS.set(willexecutors)
                return willexecutors
        except Exception as e:
            _logger.error(f"error opening willexecutors json: {e}")

            return {}

    @staticmethod
    def check_transaction(txid, url, *, timeout=CHECK_TIMEOUT,
                          max_retries=CHECK_MAX_RETRIES,
                          retry_sleep=CHECK_RETRY_SLEEP):
        _logger.debug(f"{url}:{txid}")
        try:
            w = Willexecutors.send_request(
                "post", url + "/searchtx", data=txid.encode("ascii"),
                timeout=timeout, max_retries=max_retries, retry_sleep=retry_sleep,
            )
            if not isinstance(w, dict):
                _logger.warning(
                    f"unexpected check_transaction response type "
                    f"from {url}: {type(w).__name__}"
                )
                return None
            return w
        except Exception as e:
            _logger.error(f"error contacting {url} for checking txs {e}")
            raise e

    @staticmethod
    def compute_id(willexecutor):
        return "{}-{}".format(willexecutor.get("url"), willexecutor.get("chain"))


#class WillExecutor:
#    def __init__(
#        self,
#        url,
#        base_fee,
#        chain,
#        info,
#        version,
#        status,
#        is_selected=False,
#        promo_code="",
#    ):
#        self.url = url
#        self.base_fee = base_fee
#        self.chain = chain
#        self.info = info
#        self.version = version
#        self.status = status
#        self.promo_code = promo_code
#        self.is_selected = is_selected
#        self.id = self.compute_id()
#
#    def from_dict(d):
#        return WillExecutor(
#            url=d.get("url", "http://localhost:8000"),
#            base_fee=d.get("base_fee", 1000),
#            chain=d.get("chain", chainname),
#            info=d.get("info", ""),
#            version=d.get("version", 0),
#            status=d.get("status", "Ko"),
#            is_selected=d.get("is_selected", "False"),
#            promo_code=d.get("promo_code", ""),
#        )
#
#    def to_dict(self):
#        return {
#            "url": self.url,
#            "base_fee": self.base_fee,
#            "chain": self.chain,
#            "info": self.info,
#            "version": self.version,
#            "promo_code": self.promo_code,
#        }
#
#    def compute_id(self):
#        return f"{self.url}-{self.chain}"
