"""Plan executor for Autopilot (policy-enforced)."""

from __future__ import annotations

import hashlib
import json
import time
from collections import Counter
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

from .contracts import PlanStep, PlanV1, ReceiptChange, ReceiptV1
from .module_action_budget_store import ModuleActionBudgetStore
from .modules import (
    ModuleSafetyManifest,
    ModuleStore,
    ReversibilityContract,
    RiskTierConfig,
    infer_module_write_class_from_tool,
    resolve_module_execution_contract,
)
from .policy import AutonomyPolicy, compute_policy_hash, evaluate_policy
from .tools import AutopilotToolDefinition, ToolRegistry
from .verification import run_postchecks, run_prechecks, run_verification
from .workflow_composer import WorkflowCompositionError, compose_steps


EXECUTOR_VERSION = "0.1.0"


@dataclass(frozen=True)
class _ModuleExecutionContext:
    module_id: str
    manifest: ModuleSafetyManifest
    reversibility: ReversibilityContract
    risk_tiers: RiskTierConfig | None = None


class PlanExecutor:
    def __init__(self, policy: AutonomyPolicy, registry: ToolRegistry, state_dir: Path):
        self.policy = policy
        self.registry = registry
        self.state_dir = Path(state_dir).resolve()
        (self.state_dir / "db").mkdir(parents=True, exist_ok=True)
        self._idempotency_seen: dict[str, set[str]] = {}
        self._sleep = time.sleep
        self._module_store = ModuleStore(self.state_dir)
        self._module_budget_store = ModuleActionBudgetStore(
            self.state_dir / "db" / "module_action_budgets.sqlite"
        )

    def execute(
        self,
        plan: PlanV1,
        approve_token: str | None = None,
        *,
        dry_run: bool = False,
    ) -> ReceiptV1:
        started_at = datetime.now(timezone.utc).isoformat()
        decision_path: list[str] = []
        changes: list[ReceiptChange] = []
        executed_any = False
        denied_any = False
        skipped_any = False
        module_budget_exhausted_any = False
        entity_budget_exhausted_any = False
        workflow_rollback_chain: list[str] = []
        workflow_rollback_results: dict[str, str] = {}

        try:
            composed = compose_steps(plan.steps, registry=self.registry, infer_dependencies=False)
            execution_steps = composed.ordered_steps
            if composed.execution_order != [step.step_id for step in plan.steps]:
                decision_path.append("workflow:execution_reordered")
        except WorkflowCompositionError as exc:
            completed_at = datetime.now(timezone.utc).isoformat()
            verification = run_verification(plan, self.registry, policy=self.policy, state_dir=self.state_dir)
            return ReceiptV1(
                run_id=plan.run_id,
                policy_hash=plan.policy_hash,
                plan_hash=plan.plan_hash,
                executor_version=EXECUTOR_VERSION,
                tool_versions={"g-gremlin": EXECUTOR_VERSION},
                changes=[],
                verification=verification,
                artifacts=[],
                decision_path=[f"workflow:composition_error:{exc}"],
                module_tags=[],
                write_class_tags=[],
                started_at=started_at,
                completed_at=completed_at,
                outcome="failed",
                rollback_chain=[],
                rollback_results={},
            )

        plan.policy_hash = compute_policy_hash(self.policy)
        policy_hash = plan.policy_hash

        total_records = 0
        steps_attempted = 0
        outcome = "success"
        executed_step_ids: set[str] = set()
        executed_steps: list[tuple[PlanStep, dict[str, Any], _ModuleExecutionContext | None]] = []

        for step in execution_steps:
            unmet_dependencies = [dep for dep in step.depends_on if dep not in executed_step_ids]
            if unmet_dependencies:
                skipped_any = True
                decision_path.append(
                    f"step:{step.step_id}:dependency_blocked:{','.join(sorted(unmet_dependencies))}"
                )
                continue

            tool_def = self.registry.get_tool(step.tool)
            decision = evaluate_policy(self.policy, step, tool_def)
            decision_path.extend(decision.decision_path)

            if decision.action == "deny":
                denied_any = True
                decision_path.append(f"step:{step.step_id}:denied")
                continue

            if decision.action == "approval_required":
                if not approve_token:
                    outcome = "approval_required"
                    decision_path.append(f"step:{step.step_id}:approval_required")
                    break
                decision_path.append(f"step:{step.step_id}:approval_granted")

            module_context = self._resolve_module_context(step)
            if module_context is not None and module_context.risk_tiers is not None:
                risk_score = _resolve_step_risk_score(step, plan)
                risk_tier = _classify_risk_tier(risk_score, module_context.risk_tiers)
                decision_path.append(f"step:{step.step_id}:risk_tier:{risk_tier}")
                if risk_tier == "yellow":
                    if not approve_token:
                        outcome = "approval_required"
                        decision_path.append(
                            f"step:{step.step_id}:risk_tier:yellow:approval_required"
                        )
                        break
                elif risk_tier == "red":
                    denied_any = True
                    decision_path.append(f"step:{step.step_id}:risk_tier:red:denied")
                    continue

            record_count = _estimate_record_count(step)
            if record_count > self.policy.defaults.max_bulk_batch:
                outcome = "budget_exceeded"
                decision_path.append("budget:max_bulk_batch")
                break
            if total_records + record_count > self.policy.defaults.max_records_per_run:
                outcome = "budget_exceeded"
                decision_path.append("budget:max_records_per_run")
                break

            step_idempotency_key = step.idempotency_key
            if not step_idempotency_key and module_context is not None:
                step_idempotency_key = _derive_scoped_idempotency_key(
                    step,
                    module_id=module_context.module_id,
                    scope=module_context.manifest.idempotency_scope,
                )
                decision_path.append(
                    f"step:{step.step_id}:idempotency_scope:{module_context.manifest.idempotency_scope}"
                )

            if step_idempotency_key:
                seen = self._idempotency_seen.setdefault(plan.run_id, set())
                if step_idempotency_key in seen:
                    skipped_any = True
                    decision_path.append(f"step:{step.step_id}:idempotent_skip")
                    continue
                seen.add(step_idempotency_key)

            if module_context is not None:
                budget_reason = self._reserve_module_budget(
                    step,
                    module_context,
                    planned_count=record_count,
                    dry_run=dry_run,
                )
                if budget_reason:
                    skipped_any = True
                    module_budget_exhausted_any = True
                    if budget_reason == "entity_budget_exhausted":
                        entity_budget_exhausted_any = True
                    decision_path.append(f"step:{step.step_id}:{budget_reason}")
                    continue

            if steps_attempted >= self.policy.defaults.max_steps_per_run:
                outcome = "budget_exceeded"
                decision_path.append("budget:max_steps_per_run")
                break
            steps_attempted += 1

            if step.diff_preview is not None and step.args.get("simulate_live_mismatch"):
                decision_path.append("precheck:live_state_mismatch")

            prechecks = run_prechecks(step, tool_def)
            _append_check_results(decision_path, step_id=step.step_id, phase="precheck", results=prechecks)
            if any(not check.passed for check in prechecks):
                if executed_steps and not dry_run:
                    workflow_rollback_chain, workflow_rollback_results = self._attempt_workflow_rollback(
                        executed_steps
                    )
                    _append_workflow_rollback_results(
                        decision_path,
                        rollback_chain=workflow_rollback_chain,
                        rollback_results=workflow_rollback_results,
                    )
                outcome = "precheck_failed"
                decision_path.append(f"step:{step.step_id}:precheck_failed")
                break

            try:
                if dry_run:
                    result = self._execute_step_dry_run(step, tool_def)
                else:
                    result = self._execute_with_retry(step, tool_def)
            except Exception as exc:
                partial_successes = _extract_partial_successes(exc)
                if partial_successes:
                    changes.append(
                        ReceiptChange(
                            tool=step.tool,
                            targets=_extract_partial_targets(partial_successes, step),
                            before=step.diff_preview,
                            after={"partial_successes": partial_successes},
                        )
                    )
                    executed_any = True
                    decision_path.append(
                        f"step:{step.step_id}:partial_success:{len(partial_successes)}"
                    )
                if executed_steps and not dry_run:
                    workflow_rollback_chain, workflow_rollback_results = self._attempt_workflow_rollback(
                        executed_steps
                    )
                    _append_workflow_rollback_results(
                        decision_path,
                        rollback_chain=workflow_rollback_chain,
                        rollback_results=workflow_rollback_results,
                    )
                outcome = "partial_failure" if executed_any else "failed"
                decision_path.append(f"step:{step.step_id}:error:{exc}")
                break

            postchecks = run_postchecks(step, tool_def, result=result)
            _append_check_results(decision_path, step_id=step.step_id, phase="postcheck", results=postchecks)
            if any(not check.passed for check in postchecks):
                if executed_steps and not dry_run:
                    workflow_rollback_chain, workflow_rollback_results = self._attempt_workflow_rollback(
                        executed_steps
                    )
                    _append_workflow_rollback_results(
                        decision_path,
                        rollback_chain=workflow_rollback_chain,
                        rollback_results=workflow_rollback_results,
                    )
                outcome = "partial_failure"
                decision_path.append(f"step:{step.step_id}:postcheck_failed")
                break

            targets = result.get("targets", _extract_targets(step))
            before_payload = result.get("before", step.diff_preview)
            after_payload = result.get("after", {"status": "executed"})

            if module_context is not None and not dry_run and module_context.manifest.invariants:
                failed_invariants = _evaluate_invariants(
                    module_context.manifest.invariants,
                    before=before_payload,
                    after=after_payload,
                )
                if failed_invariants:
                    rollback_status = self._attempt_invariant_rollback(
                        step,
                        result,
                        module_context.reversibility,
                    )
                    changes.append(
                        ReceiptChange(
                            tool=step.tool,
                            targets=targets,
                            before=before_payload,
                            after={
                                "status": "invariant_failed",
                                "failed_invariants": failed_invariants,
                                "rollback_status": rollback_status,
                            },
                        )
                    )
                    executed_any = True
                    outcome = "invariant_failed"
                    decision_path.append(
                        f"step:{step.step_id}:invariant_failed:{','.join(failed_invariants)}"
                    )
                    decision_path.append(f"step:{step.step_id}:rollback:{rollback_status}")
                    if executed_steps and not dry_run:
                        workflow_rollback_chain, workflow_rollback_results = self._attempt_workflow_rollback(
                            executed_steps
                        )
                        _append_workflow_rollback_results(
                            decision_path,
                            rollback_chain=workflow_rollback_chain,
                            rollback_results=workflow_rollback_results,
                        )
                    break

            changes.append(
                ReceiptChange(
                    tool=step.tool,
                    targets=targets,
                    before=before_payload,
                    after=after_payload,
                )
            )
            executed_any = True
            executed_step_ids.add(step.step_id)
            executed_steps.append((step, result, module_context))
            total_records += len(targets) if targets else record_count

        if outcome == "success":
            if executed_any:
                outcome = "success"
            elif denied_any:
                outcome = "denied"
            elif entity_budget_exhausted_any:
                outcome = "entity_budget_exhausted"
            elif module_budget_exhausted_any:
                outcome = "budget_exceeded"
            elif skipped_any:
                outcome = "skipped"
            else:
                outcome = "no_op"

        verification = run_verification(plan, self.registry, policy=self.policy, state_dir=self.state_dir)
        module_tags: set[str] = set()
        write_class_tags: set[str] = set()
        for step in execution_steps:
            module_id, write_class = infer_module_write_class_from_tool(step.tool)
            if module_id:
                module_tags.add(module_id)
            if write_class:
                write_class_tags.add(write_class)

        completed_at = datetime.now(timezone.utc).isoformat()
        receipt = ReceiptV1(
            run_id=plan.run_id,
            policy_hash=policy_hash,
            plan_hash=plan.plan_hash,
            executor_version=EXECUTOR_VERSION,
            tool_versions={"g-gremlin": EXECUTOR_VERSION},
            changes=changes,
            verification=verification,
            artifacts=[],
            decision_path=decision_path,
            module_tags=sorted(module_tags),
            write_class_tags=sorted(write_class_tags),
            started_at=started_at,
            completed_at=completed_at,
            outcome=outcome,
            rollback_chain=workflow_rollback_chain,
            rollback_results=workflow_rollback_results,
        )
        return receipt

    def _attempt_workflow_rollback(
        self,
        executed_steps: list[tuple[PlanStep, dict[str, Any], _ModuleExecutionContext | None]],
    ) -> tuple[list[str], dict[str, str]]:
        rollback_chain: list[str] = []
        rollback_results: dict[str, str] = {}
        for step, result, context in reversed(executed_steps):
            if not step.rollback_on_failure:
                continue
            rollback_chain.append(step.step_id)
            if context is None:
                if step.tool == "sfdc.record.update":
                    reversibility = ReversibilityContract(
                        reversible=True,
                        undo_action_type="sfdc.record.update",
                    )
                    rollback_results[step.step_id] = self._attempt_invariant_rollback(
                        step,
                        result,
                        reversibility,
                    )
                else:
                    rollback_results[step.step_id] = "rollback_unavailable:no_module_context"
                continue
            rollback_results[step.step_id] = self._attempt_invariant_rollback(
                step,
                result,
                context.reversibility,
            )
        return rollback_chain, rollback_results

    def _resolve_module_context(self, step: PlanStep) -> _ModuleExecutionContext | None:
        module_id, _write_class = infer_module_write_class_from_tool(step.tool)
        if not module_id:
            return None

        config: dict[str, Any] | None = None
        risk_tiers: RiskTierConfig | None = None
        try:
            envelope = self._module_store.load_module(module_id)
            config = envelope.config
            risk_tiers = envelope.risk_tiers
        except Exception:
            config = None
            risk_tiers = None

        reversibility, manifest = resolve_module_execution_contract(module_id, config)
        return _ModuleExecutionContext(
            module_id=module_id,
            manifest=manifest,
            reversibility=reversibility,
            risk_tiers=risk_tiers,
        )

    def _reserve_module_budget(
        self,
        step: PlanStep,
        context: _ModuleExecutionContext,
        *,
        planned_count: int,
        dry_run: bool,
    ) -> str | None:
        manifest = context.manifest
        if planned_count > manifest.blast_radius_max_records_per_run:
            return "run_budget_exhausted"

        day_key = datetime.now(timezone.utc).date().isoformat()
        entity_keys = _extract_entity_keys(step, expected_count=planned_count)
        entity_counter = Counter(entity_keys)
        planned_total = int(sum(entity_counter.values()))
        daily_used = self._module_budget_store.daily_usage(
            module_id=context.module_id,
            day_key=day_key,
        )
        if daily_used + planned_total > manifest.blast_radius_max_records_per_day:
            return "daily_budget_exhausted"

        for entity_key, count in entity_counter.items():
            used = self._module_budget_store.entity_usage(
                module_id=context.module_id,
                day_key=day_key,
                entity_key=entity_key,
            )
            if used + int(count) > manifest.max_actions_per_entity_per_day:
                return "entity_budget_exhausted"

        if dry_run:
            return None

        for entity_key, count in entity_counter.items():
            for _index in range(int(count)):
                decision = self._module_budget_store.consume(
                    module_id=context.module_id,
                    day_key=day_key,
                    entity_key=entity_key,
                    daily_limit=manifest.blast_radius_max_records_per_day,
                    entity_limit=manifest.max_actions_per_entity_per_day,
                )
                if not decision.allowed:
                    return str(decision.reason or "budget_exhausted")
        return None

    def _attempt_invariant_rollback(
        self,
        step: PlanStep,
        result: dict[str, Any],
        reversibility: ReversibilityContract,
    ) -> str:
        if not reversibility.reversible:
            return "rollback_unavailable:not_reversible"

        if step.tool == "sfdc.record.update":
            return self._rollback_sfdc_record_update(step, result)
        if step.tool == "dealdesk.approval.stamp":
            return self._rollback_sfdc_record_update(step, result)
        if step.tool == "sfdc.record.reassign_owner":
            return self._rollback_sfdc_record_reassign_owner(step, result)
        if step.tool == "user.offboard" and _delegated_tool(result) == "sfdc.record.reassign_owner":
            return self._rollback_sfdc_record_reassign_owner(step, result)
        if step.tool == "sequence.pause":
            return self._rollback_sequence_pause(step, result)

        undo_action = reversibility.undo_action_type or "manual_undo"
        return f"rollback_unavailable:{undo_action}"

    def _rollback_sfdc_record_update(self, step: PlanStep, result: dict[str, Any]) -> str:
        before_payload = result.get("before")
        after_payload = result.get("after")
        rollback_data = after_payload.get("rollback_data") if isinstance(after_payload, dict) else None
        records = rollback_data.get("records") if isinstance(rollback_data, dict) else None
        if not isinstance(records, list):
            if not isinstance(before_payload, dict):
                return "rollback_unavailable:missing_before"
            records = before_payload.get("records")
        if not isinstance(records, list):
            return "rollback_unavailable:missing_before_records"

        sobject = str(
            (rollback_data.get("sobject") if isinstance(rollback_data, dict) else None)
            or step.args.get("sobject")
            or step.args.get("target_sobject")
            or "Opportunity"
        )
        org_alias = step.args.get("org_alias")
        restored = 0
        try:
            from g_gremlin import sfdc as sfdc_helpers

            for snapshot in records:
                if not isinstance(snapshot, dict):
                    continue
                record_id = (
                    snapshot.get("Id")
                    or snapshot.get("id")
                    or snapshot.get("record_id")
                )
                if not record_id:
                    continue

                restore_fields: dict[str, Any] = {}
                for key, value in snapshot.items():
                    key_name = str(key)
                    if key_name.lower() in {"id", "attributes", "record_id"}:
                        continue
                    restore_fields[key_name] = value
                if not restore_fields:
                    continue

                sfdc_helpers.update_record(
                    sobject,
                    str(record_id),
                    restore_fields,
                    org_alias=org_alias,
                )
                restored += 1
        except Exception as exc:
            return f"rollback_failed:{exc}"

        if restored <= 0:
            return "rollback_noop"
        return f"rollback_applied:{restored}"

    def _rollback_sfdc_record_reassign_owner(self, step: PlanStep, result: dict[str, Any]) -> str:
        after_payload = result.get("after")
        rollback_data = after_payload.get("rollback_data") if isinstance(after_payload, dict) else None
        original_owners = rollback_data.get("original_owners") if isinstance(rollback_data, dict) else None
        if not isinstance(original_owners, dict) or not original_owners:
            before_payload = result.get("before")
            if isinstance(before_payload, dict):
                original_owners = before_payload.get("original_owners")
        if not isinstance(original_owners, dict) or not original_owners:
            return "rollback_unavailable:missing_original_owners"

        sobject = str(
            (rollback_data.get("sobject") if isinstance(rollback_data, dict) else None)
            or step.args.get("sobject")
            or step.args.get("target_sobject")
            or "Lead"
        ).strip() or "Lead"
        org_alias = step.args.get("org_alias")
        restored_owners = 0
        errors: list[str] = []

        try:
            from g_gremlin import sfdc as sfdc_helpers
        except Exception as exc:
            return f"rollback_failed:{exc}"

        for record_id, owner_id in original_owners.items():
            normalized_record_id = str(record_id or "").strip()
            normalized_owner_id = str(owner_id or "").strip()
            if not normalized_record_id or not normalized_owner_id:
                errors.append("owner_restore_missing_record_context")
                continue
            try:
                sfdc_helpers.update_record(
                    sobject,
                    normalized_record_id,
                    {"OwnerId": normalized_owner_id},
                    org_alias=org_alias,
                )
                restored_owners += 1
            except Exception as exc:
                errors.append(f"owner_restore:{normalized_record_id}:{exc}")

        if errors:
            if restored_owners:
                return f"rollback_partial:owners={restored_owners}:errors={len(errors)}"
            detail = ";".join(errors[:3])
            return f"rollback_failed:{detail}"
        if restored_owners:
            return f"rollback_applied:owners={restored_owners}"
        return "rollback_noop"

    def _rollback_sequence_pause(self, step: PlanStep, result: dict[str, Any]) -> str:
        after_payload = result.get("after")
        rollback_data = after_payload.get("rollback_data") if isinstance(after_payload, dict) else None
        records = rollback_data.get("records") if isinstance(rollback_data, dict) else None
        if not isinstance(records, list) or not records:
            return "rollback_unavailable:missing_sequence_pause_rollback_data"

        try:
            from g_gremlin import sfdc as sfdc_helpers
        except Exception as exc:
            return f"rollback_failed:{exc}"

        org_alias = step.args.get("org_alias")
        restored_tags = 0
        resumed_states = 0
        errors: list[str] = []

        needs_outreach = any(
            isinstance(entry, dict)
            and isinstance(entry.get("paused_sequence_state_ids"), list)
            and entry.get("paused_sequence_state_ids")
            for entry in records
        )
        outreach_client = None
        if needs_outreach:
            try:
                from . import sfdc_handlers as sfdc_handlers_mod

                outreach_client = sfdc_handlers_mod._load_outreach_client()
            except Exception as exc:
                errors.append(f"outreach_client:{exc}")
                outreach_client = None
            if outreach_client is None:
                errors.append("outreach_unavailable")

        for entry in records:
            if not isinstance(entry, dict):
                continue

            record_id = str(entry.get("record_id") or "").strip()
            sobject = str(entry.get("sobject") or step.args.get("sobject") or "Lead").strip() or "Lead"
            tag_field = str(entry.get("tag_field") or "").strip()
            tag_written = bool(entry.get("tag_written"))

            if tag_written:
                if not record_id or not tag_field:
                    errors.append("tag_restore_missing_record_context")
                else:
                    try:
                        sfdc_helpers.update_record(
                            sobject,
                            record_id,
                            {tag_field: entry.get("original_tag_value")},
                            org_alias=org_alias,
                        )
                        restored_tags += 1
                    except Exception as exc:
                        errors.append(f"tag_restore:{record_id}:{exc}")

            sequence_state_ids = entry.get("paused_sequence_state_ids")
            if not isinstance(sequence_state_ids, list):
                sequence_state_ids = []
            for state_id in sequence_state_ids:
                normalized_state_id = str(state_id or "").strip()
                if not normalized_state_id:
                    continue
                if outreach_client is None:
                    errors.append(f"resume_sequence:{normalized_state_id}:outreach_unavailable")
                    continue
                try:
                    outreach_client.resume_prospect_sequence(normalized_state_id)
                    resumed_states += 1
                except Exception as exc:
                    errors.append(f"resume_sequence:{normalized_state_id}:{exc}")

        if errors:
            if restored_tags or resumed_states:
                return f"rollback_partial:tags={restored_tags}:states={resumed_states}:errors={len(errors)}"
            detail = ";".join(errors[:3])
            return f"rollback_failed:{detail}"
        if restored_tags or resumed_states:
            return f"rollback_applied:tags={restored_tags}:states={resumed_states}"
        return "rollback_noop"

    def _execute_with_retry(
        self,
        step: PlanStep,
        tool_def: AutopilotToolDefinition | None,
    ) -> dict[str, Any]:
        # Max retries is the number of retries after the initial attempt.
        # With 3 retries and delays [1, 2, 4], total attempts = 4.
        max_retries = 3
        delays = [1, 2, 4]
        attempt = 0
        while True:
            attempt += 1
            try:
                return self._execute_step(step, tool_def)
            except Exception as exc:
                if _is_lock_error(exc) and attempt <= max_retries:
                    self._sleep(delays[attempt - 1])
                    continue
                raise

    def _execute_step(
        self,
        step: PlanStep,
        tool_def: AutopilotToolDefinition | None,
    ) -> dict[str, Any]:
        simulated = step.args.get("simulate_error")
        if simulated == "lock":
            raise RuntimeError("UNABLE_TO_LOCK_ROW")
        if simulated == "fail":
            raise RuntimeError("simulated failure")
        handler = self.registry.get_handler(step.tool)
        if handler is None:
            raise RuntimeError(f"No handler registered for tool: {step.tool}")
        return handler(step, tool_def)

    def _execute_step_dry_run(
        self,
        step: PlanStep,
        tool_def: AutopilotToolDefinition | None,
    ) -> dict[str, Any]:
        return {
            "targets": _extract_targets(step),
            "before": step.diff_preview,
            "after": {"status": "dry_run", "tool": step.tool},
        }


