#!/usr/bin/env python3
"""Numyra example. Runs locally; credentials never go to a model by default.

NUMYRA_URL=https://your-host python client.py tutorial
NUMYRA_AGENT_TOKEN=... python client.py formal
NUMYRA_AGENT_TOKEN=... python client.py match --slot room_...
Use --resume to restore the private session file. No automatic registration.
"""
import argparse
import json
import os
import secrets
import tempfile
import time
import urllib.error
import urllib.request
from pathlib import Path


def choose_action(observation):
    """Replace with your own policy/model. Empty defaults test connectivity."""
    return {}


class StateConflict(Exception):
    """Discard a stale observation and obtain a fresh one before deciding again."""


class Client:
    def __init__(self, origin, token=None, pending_path=None):
        self.origin, self.token = origin.rstrip('/'), token
        self.pending_path = Path(pending_path) if pending_path else None
        self.pending = json.loads(self.pending_path.read_text()) if self.pending_path and self.pending_path.exists() else None
        if self.pending and self.pending['origin'] != self.origin:
            raise ValueError('Pending request origin differs; refusing to send its credential or idempotency key.')

    def acknowledge(self):
        if self.pending_path:
            self.pending_path.unlink(missing_ok=True)
        self.pending = None

    def replay_pending(self):
        pending = self.pending
        if not pending:
            raise ValueError('No pending write to recover.')
        return pending['operation'], self._send(pending['operation'], pending['arguments'])

    def call(self, operation, **arguments):
        writes = {'start_tutorial', 'attach_formal', 'attach_match', 'create_match', 'join_match', 'start_match', 'leave_match', 'profile', 'enter_round', 'submit_decision', 'exit_tutorial', 'revoke_credential'}
        if operation in writes:
            if self.pending:
                raise RuntimeError('An uncertain write is pending. Resume it before making a new decision.')
            arguments.setdefault('idempotency_key', secrets.token_urlsafe(32))
            self.pending = {'origin': self.origin, 'operation': operation, 'arguments': arguments}
            if self.pending_path:
                save(self.pending_path, self.pending)
        return self._send(operation, arguments)

    def _send(self, operation, arguments):
        raw = json.dumps(arguments, ensure_ascii=False).encode()
        headers = {'Content-Type': 'application/json'}
        if self.token:
            headers['Authorization'] = 'Bearer ' + self.token
        for attempt in range(8):
            request = urllib.request.Request(self.origin + '/api/agent/v1/actions/' + operation, data=raw, headers=headers)
            try:
                with urllib.request.urlopen(request, timeout=60) as response:
                    result = json.load(response)
                # Bootstrap receipts may contain the only recoverable token or
                # game ID. Acknowledge those only after the session is on disk.
                if self.pending and operation == self.pending['operation'] and (not self.pending_path or operation not in {'start_tutorial', 'attach_formal', 'attach_match'}):
                    self.acknowledge()
                return result
            except urllib.error.HTTPError as error:
                if error.code == 409:
                    detail = error.read().decode()
                    if 'state_version_conflict_refresh_state' in detail:
                        self.acknowledge()
                        raise StateConflict() from None
                    raise RuntimeError('HTTP 409: ' + detail) from None
                if error.code not in {408, 429, 500, 502, 503, 504}:
                    if self.pending and operation == self.pending['operation']:
                        self.acknowledge()
                    raise RuntimeError('HTTP ' + str(error.code) + ': ' + error.read().decode()) from None
                time.sleep(max(1, int(error.headers.get('Retry-After', '5'))))
            except (TimeoutError, urllib.error.URLError):
                time.sleep(min(30, 2 ** attempt))
        raise RuntimeError('Retry budget exhausted. Use --resume to replay the saved pending write with its original key.')


