#!/usr/bin/env python3
"""Gerador de seeds Bitcoin (BIP39) com consulta de saldo no fullnode local.

O que faz:
  1. Gera NUM_SEEDS seeds BIP39 (entropia configuravel via ENTROPY_BYTES).
  2. Deriva o endereco nativo segwit (bc1...) de cada seed via BIP84 (m/84'/0'/0'/0/0).
  3. Consulta o saldo de todos os enderecos no fullnode local usando `scantxoutset`.
  4. Gera a cada execucao: arquivo de seeds + arquivo de resumo de saldos.
"""

import os
import hmac
import hashlib
import struct
import json
import base64
import ctypes
import secrets
import time
import threading
import subprocess
import multiprocessing
import urllib.request
from pathlib import Path
from datetime import datetime
from concurrent.futures import ProcessPoolExecutor

# ---------------------------------------------------------------------------
# Configuracao do fullnode (AJUSTE para o seu ambiente)
# ---------------------------------------------------------------------------
BITCOIN_CLI = "/caminho/para/o/bitcoin-cli"   # ex: /usr/local/bin/bitcoin-cli
DATADIR = "/caminho/para/o/datadir"           # ex: /home/usuario/.bitcoin
WALLET = "nome-do-seu-wallet"                 # nome do wallet carregado no node
RPC_USER = "seu_usuario_rpc"
RPC_PASS = "sua_senha_rpc"
RPC_URL = "http://127.0.0.1:8332"

WORDLIST_FILE = Path(__file__).parent / "bip39_english.txt"
SECP_LIB_PATH = Path(__file__).parent / "libsecp256k1.so"
NUM_SEEDS = 100

# Entropia em bytes. Padrao BIP39 para wallets: 16 bytes = 128 bits = 12 palavras.
# (Outros padroes: 20=15 palavras, 24=18 palavras, 28=21 palavras, 32=24 palavras.)
ENTROPY_BYTES = 16

# Entropia REAL (bytes aleatorios). O restante ate ENTROPY_BYTES e preenchido
# com um padrao FIXO (PAD_BYTES). 16 = 128 bits reais (seguro, padrao BIP39).
# Valores menores (ex.: 5 = 40 bits) geram 12 palavras com seguranca FRACA.
REAL_ENTROPY_BYTES = 16

# Padrao fixo (nao-zero) usado para completar a entropia ate ENTROPY_BYTES.
# Fixo de proposito: eh o que mantem a seguranca real em REAL_ENTROPY_BYTES.
# (Se fosse aleatorio, a entropia total deixaria de ser 40 bits.)
PAD_BYTES = bytes.fromhex("5a7b3c9d1e8f4a2b6c0d37")  # 11 bytes

# Numero de palavras do mnemonic derivado de ENTROPY_BYTES.
_ent_bits = ENTROPY_BYTES * 8
_checksum_len = _ent_bits // 32
_pad = (11 - (_ent_bits + _checksum_len) % 11) % 11
NUM_WORDS = (_ent_bits + _checksum_len + _pad) // 11

# Quantos enderecos (por cadeia) derivar de cada seed em cada padrao.
# 0 = so o indice 0 (1 endereco por cadeia); 2 = indices 0,1,2 (3 enderecos).
SCAN_DEPTH = 0

# Processos para derivacao paralela (multiprocessing). Threads nao ajudam
# (GIL do Python); processos usam nucleos de verdade.
PARALLEL_WORKERS = 24

# Limite do corpo da requisicao HTTP RPC no node (via -rpcmaxbodysize).
# Usado como trava de seguranca antes de montar um scantxoutset gigante.
RPC_MAX_BODY_SIZE = 2 * 1024 * 1024 * 1024  # 2 GB

# Tamanho do lote do scantxoutset (enderecos por chamada). Um lote gigante
# (ex.: 30M) derruba o node por memoria/timeout; lotes menores sao seguros.
SCAN_BATCH_SIZE = 10_000_000

