#!/usr/bin/env python3
"""Check Pulse's existing exports. No wallet, payments or execution of instructions from bundles.
Capture uses only fixed public Pulse and Blockstream URLs. Verify is offline.
"""
import argparse
import base64
import datetime as dt
import hashlib
import json
import pathlib
import re
import sys
import urllib.request

HEX = re.compile(r'^[0-9a-f]{64}$')
EARLIEST_COMPLETE_REPAIRED_DAY = '2026-10-04'
PINNED_KEY = {'kid':'b0bd6062bb630141','x':'H93waGuQrHkdnSgRiDLph-2YpqjlFFkiBm57pDF0M2g'}
MAX_COMMITMENT_LAG_SECONDS = 72 * 3600
LIMITS = [
    'Record integrity is not observation accuracy, service safety or paid delivery.',
    'Observations come from one operator and one vantage point; gaps between probes are not measured.',
    'Daily records have no x401 credential evidence and cannot establish proof-gated access.',
    'A supplied Bitcoin header is a trust input. Declared proof of work is checked, but minimum-difficulty headers can pass; Bitcoin consensus, difficulty history and the best chain are not verified.',
    'All days before 4 October 2026 are excluded: 56 damaged legacy days, the 2 October repair transition and 3 October collector-runtime incident.',
    'The as-of UTC date is supplied by the caller; freshness is relative to that date.',
    'Issuer authentication uses the public key pinned in this checker, not a key supplied by the bundle.',
    'Commitments more than 72 hours after day-end are excluded by a conservative client policy, not a protocol rule.',
]

def digest(value):
    return hashlib.sha256(value).digest()

def day(value):
    if not isinstance(value, str) or not re.fullmatch(r'\d{4}-\d{2}-\d{2}', value):
        raise ValueError('invalid UTC day')
    return dt.date.fromisoformat(value)

def merkle(lines):
    nodes = [digest(line.encode('utf-8')) for line in lines]
    if not nodes:
        raise ValueError('empty record set')
    while len(nodes) > 1:
        nodes = [digest(nodes[i] + nodes[i+1]) if i+1 < len(nodes) else nodes[i]
                 for i in range(0, len(nodes), 2)]
    return nodes[0].hex()

def unique(pairs):
    out = {}
    for k, v in pairs:
        if k in out:
            raise ValueError('duplicate JSON key')
        out[k] = v
    return out

def load_json(path, limit=16_000_000):
    raw = pathlib.Path(path).read_bytes()
    if len(raw) > limit:
        raise ValueError('input exceeds size limit')
    return json.loads(raw, object_pairs_hook=unique)

def validate_records(export):
    date = export['day']; day(date)
    records = export['records']
    if not isinstance(records, list) or not 1 <= len(records) <= 100_000:
        raise ValueError('invalid record count')
    if any(not isinstance(x, str) or len(x) > 100_000 for x in records):
        raise ValueError('invalid record encoding')
    # JavaScript's sort uses UTF-16 code units; match the existing publisher exactly.
    if records != sorted(records, key=lambda x: x.encode('utf-16-be', errors='surrogatepass')):
        raise ValueError('records are not in canonical order')
    urls = set()
    for line in records:
        r = json.loads(line, object_pairs_hook=unique)
        expected = ['url','d','p','a','x','v','l'] + (['self'] if 'self' in r else [])
        if list(r) != expected or r['d'] != date or not isinstance(r['url'], str):
            raise ValueError('unexpected record schema')
        if not r['url'].startswith(('https://','http://')) or r['url'] in urls:
            raise ValueError('invalid or duplicate endpoint')
        urls.add(r['url'])
        if any(type(r[k]) is not int or not 0 <= r[k] <= 9007199254740991 for k in ['p','a','x','v']):
            raise ValueError('invalid observation counts')
        if r['p'] == 0 or r['a'] > r['p'] or r['x'] > r['p'] or r['v'] > r['x']:
            raise ValueError('inconsistent observation counts')
        if r['l'] is not None and (type(r['l']) is not int or r['l'] < 0):
            raise ValueError('invalid mean latency')
        if 'self' in r and (type(r['self']) is not int or r['self'] != 1):
            raise ValueError('invalid self-observation marker')
    root = merkle(records)
    if type(export['endpoints']) is not int or export['endpoints'] != len(records) or export['merkleRoot'] != root:
        raise ValueError('export does not match its records')
    return root

