"""Bounded Litecoin Core regtest experiment: exact rebroadcast vs another payment.

Python 3 standard library; requires the pinned Core binaries. No real funds.
Usage: python reproduce.py.txt /path/litecoind /path/litecoin-cli output-directory
Creates its own fresh data directory and two fresh wallets. Never accepts an
existing wallet, configuration, seed, private key or RPC credential.
"""
from datetime import datetime, timezone
from decimal import Decimal
from pathlib import Path
import csv
import hashlib
import json
import re
import shutil
import signal
import socket
import subprocess
import sys
import tempfile
import time

PINS = {
    "litecoind": "63fb440cdc5e2e7d46144b8e3a47dab4725fa9c056a77b8b12b11854504f4838",
    "litecoin-cli": "d185011cc0dbb5d2dd20611e9dc060dbb0a5d9be31a03f57da5dbd3dad4c3769",
}
COMMENT = "INV-LW-DUPLICATE-001"
AMOUNT = Decimal("0.12500000")


def utc():
    return datetime.now(timezone.utc).isoformat()


def serial(value):
    if isinstance(value, Decimal):
        return format(value, "f")
    if isinstance(value, dict):
        return {key: serial(item) for key, item in value.items()}
    if isinstance(value, (list, tuple)):
        return [serial(item) for item in value]
    return value


def write(path, value):
    path.write_text(json.dumps(serial(value), indent=2, ensure_ascii=False) + "\n", encoding="utf-8")


