#!/usr/bin/env python3
"""
gnp1_rebate.py  —  GNP1 minimum-pool-fee rebate (Script 2: distribute)

Returns the protocol-enforced 170 ADA minimum pool fee to GNP1 delegators,
pro-rata by active stake, once per epoch. Pays only delegators whose share is
>= 1 ADA (Cardano min-UTxO makes smaller outputs impossible); the full 170 ADA
is divided among those qualifiers so nothing is retained.

SAFE BY DEFAULT: runs as a DRY RUN unless you pass --live.
  Dry run  -> prints the full payout table (stake, resolved address, payout),
              builds the tx to get the real fee, but DOES NOT sign or submit.
  --live   -> builds, signs (wallet key), and submits the tx.

Idempotent: refuses to pay the same epoch twice (records paid epochs).

Data source: local cexplorer db-sync via psql.
  NB: live balances use the tx_in NOT-EXISTS pattern, NOT consumed_by_tx_id,
  because consumed_by_tx_id is unreliable in the restored DB.

Requirements (interactive shell with Guild env sourced):
  - psql reachable, db 'cexplorer'
  - cardano-cli on PATH, CARDANO_NODE_SOCKET_PATH set (run `cardano-cli query tip --mainnet` to check)
  - wallet key files present in the (redacted) wallet directory

Usage:
  python3 gnp1_rebate.py            # dry run (default) - review this first
  python3 gnp1_rebate.py --live     # actually pay
  python3 gnp1_rebate.py --epoch N  # override the epoch basis (advanced)
"""

import os
import sys
import json
import subprocess
import argparse
from datetime import datetime, timezone

# ─────────────────────────── CONFIG ───────────────────────────
POOL_ID           = 'pool1fv9f8phzn7hp623ypw6ctf73a98hd7nrh8wm7glpcuhf64856g2'
REBATE_LOVELACE   = 170_000_000      # 170 ADA total returned each epoch
MIN_PAYOUT_LOV    = 1_000_000        # 1 ADA floor (below Cardano min-UTxO you can't pay)
MAX_OUTPUTS       = 200              # safety rail: refuse absurd output counts
MAX_TOTAL_LOV     = 175_000_000      # safety rail: refuse if computed total exceeds this

WALLET_DIR = '[REDACTED - wallet directory]'
ADDR_FILE  = os.path.join(WALLET_DIR, '[REDACTED].addr')
SKEY_FILE  = os.path.join(WALLET_DIR, '[REDACTED].skey')
PAID_FILE  = os.path.join(WALLET_DIR, 'paid_epochs.txt')

DB      = 'cexplorer'
CLI     = os.environ.get('CARDANO_CLI', 'cardano-cli')
NETWORK = '--mainnet'
ERA     = 'conway'                   # cardano-cli era subcommand
TMPDIR  = '/tmp'
# ───────────────────────────────────────────────────────────────


def die(msg, code=1):
    print(f"\n✗ {msg}", file=sys.stderr)
    sys.exit(code)


def run_psql(query):
    """Run a query, return list of rows (each a list of string fields)."""
    out = subprocess.run(
        ['psql', '-d', DB, '-t', '-A', '-F', '\t', '-c', query],
        capture_output=True, text=True)
    if out.returncode != 0:
        die(f"psql failed:\n{out.stderr.strip()}")
    rows = []
    for line in out.stdout.splitlines():
        line = line.rstrip()
        if line == '':
            continue
        rows.append(line.split('\t'))
    return rows


def run_cli(args, capture=True):
    r = subprocess.run([CLI] + args, capture_output=capture, text=True)
    if r.returncode != 0:
        die(f"cardano-cli failed: {' '.join(args)}\n{(r.stderr or '').strip()}")
    return r.stdout


def ada(lov):
    return f"{lov/1_000_000:,.6f}".rstrip('0').rstrip('.')


# ─────────────────────── STEP 1: preflight ───────────────────────
def preflight():
    for f in (ADDR_FILE, SKEY_FILE):
        if not os.path.exists(f):
            die(f"missing wallet file: {f}")
    # confirm node socket / cli works
    try:
        run_cli([ERA, 'query', 'tip', NETWORK])
    except SystemExit:
        die("cardano-cli can't reach the node. Source your Guild env / set "
            "CARDANO_NODE_SOCKET_PATH, then retry.")
    with open(ADDR_FILE) as fh:
        return fh.read().strip()


