"""Official CLI: free reads are common; only paid action calls are opt-in.

Travel wings / the UI's T shortcut are not governed by this action budget.
No shell, private IPC, model, or shared response file.
"""
import base64
import json
import os
from pathlib import Path
import re
import subprocess
import threading
import time

from .automation.contracts import NeedsAttention, TaskUnavailable, require, integer

CLI = r'C:\Nexon\MabinogiMobile\MabinogiMobile_CLI.exe'
READS = frozenset({'get_items', 'get_inventory', 'get_activity', 'get_currencies',
    'get_gatherable_items', 'get_craftable_items', 'get_alterable_items', 'get_altering_works',
    'get_quests','get_current_environment','get_my_info'})
WRITES = frozenset({'execute_gathering', 'execute_crafting', 'execute_altering', 'complete_altering_work', 'stop_action'})
PAID_ACTIONS = frozenset({'execute_gathering', 'execute_crafting', 'execute_altering'})


class ConnectorUnavailable(NeedsAttention):
    """A read can be retried; this never authorizes replaying an action."""


class ReadPacer:
    """Shared runtime request spacing, never a cache of inventory/state values."""
    def __init__(self,clock=time.monotonic):
        self.clock=clock;self.last={};self.lock=threading.Lock()

    def before(self,command,check,wait):
        group,interval=(('inventory',3.) if command in ('get_items','get_inventory') else
                        ('activity',1.) if command=='get_activity' else (None,0.))
        if group is None:return False
        while True:
            check()
            with self.lock:
                now=self.clock();remaining=interval-(now-self.last.get(group,-1e9))
                if remaining<=0:
                    self.last[group]=now
                    return True
            wait(min(.1,remaining))


def temporarily_unavailable(value):
    if not isinstance(value,dict):return False
    error=value.get('error')
    if isinstance(error,str) and error.lower() in (
            'not_in_game','not_connected','disconnected','connection_lost',
            'pipe_disconnected','pipe_not_found','timeout','connection_timeout'):
        return True
    if value.get('pipe')=='disconnected' or value.get('loading') is True:return True
    body=value.get('body')
    if isinstance(body,str):
        try:body=json.loads(body)
        except ValueError:return False
    return temporarily_unavailable(body) if isinstance(body,dict) else False


def settings(value=None):
    defaults = dict(mode='click', cli_path=CLI, wing_limit=1000, retry_stopped=True, fallback='click',inventory_source='auto')
    if value is None:return defaults
    if not isinstance(value, dict) or not set(value) <= set(defaults):raise ValueError('제어 방식 설정 오류')
    result = defaults | value
    if result['mode'] not in ('click', 'connector') or result['fallback'] != 'click':raise ValueError('제어 방식 오류')
    if type(result['wing_limit']) is not int or not 0 <= result['wing_limit'] <= 100000:raise ValueError('날개 한도는 0~100000 정수입니다.')
    if type(result['retry_stopped']) is not bool:raise ValueError('채집 재시도 설정 오류')
    if result['inventory_source'] not in ('auto','connector','screen'):raise ValueError('재고 정보 출처 오류')
    path = result['cli_path']
    if not isinstance(path,str) or not Path(path).is_absolute() or Path(path).name.casefold() != 'mabinogimobile_cli.exe':
        raise ValueError('공식 MabinogiMobile_CLI.exe의 절대 경로를 지정하세요.')
    return result


def inventory_enabled(config, *, life=False):
    # Gathering/processing always use free official information. Keep older
    # settings for combat/missions, whose routes are outside this refactor.
    return life or config['inventory_source']=='connector' or config['inventory_source']=='auto' and config['mode']=='connector'


def paid_actions_enabled(config):
    """This flag never authorizes or forbids a movement-wing shortcut."""
    return config['mode']=='connector'