def main(daemon, cli, output):
    started = utc()
    hashes = {name: hashlib.sha256(path.read_bytes()).hexdigest() for name, path in (("litecoind", daemon), ("litecoin-cli", cli))}
    if hashes != PINS:
        raise RuntimeError("Binary SHA256 does not match this experiment's pinned release.")
    output.mkdir(parents=True, exist_ok=True)
    datadir = Path(tempfile.mkdtemp(prefix="lw-duplicate-payment-regtest-")).resolve()
    temp_parent = Path(tempfile.gettempdir()).resolve()
    if datadir.parent != temp_parent or not datadir.name.startswith("lw-duplicate-payment-regtest-"):
        raise RuntimeError("Fresh-directory safety check failed.")
    reservation = socket.socket()
    reservation.bind(("127.0.0.1", 0))
    port = reservation.getsockname()[1]
    flags = ["-regtest", "-server=1", "-listen=0", "-connect=0", "-dnsseed=0", "-dns=0", "-discover=0",
             "-listenonion=0", "-upnp=0", "-dbcache=32", "-par=1", "-maxmempool=5", "-persistmempool=0",
             "-fallbackfee=0.00001", "-acceptnonstdtxn=0", "-rpcbind=127.0.0.1", "-rpcallowip=127.0.0.1"]
    base = [str(cli), "-regtest", "-datadir=" + str(datadir), "-rpcconnect=127.0.0.1", "-rpcport=" + str(port)]
    trace, assertions, stages, transactions = [], [], [], []
    process, logfile, result = None, None, None
    cleanup = {"own_node_stopped": False, "fresh_directory_removed": False}

    def deadline(signum, frame):
        raise TimeoutError("The isolated experiment exceeded its 75-second work budget.")

    signal.signal(signal.SIGALRM, deadline)
    signal.signal(signal.SIGTERM, deadline)
    signal.alarm(75)

    def check(name, condition):
        assertions.append({"name": name, "passed": bool(condition)})
        if not condition:
            raise AssertionError(name)

    def rpc(method, *args, wallet=None, allow_error=False, wait=False):
        command = base + (["-rpcwait"] if wait else [])
        if wallet:
            command.append("-rpcwallet=" + wallet)
        params = [json.dumps(item, separators=(",", ":")) if isinstance(item, (dict, list, bool)) else str(item) for item in args]
        response = subprocess.run(command + [method, *params], capture_output=True, text=True, timeout=12)
        call = {"at_utc": utc(), "wallet": wallet, "method": method, "params": params, "return_code": response.returncode}
        if response.returncode:
            error = response.stderr.strip()
            code = re.search(r"error code:\s*(-?\d+)", error)
            value = {"kind": "rpc_error", "code": int(code.group(1)) if code else None, "message": error}
            call["error"] = value
            trace.append(call)
            if allow_error:
                return value
            raise RuntimeError(method + ": " + error)
        try:
            value = json.loads(response.stdout, parse_float=Decimal) if response.stdout.strip() else None
        except json.JSONDecodeError:
            value = response.stdout.strip()
        call["result"] = serial(value)
        trace.append(call)
        return value

    def wait_for(predicate, message):
        until = time.monotonic() + 5
        while time.monotonic() < until:
            if predicate():
                return
            time.sleep(0.05)
        raise TimeoutError(message)

    def snapshot(stage, address):
        receipts = rpc("listunspent", 0, 9999999, [address], True, wallet="recipient")
        item = {"stage": stage, "observed_at_utc": utc(), "height": rpc("getblockcount"),
                "mempool_txids": rpc("getrawmempool"), "peer_count": rpc("getconnectioncount"),
                "recipient_received_minconf0_ltc": rpc("getreceivedbyaddress", address, 0, wallet="recipient"),
                "recipient_received_minconf1_ltc": rpc("getreceivedbyaddress", address, 1, wallet="recipient"),
                "recipient_unspent_outputs": [{key: row[key] for key in ("txid", "vout", "amount", "confirmations")} for row in receipts]}
        check(stage + ": zero external or loopback peers", item["peer_count"] == 0)
        stages.append(item)
        return item

    def payment(txid, payment_id, address):
        wallet_tx = rpc("gettransaction", txid, wallet="sender")
        decoded = rpc("decoderawtransaction", wallet_tx["hex"])
        targets = [out for out in decoded["vout"] if address in out["scriptPubKey"].get("addresses", [])]
        check(payment_id + ": exactly one intended native SegWit recipient output", len(targets) == 1 and targets[0]["scriptPubKey"]["type"] == "witness_v0_keyhash")
        check(payment_id + ": complete0.125 amount without fee subtraction", targets[0]["value"] == AMOUNT)
        check(payment_id + ": same supplied local invoice comment", wallet_tx.get("comment") == COMMENT)
        inputs = []
        for vin in decoded["vin"]:
            previous = rpc("gettransaction", vin["txid"], wallet="sender")
            previous_decoded = rpc("decoderawtransaction", previous["hex"])
            prevout = previous_decoded["vout"][vin["vout"]]
            inputs.append({"txid": vin["txid"], "vout": vin["vout"], "value_ltc": prevout["value"], "previous_raw_hex": previous["hex"]})
        fee = -wallet_tx["fee"]
        input_total = sum((row["value_ltc"] for row in inputs), Decimal(0))
        output_total = sum((row["value"] for row in decoded["vout"]), Decimal(0))
        check(payment_id + ": positive miner fee", fee > 0)
        check(payment_id + ": inputs minus all outputs equal wallet fee", input_total - output_total == fee)
        record = {"payment_id": payment_id, "txid": txid, "raw_hex": wallet_tx["hex"],
                  "raw_sha256": hashlib.sha256(bytes.fromhex(wallet_tx["hex"])).hexdigest(),
                  "recipient_address": address, "recipient_vout": targets[0]["n"],
                  "recipient_script_hex": targets[0]["scriptPubKey"]["hex"], "recipient_value_ltc": targets[0]["value"],
                  "fee_ltc": fee, "sender_comment": wallet_tx.get("comment"), "inputs": inputs,
                  "all_outputs_ltc": [row["value"] for row in decoded["vout"]], "vsize": decoded["vsize"]}
        transactions.append(record)
        return record

    try:
        reservation.close()
        logfile = (datadir / "process.log").open("ab")
        process = subprocess.Popen([str(daemon), *flags, "-datadir=" + str(datadir), "-rpcport=" + str(port)], stdout=logfile, stderr=logfile)
        chain = rpc("getblockchaininfo", wait=True)
        check("Fresh regtest chain starts at height0", chain["chain"] == "regtest" and chain["blocks"] == 0)
        version = rpc("getnetworkinfo")["subversion"]
        check("Pinned Core version0.21.5.8", "0.21.5.8" in version)
        check("No peers before wallet creation", rpc("getconnectioncount") == 0)
        rpc("setnetworkactive", False)
        check("Peer networking disabled by RPC", rpc("getnetworkinfo")["networkactive"] is False)
        rpc("createwallet", "sender")
        rpc("createwallet", "recipient")
        help_text = {method: rpc("help", method) for method in ("sendtoaddress", "sendrawtransaction", "gettransaction", "getreceivedbyaddress", "listunspent")}
        mining = rpc("getnewaddress", "research-funding", "bech32", wallet="sender")
        address = rpc("getnewaddress", "test-recipient", "bech32", wallet="recipient")
        rpc("generatetoaddress", 101, mining)
        before = snapshot("funded_before_payment", address)
        check("Recipient initially received0", before["recipient_received_minconf0_ltc"] == 0)
        first_id = rpc("sendtoaddress", address, "0.125", COMMENT, "Disposable regtest recipient", False, wallet="sender")
        first = payment(first_id, "P1", address)
        wait_for(lambda: rpc("getreceivedbyaddress", address, 0, wallet="recipient") == AMOUNT, "First wallet notification")
        first_stage = snapshot("first_payment_in_mempool", address)
        check("First mempool contains onlyP1", first_stage["mempool_txids"] == [first_id])
        check("First receipt is unconfirmed", first_stage["recipient_received_minconf1_ltc"] == 0 and len(first_stage["recipient_unspent_outputs"]) == 1)
        response1 = rpc("sendrawtransaction", first["raw_hex"], allow_error=True)
        repeated = snapshot("exact_raw_rebroadcast_in_mempool", address)
        check("Raw rebroadcast leaves same mempool identity", repeated["mempool_txids"] == [first_id])
        check("Raw rebroadcast leaves one0.125 receipt", repeated["recipient_received_minconf0_ltc"] == AMOUNT and len(repeated["recipient_unspent_outputs"]) == 1)
        check("Raw rebroadcast preserves original wallet fee", -rpc("gettransaction", first_id, wallet="sender")["fee"] == first["fee_ltc"])
        rpc("generatetoaddress", 1, mining)
        confirmed = snapshot("first_payment_confirmed", address)
        check("P1 confirmed with0.125 received", confirmed["recipient_received_minconf1_ltc"] == AMOUNT and confirmed["mempool_txids"] == [])
        response2 = rpc("sendrawtransaction", first["raw_hex"], allow_error=True)
        already = snapshot("exact_raw_rebroadcast_after_confirmation", address)
        check("Post-confirmation rebroadcast adds no mempool transaction", already["mempool_txids"] == [])
        check("Post-confirmation rebroadcast leaves one0.125 receipt", already["recipient_received_minconf1_ltc"] == AMOUNT and len(already["recipient_unspent_outputs"]) == 1)
        second_id = rpc("sendtoaddress", address, "0.125", COMMENT, "Disposable regtest recipient", False, wallet="sender")
        second = payment(second_id, "P2", address)
        check("Second Send creates distinct transaction", second_id != first_id)
        check("Second Send has distinct raw bytes", second["raw_hex"] != first["raw_hex"])
        wait_for(lambda: rpc("getreceivedbyaddress", address, 0, wallet="recipient") == AMOUNT * 2, "Second wallet notification")
        second_stage = snapshot("new_second_payment_in_mempool", address)
        check("OnlyP2 pending after second Send", second_stage["mempool_txids"] == [second_id])
        check("Recipient now has two distinct receipts", len(second_stage["recipient_unspent_outputs"]) == 2)
        check("Same comment did not deduplicate payments", first["sender_comment"] == second["sender_comment"] and second_stage["recipient_received_minconf0_ltc"] == Decimal("0.25"))
        rpc("generatetoaddress", 1, mining)
        final = snapshot("both_payments_confirmed", address)
        check("Both confirmed, total recipient0.25", final["recipient_received_minconf1_ltc"] == Decimal("0.25") and final["mempool_txids"] == [])
        expected = {(item["txid"], item["recipient_vout"]) for item in transactions}
        actual = {(item["txid"], item["vout"]) for item in final["recipient_unspent_outputs"]}
        check("Recipient output identities exactly match two payments", actual == expected)
        check("Independent receipt sum0.25", sum((item["amount"] for item in final["recipient_unspent_outputs"]), Decimal(0)) == Decimal("0.25"))
        final_fee = sum((item["fee_ltc"] for item in transactions), Decimal(0))
        for item in transactions:
            item["final_confirmations"] = rpc("gettransaction", item["txid"], wallet="sender")["confirmations"]
        result = {"schema": 1, "version": "1.0.0", "started_at_utc": started, "completed_at_utc": utc(), "core_version": version,
                  "network": "regtest", "binary_sha256": hashes, "node_options": flags, "work_budget_seconds": 75,
                  "scope": "One fresh node with peer networking disabled, two disposable wallets, transparent native SegWit test coins. No mainnet transaction, provider interface, replacement, reorg, MWEB or latency experiment.",
                  "invoice_comment": COMMENT, "transactions": transactions,
                  "rebroadcasts": [{"stage": "mempool", "txid": first_id, "response": response1}, {"stage": "confirmed", "txid": first_id, "response": response2}],
                  "stages": stages, "summary": {"distinct_transactions": 2, "distinct_recipient_outputs": 2, "recipient_total_ltc": Decimal("0.25"), "total_miner_fees_ltc": final_fee},
                  "assertions_passed": len(assertions), "assertions": assertions}
        write(output / "rpc-help.json", help_text)
    except Exception:
        write(output / "failed-assertions.json", assertions)
        write(output / "failed-rpc-trace.json", {"calls": trace})
        raise
    finally:
        signal.alarm(0)
        if process is not None and process.poll() is None:
            try:
                rpc("stop")
                process.wait(timeout=5)
            except Exception:
                process.terminate()
                try:
                    process.wait(timeout=3)
                except subprocess.TimeoutExpired:
                    process.kill()
                    process.wait(timeout=2)
        cleanup["own_node_stopped"] = process is None or process.poll() is not None
        cleanup["exit_code"] = process.returncode if process else None
        if logfile:
            logfile.close()
        reservation.close()
        # Delete only the resolved directory created by this invocation, after our process exited.
        if cleanup["own_node_stopped"] and datadir.parent == temp_parent and datadir.name.startswith("lw-duplicate-payment-regtest-"):
            shutil.rmtree(datadir)
            cleanup["fresh_directory_removed"] = not datadir.exists()
        write(output / "cleanup.json", cleanup)
    if result is None:
        raise RuntimeError("No completed result.")
    result["cleanup"] = cleanup
    result["completed_at_utc"] = utc()
    write(output / "results.json", result)
    write(output / "rpc-trace.json", {"scope": "Generated regtest wallet activity only; no private keys, seed, authentication cookie or mainnet data.", "calls": trace})
    write(output / "transactions.json", transactions)
    with (output / "stages.csv").open("w", encoding="utf-8", newline="") as handle:
        fields = ["stage", "observed_at_utc", "height", "peer_count", "recipient_received_minconf0_ltc", "recipient_received_minconf1_ltc", "recipient_output_count", "mempool_count"]
        writer = csv.DictWriter(handle, fieldnames=fields)
        writer.writeheader()
        for stage in serial(stages):
            row = {key: stage[key] for key in fields if key in stage}
            row.update(recipient_output_count=len(stage["recipient_unspent_outputs"]), mempool_count=len(stage["mempool_txids"]))
            writer.writerow(row)
    print(json.dumps(serial({"success": True, "assertions_passed": len(assertions), "summary": result["summary"], "rebroadcasts": result["rebroadcasts"], "cleanup": cleanup})))


if __name__ == "__main__":
    if len(sys.argv) != 4:
        raise SystemExit("Usage: python reproduce.py.txt litecoind litecoin-cli output-directory")
    main(Path(sys.argv[1]).resolve(), Path(sys.argv[2]).resolve(), Path(sys.argv[3]).resolve())