# Reiniciar o node entre rodadas para liberar memoria acumulada (fragmentacao
# do allocator). Recomendado quando ha multiplas rodadas.
RESTART_NODE = True
# Comando para (re)iniciar o node — AJUSTE para o seu ambiente.
NODE_LAUNCH_CMD = ["bitcoind", "-datadir=/caminho/para/o/datadir"]
NODE_START_TIMEOUT = 180  # segundos aguardando o node subir
NODE_STOP_TIMEOUT = 60    # segundos aguardando o node parar
ROUND_RETRIES = 3         # tentativas por rodada em caso de falha

OUTPUT_SEEDS = Path(__file__).parent / "seeds_geradas.txt"  # sobrescrito a cada rodada
OUTPUT_FOUND = Path(__file__).parent / "carteiras_encontradas.txt"  # persistente (append)

# ---------------------------------------------------------------------------
# Constantes criptograficas (secp256k1 / BIP39 / BIP32 / BIP84)
# ---------------------------------------------------------------------------
N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"


def load_wordlist() -> list[str]:
    with open(WORDLIST_FILE, encoding="utf-8") as f:
        return [w.strip() for w in f if w.strip()]


def make_entropy() -> bytes:
    """Gera ENTROPY_BYTES bytes, sendo os primeiros REAL_ENTROPY_BYTES
    aleatorios e o restante preenchido com o padrao fixo PAD_BYTES."""
    pad = ENTROPY_BYTES - REAL_ENTROPY_BYTES
    if len(PAD_BYTES) < pad:
        raise ValueError(
            f"PAD_BYTES precisa de {pad} bytes, mas tem {len(PAD_BYTES)}."
        )
    return secrets.token_bytes(REAL_ENTROPY_BYTES) + PAD_BYTES[:pad]


def entropy_to_mnemonic(entropy: bytes, wordlist: list[str]) -> str:
    ent_bits = len(entropy) * 8
    checksum_len = ent_bits // 32
    checksum = hashlib.sha256(entropy).digest()[0] >> (8 - checksum_len)
    bits = (int.from_bytes(entropy, "big") << checksum_len) | checksum
    total_bits = ent_bits + checksum_len
    pad = (11 - total_bits % 11) % 11
    bits <<= pad
    words = []
    for _ in range((total_bits + pad) // 11):
        words.append(wordlist[bits & 0x7FF])
        bits >>= 11
    return " ".join(reversed(words))


def mnemonic_to_seed(mnemonic: str, passphrase: str = "") -> bytes:
    return hashlib.pbkdf2_hmac(
        "sha512", mnemonic.encode(), ("mnemonic" + passphrase).encode(), 2048, 64
    )


class _Secp256k1:
    """Wrapper ctypes sobre libsecp256k1 (a mesma lib usada no Bitcoin Core).
    ~35x mais rapida que a alternativa generica via OpenSSL."""

    def __init__(self, path: str):
        self._lib = ctypes.CDLL(path)
        self._lib.secp256k1_context_create.argtypes = [ctypes.c_uint]
        self._lib.secp256k1_context_create.restype = ctypes.c_void_p
        self._lib.secp256k1_ec_pubkey_create.argtypes = [
            ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p]
        self._lib.secp256k1_ec_pubkey_create.restype = ctypes.c_int
        self._lib.secp256k1_ec_pubkey_serialize.argtypes = [
            ctypes.c_void_p, ctypes.c_void_p, ctypes.POINTER(ctypes.c_size_t),
            ctypes.c_void_p, ctypes.c_uint]
        self._lib.secp256k1_ec_pubkey_serialize.restype = ctypes.c_int
        self._ctx = self._lib.secp256k1_context_create(1)

    def pubkey(self, k: int) -> bytes:
        pk = ctypes.create_string_buffer(64)
        if not self._lib.secp256k1_ec_pubkey_create(self._ctx, pk, k.to_bytes(32, "big")):
            raise ValueError("chave privada invalida")
        out = ctypes.create_string_buffer(33)
        outlen = ctypes.c_size_t(33)
        self._lib.secp256k1_ec_pubkey_serialize(self._ctx, out, ctypes.byref(outlen), pk, 258)
        return out.raw[:33]


_secp = None


def _get_secp():
    global _secp
    if _secp is None:
        _secp = _Secp256k1(str(SECP_LIB_PATH)) if SECP_LIB_PATH.exists() else False
    return _secp


def priv_to_pub(k: int) -> bytes:
    s = _get_secp()
    if s:
        return s.pubkey(k)
    # fallback lento (caso libsecp256k1.so nao esteja presente)
    from cryptography.hazmat.primitives.asymmetric import ec
    priv = ec.derive_private_key(k, ec.SECP256K1())
    pn = priv.public_key().public_numbers()
    return bytes([0x02 | (pn.y & 1)]) + pn.x.to_bytes(32, "big")


def ckd_priv(k: int, c: bytes, i: int) -> tuple[int, bytes]:
    if i >= 0x80000000:
        data = b"\x00" + k.to_bytes(32, "big") + struct.pack(">I", i)
    else:
        data = priv_to_pub(k) + struct.pack(">I", i)
    I = hmac.new(c, data, hashlib.sha512).digest()
    return (int.from_bytes(I[:32], "big") + k) % N, I[32:]


def hash160(b: bytes) -> bytes:
    return hashlib.new("ripemd160", hashlib.sha256(b).digest()).digest()


def tagged_hash(tag: str, msg: bytes) -> bytes:
    """Hash etiquetado do BIP340 (usado no tweak do Taproot)."""
    th = hashlib.sha256(tag.encode()).digest()
    return hashlib.sha256(th + th + msg).digest()


def bech32_polymod(values):
    GEN = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3]
    chk = 1
    for v in values:
        b = chk >> 25
        chk = (chk & 0x1FFFFFF) << 5 ^ v
        for i in range(5):
            chk ^= GEN[i] if ((b >> i) & 1) else 0
    return chk


