#!/usr/bin/env python3
"""Independently verify TheFantasyTool's prediction ledger. Python 3 standard library only.

    python3 verify_prediction_ledger.py                     # the newest 50 batches
    python3 verify_prediction_ledger.py --all               # the whole chain
    python3 verify_prediction_ledger.py --ots               # also run `ots verify` on proofs

What it proves, step by step:
  1. It downloads each batch's canonical lines and recomputes
        leaf       = sha256( line )   (a prediction before its deadline arrives as its leaf only)
        rows_hash  = sha256( each leaf + "\\n", leaves sorted )
        chain_hash = sha256( "prev_hash|batch_id|kind|started_at|n_rows|rows_hash" )
     and checks both against what the site publishes, and that every batch links to the
     one before it. Any edited, inserted or deleted prediction breaks this.
  2. With --ots, it checks each anchored batch's OpenTimestamps proof (needs the `ots`
     client: pip install opentimestamps-client). A proof that verifies against Bitcoin
     means that chain hash - and so every prediction in that batch and every batch before
     it - existed no later than the Bitcoin block it names.
  3. A prediction's lock time is in its batch lines ('L|game|round|person|recorded_at|lock_at'),
     its value in 'V|...' lines and its sealed value in 'S|...' lines - so anyone can
     check that the value we score was recorded before its deadline, in a batch that
     was anchored before that deadline.
"""
import argparse
import hashlib
import json
import os
import subprocess
import sys
import tempfile
import urllib.request

BASE = 'https://thefantasytool.com'
GENESIS = '0' * 64


def get(url, binary=False):
    req = urllib.request.Request(url, headers={'User-Agent': 'tft-ledger-verifier/1'})
    with urllib.request.urlopen(req, timeout=120) as r:
        data = r.read()
    return data if binary else data.decode('utf-8')


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('--base', default=BASE)
    ap.add_argument('--all', action='store_true')
    ap.add_argument('--limit', type=int, default=50)
    ap.add_argument('--ots', action='store_true')
    a = ap.parse_args()

    batches, before = [], None
    while True:
        url = '%s/api/prediction-ledger/checkpoints?limit=500' % a.base
        if before:
            url += '&before=%d' % before
        page = json.loads(get(url))
        batches += page
        if not page or not a.all or len(page) < 500:
            break
        before = page[-1]['batch_id']
    if not a.all:
        batches = batches[:a.limit]
    batches.sort(key=lambda b: b['batch_id'])
    bad = 0
    prev_known = None
    for b in batches:
        text = get('%s/api/prediction-ledger/batch/%d' % (a.base, b['batch_id']))
        lines = text.split('\n')
        header, rows = lines[0], [ln for ln in lines[1:] if ln]
        # A revealed line's leaf is sha256(line); a prediction whose deadline has not
        # passed yet arrives as 'X|<leaf>' - committed, not yet revealed.
        leaves = [ln[2:] if ln.startswith('X|')
                  else hashlib.sha256(ln.encode('utf-8')).hexdigest() for ln in rows]
        hidden = sum(1 for ln in rows if ln.startswith('X|'))
        h = hashlib.sha256()
        for lf in sorted(leaves):
            h.update(lf.encode('ascii'))
            h.update(b'\n')
        rh = h.hexdigest()
        ch = hashlib.sha256(('%s|%s|%s|%s|%s|%s' % (b['prev_hash'], b['batch_id'], b['kind'],
                                                     b['started_at'], len(rows), rh)
                             ).encode('utf-8')).hexdigest()
        ok = rh == b['rows_hash'] and ch == b['chain_hash'] and len(rows) == b['n_rows']
        if prev_known is not None and b['prev_hash'] != prev_known:
            ok = False
            print('batch %d: does not link to batch %d' % (b['batch_id'], b['batch_id'] - 1))
        if b['batch_id'] == 1 and b['prev_hash'] != GENESIS:
            ok = False
        prev_known = b['chain_hash']
        status = 'OK' if ok else 'MISMATCH'
        anchor = b.get('anchor')
        if anchor and a.ots:
            with tempfile.TemporaryDirectory() as td:
                t = os.path.join(td, 'checkpoint-%d.txt' % b['batch_id'])
                with open(t, 'wb') as f:
                    f.write(get('%s/api/prediction-ledger/proof/%d.txt' % (a.base, b['batch_id']),
                                binary=True))
                with open(t + '.ots', 'wb') as f:
                    f.write(get(a.base + anchor['proof_url'], binary=True))
                r = subprocess.run(['ots', 'verify', t + '.ots'], capture_output=True, text=True)
                status += ' | ots: ' + ((r.stderr or r.stdout).strip().splitlines() or ['?'])[-1]
        elif anchor:
            status += ' | anchored %s (%s)' % (anchor['submitted_at'], anchor['status'])
        if hidden:
            status += ' | %d not revealed until their deadline' % hidden
        print('batch %6d %-13s rows=%-7d %s' % (b['batch_id'], b['kind'], len(rows), status))
        bad += 0 if ok else 1
    print('\n%d batches checked, %d mismatches' % (len(batches), bad))
    return 1 if bad else 0


if __name__ == '__main__':
    sys.exit(main())
