"""Standard-library merchant client. Persist idempotency keys with business orders."""
import hashlib, hmac, json, re, time, urllib.request, urllib.error, urllib.parse
class PaymentsError(Exception):
    def __init__(self, status, body, request_id=None):
        self.status,self.code,self.request_id=status,body.get('code'),body.get('requestId') or request_id
        super().__init__(body.get('error','Payment API error'))
class NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None
class PaymentsClient:
    def __init__(self, base, token):
        u=urllib.parse.urlsplit(base)
        if u.scheme!='https' or u.username or u.password or u.query or u.fragment:
            raise ValueError('HTTPS API base required')
        self.base,self.token=base.rstrip('/'),token
        self.http=urllib.request.build_opener(NoRedirect())
    def changes(self, after='0'):
        if not re.fullmatch(r'[0-9]+',str(after)): raise ValueError('Decimal cursor required')
        return self.request('changes?after='+str(after))
    def quote_batch(self, items, budget_credits, key):
        return self.request('invoice-batches/quote','POST',{'items':items,'budgetCredits':budget_credits},key)
    def create_batch(self, items, budget_credits, key):
        return self.request('invoice-batches','POST',{'items':items,'budgetCredits':budget_credits},key)
    def request(self, path, method='GET', body=None, key=None):
        if not re.fullmatch(r'[a-zA-Z0-9_/?=&.\-]+',path) or '..' in path: raise ValueError('Invalid path')
        if method!='GET' and not key: raise ValueError('Persist an idempotency key per operation')
        for attempt in range(3):
            headers={'Authorization':'Bearer '+self.token,'Content-Type':'application/json'}
            if key: headers['Idempotency-Key']=key
            req=urllib.request.Request(self.base+'/'+path,data=None if body is None else json.dumps(body).encode(),headers=headers,method=method)
            try:
                with self.http.open(req,timeout=15) as response: return json.load(response)
            except urllib.error.HTTPError as error:
                if error.code not in (429,502,503,504) or attempt==2:
                    try: body=json.loads(error.read(65536))
                    except (ValueError, OSError): body={'error':'Payment API error'}
                    if not isinstance(body,dict): body={'error':'Payment API error'}
                    request_id=error.headers.get('X-Request-ID');error.close()
                    raise PaymentsError(error.code,body,request_id) from error
                delay=error.headers.get('Retry-After','1')
                error.close()
                time.sleep(min(30,max(1,int(delay))) if delay.isdigit() else 2**attempt)
            except (urllib.error.URLError,TimeoutError):
                if attempt==2: raise
                time.sleep(2**attempt)
def verify_webhook(raw,headers,secret,now=None):
    stamp=headers.get('X-Payment-Timestamp','');sig=headers.get('X-Payment-Signature','')
    if len(raw)>65536 or not stamp.isdigit() or abs((time.time() if now is None else now)-int(stamp))>300 or not re.fullmatch(r'v1=[a-f0-9]{64}',sig): raise ValueError('Invalid webhook')
    expected=hmac.new(secret.encode(),stamp.encode()+b'.'+raw,hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected,sig[3:]): raise ValueError('Invalid signature')
    event=json.loads(raw)
    if str(event['id'])!=headers.get('X-Payment-Event-ID'): raise ValueError('Event ID mismatch')
    return event