def bech32_hrp_expand(hrp: str):
    return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp]


def convertbits(data, frombits, tobits, pad=True):
    acc = 0
    bits = 0
    ret = []
    maxv = (1 << tobits) - 1
    for value in data:
        acc = (acc << frombits) | value
        bits += frombits
        while bits >= tobits:
            bits -= tobits
            ret.append((acc >> bits) & maxv)
    if pad and bits:
        ret.append((acc << (tobits - bits)) & maxv)
    return ret


def bech32_encode(hrp: str, witver: int, prog: bytes, const: int = 1) -> str:
    data = [witver] + convertbits(prog, 8, 5)
    polymod = bech32_polymod(bech32_hrp_expand(hrp) + data + [0] * 6) ^ const
    checksum = [(polymod >> 5 * (5 - i)) & 31 for i in range(6)]
    return hrp + "1" + "".join(CHARSET[d] for d in data + checksum)


# --- base58 (para enderecos legacy "1..." e p2sh-segwit "3...") ---
B58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"


def base58_encode(b: bytes) -> str:
    n = int.from_bytes(b, "big")
    s = ""
    while n > 0:
        n, r = divmod(n, 58)
        s = B58_ALPHABET[r] + s
    pad = 0
    for byte in b:
        if byte == 0:
            pad += 1
        else:
            break
    return B58_ALPHABET[0] * pad + s


def base58check_encode(payload: bytes) -> str:
    checksum = hashlib.sha256(hashlib.sha256(payload).digest()).digest()[:4]
    return base58_encode(payload + checksum)


def key_to_addr_script(k: int, addr_type: str) -> tuple[str, str]:
    """Converte a chave privada em (endereco, scriptPubKey hex).
    addr_type: p2pkh | p2sh-p2wpkh | p2wpkh | p2tr"""
    pub = priv_to_pub(k)  # 33 bytes (prefixo + x)
    if addr_type == "p2pkh":
        h = hash160(pub)
        return base58check_encode(b"\x00" + h), "76a914" + h.hex() + "88ac"
    if addr_type == "p2sh-p2wpkh":
        redeem = b"\x00\x14" + hash160(pub)
        sh = hash160(redeem)
        return base58check_encode(b"\x05" + sh), "a914" + sh.hex() + "87"
    if addr_type == "p2wpkh":
        h = hash160(pub)
        return bech32_encode("bc", 0, h), "0014" + h.hex()
    if addr_type == "p2tr":
        d = k if pub[0] == 0x02 else (N - k) % N  # even-Y lift (BIP340)
        Px = pub[1:33]
        t = int.from_bytes(tagged_hash("TapTweak", Px), "big") % N
        Q = priv_to_pub((d + t) % N)
        Qx = Q[1:33]
        return bech32_encode("bc", 1, Qx, const=0x2BC830A3), "5120" + Qx.hex()
    raise ValueError(f"tipo de endereco nao suportado: {addr_type}")


