import copy
import json
import unittest
from dataclasses import replace
from profiles import payments as p
from odexa_ref import contracts as c
NOW='2026-09-17T00:02:00Z'
U=lambda x:f'00000000-0000-4000-8000-{x:012d}'
ENV=dict(protocol_version=p.VERSION,profile=p.PROFILE)

def fixtures():
    q=dict(ENV,record_type='payment.quote',quote_id=U(1),origin='https://origin.example',service_id='https://origin.example/services/agreement',verification_service_id='https://origin.example/services/payment',provider_id='https://provider.example/issuer',verification_endpoint='https://provider.example/api/verify',payer_id='https://payer.example/alice',offer_id=U(2),currency='AUD',minor_unit_scale=2,items=[dict(item_id='asset',kind='resource_license',payee_id='https://origin.example/payee',amount_minor='100',description='Resource licence'),dict(item_id='fee',kind='provider_service',payee_id='https://provider.example/payee',amount_minor='25',description='Payment service')],total_minor='125',issued_at='2026-09-17T00:00:00Z',expires_at='2026-09-17T00:05:00Z')
    a=dict(ENV,record_type='payment.accepted_binding',agreement_id=U(3),origin=q['origin'],service_id=q['service_id'],principal_id='https://agent.example/principal',offer_id=q['offer_id'],offer_digest='sha256:'+'f'*64,request={'url':q['origin']+'/asset','actions':['retrieve'],'purposes':['public_retrieval'],'supported_obligations':[]},quote_digest=c.digest(c.json_bytes(q)),accepted_at='2026-09-17T00:01:00Z',access_expires_at='2026-09-17T01:00:00Z',use_expires_at='2026-09-17T02:00:00Z')
    m=dict(ENV,record_type='payment.mandate',mandate_id=U(4),agreement_id=a['agreement_id'],origin=q['origin'],service_id=q['service_id'],quote_id=q['quote_id'],quote_digest=a['quote_digest'],provider_id=q['provider_id'],payer_id=q['payer_id'],currency=q['currency'],minor_unit_scale=q['minor_unit_scale'],total_minor=q['total_minor'],authorization='pay_exact_quote_once',issued_at='2026-09-17T00:01:10Z',expires_at='2026-09-17T00:30:00Z')
    v=dict(ENV,record_type='payment.verification',check_id=U(5),sequence=1,quote_id=q['quote_id'],quote_digest=a['quote_digest'],offer_digest=a['offer_digest'],request_digest=c.digest(c.json_bytes(a['request'])),agreement_id=a['agreement_id'],mandate_id=m['mandate_id'],origin=q['origin'],verification_service_id=q['verification_service_id'],provider_id=q['provider_id'],payer_id=q['payer_id'],currency=q['currency'],minor_unit_scale=q['minor_unit_scale'],total_minor=q['total_minor'],state='confirmed',provider_reference='txn-1',effective_at='2026-09-17T00:01:30Z',checked_at=NOW)
    return q,a,m,v