def argv(path, command, body=None):
    require(command in READS | WRITES | {'status','capabilities'}, '등록되지 않은 커넥터 명령')
    result = [str(path), command]
    if body is not None:
        raw = body if isinstance(body,str) else json.dumps(body,ensure_ascii=False,separators=(',',':'))
        result.append('base64:' + base64.b64encode(raw.encode('utf-8')).decode('ascii'))
    return result


def decode(code, stdout):
    try:value = json.loads(stdout.lstrip('\ufeff'))
    except (ValueError,TypeError) as exc:raise ConnectorUnavailable('커넥터 응답 수신/해석 실패') from exc
    if code!=0 and temporarily_unavailable(value):
        raise ConnectorUnavailable('커넥터 통신 불가: '+str(value))
    require(code == 0, '커넥터 연결/전송 실패: '+str(value)+' · 게임 실행 및 AI 에이전트 설정 확인 필요')
    if not isinstance(value,(dict,list)):raise ConnectorUnavailable('커넥터 응답 형식 오류')
    if isinstance(value,dict) and 'status' in value:
        status = value['status']
        body = value.get('body',{})
        if isinstance(body,str):
            try:body=json.loads(body)
            except ValueError:body={'message':body}
        if not isinstance(body,(dict,list)):raise ConnectorUnavailable('커넥터 본문 형식 오류')
        if status != 'accepted':
            if isinstance(body,dict):
                return dict(body,error=body.get('error') or 'rejected',connector_status=status)
            return {'error':'rejected','message':str(body),'connector_status':status}
        value=body
    return value


def successful(value):
    if isinstance(value,dict):
        require(not value.get('error'), '커넥터 중단: '+str(value))
        require(not value.get('_interrupted'), '커넥터 실행 중 사용자/안전 중단: '+str(value.get('_interrupted')))
    return value