def master_key(mnemonic: str, passphrase: str = "") -> tuple[int, bytes]:
    """Deriva a chave mestre BIP32 (k, chaincode) a partir do mnemonic."""
    seed = mnemonic_to_seed(mnemonic, passphrase)
    I = hmac.new(b"Bitcoin seed", seed, hashlib.sha512).digest()
    return int.from_bytes(I[:32], "big"), I[32:]


def derive_privkey(mnemonic: str, path: list[int], passphrase: str = "") -> int:
    k, c = master_key(mnemonic, passphrase)
    for idx in path:
        k, c = ckd_priv(k, c, idx)
    return k


def ckd_priv_nh(k: int, c: bytes, i: int, parent_pub: bytes) -> tuple[int, bytes]:
    """CKD nao-endurecido usando a chave publica do pai ja calculada."""
    I = hmac.new(c, parent_pub + struct.pack(">I", i), hashlib.sha512).digest()
    return (int.from_bytes(I[:32], "big") + k) % N, I[32:]


# Padroes de derivacao (moeda, tipo de endereco, rotulo)
BIP_STANDARDS = [
    (44, "p2pkh", "BIP44-legacy"),
    (49, "p2sh-p2wpkh", "BIP49-p2sh-segwit"),
    (84, "p2wpkh", "BIP84-segwit"),
    (86, "p2tr", "BIP86-taproot"),
]


def derive_all_addresses(
    mnemonic: str, max_index: int = 2, passphrase: str = ""
) -> list[tuple[str, str, str]]:
    """Deriva enderecos em todos os padroes: BIP44/49/84/86 (conta 0,
    recebimento + troco, indices 0..max_index) e Electrum legacy.
    Retorna lista de (rotulo, endereco, scriptPubKey hex)."""
    mk, mc = master_key(mnemonic, passphrase)
    out: list[tuple[str, str, str]] = []
    for coin, atype, name in BIP_STANDARDS:
        # conta m/coin'/0'/0' (endurecido, nao precisa de pubkey)
        ak, ac = mk, mc
        for idx in [coin + 0x80000000, 0x80000000, 0x80000000]:
            ak, ac = ckd_priv(ak, ac, idx)
        apub = priv_to_pub(ak)
        for change in (0, 1):
            chk, chc = ckd_priv_nh(ak, ac, change, apub)
            chpub = priv_to_pub(chk)
            for idx in range(max_index + 1):
                adk, _ = ckd_priv_nh(chk, chc, idx, chpub)
                addr, spk = key_to_addr_script(adk, atype)
                out.append(
                    (f"{name} m/{coin}'/0'/0'/{change}/{idx}", addr, spk)
                )
    # Electrum legacy: m/0/i (recebimento) e m/1/i (troco)
    mpub = priv_to_pub(mk)
    for change in (0, 1):
        chk, chc = ckd_priv_nh(mk, mc, change, mpub)
        chpub = priv_to_pub(chk)
        for idx in range(max_index + 1):
            adk, _ = ckd_priv_nh(chk, chc, idx, chpub)
            addr, spk = key_to_addr_script(adk, "p2pkh")
            out.append(
                (f"Electrum-legacy m/{change}/{idx}", addr, spk)
            )
    return out


def _derive_one(mn: str) -> list[tuple[str, str, str]]:
    """Worker (modulo top-level) para derivacao paralela via multiprocessing."""
    return derive_all_addresses(mn, max_index=SCAN_DEPTH)