def _is_lock_error(exc: Exception) -> bool:
    msg = str(exc).lower()
    return "unable_to_lock_row" in msg or "unable to obtain exclusive access" in msg


def _append_check_results(
    decision_path: list[str],
    *,
    step_id: str,
    phase: str,
    results: list[Any],
) -> None:
    for check in results:
        check_name = str(getattr(check, "check_name", "") or "").strip()
        if not check_name:
            continue
        passed = bool(getattr(check, "passed", False))
        detail = str(getattr(check, "detail", "") or "")
        if passed and detail.startswith("skipped:"):
            if detail == "skipped: dry run result":
                continue
            status = "skip"
        else:
            status = "pass" if passed else "fail"
        decision_path.append(f"step:{step_id}:{phase}:{check_name}:{status}")


def _append_workflow_rollback_results(
    decision_path: list[str],
    *,
    rollback_chain: list[str],
    rollback_results: dict[str, str],
) -> None:
    if not rollback_chain:
        return
    decision_path.append(f"workflow:rollback:{','.join(rollback_chain)}")
    for step_id in rollback_chain:
        result = rollback_results.get(step_id) or "rollback_unknown"
        decision_path.append(f"step:{step_id}:rollback:{result}")


def _delegated_tool(result: dict[str, Any]) -> str | None:
    delegated_to = result.get("delegated_to")
    if isinstance(delegated_to, str) and delegated_to.strip():
        return delegated_to.strip()
    after_payload = result.get("after")
    if isinstance(after_payload, dict):
        nested = after_payload.get("delegated_to")
        if isinstance(nested, str) and nested.strip():
            return nested.strip()
    return None


