"""The retained free stop_action only; input defaults and guards are shared.

No generic official action entry point is exposed. Dry preparation never runs
the CLI. A dispatched command is durable and never replayed on missing output.
"""
from datetime import datetime, timezone
import json
import os
from pathlib import Path
import subprocess
from .live_io import canonical, digest, utc_now


class GuardedOfficialStop:
    def __init__(self, input_adapter, cli, *, launcher=subprocess.Popen):
        self.input, self.cli, self.launcher = input_adapter, cli, launcher

    def send(self, command, operation_id, context):
        receipt=dict(status='blocked',attempted=False,command=command,observed_at=utc_now(),effect_confirmed=False)
        adapter=self.input
        with adapter.lock:
            fingerprint=digest(canonical(command).encode('utf8'))
            old=adapter.ledger.get(operation_id)
            if old:
                if old[0]!=fingerprint:return receipt | {'reason':'operation_id_reused_with_different_stop'}
                return old[1] | {'duplicate_prevented':True}
            try:
                if (command.get('kind')!='official_stop' or command.get('method')!='stop_action'
                        or command.get('params') is not None or command.get('role')!='gather.owned.stop'):
                    raise ValueError('only_source_owned_stop_action_without_arguments')
                if not operation_id or not command.get('owned_operation_id') or not command.get('source_refs') or not command.get('goal_evidence'):
                    raise ValueError('owned_native_action_goal_and_source_required')
                if any(command.get(k)!=context.get(k) for k in ('session_id','character_id')):
                    raise ValueError('stop_owner_changed')
                from .life_observer import activity_stoppable,validate_gather_termination_stop
                validate_gather_termination_stop(command,context,now=utc_now())
                activity=command.get('current_activity') or {}
                if not activity_stoppable(activity):raise ValueError('current_free_Stop_eligibility_required')
                stamp=datetime.fromisoformat(activity['observed_at'].replace('Z','+00:00'))
                if stamp.tzinfo is None or not -.5<=(datetime.now(timezone.utc)-stamp).total_seconds()<=5:
                    raise ValueError('current_Stop_activity_stale')
                current=adapter._guard(require_monitor=not adapter.dry_run)
                if tuple(context['window'].client_rect)!=tuple(current.client_rect):
                    raise ValueError('stop_current_window_changed')
                if adapter.dry_run:
                    return receipt | {'status':'dry_run','reason':'game_input_disabled'}
                if not adapter.armed or command.get('input_epoch')!=adapter.input_epoch:
                    raise ValueError('same_armed_input_epoch_required')
                capabilities=self.cli.query('capabilities')
                body=capabilities.get('body') or {}
                commands=body.get('commands',[]) if isinstance(body,dict) else []
                if not capabilities.get('ok') or not any(r.get('Command')=='stop_action' for r in commands if isinstance(r,dict)):
                    raise ValueError('current_official_stop_capability_unavailable')
                path=Path(self.cli.cli_path)
                if not path.is_absolute() or path.name.casefold()!='mabinogimobile_cli.exe':
                    raise ValueError('official_cli_absolute_path_required')
                cli_sha256=digest(path.read_bytes())
                # Reads may outlive both the activity fact and the input lease.
                adapter._guard()
                if command['input_epoch']!=adapter.input_epoch:raise ValueError('stop_input_lease_changed')
                validate_gather_termination_stop(command,context,now=utc_now())
                if not -.5<=(datetime.now(timezone.utc)-stamp).total_seconds()<=5:
                    raise ValueError('current_Stop_activity_stale_after_capability_read')
                receipt.update(status='unknown',attempted=True,cli_sha256=cli_sha256)
                adapter.ledger.reserve(operation_id,fingerprint,receipt)
            except (OSError,RuntimeError,ValueError,KeyError,TypeError) as exc:
                return receipt | {'reason':str(exc)}
            # The durable reserve can block on storage. A stop arriving there
            # must not be followed by a newly launched CLI command.
            try:
                adapter._guard()
                if not adapter.armed or command['input_epoch']!=adapter.input_epoch:
                    raise ValueError('stop_input_lease_changed_before_dispatch')
                validate_gather_termination_stop(command,context,now=utc_now())
                if not -.5<=(datetime.now(timezone.utc)-stamp).total_seconds()<=5:
                    raise ValueError('current_Stop_activity_stale_before_dispatch')
            except (OSError,RuntimeError,ValueError,KeyError,TypeError) as exc:
                receipt.update(status='blocked',attempted=False,explicit_no_effect=True,
                    reason='stop_cancelled_before_dispatch:'+str(exc))
                adapter.ledger.finish(operation_id,receipt)
                return receipt
            receipt['dispatch_started']=False
            try:
                # Linearize Stop and creation of this one owned CLI process.
                # A cancellation that wins this fence launches nothing. Once
                # creation wins, it is an in-flight command, not cancellable
                # unsent work. No disk access, observation or process wait is
                # allowed under the fence, so F8 does not wait for CLI output.
                with adapter.dispatch_fence:
                    if (adapter.cancelled.is_set() or not adapter.armed or adapter.dry_run
                            or command['input_epoch']!=adapter.input_epoch):
                        receipt.update(status='blocked',attempted=False,explicit_no_effect=True,
                            reason='stop_cancelled_before_process_creation')
                        process=None
                    else:
                        receipt.update(dispatch_started=True,attempted=True,
                            dispatch_boundary='owned_process_creation_started',dispatch_started_at=utc_now())
                        process=self.launcher([str(path),'stop_action'],shell=False,stdin=subprocess.DEVNULL,
                            stdout=subprocess.PIPE,stderr=subprocess.PIPE,
                            creationflags=subprocess.CREATE_NO_WINDOW if os.name=='nt' else 0)
                if process is None:
                    adapter.ledger.finish(operation_id,receipt)
                    return receipt
                receipt.update(process_created=True,dispatch_boundary='owned_process_created')
                try:
                    stdout,_=process.communicate(timeout=30)
                except subprocess.TimeoutExpired:
                    # Only our newly created helper is cleaned up. Its command
                    # may already have reached the game; never retry it.
                    process.kill()
                    try:process.communicate(timeout=5)
                    except subprocess.TimeoutExpired:pass
                    raise
                raw=stdout.decode('utf-8-sig') if isinstance(stdout,bytes) else stdout
                body=json.loads(raw)
                accepted=True
                if isinstance(body,dict) and 'status' in body:
                    accepted=body['status']=='accepted'
                    body=body.get('body',{})
                    if isinstance(body,str):body=json.loads(body)
                receipt.update(response=body,response_sha256=digest(raw.encode('utf8')),returncode=process.returncode)
                if process.returncode==0 and accepted and isinstance(body,dict) and not body.get('error') and isinstance(body.get('message'),str) and body['message'].strip():
                    receipt.update(status='sent_unconfirmed',reason='stop_accepted_current_idle_effect_required')
                elif isinstance(body,dict) and body.get('error')=='invalid_state':
                    receipt.update(status='no_effect',reason='official_explicit_invalid_state',explicit_no_effect=True)
                else:receipt['reason']='stop_response_does_not_confirm_effect'
            except (OSError,ValueError,TypeError,subprocess.TimeoutExpired) as exc:
                receipt['reason']='stop_dispatch_outcome_unknown:'+type(exc).__name__
            adapter.ledger.finish(operation_id,receipt)
            return receipt
