"""Small goal executor: authored action contracts, state-changing recovery.

No arbitrary timeout, global replanner, OCR gate, or live transport. An unknown
effect retains its intent for evidence reconciliation instead of blind retry.
"""
from dataclasses import asdict
import json
import os
from pathlib import Path
import uuid
from .adapters import ActionRequest, SyntheticAdapter
from .catalog import read_json
from .state import State


class UnsupportedGoal(ValueError):
    pass


def compile_goal(catalog, quest_id):
    definition = catalog.quest(quest_id)
    if definition is None:
        raise UnsupportedGoal("Definition not in the imported catalog")
    # Only this reviewed template is authored. Other 994 definitions remain
    # searchable without being promoted into executable coverage.
    if definition["name"] != "AB_Caitin_GatherableEgg":
        raise UnsupportedGoal("No authored action binding for this definition")
    objectives = definition["steps"][0]["objectives"]
    hold = next(o for o in objectives if o["kind"] == "hold")
    delivery = next(o for o in objectives if o["kind"] == "deliver")
    if not any(t["arguments"][0] == "Egg" for t in hold["targets"]):
        raise UnsupportedGoal("Target changed; update reviewed binding")
    npc = next(t for t in delivery["targets"] if t["kind"] == "npc")
    if npc["arguments"][0] != "Caitin" or type(hold["goal"]) is not int or hold["goal"] <= 0:
        raise UnsupportedGoal("Definition changed; review impact")
    return {"id": "quest.egg.caitin", "title": definition["title"], "source": "quest-catalog",
            "parameters": {"quest_id": definition["id"], "quantity": hold["goal"], "item": "Egg", "npc": "Caitin"},
            "static_destination_candidates": npc.get("static_location_candidates", []),
            "steps": list(catalog.contracts["goal"]["steps"])}