def _extract_targets(step: PlanStep) -> list[str]:
    args = step.args or {}
    for key in ("record_ids", "user_ids", "targets", "ids"):
        if isinstance(args.get(key), list):
            return [str(x) for x in args.get(key, [])]
    if isinstance(args.get("records"), list):
        return [str(row.get("record_id")) for row in args.get("records", []) if row.get("record_id")]
    return []


def _estimate_record_count(step: PlanStep) -> int:
    args = step.args or {}
    for key in ("record_ids", "user_ids", "targets", "ids"):
        if isinstance(args.get(key), list):
            return len(args.get(key))
    if isinstance(args.get("records"), list):
        return len(args.get("records"))
    count = args.get("count")
    if count is not None:
        try:
            return int(count)
        except Exception:
            return 1
    return 1


def _resolve_step_risk_score(step: PlanStep, plan: PlanV1) -> int:
    args = step.args or {}
    if "risk_score" in args:
        return _normalize_risk_score(args.get("risk_score"), default=0)
    return _normalize_risk_score(getattr(plan.risk, "max_risk_level", 0), default=0)


def _normalize_risk_score(value: Any, *, default: int) -> int:
    try:
        numeric = int(float(value))
    except Exception:
        numeric = default
    if numeric < 0:
        return 0
    if numeric > 100:
        return 100
    return numeric


