"""A simulated cursor-API contract with bounded read retries; no network client."""
from __future__ import annotations
import math
import time
from datetime import datetime,timezone
from email.utils import parsedate_to_datetime


class APIContractError(ValueError):pass


class RetryDeferred(RuntimeError):pass


class HTTPFailure(RuntimeError):
    def __init__(self,status,retry_after=None):
        self.status=status;self.retry_after=retry_after
        super().__init__(f'http_status_{status}')


def retry_after_seconds(value,now):
    if not isinstance(value,str):raise APIContractError('invalid_retry_after')
    if value.isascii() and value.isdecimal():return int(value)
    try:when=parsedate_to_datetime(value)
    except (ValueError,TypeError) as error:raise APIContractError('invalid_retry_after') from error
    if when.tzinfo is None or now.tzinfo is None:raise APIContractError('retry_clock_needs_timezone')
    return max(0,math.ceil((when-now).total_seconds()))


def fetch_with_retry(fetch,cursor,*,sleep=time.sleep,clock=lambda:datetime.now(timezone.utc),attempts=3,max_wait_seconds=30):
    if type(attempts) is not int or attempts<1:raise APIContractError('invalid_attempt_limit')
    if type(max_wait_seconds) is not int or max_wait_seconds<0:raise APIContractError('invalid_wait_limit')
    for attempt in range(attempts):
        try:return fetch(cursor)
        except (HTTPFailure,TimeoutError,ConnectionError) as error:
            if isinstance(error,HTTPFailure) and error.status not in {429,500,502,503,504}:raise
            if attempt+1==attempts:raise
            wait=2**attempt
            if isinstance(error,HTTPFailure) and error.retry_after is not None:
                wait=max(wait,retry_after_seconds(error.retry_after,clock()))
            if wait>max_wait_seconds:raise RetryDeferred('server_or_backoff_delay_exceeds_local_wait_budget') from error
            sleep(wait)
    raise AssertionError('unreachable')


def validate_page(page):
    if not isinstance(page,dict) or set(page)!={'items','next_cursor','snapshot_id','total_items'}:
        raise APIContractError('invalid_page_fields')
    if not isinstance(page['items'],list):raise APIContractError('items_not_list')
    if not isinstance(page['snapshot_id'],str) or not page['snapshot_id']:raise APIContractError('invalid_snapshot')
    if type(page['total_items']) is not int or page['total_items']<0:raise APIContractError('invalid_total')
    cursor=page['next_cursor']
    if cursor is not None and (not isinstance(cursor,str) or not cursor):raise APIContractError('invalid_next_cursor')
    for item in page['items']:
        if not isinstance(item,dict) or set(item)!={'event_id','region','amount_paise'}:raise APIContractError('invalid_item_fields')
        if not isinstance(item['event_id'],str) or not item['event_id']:raise APIContractError('invalid_item_id')
        if not isinstance(item['region'],str) or item['region'] not in {'North','South','Unknown'}:raise APIContractError('invalid_item_region')
        if type(item['amount_paise']) is not int or item['amount_paise']<0:raise APIContractError('invalid_item_amount')
    return page


def collect_pages(fetch,*,max_pages=10,**retry_options):
    if type(max_pages) is not int or max_pages<1:raise APIContractError('invalid_page_limit')
    cursor=None;seen_cursors=set();unique={};snapshot=None;expected=None;raw_count=0;replays=0
    for page_number in range(1,max_pages+1):
        if cursor in seen_cursors:raise APIContractError('cursor_cycle')
        seen_cursors.add(cursor)
        page=validate_page(fetch_with_retry(fetch,cursor,**retry_options))
        if snapshot is None:snapshot=page['snapshot_id'];expected=page['total_items']
        elif page['snapshot_id']!=snapshot or page['total_items']!=expected:raise APIContractError('snapshot_changed')
        for item in page['items']:
            raw_count+=1;key=item['event_id']
            if key in unique:
                if unique[key]!=item:raise APIContractError('conflicting_item_id')
                replays+=1
            else:unique[key]=dict(item)
        cursor=page['next_cursor']
        if cursor is None:
            if len(unique)!=expected:raise APIContractError('declared_total_mismatch')
            return {'items':[unique[key] for key in sorted(unique)],'snapshot_id':snapshot,
                    'pages':page_number,'raw_items':raw_count,'identical_replays':replays,'unique_items':len(unique)}
    raise APIContractError('page_limit_exceeded')


def fixture_pages():
    one={'event_id':'E1','region':'North','amount_paise':1000}
    two={'event_id':'E2','region':'South','amount_paise':2000}
    five={'event_id':'E5','region':'Unknown','amount_paise':500}
    return {None:{'items':[one,two],'next_cursor':'page2','snapshot_id':'snapshot-A','total_items':3},
            'page2':{'items':[dict(two),five],'next_cursor':'page3','snapshot_id':'snapshot-A','total_items':3},
            'page3':{'items':[],'next_cursor':None,'snapshot_id':'snapshot-A','total_items':3}}