# ---------------------------------------------------------------------------
# RPC
# ---------------------------------------------------------------------------
def rpc(method: str, *params, timeout: int = 600) -> dict:
    payload = json.dumps({
        "jsonrpc": "1.0",
        "id": "bitseek",
        "method": method,
        "params": list(params),
    })
    auth = base64.b64encode(f"{RPC_USER}:{RPC_PASS}".encode()).decode()
    req = urllib.request.Request(
        RPC_URL,
        data=payload.encode("utf-8"),
        headers={
            "Content-Type": "application/json",
            "Authorization": "Basic " + auth,
        },
    )
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        data = json.loads(resp.read().decode("utf-8"))
    if data.get("error"):
        raise RuntimeError(str(data["error"]))
    return data["result"]


def node_is_up() -> bool:
    """Verifica se o node responde via RPC (timeout curto)."""
    try:
        rpc("getblockcount", timeout=5)
        return True
    except Exception:
        return False


def node_process_running() -> bool:
    """Verifica se ha um processo bitcoin-qt em execucao (segurando o lock)."""
    try:
        r = subprocess.run(
            ["pgrep", "-x", "bitcoin-qt"],
            capture_output=True, text=True, timeout=5,
        )
        return r.returncode == 0 and bool(r.stdout.strip())
    except Exception:
        # na duvida, assume que ainda esta rodando (mais seguro)
        return True


def stop_node() -> None:
    """Para o node graciosamente (RPC stop) e aguarda o PROCESSO sair
    (liberar o lock do datadir), nao apenas o RPC parar de responder."""
    try:
        rpc("stop", timeout=10)
    except Exception:
        pass  # a conexao cai durante o shutdown
    for _ in range(NODE_STOP_TIMEOUT):
        if not node_process_running():
            time.sleep(2)  # margem para liberar o lock no disco
            return
        time.sleep(1)
    raise RuntimeError(f"Node nao parou apos {NODE_STOP_TIMEOUT}s.")