def _classify_risk_tier(risk_score: int, risk_tiers: RiskTierConfig) -> str:
    if risk_score <= risk_tiers.green_max_score:
        return "green"
    if risk_score <= risk_tiers.yellow_max_score:
        return "yellow"
    if risk_score >= risk_tiers.red_min_score:
        return "red"
    # Fail closed for malformed threshold windows.
    return "red"


def _extract_entity_keys(step: PlanStep, *, expected_count: int) -> list[str]:
    args = step.args or {}
    rows = args.get("records")
    if isinstance(rows, list):
        keys = [
            _extract_entity_key_from_row(row)
            for row in rows
            if isinstance(row, dict)
        ]
        normalized = [key for key in keys if key]
        if normalized:
            return normalized

    targets = _extract_targets(step)
    if targets:
        return [f"record:{target.strip().lower()}" for target in targets if str(target).strip()]

    fallback_count = max(1, int(expected_count or 1))
    return [f"entity:unknown:{step.tool}"] * fallback_count


def _extract_entity_key_from_row(row: dict[str, Any]) -> str:
    fields = row.get("fields") if isinstance(row.get("fields"), dict) else {}
    candidates: list[tuple[str, Any]] = [
        ("entity", row.get("entity_id")),
        ("entity", fields.get("entity_id") if isinstance(fields, dict) else None),
        ("entity", fields.get("entityId") if isinstance(fields, dict) else None),
        ("entity", fields.get("canonical_entity_id") if isinstance(fields, dict) else None),
        ("email", fields.get("email") if isinstance(fields, dict) else None),
        ("email", fields.get("Email") if isinstance(fields, dict) else None),
        ("record", row.get("record_id")),
    ]
    for kind, value in candidates:
        if value in (None, ""):
            continue
        normalized = str(value).strip()
        if not normalized:
            continue
        if kind == "email":
            return f"email:{normalized.lower()}"
        if kind == "record":
            return f"record:{normalized.lower()}"
        return f"entity:{normalized.lower()}"
    return ""


