"""Fresh bounded regtest: prepare/sign/extract/test are separate from submission.
Python3 standard library; pinned Linux Core binaries only. No existing wallet.
Usage: python reproduce.py.txt litecoind litecoin-cli new-output-directory
"""
from datetime import datetime,timezone
from decimal import Decimal
from pathlib import Path
import csv,hashlib,importlib.machinery,importlib.util,json,re,shutil,signal,socket,subprocess,sys,tempfile,time
sys.dont_write_bytecode=True
loader=importlib.machinery.SourceFileLoader('fixture_codec',str(Path(__file__).with_name('fixture-codec.py.txt')))
codec=importlib.util.module_from_spec(importlib.util.spec_from_loader(loader.name,loader));loader.exec_module(codec)
PINS={'litecoind':'63fb440cdc5e2e7d46144b8e3a47dab4725fa9c056a77b8b12b11854504f4838','litecoin-cli':'d185011cc0dbb5d2dd20611e9dc060dbb0a5d9be31a03f57da5dbd3dad4c3769'}
def utc():return datetime.now(timezone.utc).isoformat()
def serial(v):
    if isinstance(v,Decimal):return format(v,'f')
    if isinstance(v,dict):return {k:serial(x) for k,x in v.items()}
    if isinstance(v,(list,tuple)):return [serial(x) for x in v]
    return v
