#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
BITCOIN PUZZLE POOL (HoMLoL) - единый скрипт для Windows и Linux.

Режимы:
  1. Майнинг (по умолчанию): getRange -> VanitySearch -> setRange, по кругу.
  2. Массовая отправка (-s / --submit): отправляет в пул уже посчитанные области
     из папки tmp_files_output_{puzzle} (бывший submitterRangeApi.py).

Требования:
  - Python 3.7+
  - pip install requests
  - VanitySearch (FixedPaul/VanitySearch-Bitcrack) рядом со скриптом:
      Windows: VanitySearch.exe      Linux: VanitySearch или vanitysearch
    Другой путь: параметр --vs или переменная окружения VS_BIN.
  - Драйвер видеокарты (для Linux/NVIDIA: nvidia-smi должен видеть GPU)

Адрес для выплат (обязателен), приоритет источников:
  1. параметр -a / --address
  2. переменная окружения PAYOUT_ADDRESS
  3. файл payout_address.txt рядом со скриптом
  4. интерактивный ввод при запуске
Адрес конфиденциален: он передаётся в пул только в заголовке X-Payout-Address,
а публично показывается лишь его sha256 (ваш идентификатор).
"""

import argparse
import hashlib
import json
import os
import re
import stat
import subprocess
import sys
import time

import requests

API_BASE = os.environ.get("HONESTPOOL_API", "https://honestpool.ru/api/v2")

REQUEST_TIMEOUT = 30
MAX_RETRIES = 9999       # повторов при сетевых ошибках / 5xx
RETRY_DELAY = 10         # секунд между повторами
MAX_RATE_WAIT = 3700     # дольше этого при 429 не ждём (это суточный лимит или бан) и выходим
SUBMIT_DELAY = 61        # пауза между отправками в режиме --submit (лимит setRange: 60 в час)

IS_WINDOWS = os.name == "nt"
MAIN_PATH = os.path.dirname(os.path.abspath(__file__))
SCRIPT_PATH = os.path.abspath(__file__)
LOG_ERROR = os.path.join(MAIN_PATH, "LOG_ERROR.txt")
FOUND_FILE = os.path.join(MAIN_PATH, "FOUND!!!!!!!!.txt")
ADDRESS_FILE = os.path.join(MAIN_PATH, "payout_address.txt")

DEFAULT_COMMENT = "BITCOIN PUZZLE HoMLoL POOL bc1qdn2wng73y80phr7kul5aa24n850f5c82zwq27h"

ADDRESS_RE = re.compile(
    r"^(?:[13][a-km-zA-HJ-NP-Z1-9]{25,34}|bc1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{11,87})$"
)


# ----------------------------------------------------------------------------
# Утилиты
# ----------------------------------------------------------------------------

def log_error(text):
    try:
        with open(LOG_ERROR, "a", encoding="utf-8") as f:
            f.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] ERROR: {text}\n")
    except OSError:
        pass


def fatal(text):
    print(f"ERROR: {text}")
    log_error(text)
    sys.exit(1)


def clear_screen():
    if sys.stdout.isatty():
        os.system("cls" if IS_WINDOWS else "clear")


def print_banner():
    print("-----------------------------------------------------------------------------")
    print("----------------------- BITCOIN PUZZLE POOL от HoMLoL -----------------------")
    print("-----------------------------------------------------------------------------")
    print("-- Site: https://honestpool.ru")
    print("-- Git: https://github.com/homlol/honestpool")
    print("-- VanitySearch: https://github.com/FixedPaul/VanitySearch-Bitcrack")
    print("-- Questions/Offers: TG @homlol_official")
    print("-- Group: TG @homlol_pool")
    print("-- Donate BTC: bc1q3plfvghrdsh768h3ukejs8zy5ev4jsr76tjtgk")
    print("-- Donate ETH: 0xa97888173A0A41242294299BE1b81Fe72433ac06")
    print("-- Donate USDT (TRC 20): TNf8KHmmwEiVGSQyqVyKuZ8f95zEdnb3ND")
    print("-- Donate TON: UQDVvbUuncGxvGEqUUuchRfH7a_p8I3S7_6dMsuTkIw7amtL")
    print("-----------------------------------------------------------------------------")


def read_first_line(path):
    try:
        with open(path, "r", encoding="utf-8") as f:
            return f.readline().strip()
    except OSError:
        return ""


def parse_key_file(filename):
    """Читает вывод VanitySearch, возвращает {адрес: приватный ключ HEX}."""
    result = {}
    try:
        with open(filename, "r", encoding="utf-8") as f:
            content = f.read()

        pattern = (r"Public Addr: ([1-9A-HJ-NP-Za-km-z]+)\s*"
                   r"Priv \(WIF\): [^\n]+\s*"
                   r"Priv \(HEX\): 0x\s*([0-9A-Fa-f]+)")
        for address, key_hex in re.findall(pattern, content):
            result[address] = key_hex.lstrip("0") or "0"
    except FileNotFoundError:
        print(f"Ошибка: файл {filename} не найден")
        log_error(f"Файл {filename} не найден")
    except Exception as e:
        print(f"Ошибка при чтении файла {filename}: {e}")
        log_error(f"Ошибка при чтении файла {filename}: {e}")
    return result


def report_found(address, key_hex):
    print("-" * 101)
    print("-- !!! НАЙДЕНО !!! !!! НАЙДЕНО !!! !!! НАЙДЕНО !!! !!! НАЙДЕНО !!! !!! НАЙДЕНО !!! --")
    print(f"-- ADDRESS: {address}")
    print(f"-- PRIVATE KEY: {key_hex}")
    print("-- !!! НАЙДЕНО !!! !!! НАЙДЕНО !!! !!! НАЙДЕНО !!! !!! НАЙДЕНО !!! !!! НАЙДЕНО !!! --")
    print("-" * 101)
    with open(FOUND_FILE, "a", encoding="utf-8") as f:
        f.write("\n-------------------- FOUND --------------------\n"
                f"PRIVATE KEY HEX: {key_hex}\nADDRESS: {address}\n"
                "----------------------------------------\n")


# ----------------------------------------------------------------------------
# Адрес для выплат
# ----------------------------------------------------------------------------

def normalize_address(address):
    address = address.strip()
    # bech32 нечувствителен к регистру; пул хеширует нижний регистр
    return address.lower() if address.lower().startswith("bc1") else address


def save_address(address):
    try:
        with open(ADDRESS_FILE, "w", encoding="utf-8") as f:
            f.write(address + "\n")
        if not IS_WINDOWS:
            os.chmod(ADDRESS_FILE, 0o600)
        print(f"Адрес сохранён в {ADDRESS_FILE}")
    except OSError as e:
        print(f"Не удалось сохранить адрес: {e}")


def resolve_payout_address(cli_value):
    address, source = "", ""

    if cli_value and cli_value.strip():
        address, source = cli_value, "arg"
    elif os.environ.get("PAYOUT_ADDRESS", "").strip():
        address, source = os.environ["PAYOUT_ADDRESS"], "env"
    elif read_first_line(ADDRESS_FILE):
        address, source = read_first_line(ADDRESS_FILE), "file"
    elif sys.stdin.isatty():
        address = input("Введите адрес для выплат (Bitcoin): ")
        source = "prompt"
    else:
        fatal("Не указан адрес для выплат (-a, PAYOUT_ADDRESS или payout_address.txt)")

    address = normalize_address(address)
    if not ADDRESS_RE.match(address):
        fatal(f"Некорректный адрес для выплат (источник: {source})")

    # Бэкап-скрипт запускается заново и берёт адрес из файла
    if source in ("arg", "prompt") and read_first_line(ADDRESS_FILE) != address:
        save_address(address)

    return address


# ----------------------------------------------------------------------------
# API
# ----------------------------------------------------------------------------

def _retry_after(response, data):
    for value in (response.headers.get("Retry-After"), data.get("retry_after")):
        try:
            seconds = int(value)
            if seconds > 0:
                return seconds
        except (TypeError, ValueError):
            pass
    return 60


def api_request(name, method, url, headers=None, **kwargs):
    """
    Запрос к API. Возвращает dict с полями ok / text / status.
      - 2xx и 4xx: ответ возвращается как есть (повторять бессмысленно);
      - 429: ждём Retry-After и повторяем; если ждать слишком долго — возвращаем ответ;
      - сеть и 5xx: повторяем.
    """
    attempt = 0
    while True:
        attempt += 1
        wait = RETRY_DELAY
        try:
            response = requests.request(method, url, headers=headers,
                                        timeout=REQUEST_TIMEOUT, **kwargs)
            status = response.status_code
            try:
                data = response.json()
            except ValueError:
                data = None

            if status < 500:
                if not isinstance(data, dict):
                    return {"ok": False, "text": f"HTTP {status}", "status": status}
                data["status"] = status
                if status == 429:
                    seconds = _retry_after(response, data)
                    if seconds <= MAX_RATE_WAIT:
                        print(f"{name}: лимит запросов. Ожидание {seconds} сек...")
                        time.sleep(seconds + 1)
                        continue
                return data

            message = f"{name}: ошибка сервера HTTP {status} (попытка {attempt}/{MAX_RETRIES})"
            if status == 503 and isinstance(data, dict):
                wait = _retry_after(response, data)
        except requests.exceptions.RequestException as e:
            message = f"{name}: ошибка сети (попытка {attempt}/{MAX_RETRIES}): {e}"

        print(message)
        log_error(message)
        if attempt >= MAX_RETRIES:
            return {"ok": False, "text": f"{name}: все попытки исчерпаны", "status": 0}
        print(f"Повторная попытка через {wait} сек...")
        time.sleep(wait)


def get_range(id_puzzle, number_range):
    return api_request("getRange", "GET", f"{API_BASE}/getRange",
                       params={"idPuzzle": id_puzzle, "numberRange": number_range})


def set_range(id_puzzle, number_range, key_pairs, comment, payout_address):
    return api_request(
        "setRange", "POST", f"{API_BASE}/setRange",
        headers={"X-Payout-Address": payout_address},   # адрес — только в заголовке
        data={
            "idPuzzle": id_puzzle,
            "numberRange": number_range,
            "checkProofAddresses": json.dumps(key_pairs),
            "comment": comment,
        },
    )


def is_payout_error(response):
    return response.get("status") == 422 and "payout" in str(response.get("text", "")).lower()


# ----------------------------------------------------------------------------
# VanitySearch
# ----------------------------------------------------------------------------

def find_vanitysearch(cli_path):
    if cli_path:
        return cli_path
    if os.environ.get("VS_BIN"):
        return os.environ["VS_BIN"]

    names = ["VanitySearch.exe", "vanitysearch.exe"] if IS_WINDOWS else ["VanitySearch", "vanitysearch"]
    for name in names:
        path = os.path.join(MAIN_PATH, name)
        if os.path.isfile(path):
            return path
    return os.path.join(MAIN_PATH, names[0])


def run_vanitysearch(vs_bin, gpu, in_file, out_file, start_hex, bits, backup):
    cmd = [vs_bin, "-gpuId", str(gpu), "-i", in_file, "-o", out_file,
           "-start", str(start_hex), "-range", str(bits)]
    if backup:
        cmd.append("-backup")

    proc = None
    try:
        proc = subprocess.Popen(cmd, cwd=MAIN_PATH)
        proc.wait()
    except KeyboardInterrupt:
        print("Остановка VanitySearch...")
        if proc is not None:
            proc.terminate()
            try:
                proc.wait(timeout=10)
            except subprocess.TimeoutExpired:
                proc.kill()
        sys.exit(0)
    except FileNotFoundError:
        fatal(f"не найден файл {vs_bin} (положите VanitySearch рядом со скриптом или укажите --vs)")
    except PermissionError:
        fatal(f"нет прав на запуск {vs_bin}" + ("" if IS_WINDOWS else f" (chmod +x {vs_bin})"))


def write_backup_script(puzzle_id, gpu, number_range):
    """start_VSbackup_gpuN.bat / .sh — запуск с продолжением (-backup) той же области."""
    args = f"-p {puzzle_id} -g {gpu} -n {number_range} --backup"
    base = os.path.join(MAIN_PATH, f"start_VSbackup_gpu{gpu}")

    if IS_WINDOWS:
        lines = ["@echo off", 'cd /d "%~dp0"', f'"{sys.executable}" "{SCRIPT_PATH}" {args}', "pause"]
        with open(base + ".bat", "w", encoding="utf-8", newline="\r\n") as f:
            f.write("\n".join(lines) + "\n")
    else:
        lines = ["#!/bin/bash", f'cd "{MAIN_PATH}"', f'"{sys.executable}" "{SCRIPT_PATH}" {args}',
                 'read -r -p "Нажмите Enter для выхода..." _']
        path = base + ".sh"
        with open(path, "w", encoding="utf-8", newline="\n") as f:
            f.write("\n".join(lines) + "\n")
        os.chmod(path, os.stat(path).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)


# ----------------------------------------------------------------------------
# Режим 1: майнинг
# ----------------------------------------------------------------------------

def mine(args, payout):
    puzzle = 70 if args.test else args.puzzle
    number = args.number
    backup = args.backup
    brute = args.brute

    vs_bin = find_vanitysearch(args.vs)
    in_file = os.path.join(MAIN_PATH, f"in_gpu_{args.gpu}.txt")

    def advance():
        nonlocal number
        if number != "random":
            number = int(number) + 1 if brute else "random"

    while True:
        clear_screen()
        print_banner()

        res = get_range(puzzle, number)

        if not res.get("ok"):
            backup = False
            status = res.get("status", 0)
            text = res.get("text", "Unknown error")
            print(f"ERROR: {text}")
            log_error(f"getRange: {text}")

            if status in (404, 410):
                sys.exit(1)                       # пазл не найден / поиск завершён
            if status == 429:
                print("Лимит запросов исчерпан надолго (или IP заблокирован). Попробуйте позже.")
                sys.exit(1)
            if status == 422 and brute:
                print("Номер области вне диапазона пазла. Завершение.")
                sys.exit(0)

            advance()
            print("Запуск через 3 секунд... Ожидайте...")
            time.sleep(3)
            continue

        target = res["targetAddress"]
        proofs = res["proofAddresses"]
        number_for_send = res["numberRange"]
        id_puzzle = res["idPuzzle"]

        if args.test or puzzle == 70:
            print("\n-----------------------------")
            print("- !!!WARNING!!! - TEST MODE -")
            print("-----------------------------\n")

        tmp_dir = os.path.join(MAIN_PATH, f"tmp_files_output_{id_puzzle}")
        os.makedirs(tmp_dir, exist_ok=True)
        out_file = os.path.join(tmp_dir, f"out_{id_puzzle}_{number_for_send}.txt")

        # Целевой адрес нужен режиму --submit для обнаружения FOUND
        with open(os.path.join(tmp_dir, "target_address.txt"), "w", encoding="utf-8") as f:
            f.write(target + "\n")

        print(f"* ID puzzle: {id_puzzle}")
        print(f"* Range number: {number_for_send}")
        print(f"* Target Address: {target}")
        if brute:
            print("* Brute mode: enabled")
        print("* Proof addresses:")
        with open(in_file, "w", encoding="utf-8", newline="\n") as f:
            f.write(target + "\n")
            for address in proofs:
                f.write(address + "\n")
                print(f"\t - {address}")
        print(f"* Bits: {res['bits']}")
        print(f"* Range hex: {res['startRangeHex']}:{res['endRangeHex']}")
        print(f"* Start VanitySearch: {res['bits']}")

        write_backup_script(id_puzzle, args.gpu, number_for_send)

        run_vanitysearch(vs_bin, args.gpu, in_file, out_file,
                         res["startRangeHex"], res["bits"], backup)

        found = parse_key_file(out_file)

        if target in found:
            report_found(target, found[target])
            return

        key_pairs = {a: k for a, k in found.items() if a in proofs}
        backup = False

        if len(key_pairs) != len(proofs):
            msg = f"Найдено проверочных ключей: {len(key_pairs)}/{len(proofs)}. Отправка пропущена."
            print(f"* Error: {msg}")
            log_error(msg)
            time.sleep(3)
            continue

        reply = set_range(puzzle, number_for_send, key_pairs, args.comment, payout)
        status = reply.get("status", 0)
        text = reply.get("text", "Unknown error")

        if reply.get("ok"):
            print(text)
            print(f"Ваш публичный идентификатор (sha256 адреса): {reply.get('payoutHash', '')}")
            print("Успешно пройденный диапазон!")
            advance()
        elif is_payout_error(reply):
            fatal(f"Пул отклонил адрес для выплат: {text}")
        elif status == 410:
            print(f"ERROR: {text}")
            sys.exit(1)
        elif status == 429:
            print(f"ERROR: {text}")
            print("Лимит запросов исчерпан надолго (или IP заблокирован). Попробуйте позже.")
            sys.exit(1)
        elif status == 409:
            print(f"Область уже подтверждена: {text}")
            advance()
        else:
            print(f"ERROR: {text}")
            log_error(f"setRange {puzzle}/{number_for_send}: {text}")
            advance()

        print("Запуск через 3 секунд... Ожидайте...")
        time.sleep(3)


# ----------------------------------------------------------------------------
# Режим 2: массовая отправка
# ----------------------------------------------------------------------------

def submit_mode(args, payout):
    puzzle = 70 if args.test else args.puzzle
    folder = args.folder or os.path.join(MAIN_PATH, f"tmp_files_output_{puzzle}")

    print_banner()
    print("РЕЖИМ: массовая отправка областей")
    print(f"ID Puzzle: {puzzle}")
    print(f"Папка: {folder}")
    if args.dry_run:
        print("Тестовый прогон: на сервер ничего не отправляется")

    if not os.path.isdir(folder):
        print(f"Папка не найдена: {folder}")
        return 1

    pattern = re.compile(rf"^out_{puzzle}_(\d+)\.txt$")
    files = sorted(
        (int(m.group(1)), name)
        for name in os.listdir(folder)
        for m in [pattern.match(name)] if m
    )

    done_path = os.path.join(folder, f"submitted_{puzzle}.txt")
    try:
        with open(done_path, "r", encoding="utf-8") as f:
            done = {line.strip() for line in f if line.strip()}
    except OSError:
        done = set()

    target = args.target or read_first_line(os.path.join(folder, "target_address.txt"))
    if not target:
        print("Внимание: целевой адрес неизвестен, проверка на FOUND отключена (--target ADDRESS).")

    pending = [(n, name) for n, name in files if str(n) not in done]
    print(f"Файлов: {len(files)}, уже отправлено ранее: {len(files) - len(pending)}, к отправке: {len(pending)}")

    stats = {"accepted": 0, "already": 0, "errors": 0, "skipped": 0}
    started = time.time()

    for idx, (number, name) in enumerate(pending, 1):
        print(f"\n[{idx}/{len(pending)}] {name}")
        found = parse_key_file(os.path.join(folder, name))

        if target and target in found:
            report_found(target, found[target])
            break

        key_pairs = {a: k for a, k in found.items() if a != target}
        if len(key_pairs) != 5:
            msg = f"{name}: найдено проверочных ключей {len(key_pairs)}/5, пропуск"
            print(f"   {msg}")
            log_error(msg)
            stats["skipped"] += 1
            continue

        if args.dry_run:
            print("   Тестовый режим: не отправляется")
            continue

        reply = set_range(puzzle, number, key_pairs, args.comment, payout)
        status = reply.get("status", 0)
        text = reply.get("text", "Unknown error")
        sent_ok = False

        if reply.get("ok"):
            print(f"   УСПЕШНО: {text}")
            stats["accepted"] += 1
            sent_ok = True
        elif status == 409:
            print(f"   Уже подтверждена: {text}")
            stats["already"] += 1
            sent_ok = True
        elif is_payout_error(reply):
            print(f"   Пул отклонил адрес для выплат: {text}")
            stats["errors"] += 1
            break
        elif status in (410, 429):
            print(f"   Остановка: {text}")
            stats["errors"] += 1
            break
        else:
            print(f"   Ошибка: {text}")
            log_error(f"setRange {puzzle}/{number}: {text}")
            stats["errors"] += 1

        if sent_ok:
            with open(done_path, "a", encoding="utf-8") as f:
                f.write(f"{number}\n")

        if idx < len(pending):
            time.sleep(args.delay)

    print("\n" + "=" * 60)
    print(f"Время работы:       {time.time() - started:.1f} сек.")
    print(f"Принято новых:      {stats['accepted']}")
    print(f"Уже подтверждено:   {stats['already']}")
    print(f"Пропущено файлов:   {stats['skipped']}")
    print(f"Ошибки:             {stats['errors']}")
    print("=" * 60)
    if stats["errors"]:
        print(f"Подробности: {LOG_ERROR}")

    return 0 if stats["errors"] == 0 else 1


# ----------------------------------------------------------------------------
# main
# ----------------------------------------------------------------------------

def main():
    parser = argparse.ArgumentParser(description="BITCOIN PUZZLE POOL (HoMLoL) — Windows / Linux")

    parser.add_argument("-g", "--gpu", type=int, default=0, help="Номер GPU. По умолчанию 0")
    parser.add_argument("-p", "--puzzle", type=int, default=71, help="Номер пазла. По умолчанию 71")
    parser.add_argument("-n", "--number", type=str, default="random",
                        help="Номер области (части). По умолчанию random")
    parser.add_argument("-a", "--address", type=str, default=None,
                        help="Адрес для выплат (конфиденциально; лучше PAYOUT_ADDRESS или payout_address.txt)")
    parser.add_argument("-c", "--comment", type=str, default=DEFAULT_COMMENT,
                        help="Комментарий после завершения области")
    parser.add_argument("-b", "--brute", action="store_true",
                        help="Только вместе с -n: последовательная проверка частей (n, n+1, ...)")
    parser.add_argument("--backup", action="store_true", help="Продолжить область из бэкапа VanitySearch")
    parser.add_argument("-t", "--test", action="store_true",
                        help="ТЕСТОВЫЙ РЕЖИМ ПАЗЛА №70 (17179869184 областей по 2^35). "
                             "Для FOUND укажите -n 11063563977")
    parser.add_argument("--vs", type=str, default=None, help="Путь к VanitySearch (или переменная VS_BIN)")

    parser.add_argument("-s", "--submit", action="store_true",
                        help="Массовая отправка областей из tmp_files_output_{puzzle}")
    parser.add_argument("-f", "--folder", type=str, default=None,
                        help="Папка с out_*.txt для --submit (по умолчанию tmp_files_output_{puzzle})")
    parser.add_argument("--target", type=str, default=None,
                        help="Целевой адрес пазла для проверки FOUND в режиме --submit")
    parser.add_argument("--delay", type=float, default=SUBMIT_DELAY,
                        help=f"Пауза между отправками в --submit, сек. По умолчанию {SUBMIT_DELAY}")
    parser.add_argument("--dry-run", action="store_true", help="--submit без отправки на сервер")

    args = parser.parse_args()

    if args.number != "random" and not re.fullmatch(r"[1-9][0-9]{0,14}", args.number):
        parser.error("-n должен быть положительным числом или random")
    if args.brute and args.number == "random":
        print("Внимание: -b работает только вместе с -n, режим brute отключён.")
        args.brute = False

    payout = resolve_payout_address(args.address)
    print(f"Идентификатор (sha256 адреса): {hashlib.sha256(payout.encode()).hexdigest()}")

    if args.submit:
        sys.exit(submit_mode(args, payout))

    mine(args, payout)


if __name__ == "__main__":
    try:
        sys.stdout.reconfigure(errors="replace")
    except Exception:
        pass
    try:
        main()
    except KeyboardInterrupt:
        print("\nОстановлено пользователем.")
        sys.exit(0)