def _derive_scoped_idempotency_key(step: PlanStep, *, module_id: str, scope: str) -> str:
    tokens = [token.strip() for token in str(scope or "").split("+") if token.strip()]
    if not tokens:
        tokens = ["record_id", "tool", "date"]
    payload: dict[str, Any] = {}
    for token in tokens:
        payload[token] = _resolve_scope_token(step, token)
    digest = hashlib.sha256(_canonical_json(payload).encode("utf-8")).hexdigest()
    return f"{module_id}:{digest}"


def _resolve_scope_token(step: PlanStep, token: str) -> Any:
    args = step.args or {}
    details = args.get("action_details") if isinstance(args.get("action_details"), dict) else {}
    token_key = token.strip()
    token_key_lower = token_key.lower()

    if token_key_lower == "date":
        return datetime.now(timezone.utc).date().isoformat()
    if token_key_lower == "tool":
        return step.tool
    if token_key_lower == "step_id":
        return step.step_id

    if token_key_lower == "record_id":
        direct = args.get("record_id")
        if direct not in (None, ""):
            return str(direct)
        targets = _extract_targets(step)
        if targets:
            return targets[0]

    if token_key_lower == "sequence_id":
        for key in ("sequence_id", "sequence_state_id"):
            value = args.get(key) if key in args else details.get(key)
            if value not in (None, ""):
                return _normalize_scope_value(value)
        rows = args.get("records")
        if isinstance(rows, list):
            for row in rows:
                if not isinstance(row, dict):
                    continue
                fields = row.get("fields") if isinstance(row.get("fields"), dict) else {}
                for key in ("sequence_id", "sequence_state_id", "sequence_state_ids", "pause_sequence_state_ids"):
                    value = fields.get(key)
                    if value not in (None, ""):
                        return _normalize_scope_value(value)

    if token_key_lower == "reason_code":
        value = args.get("reason_code")
        if value in (None, ""):
            value = details.get("reason_code")
        if value not in (None, ""):
            return str(value)

    if token_key_lower in {"former_owner_id", "old_owner_id", "previous_owner_id"}:
        for key in ("former_owner_id", "old_owner_id", "previous_owner_id", "owner_id"):
            value = args.get(key) if key in args else details.get(key)
            if value not in (None, ""):
                return _normalize_scope_value(value)
        rows = args.get("records")
        if isinstance(rows, list):
            owners: list[str] = []
            for row in rows:
                if not isinstance(row, dict):
                    continue
                fields = row.get("fields") if isinstance(row.get("fields"), dict) else {}
                owner = None
                if isinstance(fields, dict):
                    owner = fields.get("OwnerId") or fields.get("owner_id")
                if owner in (None, ""):
                    owner = row.get("owner_id")
                if owner in (None, ""):
                    continue
                normalized = str(owner).strip()
                if normalized and normalized not in owners:
                    owners.append(normalized)
            if owners:
                return owners

    if token_key in args:
        return _normalize_scope_value(args[token_key])
    if token_key in details:
        return _normalize_scope_value(details[token_key])

    return ""