def bitcoin_attestations(entry):
    from opentimestamps.core.timestamp import Timestamp
    from opentimestamps.core.serialize import BytesDeserializationContext
    from opentimestamps.core.notary import BitcoinBlockHeaderAttestation
    for proof in entry.get('proofs', []):
        encoded = proof.get('ots', '')
        if not isinstance(encoded, str) or len(encoded) > 1_000_000:
            raise ValueError('timestamp proof exceeds limit')
        ctx = BytesDeserializationContext(base64.b64decode(encoded, validate=True))
        stamp = Timestamp.deserialize(ctx, bytes.fromhex(entry['chained']))
        ctx.assert_eof()
        for message, att in stamp.all_attestations():
            if isinstance(att, BitcoinBlockHeaderAttestation):
                yield message, att

def unb64url(value):
    if not isinstance(value, str) or not re.fullmatch(r'[A-Za-z0-9_-]+', value):
        raise ValueError('invalid base64url')
    return base64.urlsafe_b64decode(value + '=' * (-len(value) % 4))

def verify_issuer(bundle, trusted_key=PINNED_KEY):
    try:
        from Cryptodome.Signature import eddsa
        token = bundle.get('checkpoint', '')
        if not isinstance(token, str) or len(token) > 4_000_000:
            raise ValueError('invalid checkpoint')
        h, p, signature = token.split('.')
        header = json.loads(unb64url(h), object_pairs_hook=unique)
        payload = json.loads(unb64url(p), object_pairs_hook=unique)
        if header != {'alg':'EdDSA','typ':'chainverdict-archive-checkpoint+jws','kid':trusted_key['kid']}:
            raise ValueError('unexpected checkpoint header')
        eddsa.new(eddsa.import_public_key(unb64url(trusted_key['x'])), 'rfc8032').verify((h+'.'+p).encode(),unb64url(signature))
        expected = [{k:a[k] for k in ['day','merkleRoot','chained','prevChained','records']} for a in bundle['chain']]
        if payload.get('iss') != 'https://pulse.chainverdict.xyz' or payload.get('schema') != 'chainverdict.archive-checkpoint.v1' or payload.get('anchors') != expected:
            raise ValueError('checkpoint scope mismatch')
        return {'status':'authenticated','kid':trusted_key['kid']}
    except Exception:
        return {'status':'unverified','reason':'missing, invalid or untrusted publisher checkpoint'}

def check_header_work(raw):
    from bitcoin.core import CBlockHeader, CheckProofOfWork
    header = CBlockHeader.deserialize(raw)
    CheckProofOfWork(header.GetHash(), header.nBits)

def verify_timestamp(entry, headers):
    try:
        from bitcoin.core import CBlockHeader
        matches = []
        for message, att in bitcoin_attestations(entry):
            supplied = headers.get(str(att.height))
            if not supplied:
                continue
            raw = bytes.fromhex(supplied['headerHex'])
            if len(raw) != 80 or digest(digest(raw))[::-1].hex() != supplied['blockHash']:
                raise ValueError('supplied header hash mismatch')
            check_header_work(raw)
            timestamp = att.verify_against_blockheader(message, CBlockHeader.deserialize(raw))
            matches.append({'height': att.height, 'blockHash': supplied['blockHash'], 'timestamp': timestamp})
        return {'status': 'verified_against_supplied_header', 'matches': sorted(matches, key=lambda x: x['height'])} if matches else {'status': 'unverified', 'reason': 'no Bitcoin attestation with a supplied header'}
    except ImportError:
        return {'status': 'unverified', 'reason': 'official OpenTimestamps verification dependency unavailable'}
    except Exception:
        return {'status': 'invalid', 'reason': 'timestamp proof or supplied header failed verification'}