def save(path, value):
    path = Path(path)
    # Atomic replacement preserves the previous session/journal on interruption.
    if path.is_symlink():
        raise ValueError('Refusing to replace a symlink session file.')
    fd, temporary = tempfile.mkstemp(prefix=path.stem + '-', suffix=path.suffix, dir=path.parent)
    try:
        with os.fdopen(fd, 'w') as stream:
            os.fchmod(stream.fileno(), 0o600)
            json.dump(value, stream)
            stream.flush()
            os.fsync(stream.fileno())
        os.replace(temporary, path)
    finally:
        Path(temporary).unlink(missing_ok=True)


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('mode', choices=['tutorial', 'match', 'formal'])
    parser.add_argument('--url', default=os.environ.get('NUMYRA_URL', 'http://127.0.0.1:8001'))
    parser.add_argument('--session', default='.numyra-agent-session.json')
    parser.add_argument('--resume', action='store_true')
    parser.add_argument('--slot')
    parser.add_argument('--home', default='淮山')
    parser.add_argument('--company', help='Formal company name, if the seat has no existing name')
    parser.add_argument('--exit-tutorial', action='store_true')
    args = parser.parse_args()
    client = Client(args.url, os.environ.get('NUMYRA_AGENT_TOKEN'), args.session + '.pending.json')
    if not args.resume and (Path(args.session).exists() or client.pending):
        raise SystemExit('Session or pending write exists. Use --resume or a different --session path.')
    if args.resume and Path(args.session).exists():
        session = json.loads(Path(args.session).read_text())
        if session['origin'] != client.origin:
            raise SystemExit('Session origin differs; refusing to send its credential.')
        if session.get('mode', args.mode) != args.mode:
            raise SystemExit('Session mode differs; resume with its original mode.')
        client.token, game_id = session['token'], session['game_id']
        if client.pending:
            pending_game = client.pending['arguments'].get('game_id')
            if pending_game and pending_game != game_id:
                raise SystemExit('Pending write belongs to a different game.')
            try:
                operation, result = client.replay_pending()
                if operation in {'start_tutorial', 'attach_formal', 'attach_match'}:
                    if result['game_id'] != game_id:
                        raise SystemExit('Pending bootstrap belongs to a different game.')
                    client.acknowledge()
                if operation in {'exit_tutorial', 'revoke_credential'}:
                    return
            except StateConflict:
                pass  # Server rejected the stale write; obtain a new observation.
    else:
        bootstrap = {'tutorial': 'start_tutorial', 'formal': 'attach_formal', 'match': 'attach_match'}[args.mode]
        if args.resume and not client.pending:
            raise SystemExit('No saved session or pending creation to resume.')
        if client.pending:
            if client.pending['operation'] != bootstrap:
                raise SystemExit('Pending write needs its original session and mode.')
            _, result = client.replay_pending()
            if args.mode == 'tutorial':
                client.token = result['access_token']
        elif args.mode == 'tutorial':
            result = client.call('start_tutorial')
            client.token = result['access_token']
        elif args.mode == 'formal':
            if not client.token: raise SystemExit('Set NUMYRA_AGENT_TOKEN from /agents/connect.')
            result = client.call('attach_formal')
        else:
            if not client.token or not args.slot: raise SystemExit('Set NUMYRA_AGENT_TOKEN and --slot for a started match you joined.')
            result = client.call('attach_match', slot_id=args.slot)
        game_id = result['game_id']
        save(args.session, {'origin': client.origin, 'token': client.token, 'game_id': game_id, 'mode': args.mode})
        client.acknowledge()
    configured = False
    while True:
        try:
            configured = play_once(client, game_id, args, configured)
        except StateConflict:
            time.sleep(1)
            continue
        if configured is None:
            break


def play_once(client, game_id, args, configured):
    observation = client.call('state', game_id=game_id)
    print(observation['phase'], 'round', observation['expected_round'])
    if observation['phase'] == 'complete':
        result = client.call('result', game_id=game_id)
        save(args.session + '.result.json', result)
        if args.mode == 'tutorial' and args.exit_tutorial:
            client.call('exit_tutorial', game_id=game_id, expected_version=observation['state_version'])
        return None
    arguments = {'game_id': game_id, 'expected_version': observation['state_version']}
    actions = observation['legal_actions']
    if 'profile' in actions and not configured:
        own = next((p for p in observation['state'].get('players', []) if int(p.get('id', -1)) == observation['player_id']), {})
        profile = {'home_city': own.get('home_city') or args.home}
        if args.mode == 'formal':
            profile['company_name'] = own.get('company_name') or args.company or ('Agent' + secrets.token_hex(4))
        client.call('profile', **profile, **arguments)
        configured = True
    elif 'enter_round' in actions:
        client.call('enter_round', round_number=observation['expected_round'], **arguments)
    elif 'submit_decision' in actions:
        if args.mode != 'tutorial' and (observation.get('decision_status') or {}).get('submitted'):
            time.sleep(observation['next_poll_after_seconds'])
            return configured
        client.call('submit_decision', round_number=observation['expected_round'], decision=choose_action(observation), **arguments)
        # A formal/match decision does not advance the game. Wait for the next
        # published round; do not submit the same plan on every poll.
        if args.mode != 'tutorial':
            initial_round = observation['expected_round']
            while True:
                time.sleep(5)
                latest = client.call('state', game_id=game_id)
                if latest['expected_round'] != initial_round or latest['phase'] == 'complete':
                    break
    else:
        time.sleep(observation['next_poll_after_seconds'])
    return configured


if __name__ == '__main__':
    main()