def _normalize_scope_value(value: Any) -> Any:
    if isinstance(value, list):
        cleaned = [str(item).strip() for item in value if str(item).strip()]
        return cleaned
    if isinstance(value, dict):
        return value
    if value is None:
        return ""
    return str(value).strip()


def _evaluate_invariants(
    invariants: list[Any],
    *,
    before: Any,
    after: Any,
) -> list[str]:
    pairs = _build_invariant_pairs(before, after)
    if not pairs:
        return [getattr(invariant, "name", "invalid_invariant") for invariant in invariants]

    failed: list[str] = []
    for invariant in invariants:
        name = str(getattr(invariant, "name", "unnamed")).strip() or "unnamed"
        check = str(getattr(invariant, "check", "")).strip().lower()
        params = getattr(invariant, "params", {}) or {}
        passed = True
        for before_row, after_row in pairs:
            if not _evaluate_single_invariant(check, params, before_row, after_row):
                passed = False
                break
        if not passed:
            failed.append(name)
    return failed


def _build_invariant_pairs(before: Any, after: Any) -> list[tuple[dict[str, Any], dict[str, Any]]]:
    if isinstance(after, dict) and isinstance(after.get("records"), list):
        after_rows = [row for row in after["records"] if isinstance(row, dict)]
        if not after_rows:
            return []
        before_rows: list[dict[str, Any]] = []
        if isinstance(before, dict) and isinstance(before.get("records"), list):
            before_rows = [row for row in before["records"] if isinstance(row, dict)]
        before_by_key = {_record_key(row): row for row in before_rows if _record_key(row)}
        pairs: list[tuple[dict[str, Any], dict[str, Any]]] = []
        for after_row in after_rows:
            key = _record_key(after_row)
            pairs.append((before_by_key.get(key, {}), after_row))
        return pairs

    if isinstance(after, dict):
        before_row = before if isinstance(before, dict) else {}
        return [(before_row, after)]
    return []


