"""Composed-actions runner with atomicity invariant.

Used by every choreographer verb body that has a non-trivial
composition. The runner:

  1. Wraps the composed atomic actions in `session.begin_nested()`
     (a SAVEPOINT). A mid-sequence failure rolls the DB back to the
     pre-verb state.
  2. Runs side effects (git push, PR creation, etc.) AFTER the
     savepoint commits, never before. Each side effect is itself
     idempotent + retryable per the open_pr atomicity pattern.

Preconditions are NOT this runner's concern — `spec.can_invoke_intent`
runs before the verb body, so by the time the runner is called the
Decision is `allow`.
"""

from __future__ import annotations

from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any

from roboco.foundation.policy import lifecycle as spec

_AtomicHandler = Callable[[Any, Any, Any, spec.Context], Awaitable[Any]]
_SideEffectHandler = Callable[[Any, Any, Any], Awaitable[Any]]


@dataclass(frozen=True)
class VerbRunner:
    """Lightweight composition runner; one instance per choreographer."""

    task_service: Any
    git_service: Any

    async def run_intent(
        self,
        intent_name: str,
        task: Any,
        agent: Any,
        context: spec.Context,
    ) -> Any:
        """Run pre-side-effects, then composed atomic actions, then side effects.

        Most verbs only need composes→side_effects. A few (submit_up) need a
        git op to run BEFORE the DB transition it gates — those declare
        ``pre_side_effects`` which run first, outside the savepoint.

        Returns the final task object (post-composition). Raises whatever
        the underlying TaskService methods raise; the savepoint context
        rolls the DB back on raise.
        """
        # Fail loud + clean on a missing task/agent. The atomic handlers below
        # dereference task.id / agent.id, so a None here would otherwise crash
        # with a cryptic "'NoneType' object has no attribute 'id'" (observed when
        # a task was forced into an unexpected state out-of-band) instead of an
        # actionable error the agent can recover from.
        if task is None or agent is None:
            missing = "task" if task is None else "agent"
            raise ValueError(
                f"INVALID_STATE: cannot run '{intent_name}' — its {missing} "
                "could not be resolved. Re-fetch with evidence(task_id) and "
                "re-issue your claim verb."
            )
        intent = spec._INTENT_VERBS[intent_name]
        for side_effect_name in intent.pre_side_effects:
            await self._dispatch_side_effect(side_effect_name, task, agent)
        async with self.task_service.session.begin_nested():
            for position, action_name in enumerate(intent.composes):
                # A composed atomic action (claim/set_plan/start) returns None when
                # its source-status check fails — which happens mid-sequence when a
                # CONCURRENT agent transitions the row between the verb's precondition
                # gate and this execution (e.g. i_will_plan's gate saw `needs_revision`
                # but a racing i_am_blocked moved it to `blocked`, so claim() found no
                # valid transition and returned None). The NEXT action would then
                # dereference None.id and crash with the cryptic "'NoneType' object has
                # no attribute 'id'" — the entry guard above only covers the INITIAL
                # task. Fail loud + clean before the next dispatch so the choreographer
                # surfaces an actionable rejection and the agent re-fetches, not
                # respawn-loops.
                #
                # Only an INTERMEDIATE None is fatal here. A None from the LAST
                # composed action is the verb's own result: it flows out as the
                # runner's return value so the caller's existing `if task is None`
                # handler can surface the verb-specific message (e.g. "start
                # failed for task ...", or the board verb's decline envelope) —
                # preserving that contract instead of masking it as a crash.
                if position > 0 and task is None:
                    raise ValueError(
                        f"INVALID_STATE: a composed action before '{action_name}' in "
                        f"'{intent_name}' returned no task — its source status was "
                        "invalid, most likely because a concurrent transition changed "
                        "the task between the precondition gate and execution. "
                        "Re-fetch with evidence(task_id) and re-issue your verb."
                    )
                task = await self._dispatch_atomic(action_name, task, agent, context)
        # A TRAILING None (the last composed action returned None because its
        # source-status check failed under a concurrent transition) is the
        # verb's own result and flows out as the runner's return value. The
        # side_effects loop must NOT run on that None — a side effect
        # dereferences task.branch_name / task.pr_number and crashes
        # (_do_push_branch(None) -> None.branch_name AttributeError), turning
        # the clean INVALID_STATE the entry/intermediate guards give into a
        # 500/respawn loop. Skip side effects so the caller's `if task is None`
        # handler surfaces the verb-specific message.
        if task is not None:
            for side_effect_name in intent.side_effects:
                await self._dispatch_side_effect(side_effect_name, task, agent)
        return task

    async def _dispatch_atomic(
        self, action_name: str, task: Any, agent: Any, context: spec.Context
    ) -> Any:
        """Dispatch one atomic action by name. Returns the post-action task."""
        handler = self._atomic_handlers().get(action_name)
        if handler is None:
            raise ValueError(f"unknown atomic action '{action_name}'")
        return await handler(self, task, agent, context)

    async def _dispatch_side_effect(
        self, side_effect_name: str, task: Any, agent: Any
    ) -> Any:
        """Dispatch one side effect by name. Idempotent operations only."""
        handler = self._side_effect_handlers().get(side_effect_name)
        if handler is None:
            raise ValueError(f"unknown side effect '{side_effect_name}'")
        return await handler(self, task, agent)

    # -- Atomic handlers ---------------------------------------------------

    async def _do_claim(self, task: Any, agent: Any, _ctx: spec.Context) -> Any:
        return await self.task_service.claim(task.id, agent.id)

    async def _do_set_plan(self, task: Any, _agent: Any, ctx: spec.Context) -> Any:
        return await self.task_service.set_plan(task.id, ctx.plan or "")

    async def _do_start(self, task: Any, agent: Any, _ctx: spec.Context) -> Any:
        return await self.task_service.start(task.id, agent.id)

    async def _do_submit_verification(
        self, task: Any, agent: Any, ctx: spec.Context
    ) -> Any:
        return await self.task_service.submit_verification(
            agent.id, task.id, ctx.notes or ""
        )

    async def _do_submit_qa(self, task: Any, agent: Any, ctx: spec.Context) -> Any:
        return await self.task_service.submit_qa(agent.id, task.id, ctx.notes or "")

    async def _do_qa_pass(self, task: Any, agent: Any, ctx: spec.Context) -> Any:
        return await self.task_service.qa_pass(agent.id, task.id, ctx.notes or "")

    async def _do_qa_fail(self, task: Any, agent: Any, ctx: spec.Context) -> Any:
        return await self.task_service.qa_fail(
            agent.id, task.id, ctx.notes or "", list(ctx.issues)
        )

    async def _do_docs_complete(self, task: Any, _agent: Any, ctx: spec.Context) -> Any:
        return await self.task_service.docs_complete(task.id, doc_notes=ctx.notes or "")

    async def _do_complete(self, task: Any, agent: Any, ctx: spec.Context) -> Any:
        return await self.task_service.cell_pm_complete(
            agent.id, task.id, ctx.notes or ""
        )

    async def _do_submit_pm_review(
        self, task: Any, agent: Any, ctx: spec.Context
    ) -> Any:
        return await self.task_service.submit_pm_review(
            agent.id, task.id, ctx.notes or ""
        )

    async def _do_submit_for_review(
        self, task: Any, agent: Any, ctx: spec.Context
    ) -> Any:
        return await self.task_service.submit_for_review(
            agent.id, task.id, ctx.notes or ""
        )

    async def _do_pr_pass(self, task: Any, agent: Any, ctx: spec.Context) -> Any:
        return await self.task_service.pr_pass(agent.id, task.id, ctx.notes or "")

    async def _do_pr_fail(self, task: Any, agent: Any, ctx: spec.Context) -> Any:
        return await self.task_service.pr_fail(
            agent.id, task.id, ctx.notes or "", list(ctx.issues)
        )

    async def _do_request_changes(
        self, task: Any, agent: Any, ctx: spec.Context
    ) -> Any:
        # Forward the actor's real role — cell_pm and main_pm both own this
        # verb and the audit row must attribute the reject to the reviewer.
        agent_role = str(agent.role) if agent is not None else "cell_pm"
        return await self.task_service.request_changes(
            agent.id,
            task.id,
            ctx.notes or "",
            list(ctx.issues),
            agent_role=agent_role,
        )

    async def _do_escalate_to_ceo(
        self, task: Any, agent: Any, ctx: spec.Context
    ) -> Any:
        # Use the actor's real role — escalate_to_ceo is allow-listed for
        # main_pm, product_owner, head_marketing in the spec, and the task
        # service stamps the escalator's role into the audit trail. Forward
        # the actor's UUID so the awaiting_ceo_approval audit row attributes
        # the escalation to the specific PM/Board agent (every sibling
        # transition forwards the actor; this branch was the only one that
        # lost it, leaving a role-only record ambiguous across same-role PMs).
        agent_role = str(agent.role) if agent is not None else "main_pm"
        return await self.task_service.escalate_to_ceo(
            task_id=task.id,
            agent_role=agent_role,
            notes=ctx.notes or "",
            actor_agent_id=agent.id,
        )

    async def _do_block(self, task: Any, agent: Any, ctx: spec.Context) -> Any:
        return await self.task_service.escalate(agent.id, task.id, ctx.notes or "")

    async def _do_unblock(self, task: Any, agent: Any, _ctx: spec.Context) -> Any:
        return await self.task_service.unblock_with_restore(
            agent.id, task.id, restore=True
        )

    async def _do_resume(self, task: Any, agent: Any, _ctx: spec.Context) -> Any:
        return await self.task_service.resume_for_agent(task.id, agent.id)

    async def _do_create_subtask(
        self, _task: Any, _agent: Any, _ctx: spec.Context
    ) -> Any:
        raise NotImplementedError(
            "create_subtask requires DelegateInputs; verb body owns dispatch"
        )

    async def _do_pr_review_done(self, task: Any, agent: Any, ctx: spec.Context) -> Any:
        return await self.task_service.complete_review(
            agent.id, task.id, ctx.notes or ""
        )

    @classmethod
    def _atomic_handlers(cls) -> dict[str, _AtomicHandler]:
        return {
            "claim": cls._do_claim,
            "set_plan": cls._do_set_plan,
            "start": cls._do_start,
            "submit_verification": cls._do_submit_verification,
            "submit_qa": cls._do_submit_qa,
            "qa_pass": cls._do_qa_pass,
            "qa_fail": cls._do_qa_fail,
            "docs_complete": cls._do_docs_complete,
            "complete": cls._do_complete,
            "submit_pm_review": cls._do_submit_pm_review,
            "submit_for_review": cls._do_submit_for_review,
            "pr_pass": cls._do_pr_pass,
            "pr_fail": cls._do_pr_fail,
            "request_changes": cls._do_request_changes,
            "escalate_to_ceo": cls._do_escalate_to_ceo,
            "block": cls._do_block,
            "unblock": cls._do_unblock,
            "resume": cls._do_resume,
            "create_subtask": cls._do_create_subtask,
            "pr_review_done": cls._do_pr_review_done,
        }

    # -- Side-effect handlers ---------------------------------------------

    async def _do_push_branch(self, task: Any, agent: Any) -> Any:
        # Forward the actor so the workspace resolves from the actor's clone
        # (actor_agent_id wins over the assigned_to/created_by fallback) —
        # matches _do_pr_merge. Without it, a verb on a task whose
        # assigned_to was cleared before the side effect falls through to the
        # wrong workspace and pushes from / opens a PR against it.
        return await self.git_service.push_branch(
            task.branch_name, actor_agent_id=agent.id
        )

    async def _do_create_pr(self, task: Any, agent: Any) -> Any:
        from roboco.services.gateway.merge_chain import resolve_parent_branch

        parent = await resolve_parent_branch(task, self.task_service)
        return await self.git_service.create_pr(
            task.branch_name,
            parent=parent,
            is_root_pr=False,
            actor_agent_id=agent.id,
        )

    async def _do_create_root_pr(self, task: Any, agent: Any) -> Any:
        # Root→master PR for the in-path gate's root level (submit_root). The
        # base is always master and is_root_pr marks it for the CEO-merge path.
        # The PM opening the master PR is the assigned_to-may-be-None case
        # create_pr's actor_agent_id exists for.
        return await self.git_service.create_pr(
            task.branch_name,
            parent="master",
            is_root_pr=True,
            actor_agent_id=agent.id,
        )

    async def _do_pr_merge(self, task: Any, agent: Any) -> Any:
        from roboco.services.gateway.merge_chain import resolve_parent_branch

        target = await resolve_parent_branch(task, self.task_service)
        return await self.git_service.pr_merge(
            task.pr_number,
            target=target,
            project_id=task.project_id,
            actor_agent_id=agent.id,
        )

    @classmethod
    def _side_effect_handlers(cls) -> dict[str, _SideEffectHandler]:
        return {
            "push_branch": cls._do_push_branch,
            "create_pr": cls._do_create_pr,
            "create_root_pr": cls._do_create_root_pr,
            "pr_merge": cls._do_pr_merge,
        }