def write(path,v):path.write_text(json.dumps(serial(v),indent=2,ensure_ascii=False)+'\n',encoding='utf-8')
def atoms(v):return int(Decimal(str(v))*100000000)
def main(daemon,cli,output):
    started=utc();hashes={n:hashlib.sha256(p.read_bytes()).hexdigest() for n,p in [('litecoind',daemon),('litecoin-cli',cli)]}
    if hashes!=PINS:raise RuntimeError('Pinned binary hash mismatch')
    output.mkdir(parents=True,exist_ok=False)
    datadir=Path(tempfile.mkdtemp(prefix='lw-offline-payment-regtest-')).resolve();parent=Path(tempfile.gettempdir()).resolve()
    if datadir.parent!=parent or not datadir.name.startswith('lw-offline-payment-regtest-'):raise RuntimeError('Fresh directory guard')
    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=[];cases=[];candidates=[];psbts={};process=logfile=result=None
    cleanup={'own_node_stopped':False,'fresh_directory_removed':False}
    def deadline(signum,frame):raise TimeoutError('75-second work budget exceeded')
    signal.signal(signal.SIGALRM,deadline);signal.signal(signal.SIGTERM,deadline);signal.alarm(75)
    def check(name,passed):
        assertions.append({'name':name,'passed':bool(passed)})
        if not passed:raise AssertionError(name)
    def rpc(method,*args,wallet=None,allow_error=False,wait=False):
        params=[json.dumps(v,separators=(',',':')) if isinstance(v,(dict,list,bool)) else str(v) for v in args]
        command=base+(['-rpcwallet='+wallet] if wallet else [])+(['-rpcwait'] if wait else [])+[method,*params]
        response=subprocess.run(command,capture_output=True,text=True,timeout=12)
        call={'at_utc':utc(),'method':method,'wallet_role':wallet,'params':params,'return_code':response.returncode}
        if response.returncode:
            message=response.stderr.strip();number=re.search(r'error code:\s*(-?\d+)',message)
            value={'kind':'rpc_error','code':int(number.group(1)) if number else None,'message':message};call['error']=value;trace.append(call)
            if allow_error:return value
            raise RuntimeError(method+': '+message)
        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 stage(name,recipient,prevout):
        value={'stage':name,'at_utc':utc(),'height':rpc('getblockcount'),'peers':rpc('getconnectioncount'),'mempool':rpc('getrawmempool'),
               'sender_balances':rpc('getbalances',wallet='sender'),'recipient_received_0_ltc':rpc('getreceivedbyaddress',recipient,0,wallet='recipient'),
               'recipient_received_1_ltc':rpc('getreceivedbyaddress',recipient,1,wallet='recipient'),
               'selected_input_chain_unspent':rpc('gettxout',prevout['txid'],prevout['vout'],False) is not None,
               'selected_input_with_mempool_unspent':rpc('gettxout',prevout['txid'],prevout['vout'],True) is not None}
        stages.append(value);return value
    def test(case_id,label,raw):
        response=rpc('testmempoolaccept',[raw])[0]
        cases.append({'case_id':case_id,'label':label,'txid':codec.details(codec.transaction(raw))['txid'],'allowed':response['allowed'],'reject_reason':response.get('reject-reason',''),'raw_hex':raw,'response':response})
        return response
    def candidate(name,raw,input_row,recipient_script=None):
        tx=codec.transaction(raw);d=codec.details(tx);d.update(candidate=name,raw_hex=raw,input_txid=input_row['txid'],input_vout=input_row['vout'],input_value_ltc=input_row['amount'],fee_litoshi=atoms(input_row['amount'])-sum(x['atoms'] for x in tx['outputs']),output_values_litoshi=[x['atoms'] for x in tx['outputs']])
        candidates.append(d);return d
    def prepare(name,input_row,recipient,change,amount):
        fee=Decimal('.00001000');change_value=input_row['amount']-amount-fee
        bare=rpc('createpsbt',[{'txid':input_row['txid'],'vout':input_row['vout']}],[{recipient:float(amount)},{change:float(change_value)}],0,False)
        prepared=rpc('walletprocesspsbt',bare,False,'ALL',True,wallet='sender')
        check(name+': unsigned updater is incomplete',prepared['complete'] is False)
        decoded=rpc('decodepsbt',prepared['psbt'])
        check(name+': prepared PSBT fee1000litoshi',atoms(decoded['fee'])==1000)
        psbts[name]={'bare':bare,'prepared':prepared['psbt'],'prepared_decoded':decoded}
        return prepared['psbt']
    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 height0',chain['chain']=='regtest' and chain['blocks']==0)
        version=rpc('getnetworkinfo')['subversion'];check('Pinned Core release',version=='/LitecoinCore:0.21.5.8/')
        rpc('setnetworkactive',False);check('Peer networking disabled',rpc('getnetworkinfo')['networkactive'] is False)
        rpc('createwallet','sender');rpc('createwallet','recipient')
        help_text={method:rpc('help',method) for method in ['createpsbt','walletprocesspsbt','finalizepsbt','decodepsbt','testmempoolaccept','sendrawtransaction']}
        mining=rpc('getnewaddress','fixture-funding','bech32',wallet='sender');recipient=rpc('getnewaddress','fixture-recipient','bech32',wallet='recipient');other=rpc('getnewaddress','alternate-recipient','bech32',wallet='recipient');change=rpc('getrawchangeaddress','bech32',wallet='sender')
        recipient_script=rpc('validateaddress',recipient)['scriptPubKey'];other_script=rpc('validateaddress',other)['scriptPubKey']
        rpc('generatetoaddress',102,mining);unspent=rpc('listunspent',1,9999999,[mining],wallet='sender')
        usable=sorted([x for x in unspent if x['spendable']],key=lambda x:(x['txid'],x['vout']))
        check('At least two mature generated fixture UTXOs',len(usable)>=2);u0,u1=usable[:2]
        funding=[]
        for u in (u0,u1):funding.append({'txid':u['txid'],'vout':u['vout'],'amount':u['amount'],'raw_hex':rpc('gettransaction',u['txid'],wallet='sender')['hex']})
        funded=stage('funded_before_preparation',recipient,u0)
        prepared=prepare('payment',u0,recipient,change,Decimal('.25'));unsigned=stage('prepared_unsigned_psbt',recipient,u0)
        signed=rpc('walletprocesspsbt',prepared,True,'ALL',True,wallet='sender');check('Signer reports complete',signed['complete'] is True)
        psbts['payment']['signed']=signed['psbt'];psbts['payment']['signed_decoded']=rpc('decodepsbt',signed['psbt'])
        signed_stage=stage('signed_psbt_not_submitted',recipient,u0)
        finalized=rpc('finalizepsbt',signed['psbt'],False);check('Finalizer complete',finalized['complete'] is True);psbts['payment']['finalized']=finalized['psbt']
        raw_result=rpc('finalizepsbt',finalized['psbt'],True);check('Extractor complete',raw_result['complete'] is True);raw=raw_result['hex'];main=candidate('approved_payment',raw,u0)
        extracted=stage('extracted_raw_not_submitted',recipient,u0)
        check('Approved raw candidate locally acceptable',test('P01','Exact approved payment before submission',raw)['allowed'] is True)
        dry=stage('policy_test_not_submitted',recipient,u0)
        for item in [unsigned,signed_stage,extracted,dry]:
            check(item['stage']+': no mempool insertion',item['mempool']==[])
            check(item['stage']+': sender balance unchanged',item['sender_balances']==funded['sender_balances'])
            check(item['stage']+': no recipient receipt',item['recipient_received_0_ltc']==0)
            check(item['stage']+': selected input remains unspent',item['selected_input_chain_unspent'] and item['selected_input_with_mempool_unspent'])
        changed=codec.transaction(raw);changed['outputs'][0]['script']=bytes.fromhex(other_script);changed_raw=codec.serialize(changed).hex();candidate('changed_recipient_original_signature',changed_raw,u0)
        check('Changed recipient rejected without re-signing',test('P02','Recipient changed; original signature retained',changed_raw)['allowed'] is False)
        changed=codec.transaction(raw);changed['outputs'][0]['atoms']+=1;changed['outputs'][1]['atoms']-=1;changed_raw=codec.serialize(changed).hex();candidate('changed_amount_original_signature',changed_raw,u0)
        check('Changed amount rejected without re-signing',test('P03','Recipient plus1litoshi/change minus1; original signature retained',changed_raw)['allowed'] is False)
        check('Negative checks did not insert transactions',rpc('getrawmempool')==[])
        check('Explicit submit returns approved txid',rpc('sendrawtransaction',raw)==main['txid'])
        sent=stage('explicitly_submitted_unconfirmed',recipient,u0);check('Only approved transaction is in mempool',sent['mempool']==[main['txid']]);check('Recipient sees0.25unconfirmed',sent['recipient_received_0_ltc']==Decimal('.25') and sent['recipient_received_1_ltc']==0)
        rpc('generatetoaddress',1,mining);confirmed=stage('approved_payment_confirmed',recipient,u0);check('Recipient0.25confirmed',confirmed['recipient_received_1_ltc']==Decimal('.25') and confirmed['mempool']==[])
        stale_prepared=prepare('held_payment',u1,recipient,change,Decimal('.10'));held=rpc('walletprocesspsbt',stale_prepared,True,'ALL',True,wallet='sender');psbts['held_payment']['signed']=held['psbt']
        held_raw=rpc('finalizepsbt',held['psbt'])['hex'];held_candidate=candidate('held_payment',held_raw,u1);check('Held signed candidate acceptable before conflict',test('P04','Held candidate before its input is spent',held_raw)['allowed'] is True)
        own=rpc('getnewaddress','fixture-own-return','bech32',wallet='sender')
        conflict_unsigned=rpc('createrawtransaction',[{'txid':u1['txid'],'vout':u1['vout']}],[{own:float(u1['amount']-Decimal('.00001'))}],0,False)
        conflict=rpc('signrawtransactionwithwallet',conflict_unsigned,[], 'ALL',wallet='sender');check('Conflicting self-spend signed completely',conflict['complete'] is True);conflict_candidate=candidate('confirmed_self_spend_of_held_input',conflict['hex'],u1)
        check('Controlled self-spend acceptable',test('P05','Alternative fixture self-spend of held input',conflict['hex'])['allowed'] is True);rpc('sendrawtransaction',conflict['hex']);rpc('generatetoaddress',1,mining)
        check('Held input no longer exists in chain UTXO set',rpc('gettxout',u1['txid'],u1['vout'],False) is None)
        check('Old held payment now rejected',test('P06','Same held bytes after its input was spent and confirmed',held_raw)['allowed'] is False)
        old_response=rpc('sendrawtransaction',held_raw,allow_error=True);check('Explicit old-submit fails',isinstance(old_response,dict) and old_response.get('kind')=='rpc_error')
        check('Complete signature flag does not check UTXO availability',rpc('finalizepsbt',held['psbt'])['complete'] is True)
        final=stage('held_payment_rejected_after_confirmed_self_spend',recipient,u1);check('No extra recipient payment',final['recipient_received_1_ltc']==Decimal('.25') and final['mempool']==[])
        result={'schema':1,'version':'1.0.0','started_at_utc':started,'completed_at_utc':utc(),'network':'regtest','core_version':version,'binary_sha256':hashes,'node_options':flags,'work_budget_seconds':75,
                'scope':'One fresh loopback-only Core node, two fresh wallet roles, peers disabled; no physical air gap, hardware, mainnet, multisig, MWEB, external service or timing/security audit.',
                'sighash':'ALL','recipient_address':recipient,'recipient_script_hex':recipient_script,'alternate_recipient_address':other,'alternate_recipient_script_hex':other_script,'funding_outputs':funding,'psbts':psbts,'candidates':candidates,'cases':cases,'stages':stages,'old_submission_response':old_response,
                'summary':{'recipient_confirmed_ltc':Decimal('.25'),'approved_fee_litoshi':main['fee_litoshi'],'self_spend_fee_litoshi':conflict_candidate['fee_litoshi'],'approved_vsize':main['vsize'],'held_input_spent':True,'held_after_spend_complete':True},'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()
        if cleanup['own_node_stopped'] and datadir.parent==parent and datadir.name.startswith('lw-offline-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 experiment')
    result['cleanup']=cleanup;result['completed_at_utc']=utc();write(output/'results.json',result);write(output/'rpc-trace.json',{'scope':'Fresh public regtest PSBT/raw transactions and generated public wallet metadata only; no secrets or credentials.','calls':trace})
    write(output/'candidates.json',candidates)
    for filename,rows,fields in [('stages.csv',stages,['stage','at_utc','height','peers','mempool_count','recipient_received_0_ltc','recipient_received_1_ltc','selected_input_chain_unspent','selected_input_with_mempool_unspent']),('cases.csv',cases,['case_id','label','txid','allowed','reject_reason'])]:
        with (output/filename).open('w',encoding='utf-8',newline='') as handle:
            writer=csv.DictWriter(handle,fieldnames=fields,extrasaction='ignore');writer.writeheader();writer.writerows(serial([dict(row,mempool_count=len(row.get('mempool',[]))) for row in rows]))
    print(json.dumps(serial({'success':True,'assertions_passed':len(assertions),'summary':result['summary'],'policy_cases':[{k:c[k] for k in ['case_id','allowed','reject_reason']} for c in cases],'cleanup':cleanup})))
if __name__=='__main__':
    if len(sys.argv)!=4:raise SystemExit('Usage: python reproduce.py.txt litecoind litecoin-cli new-output-directory')
    main(*(Path(v).resolve() for v in sys.argv[1:]))