class PaymentProfileTests(unittest.TestCase):
    def setUp(self): self.q,self.a,self.m,self.v=fixtures()
    def start(self): return p.start_payment(c.json_bytes(self.q),c.json_bytes(self.a),now=NOW)
    def authorized(self): return p.authorize_payment(self.start(),c.json_bytes(self.m),authenticated_payer_id=self.q['payer_id'],now=NOW)
    def authority(self,state,check_id=None,now=NOW):
        check_id=check_id or self.v['check_id']
        return dict(decision='allow',authority=dict(origin=self.q['origin'],service_id=self.q['verification_service_id'],issuer=self.q['provider_id'],capability='verify_payments',endpoint=self.q['verification_endpoint'],checked_at=now,authority_revision=1,authority_digest='sha256:'+'a'*64,policy_digest='sha256:'+'b'*64,key_id='https://provider.example/key',key_use='odexa-payment-status+jws',request_digest=c.digest(c.json_bytes(self.a['request'])),operation_id=check_id,payment_request_digest=c.digest(c.json_bytes(p.verification_request(state,check_id)))))
    def apply(self,state,value=None,now=NOW,authority=None):
        value=self.v if value is None else value
        authority=self.authority(state,value['check_id'],now) if authority is None else authority
        return p.apply_verification(state,c.json_bytes(value),expected_check_id=value['check_id'],authority=authority,now=now)
    def bad(self,fn):
        with self.assertRaises(p.PaymentError):fn()
    def test_valid_quote_fee_and_sum(self):self.assertEqual(p.validate_quote(self.q)['total_minor'],'125')
    def test_amounts_reject_negative_float_exponent_bool_overflow(self):
        for value in ('-1','01','1.0','1e2','',1,True,'9'*19):
            q=copy.deepcopy(self.q);q['items'][0]['amount_minor']=value
            with self.subTest(value=value):self.bad(lambda:p.validate_quote(q))
    def test_sum_scale_currency_and_duplicate_items(self):
        for key,value in [('total_minor','126'),('minor_unit_scale',10),('minor_unit_scale',True),('currency','aud')]:
            q=copy.deepcopy(self.q);q[key]=value;self.bad(lambda:p.validate_quote(q))
        q=copy.deepcopy(self.q);q['items'][1]['item_id']='asset';self.bad(lambda:p.validate_quote(q))
    def test_resource_line_required_but_may_be_free(self):
        q=copy.deepcopy(self.q);q['items'][0]['kind']='provider_service';self.bad(lambda:p.validate_quote(q))
        q=copy.deepcopy(self.q);q['items'][0]['amount_minor']='0';q['total_minor']='25';p.validate_quote(q)
    def test_unknown_field_unsafe_endpoint_and_calendar(self):
        for key,value in [('unexpected',1),('verification_endpoint','https://provider.example/api/../verify'),('expires_at','2026-02-30T00:00:00Z')]:
            q=copy.deepcopy(self.q);q[key]=value;self.bad(lambda:p.validate_quote(q))
    def test_strict_wire(self):
        raw=c.json_bytes(self.q)
        for bad in (raw.replace(b'"minor_unit_scale":2',b'"minor_unit_scale":2.0'),raw.replace(b'"minor_unit_scale":2',b'"minor_unit_scale":-0'),raw.replace(b'"minor_unit_scale":2',b'"minor_unit_scale":2e0'),b'{"a":1,"a":2}',b'\xff',b' '*131073):self.bad(lambda:p.quote_from_bytes(bad))
    def test_quote_immutable_and_unique_agreement(self):
        raw=c.json_bytes(self.q);r=p.register_quote({},raw);self.assertEqual(r,p.register_quote(r,raw));self.bad(lambda:p.register_quote(r,json.dumps(self.q,indent=2).encode()))
        q=copy.deepcopy(self.q);q['items'][0]['payee_id']='https://other.example/payee';self.bad(lambda:p.register_quote(r,c.json_bytes(q)))
        b=p.bind_quote_to_agreement(r,raw,self.a['agreement_id']);self.assertIsNone(next(iter(r.values()))['agreement_id']);self.assertEqual(b,p.bind_quote_to_agreement(b,raw,self.a['agreement_id']));self.bad(lambda:p.bind_quote_to_agreement(b,raw,U(99)))
    def test_acceptance_is_not_mandate(self):
        self.assertEqual(self.start().access_state,'pending_payment');self.bad(lambda:p.apply_verification(self.start(),c.json_bytes(self.v),expected_check_id=self.v['check_id'],authority={},now=NOW))
    def test_wrong_payer_and_exact_mandate_bindings(self):
        self.bad(lambda:p.authorize_payment(self.start(),c.json_bytes(self.m),authenticated_payer_id=self.a['principal_id'],now=NOW))
        for key,value in [('total_minor','100'),('currency','USD'),('minor_unit_scale',3),('agreement_id',U(8)),('quote_digest','sha256:'+'0'*64),('authorization','pay_any_amount')]:
            m=dict(self.m);m[key]=value;self.bad(lambda:p.authorize_payment(self.start(),c.json_bytes(m),authenticated_payer_id=self.q['payer_id'],now=NOW))
    def test_mandate_expiry_replacement_and_retry(self):
        st=self.authorized();self.assertEqual(st,p.authorize_payment(st,c.json_bytes(self.m),authenticated_payer_id=self.q['payer_id'],now=NOW))
        m=dict(self.m,mandate_id=U(9));self.bad(lambda:p.authorize_payment(st,c.json_bytes(m),authenticated_payer_id=self.q['payer_id'],now=NOW));self.bad(lambda:p.authorize_payment(self.start(),c.json_bytes(self.m),authenticated_payer_id=self.q['payer_id'],now=self.m['expires_at']))
    def test_confirmation_and_duplicate_no_charge_action(self):
        st=self.apply(self.authorized());self.assertEqual((st.payment_state,st.access_state),('confirmed','active'));self.assertEqual(st,self.apply(st));self.assertEqual(len(st.checks),1)
    def test_denied_matching_context_and_wrong_authority(self):
        st=self.authorized();au=self.authority(st);au['decision']='deny';self.bad(lambda:self.apply(st,authority=au))
        for key,value in [('origin','https://other.example'),('service_id','https://origin.example/other'),('issuer','https://other.example/issuer'),('capability','receive_events'),('endpoint','https://provider.example/else'),('checked_at','2026-09-17T00:01:54Z'),('key_use','odexa-event+jws'),('operation_id',U(99)),('payment_request_digest','sha256:'+'0'*64),('request_digest',c.digest(c.json_bytes(dict(self.a['request'],url='https://origin.example/other'))))]:
            au=self.authority(st);au['authority'][key]=value;self.bad(lambda:self.apply(st,authority=au))
    def test_response_transaction_mismatches(self):
        for key,value in [('total_minor','126'),('payer_id','https://other.example/payer'),('provider_id','https://other.example/provider'),('minor_unit_scale',3),('mandate_id',U(7)),('agreement_id',U(7)),('quote_id',U(7))]:
            v=dict(self.v);v[key]=value;self.bad(lambda:self.apply(self.authorized(),v))
    def test_unsolicited_and_conflicting_check(self):
        st=self.authorized();self.bad(lambda:p.apply_verification(st,c.json_bytes(self.v),expected_check_id=U(8),authority=self.authority(st),now=NOW))
        st=self.apply(st);self.bad(lambda:self.apply(st,dict(self.v,state='reversed')))
    def test_response_time_and_sequence(self):
        for value in ('2026-09-17T00:00:59Z','2026-09-17T00:02:01Z'):self.bad(lambda:self.apply(self.authorized(),dict(self.v,checked_at=value)))
        st=self.apply(self.authorized());self.bad(lambda:self.apply(st,dict(self.v,check_id=U(6))));self.bad(lambda:self.apply(st,dict(self.v,check_id=U(6),sequence=2,effective_at='2026-09-17T00:01:20Z')))
    def test_quote_expiry_blocks_new_assent_not_settlement(self):
        a=dict(self.a,accepted_at=self.q['expires_at']);self.bad(lambda:p.start_payment(c.json_bytes(self.q),c.json_bytes(a),now='2026-09-17T00:06:00Z'))
        now='2026-09-17T00:06:00Z';self.assertEqual(self.apply(self.authorized(),dict(self.v,checked_at=now,effective_at=now),now).access_state,'active')
    def test_delayed_confirmation_requires_in_mandate_time(self):
        now='2026-09-17T00:31:00Z';self.assertEqual(self.apply(self.authorized(),dict(self.v,checked_at=now),now).payment_state,'confirmed');self.bad(lambda:self.apply(self.authorized(),dict(self.v,checked_at=now,effective_at=self.m['expires_at']),now))
    def test_expiry_and_revocation_preserve_financial_facts(self):
        now='2026-09-17T01:00:00Z';st=self.apply(self.authorized(),dict(self.v,checked_at=now),now);self.assertEqual((st.payment_state,st.access_state),('confirmed','expired'))
        st=self.apply(p.revoke(self.authorized(),now=NOW));self.assertEqual((st.payment_state,st.access_state),('confirmed','revoked'))
    def test_reversal_terminal_old_retry_no_resurrection(self):
        st=self.apply(self.authorized());st=self.apply(st,dict(self.v,check_id=U(6),sequence=2,state='reversed'));self.assertEqual(st.access_state,'revoked');self.assertEqual(st,self.apply(st));self.bad(lambda:self.apply(st,dict(self.v,check_id=U(7),sequence=3)))
    def test_reversal_before_observed_confirmation(self):self.assertEqual(self.apply(self.authorized(),dict(self.v,state='reversed')).access_state,'revoked')
    def test_failed_then_confirmed_reference_fixed(self):
        st=self.apply(self.authorized(),dict(self.v,state='failed',provider_reference=None));self.assertEqual(st.access_state,'pending_payment');st=self.apply(st,dict(self.v,check_id=U(6),sequence=2));self.assertEqual(st.access_state,'active');self.bad(lambda:self.apply(st,dict(self.v,check_id=U(7),sequence=3,state='reversed',provider_reference='other')))
    def test_exact_free_bypass(self):
        st=p.free_payment({'required':False});self.assertEqual(st.payment_state,'free');self.bad(lambda:p.free_payment({'required':False,'quote':self.q}));self.bad(lambda:p.free_payment({'required':0}));self.bad(lambda:p.verification_request(st,U(5)));self.bad(lambda:p.authorize_payment(st,c.json_bytes(self.m),authenticated_payer_id=self.q['payer_id'],now=NOW))
    def test_bounded_history_and_immutable_inputs(self):
        st=self.authorized();before=copy.deepcopy(st);self.apply(st);self.assertEqual(before,st)
        st=replace(st,checks=tuple((U(100+i),'sha256:'+'a'*64) for i in range(p.MAX_CHECKS)));self.bad(lambda:self.apply(st))

    def test_machine_fixture_semantic_classification(self):
        from pathlib import Path
        data=json.loads(Path('profiles/fixtures/payment-cases.json').read_text())
        q,a,m=data['context']['quote'],data['context']['accepted_binding'],data['context']['mandate']
        validators={'quote':lambda v:p.validate_quote(v),'accepted_binding':lambda v:p.validate_acceptance(v,q,c.digest(c.json_bytes(q))),'mandate':lambda v:p.validate_mandate(v,q,a,c.digest(c.json_bytes(q))),'check':lambda v:p.validate_check(v,q,a,m,c.digest(c.json_bytes(q))),'verification':lambda v:p.validate_verification(v,q,a,m,c.digest(c.json_bytes(q))),'free':p.free_payment}
        for case in data['cases']:
            with self.subTest(case=case['name']):
                try: validators[case['definition']](case['value']);actual=True
                except p.PaymentError:actual=False
                self.assertEqual(actual,case['semantic_valid'])

if __name__=='__main__':unittest.main()
