"""Connection-owned, bounded pages. Paths never cross the device protocol."""
import asyncio
import json
import time
from collections import OrderedDict
from datetime import datetime, timezone
from uuid import uuid4
from .models import ControlError

LIVE_PROJECT_ID = "live_sessions"
LIVE_PROJECT_NAME = "Live Sessions"
LIVE_STATES = {"WAITING_APPROVAL": 0, "WAITING_INPUT": 1, "RUNNING": 2}
LIVE_QUERY_CONCURRENCY = 8


def updated_time(value):
    try:
        date = datetime.fromisoformat(value.replace("Z", "+00:00"))
        return date.replace(tzinfo=timezone.utc).timestamp() if date.tzinfo is None else date.timestamp()
    except (ValueError, TypeError, AttributeError, OverflowError):
        return float("-inf")


class Catalog:
    def __init__(self, projects, adapters, ttl=60, clock=time.monotonic):
        self.projects,self.adapters,self.ttl,self.clock=projects,adapters,ttl,clock
        self.snapshots=OrderedDict()
        self.live_slots = asyncio.Semaphore(LIVE_QUERY_CONCURRENCY)
    async def live_sessions(self):
        jobs = [(project, agent, adapter) for project in self.projects.list()
                for agent, adapter in self.adapters.items()]
        slots = self.live_slots

        async def query(project, agent, adapter):
            async with slots:
                return await asyncio.wait_for(adapter.list_sessions(project), 15)

        results = await asyncio.gather(*(query(*job) for job in jobs), return_exceptions=True)
        chosen, errors = {}, {}
        for (project, agent, _), result in zip(jobs, results):
            if isinstance(result, BaseException):
                errors[f"{agent}:{project.id}"] = 'Adapter unavailable'
                continue
            for session in result:
                if session.project_id != project.id or session.agent != agent or session.state not in LIVE_STATES:
                    continue
                item = dict(project_id=project.id, agent=agent, session_id=session.session_id,
                            name=f"{project.name} / {session.name}".encode()[:240].decode(errors="ignore"),
                            updated_at=session.updated_at, state=session.state)
                completeness = sum(bool(getattr(session, field)) for field in ('name', 'updated_at', 'project_id', 'session_id'))
                rank = (LIVE_STATES[session.state], -updated_time(session.updated_at), -completeness, project.id)
                identity = (agent, session.session_id)
                if identity not in chosen or rank < chosen[identity][0]:
                    chosen[identity] = (rank, item)
        items = [item for _, item in chosen.values()]
        items.sort(key=lambda item: (LIVE_STATES[item['state']], -updated_time(item['updated_at']),
                                     item['agent'], item['session_id']))
        return items, errors

    async def page(self, connection, kind, project_id=None, snapshot=None, cursor=0):
        if type(cursor) is not int or cursor<0: raise ControlError('Stale cursor',409)
        if snapshot:
            data=self.snapshots.get(snapshot)
            if not data or data['connection']!=connection or data['kind']!=kind or data['project_id']!=project_id: raise ControlError('Stale snapshot',409)
            if self.clock()>=data['expires']: raise ControlError('Expired snapshot',410)
        else:
            if cursor: raise ControlError('Stale cursor',409)
            errors={};items=[]
            if kind=='projects':
                items=[dict(project_id=LIVE_PROJECT_ID,name=LIVE_PROJECT_NAME),
                       *[dict(project_id=p.id,name=p.name.encode()[:240].decode(errors="ignore")) for p in self.projects.list()]]
            elif kind=='sessions':
                if project_id==LIVE_PROJECT_ID:
                    items,errors=await self.live_sessions()
                else:
                    project=self.projects.get(project_id)
                    results=await asyncio.gather(*(asyncio.wait_for(a.list_sessions(project),15) for a in self.adapters.values()),return_exceptions=True)
                    for agent,result in zip(self.adapters,results):
                        if isinstance(result,Exception): errors[agent]='Adapter unavailable'
                        else:
                            for s in result:
                                if s.project_id==project.id and s.agent==agent:
                                    items.append(dict(project_id=project.id,agent=agent,session_id=s.session_id,name=s.name.encode()[:240].decode(errors="ignore"),updated_at=s.updated_at,state=s.state))
                    items.sort(key=lambda s:(s['updated_at'],s['agent'],s['session_id']),reverse=True)
            else: raise ControlError('Unsupported catalog',400)
            if len(items)>10000: raise ControlError('Catalog too large',429)
            snapshot=uuid4().hex
            data=dict(connection=connection,kind=kind,project_id=project_id,items=items,errors=errors,expires=self.clock()+self.ttl)
            self.snapshots[snapshot]=data
            while len(self.snapshots)>64: self.snapshots.popitem(last=False)
        if cursor>len(data['items']) or cursor%4: raise ControlError('Stale cursor',409)
        items=data['items'][cursor:cursor+4];next_cursor=cursor+len(items)
        status='Partial' if data['errors'] and items else 'Adapter unavailable' if data['errors'] else 'Empty' if not data['items'] else 'Ready'
        result=dict(type='catalog_page',kind=kind,project_id=project_id,snapshot=snapshot,cursor=cursor,next_cursor=next_cursor if next_cursor<len(data['items']) else None,ttl_seconds=max(0,int(data['expires']-self.clock())),status=status,errors=data['errors'],items=items)
        if len(json.dumps(result,ensure_ascii=False).encode())>3900: raise ControlError('Catalog frame too large',413)
        return result