class Executor:
    def __init__(self, catalog, fixture, journal=None, *, saved=None):
        self.catalog, self.fixture = catalog, fixture
        self.journal = Path(journal) if journal else None
        self.goal = compile_goal(catalog, fixture["definition_id"])
        self.session = saved["session"] if saved else str(uuid.uuid4())
        self.adapter = SyntheticAdapter(catalog, fixture, self.session,
            cursor=saved["adapter_cursor"] if saved else 0, epoch=saved["epoch"] if saved else None)
        if saved and (saved["catalog_fingerprint"] != catalog.fingerprint or saved["fixture_fingerprint"] != self.adapter.fingerprint):
            raise ValueError("Checkpoint dependencies changed; inspect update impact before resuming")
        self.state = State(catalog.contracts["states"], self.session, saved["history"] if saved else ())
        self.cursor = saved["cursor"] if saved else 0
        self.recovery = saved["recovery"] if saved else None
        self.inflight = saved["inflight"] if saved else None
        self.receipts = saved["receipts"] if saved else []
        self.events = saved["events"] if saved else []
        self.status = saved["status"] if saved else "ready"
        self.reason = saved["reason"] if saved else ""
        self.paused = saved["paused"] if saved else False
        self.cancelled = saved["cancelled"] if saved else False
        if not saved:
            for observation in self.adapter.seed():
                self.state.add(observation)
            self.log("created", "synthetic session; no game connection")
        self.persist()

    @classmethod
    def load(cls, catalog, path):
        saved = read_json(path)
        if saved.get("schema") != 1 or saved.get("mode") != "synthetic":
            raise ValueError("Unsupported checkpoint")
        name = saved["fixture_id"]
        if name not in {p.stem for p in (catalog.root / "data/fixtures").glob("*.json")}:
            raise ValueError("Unknown checkpoint fixture")
        fixture = read_json(catalog.root / "data/fixtures" / (name + ".json"))
        return cls(catalog, fixture, path, saved=saved)

    def log(self, kind, reason, **data):
        self.events.append(dict(sequence=len(self.events), kind=kind, reason=reason, cursor=self.cursor,
                                recovery=self.recovery, **data))

    @property
    def current_action(self):
        return self.recovery or (self.goal["steps"][self.cursor] if self.cursor < len(self.goal["steps"]) else None)

    def export(self):
        return {"schema": 1, "mode": "synthetic", "session": self.session, "fixture_id": self.fixture["id"],
            "fixture_fingerprint": self.adapter.fingerprint, "catalog_fingerprint": self.catalog.fingerprint,
            "epoch": self.adapter.epoch, "adapter_cursor": self.adapter.cursor, "cursor": self.cursor,
            "recovery": self.recovery, "inflight": self.inflight, "receipts": self.receipts, "events": self.events,
            "status": self.status, "reason": self.reason, "paused": self.paused, "cancelled": self.cancelled,
            "history": self.state.export(), "goal": self.goal, "real_game_completed": False, "game_input_count": 0}

    def persist(self):
        if self.journal:
            self.journal.parent.mkdir(parents=True, exist_ok=True)
            temporary = self.journal.with_suffix(".pending")
            with temporary.open("w", encoding="utf8") as stream:
                json.dump(self.export(), stream, ensure_ascii=False, indent=2)
                stream.flush()
                os.fsync(stream.fileno())
            temporary.replace(self.journal)

    def pause(self):
        if not self.cancelled and self.status != "simulated_complete":
            self.paused = True
            self.status, self.reason = "paused", "user_pause"
            self.log("paused", "input paused; current checkpoint retained")
            self.persist()

    def resume(self):
        if self.cancelled or self.status == "stopped":
            return False
        if self.paused:
            self.paused = False
            self.status, self.reason = "ready", ""
            self.log("resumed", "same checkpoint")
            self.persist()
        return True

    def cancel(self):
        if self.status == "simulated_complete":
            return
        self.cancelled, self.paused = True, False
        self.status, self.reason = "stopped", "user_cancelled"
        self.log("stopped", self.reason)
        self.persist()

    def _wait(self, reason):
        self.status, self.reason = "waiting", reason
        self.log("waiting", reason)

    def _effects(self, contract):
        return all(self.state.holds(c, self.goal["parameters"]) for c in contract["effects"])

    def _fresh_effects(self, contract):
        evidence = {o.id for o in self.state.history[self.inflight["history_start"]:]}
        return self._effects(contract) and all(self.state.latest[c["key"]].id in evidence for c in contract["effects"])

    def _confirmed(self, details=None):
        action = self.current_action
        self.receipts.append({"operation_id": self.inflight["id"], "action": action, "status": "synthetic_effect_confirmed",
                              "effect_observations": [self.state.latest[c["key"]].id for c in self.catalog.contracts["actions"][action]["effects"]],
                              "details": details or {}})
        self.inflight = None
        if self.recovery:
            self.log("recovered", "bag state changed; resume the suspended goal action", parent_action=self.goal["steps"][self.cursor])
            self.recovery = None
        else:
            self.log("effect_confirmed", action)
            self.cursor += 1
        self.status, self.reason = ("simulated_complete", "synthetic_fixture_only") if self.cursor == len(self.goal["steps"]) else ("ready", "")

    def _begin_recovery(self):
        if self.fixture["policy"].get("bag_cleanup") is not True:
            self.status, self.reason = "stopped", "policy_bag_cleanup_not_allowed"
            self.log("stopped", self.reason)
            return
        self.recovery = "bag.cleanup"
        self.status, self.reason = "recovering", "bag_full"
        self.log("recovery_selected", "state-changing child action; parent cursor preserved")

    def tick(self):
        if self.cancelled or self.status in ("stopped", "simulated_complete"):
            return self.status
        if self.paused:
            observed = self.adapter.observe()
            for o in observed.observations:
                self.state.add(o)
            if observed.observations:
                self.log("passive_observation", "progress retained while paused")
            self.persist()
            return self.status
        action = self.current_action
        contract = self.catalog.contracts["actions"][action]
        if self.inflight:
            # Only a read is allowed after unknown delivery. Never call perform.
            observed = self.adapter.observe()
            for o in observed.observations:
                self.state.add(o)
            if self._fresh_effects(contract):
                self._confirmed({"reconciled": True, "game_input_count": 0})
            else:
                self._wait("recovery_no_effect" if self.recovery else "await_effect_evidence")
            self.persist()
            return self.status
        if action == "gather.obtain" and self._effects(contract):
            # A passive observation may already establish the hold objective.
            self.log("objective_already_satisfied", "no extra gathering request")
            self.cursor += 1
            self.status, self.reason = "ready", ""
            self.persist()
            return self.status
        if action == "gather.obtain" and self.state.value("bag.blocked") is True:
            self._begin_recovery()
            self.persist()
            return self.status
        missing = [c["key"] for c in contract["preconditions"] if not self.state.holds(c, self.goal["parameters"])]
        if missing:
            self._wait("precondition_unobserved_or_unsatisfied:" + ",".join(missing))
            observed = self.adapter.observe()
            for o in observed.observations:
                self.state.add(o)
            self.persist()
            return self.status
        self.inflight = {"id": str(uuid.uuid4()), "action": action, "history_start": len(self.state.history)}
        self.log("intent", action, operation_id=self.inflight["id"])
        self.persist()  # Intent precedes dispatch; a crash cannot silently resend.
        request = ActionRequest(action, self.inflight["id"], self.goal["parameters"], action)
        try:
            result = self.adapter.perform(request)
            for o in result.observations:
                if o.scope != "synthetic" or o.session != self.session:
                    raise ValueError("Adapter observation ownership/scope mismatch")
                self.state.add(o)
            self.log("adapter_result", result.reason or result.status, action=action, details=result.details)
            if result.status == "complete" and self._fresh_effects(contract):
                self._confirmed(result.details)
            elif result.status == "blocked" and result.reason == "bag_full" and action == "gather.obtain" and self.state.value("bag.blocked") is True:
                # This event explicitly confirms a partial outcome. It is not an
                # uncertain-send case; current absolute progress stays in State.
                self.receipts.append({"operation_id": self.inflight["id"], "action": action, "status": "synthetic_partial",
                                      "count": self.state.value("item.Egg.count"), "details": result.details})
                self.inflight = None
                self._begin_recovery()
            else:
                self._wait("recovery_no_effect" if self.recovery else "await_effect_evidence")
        except Exception as error:
            # Keep intent and checkpoint for reconciliation; do not replay.
            self._wait("adapter_error:" + str(error))
        self.persist()
        return self.status

    def run_until_boundary(self):
        """Drive finite fixture work until a meaningful pause, not a time limit."""
        while self.status not in ("stopped", "simulated_complete") and not self.paused:
            before = (self.cursor, self.recovery, self.adapter.cursor, len(self.receipts))
            self.tick()
            after = (self.cursor, self.recovery, self.adapter.cursor, len(self.receipts))
            if self.status == "waiting" or before == after:
                break
        return self.export()