def _record_key(payload: dict[str, Any]) -> str:
    for key in ("record_id", "Id", "id"):
        value = payload.get(key)
        if value not in (None, ""):
            return str(value).strip()
    return ""


def _evaluate_single_invariant(
    check: str,
    params: dict[str, Any],
    before_row: dict[str, Any],
    after_row: dict[str, Any],
) -> bool:
    field = str(params.get("field") or "").strip()
    if check == "field_not_null":
        value = _get_field_value(after_row, field)
        return value not in (None, "")

    if check == "field_in":
        allowed = params.get("values")
        if not isinstance(allowed, list):
            return False
        value = _get_field_value(after_row, field)
        allowed_values = {str(item) for item in allowed}
        return str(value) in allowed_values

    if check == "field_equals":
        expected = params.get("value")
        value = _get_field_value(after_row, field)
        return value == expected

    if check == "field_changed":
        before_value = _get_field_value(before_row, field)
        after_value = _get_field_value(after_row, field)
        return before_value != after_value

    if check == "tag_present":
        tag_prefix = str(params.get("tag_prefix") or "").strip()
        if not tag_prefix:
            return False
        for value in _collect_strings(after_row):
            if value.startswith(tag_prefix):
                return True
        return False

    return False


def _get_field_value(payload: dict[str, Any], field: str) -> Any:
    if not field:
        return None
    parts = [part for part in field.split(".") if part]
    current: Any = payload
    for part in parts:
        if not isinstance(current, dict):
            return None
        if part in current:
            current = current.get(part)
            continue
        lower = part.lower()
        match_key = next((key for key in current.keys() if str(key).lower() == lower), None)
        if match_key is None:
            return None
        current = current.get(match_key)
    return current


def _collect_strings(value: Any) -> list[str]:
    strings: list[str] = []
    if isinstance(value, str):
        cleaned = value.strip()
        if cleaned:
            strings.append(cleaned)
    elif isinstance(value, dict):
        for item in value.values():
            strings.extend(_collect_strings(item))
    elif isinstance(value, list):
        for item in value:
            strings.extend(_collect_strings(item))
    return strings


def _extract_partial_successes(exc: Exception) -> list[dict[str, Any]]:
    successes = getattr(exc, "successes", None)
    if not successes:
        return []
    if isinstance(successes, list):
        return [item for item in successes if isinstance(item, dict)]
    return []


def _extract_partial_targets(successes: list[dict[str, Any]], step: PlanStep) -> list[str]:
    targets: list[str] = []
    for item in successes:
        user_id = item.get("user_id")
        perm_id = item.get("permission_set_id") or item.get("permission_set")
        if user_id and perm_id:
            targets.append(f"{user_id}:{perm_id}")
        elif user_id:
            targets.append(str(user_id))
    if targets:
        return targets
    return _extract_targets(step)


def _canonical_json(payload: Any) -> str:
    return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True)


__all__ = ["PlanExecutor", "EXECUTOR_VERSION"]