# ─────────────── STEP 2: delegators + resolved addresses ───────────────
def fetch_delegators(epoch):
    """
    For the given epoch snapshot, return GNP1 delegators with their active
    stake AND a resolved payment address (largest current unspent UTxO for
    their stake key). Delegators with no resolvable address are returned with
    payment_addr = None and are excluded from payout.
    """
    q = f"""
    WITH pool AS (SELECT id FROM pool_hash WHERE view = '{POOL_ID}'),
    deleg AS (
        SELECT es.addr_id, SUM(es.amount) AS stake
        FROM epoch_stake es, pool p
        WHERE es.pool_id = p.id AND es.epoch_no = {epoch}
        GROUP BY es.addr_id
    ),
    unspent AS (
        SELECT txo.stake_address_id, txo.address, SUM(txo.value) AS bal
        FROM tx_out txo
        JOIN deleg d ON d.addr_id = txo.stake_address_id
        WHERE NOT EXISTS (
            SELECT 1 FROM tx_in ti
            WHERE ti.tx_out_id = txo.tx_id AND ti.tx_out_index = txo.index
        )
        GROUP BY txo.stake_address_id, txo.address
    ),
    ranked AS (
        SELECT stake_address_id, address, bal,
               ROW_NUMBER() OVER (PARTITION BY stake_address_id ORDER BY bal DESC) rn
        FROM unspent
    )
    SELECT d.addr_id, sa.view AS stake_addr, d.stake, r.address AS payment_addr
    FROM deleg d
    JOIN stake_address sa ON sa.id = d.addr_id
    LEFT JOIN ranked r ON r.stake_address_id = d.addr_id AND r.rn = 1
    ORDER BY d.stake DESC;
    """
    rows = run_psql(q)
    dels = []
    for row in rows:
        # psql drops the trailing empty field when payment_addr is NULL,
        # so a delegator with no resolvable address yields 3 fields not 4 — pad it.
        while len(row) < 4:
            row.append('')
        addr_id, stake_addr, stake, payment_addr = row[0], row[1], row[2], row[3]
        dels.append({
            'addr_id': int(addr_id),
            'stake_addr': stake_addr,
            'stake': int(stake),
            'payment_addr': payment_addr if payment_addr else None,
        })
    return dels


# ─────────────── STEP 3: qualification + payout split ───────────────
def compute_payouts(delegators):
    """
    Iteratively divide REBATE among delegators whose share >= 1 ADA, over the
    payable (address-resolved) set. Returns (payees, dropped_no_addr).
    Each payee gets an integer-lovelace 'payout'; payouts sum to exactly REBATE.
    """
    dropped_no_addr = [d for d in delegators if not d['payment_addr']]
    candidates = [d for d in delegators if d['payment_addr']]

    # iterate: dropping sub-1-ADA delegators only raises others' shares
    while True:
        total = sum(c['stake'] for c in candidates)
        if total == 0:
            die("no stake among resolvable delegators — nothing to pay.")
        survivors = [c for c in candidates
                     if REBATE_LOVELACE * c['stake'] / total >= MIN_PAYOUT_LOV]
        if len(survivors) == len(candidates):
            break
        candidates = survivors

    total = sum(c['stake'] for c in candidates)
    for c in candidates:
        c['payout'] = REBATE_LOVELACE * c['stake'] // total   # floor to lovelace
    # push the rounding remainder onto the largest stakeholder (sorted desc)
    remainder = REBATE_LOVELACE - sum(c['payout'] for c in candidates)
    if candidates:
        candidates[0]['payout'] += remainder

    return candidates, dropped_no_addr


# ─────────────── idempotency ───────────────
# ─────────────── did we mint a block this epoch? ───────────────
def blocks_minted(epoch):
    """How many blocks GNP1 produced in the given epoch (per db-sync)."""
    q = f"""
        SELECT COUNT(*)
        FROM block b
        JOIN slot_leader sl ON sl.id = b.slot_leader_id
        JOIN pool_hash ph   ON ph.id = sl.pool_hash_id
        WHERE ph.view = '{POOL_ID}' AND b.epoch_no = {epoch};
    """
    return int(run_psql(q)[0][0])