def verify(bundle, headers, as_of, endpoint=None, start=None, end=None, trusted_key=PINNED_KEY):
    today = day(as_of)
    issuer = verify_issuer(bundle, trusted_key)
    chain = bundle.get('chain', [])
    exports = bundle.get('days', [])
    if not isinstance(chain, list) or len(chain) > 10000 or not isinstance(exports, list) or len(exports) > 1000:
        raise ValueError('invalid bundle bounds')
    anchors, invalid_chain = {}, set()
    previous = '0' * 64
    chain_broken = False
    for a in chain:
        d = a['day']; day(d)
        fields = [a.get(k) for k in ['prevChained','merkleRoot','chained']]
        valid = all(isinstance(x,str) and HEX.fullmatch(x) for x in fields)
        if not valid or a['prevChained'] != previous or digest(bytes.fromhex(a['prevChained'] + a['merkleRoot'])).hex() != a['chained']:
            chain_broken = True
        if chain_broken:
            invalid_chain.add(d)
        if d in anchors:
            invalid_chain.add(d)
        anchors[d] = a
        previous = a.get('chained')
    if invalid_chain:
        invalid_chain.update(anchors)  # a broken chain cannot authenticate a later prefix
    results, seen = [], set()
    for export in exports:
        d = export.get('day'); day(d)
        if d in seen:
            raise ValueError('duplicate day export')
        seen.add(d)
        row = {'day': d, 'recordStatus': 'invalid', 'timestampStatus': {'status':'not_checked'}, 'usableEvidence': False, 'reasons': []}
        if issuer['status'] != 'authenticated':
            row['reasons'].append('publisher is not authenticated by the pinned key')
        observation = None
        try:
            root = validate_records(export)
            a = anchors.get(d)
            if not a:
                row['recordStatus'] = 'uncommitted'; row['reasons'].append('no original commitment')
            elif d in invalid_chain:
                row['reasons'].append('invalid or duplicate chain entry')
            elif root != a['merkleRoot'] or export['endpoints'] != a['records']:
                row['recordStatus'] = 'mismatched'; row['reasons'].append('records disagree with the original commitment')
            else:
                row['recordStatus'] = 'matches_commitment'
                row['timestampStatus'] = verify_timestamp(a, headers)
                for match in row['timestampStatus'].get('matches', []):
                    committed = dt.datetime.fromtimestamp(match['timestamp'],dt.timezone.utc).date()
                    end_seconds = int(dt.datetime.combine(day(d)+dt.timedelta(days=1),dt.time(),tzinfo=dt.timezone.utc).timestamp())
                    match['lagSecondsAfterDayEnd'] = match['timestamp'] - end_seconds
                    if match['lagSecondsAfterDayEnd'] > MAX_COMMITMENT_LAG_SECONDS:
                        row['reasons'].append('commitment exceeds the 72-hour client lag policy')
                    if committed <= day(d) or committed > today:
                        row['reasons'].append('timestamp outside the permitted completed-day/as-of interval')
                if row['timestampStatus']['status'] != 'verified_against_supplied_header':
                    row['reasons'].append('commitment time is not verified against a supplied Bitcoin header')
        except (ValueError, KeyError, TypeError, OverflowError):
            row['reasons'].append('invalid record set')
        if d < EARLIEST_COMPLETE_REPAIRED_DAY:
            row['reasons'].append('legacy damage, repair transition or collector incident; excluded')
        if day(d) >= today:
            row['reasons'].append('day is not completed at the supplied as-of date')
        if endpoint is not None:
            matching = [json.loads(line) for line in export.get('records', []) if isinstance(line,str) and json.loads(line).get('url') == endpoint] if row['recordStatus'] == 'matches_commitment' else []
            if len(matching) == 1:
                observation = matching[0]
            else:
                row['reasons'].append('requested endpoint is not available in verified records')
        if d == '2026-10-03':
            row['collectionNotes'] = ['Collector runtime incompatibility caused false network failures during this day; excluded from usable evidence. Collection behaviour also changed: redirects were followed before the probe-hardening release and reported directly afterward. Anonymous status checks stopped adding archive observations and automatic registry admissions were capped at 1,000 known endpoints later the same day. Integrity checks do not establish comparability across those methods.']
        row['usableEvidence'] = not row['reasons'] and row['recordStatus'] == 'matches_commitment'
        if row['usableEvidence'] and observation is not None:
            row['endpointObservation'] = observation
        results.append(row)
    first = day(start) if start else min((day(r['day']) for r in results),default=today)
    last = day(end) if end else max((day(r['day']) for r in results),default=today)
    if first > last or (last-first).days > 366:
        raise ValueError('invalid requested window')
    results = [r for r in results if first <= day(r['day']) <= last]
    present = {r['day'] for r in results}
    missing = [(first+dt.timedelta(days=n)).isoformat() for n in range((last-first).days+1)
               if (first+dt.timedelta(days=n)).isoformat() not in present]
    for d in missing:
        results.append({'day':d,'recordStatus':'missing','timestampStatus':{'status':'not_checked'},'usableEvidence':False,'reasons':['requested day was not supplied; no availability inference']})
    window_status = 'missing_days' if missing else 'stale' if (today-last).days > 2 else 'complete_recent'
    return {'schema':'chainverdict.archive-check.v1','asOf':as_of,'endpoint':endpoint,'issuer':issuer,'chainStatus':'invalid' if invalid_chain else 'valid' if chain else 'empty',
            'window':{'from':first.isoformat(),'to':last.isoformat(),'status':window_status,'missingDays':missing},
            'days':sorted(results,key=lambda x:x['day']), 'usableDays':sum(r['usableEvidence'] for r in results),'limitations':LIMITS}


class NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        raise ValueError('redirects are not followed')

OPENER = urllib.request.build_opener(NoRedirect())

def fetch_json(url, limit=8_000_000):
    # This function is only used by capture with fixed-origin paths, never bundle URLs.
    request = urllib.request.Request(url, headers={'User-Agent':'ChainVerdict-evidence-capture/1.0','Accept':'application/json'})
    with OPENER.open(request, timeout=20) as response:
        if response.url.split('/')[2] != url.split('/')[2]:
            raise ValueError('unexpected cross-origin redirect')
        raw = response.read(limit + 1)
    if len(raw) > limit:
        raise ValueError('response exceeds size limit')
    return json.loads(raw, object_pairs_hook=unique)

def capture(directory):
    dest = pathlib.Path(directory)
    if dest.exists():
        raise ValueError('capture directory already exists; prior evidence is never overwritten')
    origin = 'https://pulse.chainverdict.xyz'
    manifest = fetch_json(origin + '/v1/datasets/manifest')
    anchors = fetch_json(origin + '/v1/archive/anchors?full=1')
    dates = [r['day'] for r in manifest['days'][-7:]]
    for d in dates: day(d)
    exports = [fetch_json(origin + '/v1/datasets/day/' + d) for d in dates]
    bundle = {'capturedAt':dt.datetime.now(dt.timezone.utc).isoformat(),'source':origin,'chain':anchors['chain'],'checkpoint':anchors.get('checkpoint'),'days':exports}
    # Raw evidence survives even when external timestamp retrieval fails.
    dest.mkdir(parents=True)
    (dest/'bundle.json').write_text(json.dumps(bundle,indent=2)+'\n')
    heights = set(); errors = []
    for a in anchors['chain']:
        if a['day'] not in dates: continue
        try:
            heights.update(att.height for _,att in bitcoin_attestations(a))
        except Exception:
            errors.append({'day':a['day'],'reason':'could not parse Bitcoin attestations'})
    if len(heights) > 32:
        raise ValueError('too many Bitcoin heights in sample')
    headers = {}
    for height in sorted(heights):
        try:
            # Blockstream is an external header source, not ChainVerdict or a URL from a proof.
            with OPENER.open('https://blockstream.info/api/block-height/'+str(height),timeout=20) as r:
                block_hash = r.read(100).decode().strip()
            if not HEX.fullmatch(block_hash): raise ValueError('invalid block hash')
            with OPENER.open('https://blockstream.info/api/block/'+block_hash+'/header',timeout=20) as r:
                header_hex = r.read(200).decode().strip()
            headers[str(height)] = {'blockHash':block_hash,'headerHex':header_hex,'source':'https://blockstream.info/api'}
        except Exception:
            errors.append({'height':height,'reason':'external header unavailable'})
    (dest/'bitcoin-headers.json').write_text(json.dumps(headers,indent=2)+'\n')
    (dest/'capture-notes.json').write_text(json.dumps({'errors':errors,'headerTrust':'External explorer, not local full-node consensus verification.'},indent=2)+'\n')
    return bundle, headers

def main():
    parser = argparse.ArgumentParser(description=__doc__)
    sub = parser.add_subparsers(dest='command',required=True)
    c = sub.add_parser('capture'); c.add_argument('directory')
    v = sub.add_parser('verify'); v.add_argument('bundle'); v.add_argument('--headers',required=True); v.add_argument('--as-of',required=True); v.add_argument('--endpoint'); v.add_argument('--from',dest='start'); v.add_argument('--to',dest='end')
    args = parser.parse_args()
    try:
        if args.command == 'capture':
            bundle, headers = capture(args.directory)
            as_of = dt.datetime.now(dt.timezone.utc).date().isoformat()
        else:
            bundle, headers, as_of = load_json(args.bundle), load_json(args.headers), args.as_of
        report = verify(bundle,headers,as_of,**({"endpoint":args.endpoint,"start":args.start,"end":args.end} if args.command == "verify" else {}))
        print(json.dumps(report,indent=2,sort_keys=True))
        return 0 if report['days'] and report['window']['status'] == 'complete_recent' and all(r['usableEvidence'] for r in report['days']) else 2
    except Exception as e:
        print(json.dumps({'status':'refused','reason':str(e)},sort_keys=True))
        return 1

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