"""Manager login/introspection contracts adapted from colleague PR #3.

Only a token cache lives here. No SQLite, command recovery, or event retry.
"""
import asyncio
import json
import time
import hashlib
import os
from pathlib import Path
from control_sidecar.protocol import BoundaryError, loads, TOKEN


class Auth:
    def __init__(self, config, transport):
        self.cfg, self.transport = config, transport
        self.token = None
        self.expires = None
        self.failure = 'AUTH_NOT_READY'
        self.task = None
        self.closed = False
        self.failures = 0
        self.lock = asyncio.Lock()
        self.retry_after = 0
        self.terminal = False

    def current(self):
        if self.closed or not self.token or (self.expires is not None and time.time()*1000 >= self.expires - 1000):
            raise BoundaryError('AUTH_NOT_READY')
        return self.token

    def invalidate(self, used_token=None):
        if used_token is not None and used_token != self.token:
            return
        self.token = None
        self.failure = 'AUTH_REJECTED'
        self._clear_session()

    def _session_identity(self):
        raw = json.dumps([self.cfg.manager, self.cfg.server_id, self.cfg.login_body,
                          hashlib.sha256(self.cfg.password.encode()).hexdigest()], sort_keys=True)
        return hashlib.sha256(raw.encode()).hexdigest()

    def _clear_session(self):
        if self.cfg.session_file:
            Path(self.cfg.session_file).unlink(missing_ok=True)

    def restore(self):
        if not self.cfg.session_file:
            return False
        p = Path(self.cfg.session_file)
        if not p.exists():
            return False
        if p.is_symlink() or p.stat().st_mode & 0o077:
            raise BoundaryError('SESSION_FILE_INVALID')
        try:
            with p.open('rb') as f:
                data = loads(f.read(65537))
            if data['identity'] != self._session_identity() or not TOKEN.fullmatch(data['token']):
                raise ValueError()
            expiry = data['expires']
            if expiry is not None and (type(expiry) is not int or expiry <= time.time()*1000+1000):
                self._clear_session()
                return False
        except (KeyError, TypeError, ValueError):
            raise BoundaryError('SESSION_FILE_INVALID') from None
        self.token, self.expires, self.failure = data['token'], expiry, None
        return True

    def save_session(self):
        if not self.cfg.session_file:
            return
        p = Path(self.cfg.session_file)
        temp = p.with_name(p.name + '.pending')
        created = False
        try:
            with temp.open('x') as f:
                created = True
                os.fchmod(f.fileno(), 0o600)
                json.dump({'identity': self._session_identity(), 'token': self.token, 'expires': self.expires}, f)
                f.flush()
                os.fsync(f.fileno())
            os.replace(temp, p)
        finally:
            if created:
                temp.unlink(missing_ok=True)

    async def login(self):
        async with self.lock:
            if self.token and (self.expires is None or self.expires > time.time()*1000+30_000):
                return
            await self._login()

    async def _login(self):
        body = dict(self.cfg.login_body, password=self.cfg.password)
        code, raw = await self.transport.request('POST', self.cfg.manager + '/api/v1/auth/login',
                                                 body=json.dumps(body).encode(), retry_after_error=True)
        if code != 200:
            raise BoundaryError('LOGIN_TEMPORARY' if code in (408, 425, 429) or code >= 500 else 'LOGIN_REJECTED', code)
        doc = loads(raw)
        d = doc.get('data')
        if (doc.get('result') is not True or doc.get('error') is not None or not isinstance(d, dict)
                or d.get('actorType') != 'AI' or d.get('serverId') != self.cfg.server_id
                or not isinstance(d.get('accountId'), str) or not d['accountId']
                or not isinstance(d.get('token'), str) or not TOKEN.fullmatch(d['token'])
                or d.get('mustChangePassword') is not False):
            raise BoundaryError('LOGIN_CONTRACT')
        if ('expiresAtWallMs' not in d or type(doc.get('serverTimeMs')) is not int
                or doc['serverTimeMs'] < 0 or not isinstance(d.get('permission'), str)
                or type(d.get('replacedSession')) is not bool
                or type(d.get('revokedPreviousTokens')) is not int or d['revokedPreviousTokens'] < 0):
            raise BoundaryError('LOGIN_CONTRACT')
        expiry = d.get('expiresAtWallMs')
        if expiry is not None and (type(expiry) is not int or expiry <= time.time()*1000+1000):
            raise BoundaryError('LOGIN_EXPIRY')
        self.token, self.expires, self.failure = d['token'], expiry, None
        self.failures = 0
        self.retry_after = 0
        self.terminal = False
        self.save_session()

    def failed(self, exc):
        self.failure = exc.code
        self.failures += 1
        self.retry_after = getattr(exc, 'retry_after', 0)
        self.terminal = exc.code not in {'LOGIN_TEMPORARY', 'UPSTREAM_OUTCOME_UNKNOWN', 'UPSTREAM_RETRY_LATER'}

    async def start(self):
        if self.task is not None:
            return
        try:
            if not self.restore():
                await self.login()
        except BoundaryError as exc:
            self.failed(exc)
        self.task = asyncio.create_task(self._refresh(), name='bridge-token-refresh')

    async def _refresh(self):
        while True:
            delay = (2, 4, 8, 16, 30, 60)[min(max(self.failures-1, 0), 5)]
            await asyncio.sleep(max(delay, self.retry_after))
            if self.terminal:
                continue
            try:
                self.current()
                if self.expires is None or self.expires > time.time()*1000 + 30_000:
                    continue
            except BoundaryError:
                pass
            try:
                await self.login()
            except BoundaryError as exc:
                self.failed(exc)

    async def authorize(self, incoming):
        used = self.current()
        code, raw = await self.transport.request('POST', self.cfg.manager + '/api/v1/tokens/introspect',
                      body=json.dumps({'token': incoming}).encode(), token=used, seconds=0.8)
        if code == 401:
            self.invalidate(used)
        if code != 200:
            raise BoundaryError('AUTHORITY_UNAVAILABLE', 503)
        doc = loads(raw)
        d = doc.get('data')
        if (doc.get('result') is not True or doc.get('error') is not None or not isinstance(d, dict)
                or d.get('active') is not True or d.get('actorType') != 'MANAGER'
                or d.get('permission') != 'admin' or not isinstance(d.get('accountId'), str)
                or not d['accountId']):
            raise BoundaryError('TOKEN_AUTHORITY_INVALID', 403)

    async def close(self):
        self.closed = True
        self.token = None
        if self.task is not None:
            self.task.cancel()
            try:
                await self.task
            except asyncio.CancelledError:
                pass
            self.task = None