def start_node() -> None:
    """Lanca o node (gtk-launch) e aguarda o RPC responder."""
    env = os.environ.copy()
    env.setdefault("DISPLAY", ":0")
    subprocess.Popen(
        NODE_LAUNCH_CMD,
        env=env,
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
        start_new_session=True,
    )
    for _ in range(NODE_START_TIMEOUT // 2):
        if node_is_up():
            return
        time.sleep(2)
    raise RuntimeError(f"Node nao subiu apos {NODE_START_TIMEOUT}s.")


def restart_node() -> None:
    """Reinicia o node para liberar memoria acumulada."""
    print("  Reiniciando o node (liberar memoria)...")
    stop_node()
    time.sleep(2)
    start_node()
    print("  Node reiniciado e respondendo.")


def scan_with_progress(descs: list) -> dict:
    """Executa scantxoutset em background e exibe o progresso via 'status'."""
    box: dict = {}

    def _worker():
        try:
            box["res"] = rpc("scantxoutset", "start", descs)
        except Exception as e:
            box["err"] = e

    t = threading.Thread(target=_worker, daemon=True)
    t.start()
    last_p = None
    while t.is_alive():
        time.sleep(5)
        try:
            st = rpc("scantxoutset", "status", timeout=10)
        except Exception:
            continue
        if isinstance(st, dict) and "progress" in st:
            p = st["progress"]
            if p != last_p:
                print(f"        scan em andamento: {p}%")
                last_p = p
        elif last_p is None:
            print("        preparando scan (parse de descritores)...")
    t.join()
    if "err" in box:
        raise box["err"]
    return box.get("res", {})


def check_balances(addr_script: dict[str, str]) -> dict[str, float]:
    """Consulta o saldo de varios enderecos via scantxoutset (em lotes).
    Recebe {endereco: scriptPubKey hex} e devolve {endereco: saldo BTC}."""
    addresses = list(addr_script.keys())
    script_to_addr = {spk: a for a, spk in addr_script.items()}
    totals = {a: 0.0 for a in addresses}

    n_batches = (len(addresses) + SCAN_BATCH_SIZE - 1) // SCAN_BATCH_SIZE
    for bi in range(n_batches):
        batch = addresses[bi * SCAN_BATCH_SIZE:(bi + 1) * SCAN_BATCH_SIZE]
        est_bytes = 200 + sum(len(a) + 17 for a in batch)
        if est_bytes > RPC_MAX_BODY_SIZE:
            raise RuntimeError(
                f"Lote estimado em {est_bytes/1e6:.0f} MB excede o limite de "
                f"{RPC_MAX_BODY_SIZE/1e6:.0f} MB do node (-rpcmaxbodysize). "
                f"Reduza SCAN_BATCH_SIZE."
            )
        descs = [{"desc": f"addr({a})"} for a in batch]
        print(f"    lote {bi + 1}/{n_batches} ({len(batch)} enderecos)...")
        res = scan_with_progress(descs)
        for utxo in res.get("unspents", []):
            spk = utxo.get("scriptPubKey", "")
            addr = script_to_addr.get(spk)
            if addr is not None:
                totals[addr] += float(utxo.get("amount", 0.0))
    return totals


# ---------------------------------------------------------------------------
# Interacao com o usuario
# ---------------------------------------------------------------------------
def ask_int(prompt: str, default: int) -> int:
    raw = input(prompt).strip()
    if not raw:
        return default
    try:
        return int(raw)
    except ValueError:
        print(f"  Valor invalido, usando {default}.")
        return default


def ask_yes_no(prompt: str) -> bool:
    while True:
        raw = input(prompt + " [s/n]: ").strip().lower()
        if raw in ("s", "sim", "y", "yes"):
            return True
        if raw in ("n", "nao", "no"):
            return False
        print("  Responda 's' ou 'n'.")


def parse_manual_seed(raw: str, wordlist: list[str]):
    """Normaliza e valida uma seed colada manualmente. Retorna (seed, erro)."""
    words = raw.strip().lower().split()
    if not words:
        return None, "seed vazia."
    invalid = [w for w in words if w not in wordlist]
    if invalid:
        return None, f"palavras fora da wordlist BIP39: {', '.join(invalid)}"
    valid_sizes = {12, 15, 18, 21, 24, NUM_WORDS}
    if len(words) not in valid_sizes:
        return None, f"numero de palavras inesperado ({len(words)}). Esperado 12/15/18/21/24."
    return " ".join(words), None


# ---------------------------------------------------------------------------
# Helpers de rodada
# ---------------------------------------------------------------------------
def derive(seeds: list[str]) -> list:
    """Deriva enderecos (paralelo). Retorna lista por seed de (rotulo, endereco, script)."""
    n_workers = min(PARALLEL_WORKERS, len(seeds)) if seeds else 1
    print(f"      {len(seeds)} seeds x ~{SCAN_DEPTH + 1} endereco(s)/cadeia, {n_workers} processos...")
    try:
        ctx = multiprocessing.get_context("fork")
        chunksize = max(1, len(seeds) // (n_workers * 4))
        with ProcessPoolExecutor(max_workers=n_workers, mp_context=ctx) as ex:
            return list(ex.map(_derive_one, seeds, chunksize=chunksize))
    except Exception as e:
        print(f"      Falha na derivacao paralela ({e}); caindo para sequencial...")
        return [derive_all_addresses(mn, max_index=SCAN_DEPTH) for mn in seeds]


def scan_and_aggregate(seeds: list[str], seed_derived: list) -> list:
    """Consulta saldos (em lotes) e agrega por seed. Retorna (mnemonic, total, funded).
    Levanta excecao se a consulta falhar (para permitir retry)."""
    all_addresses = [a for derived in seed_derived for _, a, _ in derived]
    addr_script = {a: spk for derived in seed_derived for _, a, spk in derived}
    balances = check_balances(addr_script)

    results = []
    for mn, derived in zip(seeds, seed_derived):
        funded = [(lbl, a, balances[a]) for lbl, a, _ in derived if balances[a] > 0]
        total = sum(b for _, _, b in funded)
        results.append((mn, total, funded))
    return results


def write_seeds_file(seeds: list[str], results: list) -> None:
    """Sobrescreve o arquivo com TODAS as seeds da rodada (inclusive sem saldo)."""
    with open(OUTPUT_SEEDS, "w", encoding="utf-8") as f:
        f.write(f"# {len(seeds)} Seeds Bitcoin BIP39\n")
        f.write(f"# Gerado em: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
        f.write("# ATENCAO: guarde estas seeds em local seguro. Quem as tiver\n")
        f.write("#          controla os fundos dos enderecos derivados.\n")
        f.write("=" * 72 + "\n\n")
        for i, (mn, total, funded) in enumerate(results, 1):
            f.write(f"Seed #{i:03d}\n")
            f.write(f"  Seed:  {mn}\n")
            f.write(f"  Saldo: {total:.8f} BTC\n")
            if funded:
                for lbl, a, b in funded:
                    f.write(f"  {lbl}: {a} = {b:.8f} BTC\n")
            f.write("-" * 72 + "\n")


def append_found(round_num: int, found: list) -> None:
    """Anexa (nao sobrescreve) as carteiras com saldo ao arquivo persistente."""
    with open(OUTPUT_FOUND, "a", encoding="utf-8") as f:
        f.write(f"\n# Rodada {round_num} - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
        for mn, total, funded in found:
            f.write(f"seed = {mn}\n")
            f.write(f"  saldo total: {total:.8f} BTC\n")
            for lbl, a, b in funded:
                f.write(f"    {lbl}: {a} = {b:.8f} BTC\n")
            f.write("-" * 72 + "\n")


def format_duration(seconds: float) -> str:
    total = int(seconds)
    h, rem = divmod(total, 3600)
    m, s = divmod(rem, 60)
    if h:
        return f"{h}h {m:02d}min {s:02d}s"
    if m:
        return f"{m}min {s:02d}s"
    return f"{s}s"


def execute_round(round_num: int, n_seeds: int, manual_seeds: list, wordlist: list):
    """Executa uma rodada completa (gerar + derivar + consultar).
    Retorna (seeds, results). Levanta excecao em caso de falha."""
    # [1/3] Gerar seeds
    print(f"[1/3] Gerando {n_seeds} seeds BIP39 ({NUM_WORDS} palavras, {REAL_ENTROPY_BYTES*8} bits reais)...")
    seeds = []
    report_every = max(1, n_seeds // 10)
    for i in range(n_seeds):
        seeds.append(entropy_to_mnemonic(make_entropy(), wordlist))
        if (i + 1) % report_every == 0 or (i + 1) == n_seeds:
            print(f"      {i + 1}/{n_seeds} seeds geradas")
    if round_num == 1 and manual_seeds:
        seeds = manual_seeds + seeds
        print(f"      + {len(manual_seeds)} seed(s) manual(is) adicionada(s)")

    # [2/3] Derivar
    print("[2/3] Derivando enderecos...")
    t0 = datetime.now()
    seed_derived = derive(seeds)
    print(f"      derivacao concluida em {(datetime.now() - t0).total_seconds():.1f}s")

    # [3/3] Consultar saldos
    print("[3/3] Consultando saldos (scantxoutset)...")
    t0 = datetime.now()
    results = scan_and_aggregate(seeds, seed_derived)
    print(f"      consulta concluida em {(datetime.now() - t0).total_seconds():.1f}s")

    return seeds, results


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
    print("=" * 72)
    print("  GERADOR / CONSULTOR DE SEEDS BITCOIN (BIP39)")
    print("=" * 72)

    start = datetime.now()
    wordlist = load_wordlist()
    assert len(wordlist) == 2048, "Wordlist BIP39 invalida"

    # 1. Quantas seeds gerar por rodada
    n_seeds = ask_int(f"\nQuantas seeds gerar por rodada? [{NUM_SEEDS}]: ", NUM_SEEDS)

    # 2. Seeds manuais
    manual_seeds: list[str] = []
    if ask_yes_no("Deseja adicionar alguma seed manualmente?"):
        while True:
            raw = input("  Cole as palavras da seed (separadas por espaco): ")
            seed, err = parse_manual_seed(raw, wordlist)
            if err:
                print(f"  Erro: {err}")
                if ask_yes_no("  Tentar novamente?"):
                    continue
            else:
                manual_seeds.append(seed)
                print(f"  Seed adicionada ({len(seed.split())} palavras).")
            if not ask_yes_no("  Adicionar mais uma seed manual?"):
                break

    # 3. Quantas rodadas (repeticoes com novas geracoes)
    n_rounds = ask_int("\nQuantas vezes repetir com novas geracoes? [1]: ", 1)

    # 4. Confirmar
    total_gen = n_seeds * n_rounds
    if not ask_yes_no(
        f"\nGerar {n_seeds} seeds x {n_rounds} rodada(s) = {total_gen} seeds "
        f"(+ {len(manual_seeds)} manual na rodada 1) e consultar saldos?"
    ):
        print("Abortado pelo usuario.")
        return

    # 5. Loop de rodadas
    total_found = 0
    total_checked = 0
    failed_rounds = 0
    failures: list[str] = []

    # Reinicia o node antes da primeira rodada (baseline limpa)
    if RESTART_NODE:
        try:
            restart_node()
        except Exception as e:
            print(f"  Aviso: nao consegui reiniciar o node antes de comecar ({e}); seguindo assim mesmo.")

    for round_num in range(1, n_rounds + 1):
        print(f"\n{'=' * 72}")
        print(f"  RODADA {round_num}/{n_rounds}")
        print(f"{'=' * 72}")

        round_start = datetime.now()
        seeds: list = []
        results: list = []
        ok = False
        last_err = ""

        for attempt in range(1, ROUND_RETRIES + 1):
            if attempt > 1:
                print(f"  (tentativa {attempt}/{ROUND_RETRIES})")
            try:
                seeds, results = execute_round(round_num, n_seeds, manual_seeds, wordlist)
                ok = True
                break
            except Exception as e:
                last_err = str(e)
                print(f"  [ERRO] rodada {round_num} falhou (tentativa {attempt}/{ROUND_RETRIES}): {e}")
                if attempt < ROUND_RETRIES and RESTART_NODE:
                    try:
                        restart_node()
                    except Exception as re:
                        print(f"  [ERRO] falha ao reiniciar o node: {re}")

        if not ok:
            failed_rounds += 1
            failures.append(f"Rodada {round_num}: {last_err}")
            print(f"  [FALHA] rodada {round_num} nao concluida apos {ROUND_RETRIES} tentativas.")
            continue

        total_checked += len(seeds)
        write_seeds_file(seeds, results)

        found = [r for r in results if r[1] > 0]
        if found:
            append_found(round_num, found)
        total_found += len(found)

        round_elapsed = (datetime.now() - round_start).total_seconds()
        print(f"\n  --- RESUMO RODADA {round_num}/{n_rounds} ---")
        print(f"  Seeds verificadas:     {len(seeds)}")
        print(f"  Com saldo (rodada):    {len(found)}")
        print(f"  Com saldo (acumulado): {total_found}")
        print(f"  Tempo da rodada:       {format_duration(round_elapsed)}")
        if found:
            for mn, total, funded in found:
                print(f"     {total:.8f} BTC  {mn}")
                for lbl, a, b in funded:
                    print(f"        {lbl}: {a} = {b:.8f} BTC")

        # Reinicia o node para a proxima rodada
        if round_num < n_rounds and RESTART_NODE:
            try:
                restart_node()
            except Exception as e:
                print(f"  Aviso: falha ao reiniciar node ({e}); seguindo assim mesmo.")

    # 6. Resumo final
    elapsed = datetime.now() - start

    print("\n" + "=" * 72)
    print("  CONCLUIDO")
    print("=" * 72)
    print(f"  Tempo total:          {format_duration(elapsed.total_seconds())}")
    print(f"  Rodadas pedidas:      {n_rounds}")
    print(f"  Rodadas concluidas:   {n_rounds - failed_rounds}")
    if failed_rounds:
        print(f"  Rodadas com falha:    {failed_rounds}")
    print(f"  Seeds verificadas:    {total_checked}")
    print(f"  Carteiras com saldo:  {total_found}")
    print(f"  Persistente (com saldo): {OUTPUT_FOUND}")
    print(f"  Ultima rodada (todas):   {OUTPUT_SEEDS}")
    if failures:
        print("\n  Falhas registradas:")
        for f in failures:
            print(f"    - {f}")


if __name__ == "__main__":
    main()