class Client:
    def __init__(self, config, *, check=None, guard=None, before_action=None, event=None, popen=subprocess.Popen,
                 retry_seconds=0,wait=time.sleep,clock=time.monotonic,pacer=None):
        self.config=settings(config)
        self.check=check or (lambda:None)
        self.guard=guard or (lambda:None)
        self.before_action=before_action or self.check
        self.event=event or (lambda *_:None)
        self.popen=popen
        self.commands={}
        self.reserved=0
        self.retry_seconds,self.wait,self.clock=retry_seconds,wait,clock
        self.pacer=pacer

    def call(self, command, body=None, *, consuming=False, operation=None, monitor=None):
        self.check();self.guard()
        require(Path(self.config['cli_path']).is_file(), '공식 게임 CLI가 없습니다. 게임에서 AI 에이전트를 활성화하세요.')
        args=argv(self.config['cli_path'],command,body)
        if not consuming and self.pacer is not None and self.pacer.before(command,self.check,self.wait):
            # Pace at dispatch, AFTER a possible input-owner pause/resume.
            # Recheck identity after the wait; never retarget a closed client.
            self.guard()
        if operation:self.event('resource_commit_prepared',dict(key=operation.key,operation=operation.name,action_id=command))
        try:
            process=self.popen(args,shell=False,
                stdin=subprocess.DEVNULL,stdout=subprocess.PIPE,stderr=subprocess.PIPE,
                text=True,encoding='utf-8',errors='strict',
                creationflags=subprocess.CREATE_NO_WINDOW if os.name=='nt' else 0)
        except OSError:
            if operation:self.event('resource_input_not_sent',dict(key=operation.key,operation=operation.name,action_id=command,input_attempted=False))
            raise
        if consuming and operation:
            # Blocking gather calls invoke their monitor BEFORE communicate()
            # returns. Runtime now owns this dispatched action, even though its
            # outcome/consumption must still be reconciled by the journal.
            self.event('connector_request_dispatched',dict(key=operation.key,
                operation=operation.name,action_id=command,backend='connector'))
        interrupted=None;started=time.monotonic()
        while True:
            try:
                stdout,_stderr=process.communicate(timeout=.25)
                break
            except subprocess.TimeoutExpired:
                if not consuming and time.monotonic()-started>20:
                    process.kill();process.communicate()
                    raise ConnectorUnavailable('커넥터 조회 시간 초과 · 게임 행동 명령은 전송하지 않았습니다.')
                try:
                    self.check()
                    if monitor and interrupted is None:monitor()
                except Exception as exc:
                    if not consuming:
                        # Only this read-only CLI helper is ours to terminate.
                        # Never keep cancellation waiting on a hung query, and
                        # never kill the game or an already-dispatched action.
                        process.kill();process.communicate()
                        raise
                    # Do not kill a blocking game action or resend after pause.
                    # Manual stop means NO further input, including stop_action.
                    if interrupted is None:
                        interrupted=str(exc) or type(exc).__name__
                        self.event('connector_waiting_response',dict(message='입력 중단 · 이미 시작한 CLI 응답 대기',reason=interrupted))
        value=decode(process.returncode,stdout)
        if interrupted:
            if consuming and isinstance(value,dict):value=dict(value,_interrupted=interrupted)
            else:raise NeedsAttention('커넥터 조회 중 중단: '+interrupted)
        return value

    def read(self,command,body=None):
        """Retry only observations, keeping the caller's stack and baseline.

        The execution owner supplies the existing uncertainty budget. Manual
        one-shot/paused-observer clients can keep retry_seconds=0; the paused
        observer already polls externally without abandoning the running task.
        Neither a success from status nor a failed retry resets this deadline.
        """
        require(command in READS|{'status','capabilities'},'행동 명령은 통신 재조회 대상으로 사용할 수 없습니다')
        started=None;attempts=0;last_notice=-1e9
        while True:
            try:
                try:value=self.call(command,body)
                except (ConnectionError,TimeoutError,UnicodeError) as exc:
                    raise ConnectorUnavailable('커넥터 조회 통신 오류: '+str(exc)) from exc
                if temporarily_unavailable(value):raise ConnectorUnavailable('커넥터 조회 대기: '+str(value))
                value=successful(value)
            except ConnectorUnavailable as exc:
                if self.retry_seconds<=0:raise
                if started is None:started=self.clock()
                elapsed=self.clock()-started
                if elapsed>=self.retry_seconds:
                    raise NeedsAttention('CLI 통신 복구 재확인 한도 초과 · 진행 기록 보존 · 게임 행동 재전송 없음') from exc
                attempts+=1
                delay=min(15.,2.**min(attempts,4),self.retry_seconds-elapsed)
                if self.clock()-last_notice>=5:
                    last_notice=self.clock()
                    self.event('connector_read_retry',dict(command=command,attempt=attempts,elapsed_seconds=round(elapsed,1),
                        next_retry_seconds=delay,reason=str(exc),
                        message='CLI 통신 복구 대기 · '+command+' · '+str(int(delay))+'초 간격 / 기존 작업 유지'))
                until=self.clock()+delay
                while self.clock()<until:
                    self.check();self.wait(min(.1,until-self.clock()))
                continue
            if started is not None:
                self.event('connector_read_recovered',dict(command=command,retries=attempts,
                    elapsed_seconds=round(self.clock()-started,1),
                    message='CLI 통신 복구 · 최신 수량/상태 확인 후 같은 작업에서 계속'))
            return value

    def connect(self):
        require(self.read('status').get('pipe')=='connected', '게임 AI 커넥터가 연결되지 않았습니다.')
        catalog=self.read('capabilities')
        require(isinstance(catalog,dict) and not catalog.get('loading'), '게임 명령 목록 로딩 중입니다. 게임 진입 후 다시 확인하세요.')
        rows=catalog.get('commands')
        require(isinstance(rows,list) and all(isinstance(r,dict) and isinstance(r.get('Command'),str) for r in rows), '명령 목록 형식 변경')
        self.commands={row['Command']:row for row in rows}
        return dict(connected=True,commands=sorted(self.commands),message='게임 연결됨 · 명령 목록 확인')

    def supports(self, *commands):return all(c in self.commands for c in commands)

    def query(self,command,body=None):
        require(command in READS and self.supports(command), '현재 게임에서 지원하지 않는 조회: '+command)
        return self.read(command,body)

    def action(self,command,body,operation,*,monitor=None):
        require(command in WRITES and self.supports(command), '현재 게임에서 지원하지 않는 행동: '+command)
        metadata=self.commands[command].get('Metadata') or {}
        require(str(metadata.get('requiresConfirm','false')).lower()=='false', '새로운 게임 확인이 필요한 명령입니다. 자동 실행하지 않습니다.')
        note=self.commands[command].get('Note','')
        paid=command in PAID_ACTIONS
        balance=None
        if paid:
            fishing=(command=='execute_gathering' and operation.name=='connector_fishing_start')
            require(paid_actions_enabled(self.config) or fishing, 'CLI 실행용 날개 사용이 꺼져 있습니다. 해당 행동은 좌표 클릭 경로를 사용하세요. 이동용 날개 설정과는 별개입니다.')
            cost=re.findall(r'Running this command consumes (\d+) 정령의 날개\.',note)
            require(cost==['5'], '게임 행동 비용 명세가 변경되었습니다. 자동 소비하지 않습니다.')
            if self.reserved+5>self.config['wing_limit']:
                raise TaskUnavailable('이번 실행의 커넥터 날개 사용 한도에 도달했습니다. 자동 재시도/클릭 전환하지 않습니다.')
            balance=self.wing_balance()
            if balance<5:raise TaskUnavailable('CLI 실행에 필요한 정령의 날개 5개가 부족합니다. 추가 명령을 보내지 않습니다.')
        else:
            require('Running this command consumes' not in note, '무료 행동 비용 명세가 변경되었습니다.')
        self.before_action();self.guard()
        # Reserve pessimistically before dispatch; even uncertain calls count.
        if paid:self.reserved+=5
        self.event('connector_cost',dict(reserved=self.reserved,limit=self.config['wing_limit'],command=command,
            budget_scope='cli_execution_only',movement_wings_included=False,balance_before=balance))
        kwargs={'monitor':monitor} if monitor is not None else {}
        value=self.call(command,body,consuming=True,operation=operation,**kwargs)
        self.event('operation_input',dict(key=operation.key,operation=operation.name,action_id=command,commit=True,backend='connector'))
        self.event('connector_response',dict(key=operation.key,command=command,response=value))
        return value

    def wing_balance(self):
        rows=self.query('get_currencies')
        require(isinstance(rows,list) and all(isinstance(r,dict) for r in rows), '공식 재화 목록 형식 변경')
        wings=[r for r in rows if r.get('DisplayName')=='정령의 날개']
        require(len(wings)==1, '공식 재화 목록에서 정령의 날개 잔액 한 개 확인 필요')
        return integer(wings[0].get('Amount'),'정령의 날개 보유량')

    def inventory(self):
        from .inventory_snapshot import InventorySnapshot
        snapshot=InventorySnapshot(self.query('get_items'))
        self.event('inventory_snapshot',dict(snapshot_id=snapshot.id,observed_at=snapshot.observed_at,
            rows=len(snapshot.rows),source='official_cli_get_items',filtered=False))
        return snapshot

    def bag(self,name):return self.inventory().bag(name)

    def sale_stocks(self,names):
        snapshot=self.inventory()
        from .inventory_cleanup import item_key
        stocks=[]
        for name in names:
            actual={r['DisplayName'] for r in snapshot.rows if r['Location']=='inventory' and
                item_key(r['DisplayName'])==item_key(name)}
            require(len(actual)<=1,'정리 품목의 서로 다른 원문 이름이 겹칩니다: '+name)
            stocks.append(snapshot.bag(next(iter(actual),name)))
        return {'stocks':stocks,'snapshot_id':snapshot.id}