def already_paid(epoch):
    if not os.path.exists(PAID_FILE):
        return False
    with open(PAID_FILE) as fh:
        return any(line.split()[0] == str(epoch)
                   for line in fh if line.strip() and not line.startswith('#'))


def record_paid(epoch, txhash, total):
    ts = datetime.now(timezone.utc).isoformat()
    with open(PAID_FILE, 'a') as fh:
        fh.write(f"{epoch}\t{txhash}\t{ada(total)}\t{ts}\n")


# ─────────────── tx build / sign / submit ───────────────
def float_utxos(addr):
    out = run_cli([ERA, 'query', 'utxo', '--address', addr, NETWORK, '--out-file', '/dev/stdout'])
    data = json.loads(out)
    ins, bal = [], 0
    for k, v in data.items():
        ins.append(k)
        bal += int(v['value']['lovelace'])
    return ins, bal


def write_metadata(epoch, path):
    """Write CIP-20 (label 674) message + machine-readable rebate markers.
    The msg lines show in wallets/explorers; type+epoch let the website viewer
    reliably identify GNP1 rebate txs. Each msg string must be <= 64 bytes."""
    meta = {
        "674": {
            "msg": [
                "GNP1 Zero-Fee Pool",
                "Rebate of your share of the 170 ADA min pool fee",
                f"Epoch {epoch} - thank you for delegating!"
            ],
            "type": "GNP1-REBATE",
            "epoch": epoch
        }
    }
    # guard: enforce the 64-byte per-line limit
    for line in meta["674"]["msg"]:
        if len(line.encode("utf-8")) > 64:
            die(f"metadata line exceeds 64 bytes: {line!r}")
    with open(path, "w") as fh:
        json.dump(meta, fh)
    return path


def build_tx(addr, ins, payees, out_raw, metadata_file=None):
    args = [ERA, 'transaction', 'build']
    for i in ins:
        args += ['--tx-in', i]
    for p in payees:
        args += ['--tx-out', f"{p['payment_addr']}+{p['payout']}"]
    if metadata_file:
        args += ['--metadata-json-file', metadata_file]
    args += ['--change-address', addr, NETWORK, '--out-file', out_raw]
    stdout = run_cli(args)
    fee = None
    for line in stdout.splitlines():
        if 'stimated transaction fee' in line:
            fee = int(''.join(ch for ch in line if ch.isdigit()))
    return fee


def sign_and_submit(out_raw, out_signed):
    run_cli([ERA, 'transaction', 'sign',
             '--tx-body-file', out_raw,
             '--signing-key-file', SKEY_FILE,
             NETWORK, '--out-file', out_signed])
    run_cli([ERA, 'transaction', 'submit', '--tx-file', out_signed, NETWORK])
    txid = run_cli([ERA, 'transaction', 'txid', '--tx-file', out_signed]).strip()
    return txid


# ─────────────────────────── MAIN ───────────────────────────
def main():
    ap = argparse.ArgumentParser(description="GNP1 min-fee rebate distributor")
    ap.add_argument('--live', action='store_true', help="actually sign & submit (default: dry run)")
    ap.add_argument('--epoch', type=int, help="override epoch basis (default: latest epoch_stake)")
    ap.add_argument('--force', action='store_true', help="rebate even if 0 blocks minted this epoch")
    args = ap.parse_args()

    addr = preflight()

    # Use the CURRENT chain epoch (from block), not MAX(epoch_stake): epoch_stake
    # contains the UPCOMING epoch's snapshot before that epoch begins, so it runs
    # one ahead. Block production defines the epoch we're actually in and earning.
    epoch = args.epoch or int(run_psql("SELECT MAX(epoch_no) FROM block")[0][0])
    print(f"\n{'='*66}")
    print(f"  GNP1 MIN-FEE REBATE   epoch {epoch}   "
          f"{'*** LIVE ***' if args.live else 'DRY RUN (no funds move)'}")
    print(f"{'='*66}")

    if already_paid(epoch):
        die(f"epoch {epoch} already paid (see {PAID_FILE}). Refusing to double-pay.")

    # Only rebate epochs where GNP1 actually minted a block — that's when the
    # 170 ADA min fee is earned (and tops up the float). Empty epochs owe nothing,
    # so a cron can run every epoch and correctly do nothing on the empty ones.
    n_blocks = blocks_minted(epoch)
    if n_blocks == 0 and not args.force:
        print(f"\n  GNP1 minted 0 blocks in epoch {epoch} — no min fee earned, "
              f"nothing to rebate. Exiting cleanly.")
        print(f"  (Use --force to override, e.g. to rebate from float anyway.)\n")
        return
    print(f"\n  GNP1 minted {n_blocks} block(s) in epoch {epoch} — "
          f"min fee earned, proceeding.")

    delegators = fetch_delegators(epoch)
    if not delegators:
        die("no delegators found for this epoch.")

    payees, no_addr = compute_payouts(delegators)

    # ---- safety rails ----
    total_out = sum(p['payout'] for p in payees)
    if total_out != REBATE_LOVELACE:
        die(f"internal: payouts sum to {ada(total_out)} ADA, expected 170. Aborting.")
    if len(payees) > MAX_OUTPUTS:
        die(f"output count {len(payees)} exceeds safety cap {MAX_OUTPUTS}.")
    if total_out > MAX_TOTAL_LOV:
        die(f"total {ada(total_out)} exceeds safety cap. Aborting.")

    # ---- payout table ----
    print(f"\n  {len(delegators)} delegators this epoch · "
          f"{len(payees)} qualify (>=1 ADA) · "
          f"{len(no_addr)} unpayable (no resolvable address)\n")
    print(f"  {'#':>3}  {'stake ADA':>14}  {'payout ADA':>11}  payment address")
    print(f"  {'-'*3}  {'-'*14}  {'-'*11}  {'-'*20}")
    for i, p in enumerate(payees, 1):
        print(f"  {i:>3}  {p['stake']/1e6:>14,.0f}  {p['payout']/1e6:>11,.6f}  {p['payment_addr']}")
    print(f"  {'-'*3}  {'-'*14}  {'-'*11}")
    print(f"  {'':>3}  {'TOTAL':>14}  {total_out/1e6:>11,.6f}  ADA to {len(payees)} delegators")

    if no_addr:
        print(f"\n  ⚠ {len(no_addr)} delegator(s) had no resolvable payment address "
              f"and were excluded:")
        for d in no_addr:
            print(f"      {d['stake_addr']}  (stake {d['stake']/1e6:,.0f} ADA)")

    # ---- build tx (gets real fee) ----
    ins, bal = float_utxos(addr)
    if not ins:
        die("float wallet has no UTxOs — fund it first.")
    print(f"\n  float wallet balance: {ada(bal)} ADA  ({len(ins)} UTxO input(s))")
    if bal < total_out + 2_000_000:   # need payout + fee headroom
        die(f"insufficient float balance ({ada(bal)} ADA) for "
            f"{ada(total_out)} + fee. Top up the float wallet.")

    out_raw    = os.path.join(TMPDIR, f'rebate_{epoch}.raw')
    out_signed = os.path.join(TMPDIR, f'rebate_{epoch}.signed')
    meta_file  = write_metadata(epoch, os.path.join(TMPDIR, f'rebate_{epoch}_meta.json'))
    fee = build_tx(addr, ins, payees, out_raw, metadata_file=meta_file)
    print(f"  estimated network fee: {ada(fee) if fee else '?'} ADA")
    print(f"  total leaving float:   {ada(total_out + (fee or 0))} ADA")

    if not args.live:
        print(f"\n  DRY RUN — reviewed the addresses above? Re-run with --live to pay.\n")
        return

    # ---- LIVE ----
    print(f"\n  Submitting…")
    txid = sign_and_submit(out_raw, out_signed)
    record_paid(epoch, txid, total_out)
    print(f"\n  ✓ SUBMITTED  epoch {epoch}")
    print(f"    txid: {txid}")
    print(f"    170 ADA returned to {len(payees)} delegators.\n")


if __name__ == '__main__':
    main()
