"""Shared tool-call execution helper (emits tool_call_* events).

This module is used by both the main runtime loop (single tool calls) and
parallel plan execution, so frontends receive consistent live tool telemetry.
"""

from __future__ import annotations

import asyncio
import hashlib
import json
from collections.abc import Mapping, MutableMapping
from dataclasses import dataclass
from typing import Any

from pydantic import BaseModel, ValidationError

from ..rich_output.tools import (
    RICH_OUTPUT_COMPONENT_TOOL_NAMES,
    RICH_OUTPUT_RENDER_TOOL_NAMES,
    get_render_tool_component_name,
)
from ..steering import SteeringCancelled, SteeringInbox
from . import prompts
from .llm import _redact_artifacts
from .models import PlannerEvent, PlannerPause
from .pause import _PlannerPauseSignal
from .planner_context import _PlannerContext
from .react_utils import _safe_json_dumps
from .trajectory import Trajectory

_TASK_SERVICE_KEY = "task_service"
_PROACTIVE_HOPS_KEY = "proactive_hops_remaining"


def _dedupe_key(value: Any) -> str:
    try:
        canonical = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
    except Exception:
        canonical = str(value)
    return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]


def _component_tool_dedupe_key(tool_name: str, args_payload: Mapping[str, Any]) -> str:
    return _dedupe_key({"tool": tool_name, "payload": args_payload})


@dataclass(slots=True)
class ToolCallOutcome:
    """Result of executing a tool call with validation and telemetry."""

    observation: dict[str, Any] | None = None
    llm_observation: dict[str, Any] | None = None
    error: str | None = None
    failure: Mapping[str, Any] | None = None
    pause: PlannerPause | None = None
    streams: Mapping[str, list[dict[str, Any]]] | None = None
    background_spawned: bool = False


async def execute_tool_call(
    planner: Any,
    *,
    trajectory: Trajectory,
    spec: Any,
    parsed_args: BaseModel,
    tool_call_id: str,
    action_seq: int,
    step_index: int,
    artifact_collector: Any | None,
    source_collector: Any | None,
    tool_context_update: Mapping[str, Any] | None = None,
    artifact_metadata_extra: Mapping[str, Any] | None = None,
    steering: SteeringInbox | None = None,
) -> ToolCallOutcome:
    """Execute a tool call and emit tool_call_* events.

    This intentionally does not mutate trajectory.steps; callers decide how the
    outcome is recorded (single-step runtime vs. parallel plan aggregation).
    """

    try:
        args_payload: dict[str, Any] = parsed_args.model_dump(mode="json")
    except Exception:  # pragma: no cover - defensive
        args_payload = parsed_args.model_dump()
    args_json = _safe_json_dumps(args_payload)

    planner._emit_event(
        PlannerEvent(
            event_type="tool_call_start",
            ts=planner._time_source(),
            trajectory_step=len(trajectory.steps),
            extra={
                "tool_call_id": tool_call_id,
                "tool_name": spec.name,
                "args_json": args_json,
                "action_seq": action_seq,
            },
        )
    )
    planner._emit_event(
        PlannerEvent(
            event_type="tool_call_end",
            ts=planner._time_source(),
            trajectory_step=len(trajectory.steps),
            extra={
                "tool_call_id": tool_call_id,
                "tool_name": spec.name,
                "action_seq": action_seq,
            },
        )
    )

    if planner._guardrails_enabled():
        decision = await planner._evaluate_guardrail(
            planner._build_guardrail_event(
                event_type="tool_call_start",
                tool_name=spec.name,
                tool_args=args_payload,
                payload={"tool_call_id": tool_call_id, "action_seq": action_seq},
                trajectory=trajectory,
            )
        )
        if decision and planner._guardrails_enforced():
            if decision.action.value == "STOP":
                failure_payload = {"guardrail": planner._guardrail_decision_payload(decision)}
                result_json = _safe_json_dumps({"error": decision.reason, "failure": failure_payload})
                planner._emit_event(
                    PlannerEvent(
                        event_type="tool_call_result",
                        ts=planner._time_source(),
                        trajectory_step=len(trajectory.steps),
                        extra={
                            "tool_call_id": tool_call_id,
                            "tool_name": spec.name,
                            "result_json": result_json,
                            "action_seq": action_seq,
                        },
                    )
                )
                return ToolCallOutcome(
                    error=f"guardrail_stop:{decision.rule_id}",
                    failure=failure_payload,
                    streams=None,
                )
            if decision.action.value == "PAUSE":
                pause_payload = {"guardrail": planner._guardrail_decision_payload(decision)}
                pause = await planner._pause_from_context(
                    "approval_required",
                    pause_payload,
                    trajectory,
                )
                return ToolCallOutcome(
                    pause=pause,
                    streams=None,
                )

    ctx = _PlannerContext(planner, trajectory)
    if tool_context_update and isinstance(ctx.tool_context, MutableMapping):
        ctx.tool_context.update(dict(tool_context_update))
    if isinstance(ctx.tool_context, MutableMapping):
        ctx.tool_context["_current_tool_name"] = spec.name
        ctx.tool_context["_current_tool_call_id"] = tool_call_id

    # Prevent "render_component" loops where the model re-renders the exact same UI payload in
    # the next step because it can't infer success from a minimal {"ok": true} observation.
    if spec.name in RICH_OUTPUT_COMPONENT_TOOL_NAMES and isinstance(trajectory.metadata, MutableMapping):
        dedupe = _component_tool_dedupe_key(spec.name, args_payload)
        last_key = trajectory.metadata.get("_render_component_last_dedupe_key")
        last_step = trajectory.metadata.get("_render_component_last_step_index")
        if (
            isinstance(last_key, str)
            and last_key == dedupe
            and isinstance(last_step, int)
            and last_step == step_index - 1
        ):
            component = args_payload.get("component")
            if not isinstance(component, str):
                component = get_render_tool_component_name(spec.name)
            last_ref = trajectory.metadata.get("_render_component_last_artifact_ref")
            skipped_kind = "duplicate_render" if spec.name in RICH_OUTPUT_RENDER_TOOL_NAMES else "duplicate_build"
            summary = (
                "Duplicate render skipped"
                if spec.name in RICH_OUTPUT_RENDER_TOOL_NAMES
                else "Duplicate build skipped"
            )
            result_payload = {
                "ok": True,
                "component": component if isinstance(component, str) else None,
                "artifact_ref": last_ref if isinstance(last_ref, str) else None,
                "dedupe_key": dedupe,
                "summary": summary,
                "skipped": skipped_kind,
            }
            result_json = _safe_json_dumps(result_payload)
            planner._emit_event(
                PlannerEvent(
                    event_type="tool_call_result",
                    ts=planner._time_source(),
                    trajectory_step=len(trajectory.steps),
                    extra={
                        "tool_call_id": tool_call_id,
                        "tool_name": spec.name,
                        "result_json": result_json,
                        "action_seq": action_seq,
                    },
                )
            )
            planner._record_hint_progress(spec.name, trajectory)
            return ToolCallOutcome(
                observation=result_payload,
                llm_observation=result_payload,
                streams=ctx._collect_chunks() or None,
            )

    try:
        extra = spec.extra if isinstance(spec.extra, Mapping) else {}
        background_cfg = extra.get("background") if isinstance(extra, Mapping) else None
        background_allowed = bool(
            getattr(
                getattr(planner, "_background_tasks", None),
                "allow_tool_background",
                False,
            )
        )
        proactive_hops = None
        if isinstance(ctx.tool_context, Mapping):
            value = ctx.tool_context.get(_PROACTIVE_HOPS_KEY)
            if value is not None:
                try:
                    proactive_hops = int(value)
                except (TypeError, ValueError):
                    proactive_hops = None
        if proactive_hops is not None and proactive_hops <= 0:
            background_allowed = False
        if background_allowed and isinstance(background_cfg, Mapping) and background_cfg.get("enabled") is True:
            service = ctx.tool_context.get(_TASK_SERVICE_KEY)
            if service is not None:
                session_id = ctx.tool_context.get("session_id")
                parent_task_id = ctx.tool_context.get("task_id")
                if isinstance(session_id, str):
                    from penguiflow.sessions.models import MergeStrategy

                    mode = background_cfg.get("mode") if isinstance(background_cfg, Mapping) else None
                    mode_value = str(mode).lower().strip() if mode is not None else "job"
                    merge_raw = background_cfg.get("default_merge_strategy")
                    merge_value = (
                        str(merge_raw).lower().strip() if merge_raw is not None else MergeStrategy.HUMAN_GATED.value
                    )
                    merge_strategy = {
                        "append": MergeStrategy.APPEND,
                        "replace": MergeStrategy.REPLACE,
                        "human_gated": MergeStrategy.HUMAN_GATED,
                        "human-gated": MergeStrategy.HUMAN_GATED,
                        "human": MergeStrategy.HUMAN_GATED,
                    }.get(merge_value, MergeStrategy.HUMAN_GATED)
                    notify_on_complete = background_cfg.get("notify_on_complete", True) is not False

                    context_tool_context = {_PROACTIVE_HOPS_KEY: proactive_hops} if proactive_hops is not None else None
                    if mode_value == "subagent":
                        tool_query = (
                            f"Run tool {spec.name} with args {args_json}. Return the tool output and a brief digest."
                        )
                        spawned = await service.spawn(
                            session_id=session_id,
                            query=tool_query,
                            parent_task_id=parent_task_id if isinstance(parent_task_id, str) else None,
                            priority=0,
                            merge_strategy=merge_strategy,
                            propagate_on_cancel="cascade",
                            notify_on_complete=notify_on_complete,
                            context_depth="full",
                            context_tool_context=context_tool_context,
                        )
                    else:
                        spawned = await service.spawn_tool_job(
                            session_id=session_id,
                            tool_name=spec.name,
                            tool_args=args_payload,
                            parent_task_id=parent_task_id if isinstance(parent_task_id, str) else None,
                            priority=0,
                            merge_strategy=merge_strategy,
                            propagate_on_cancel="cascade",
                            notify_on_complete=notify_on_complete,
                            context_tool_context=context_tool_context,
                        )
                    from .models import BackgroundTaskHandle

                    status_obj = getattr(spawned, "status", None)
                    status_value = getattr(status_obj, "value", None)
                    status = status_value if status_value is not None else status_obj
                    handle = BackgroundTaskHandle(
                        task_id=str(getattr(spawned, "task_id", "")),
                        status=str(status or "PENDING"),
                        message=f"spawned:{mode_value}",
                    )
                    observation_json = handle.model_dump(mode="json")
                    result_json = _safe_json_dumps(observation_json)
                    planner._emit_event(
                        PlannerEvent(
                            event_type="tool_call_result",
                            ts=planner._time_source(),
                            trajectory_step=len(trajectory.steps),
                            extra={
                                "tool_call_id": tool_call_id,
                                "tool_name": spec.name,
                                "result_json": result_json,
                                "action_seq": action_seq,
                            },
                        )
                    )
                    return ToolCallOutcome(
                        observation=observation_json,
                        llm_observation=observation_json,
                        streams=ctx._collect_chunks() or None,
                        background_spawned=True,
                    )

        if steering is not None:
            tool_task = asyncio.create_task(spec.node.func(parsed_args, ctx))
            cancel_task = asyncio.create_task(steering.cancel_event.wait())
            done, _pending = await asyncio.wait(
                {tool_task, cancel_task},
                return_when=asyncio.FIRST_COMPLETED,
            )
            if cancel_task in done and steering.cancelled:
                tool_task.cancel()
                await asyncio.gather(tool_task, return_exceptions=True)
                raise SteeringCancelled(steering.cancel_reason)
            cancel_task.cancel()
            await asyncio.gather(cancel_task, return_exceptions=True)
            result = await tool_task
        else:
            result = await spec.node.func(parsed_args, ctx)
    except _PlannerPauseSignal as signal:
        result_json = _safe_json_dumps({"pause": signal.pause.reason, "payload": dict(signal.pause.payload)})
        planner._emit_event(
            PlannerEvent(
                event_type="tool_call_result",
                ts=planner._time_source(),
                trajectory_step=len(trajectory.steps),
                extra={
                    "tool_call_id": tool_call_id,
                    "tool_name": spec.name,
                    "result_json": result_json,
                    "action_seq": action_seq,
                },
            )
        )
        return ToolCallOutcome(
            pause=signal.pause,
            streams=ctx._collect_chunks() or None,
        )
    except Exception as exc:
        if spec.name in RICH_OUTPUT_COMPONENT_TOOL_NAMES:
            try:
                count = int(getattr(planner, "_render_component_failure_history_count", 0))
            except Exception:
                count = 0
            try:
                planner._render_component_failure_history_count = count + 1
            except Exception:
                pass
        failure_payload = planner._build_failure_payload(spec, parsed_args, exc)
        error = f"tool '{spec.name}' raised {exc.__class__.__name__}: {exc}"
        planner._emit_event(
            PlannerEvent(
                event_type="tool_call_result",
                ts=planner._time_source(),
                trajectory_step=len(trajectory.steps),
                extra={
                    "tool_call_id": tool_call_id,
                    "tool_name": spec.name,
                    "result_json": _safe_json_dumps({"error": error, "failure": failure_payload}),
                    "action_seq": action_seq,
                },
            )
        )
        return ToolCallOutcome(
            error=error,
            failure=failure_payload,
            streams=ctx._collect_chunks() or None,
        )

    try:
        observation_model = spec.out_model.model_validate(result)
    except ValidationError as exc:
        error = prompts.render_output_validation_error(
            spec.name,
            json.dumps(exc.errors(), ensure_ascii=False),
        )
        planner._emit_event(
            PlannerEvent(
                event_type="tool_call_result",
                ts=planner._time_source(),
                trajectory_step=len(trajectory.steps),
                extra={
                    "tool_call_id": tool_call_id,
                    "tool_name": spec.name,
                    "result_json": _safe_json_dumps({"error": error}),
                    "action_seq": action_seq,
                },
            )
        )
        return ToolCallOutcome(
            error=error,
            streams=ctx._collect_chunks() or None,
        )

    observation_json = observation_model.model_dump(mode="json")

    if spec.name in RICH_OUTPUT_COMPONENT_TOOL_NAMES and isinstance(trajectory.metadata, MutableMapping):
        key = observation_json.get("dedupe_key")
        if not isinstance(key, str):
            key = _component_tool_dedupe_key(spec.name, args_payload)
        ref = observation_json.get("artifact_ref")
        if not isinstance(ref, str):
            ref = None
        trajectory.metadata["_render_component_last_dedupe_key"] = key
        trajectory.metadata["_render_component_last_step_index"] = step_index
        trajectory.metadata["_render_component_last_artifact_ref"] = ref

    registry = getattr(planner, "_artifact_registry", None)
    if registry is not None:
        registry.register_tool_artifacts(
            spec.name,
            spec.out_model,
            observation_json,
            step_index=step_index,
            metadata_extra=dict(artifact_metadata_extra) if artifact_metadata_extra else None,
        )
        if isinstance(trajectory.metadata, MutableMapping):
            registry.write_snapshot(trajectory.metadata)

    observation_json, was_clamped = await planner._clamp_observation(
        observation_json,
        spec.name,
        step_index,
    )

    if artifact_collector is not None:
        artifact_collector.collect(spec.name, spec.out_model, observation_json)
    if source_collector is not None:
        source_collector.collect(spec.out_model, observation_json)

    llm_obs = observation_json if was_clamped else _redact_artifacts(spec.out_model, observation_json)
    result_json = _safe_json_dumps(llm_obs)
    if planner._guardrails_enabled():
        decision = await planner._evaluate_guardrail(
            planner._build_guardrail_event(
                event_type="tool_call_result",
                text_content=result_json,
                tool_name=spec.name,
                tool_args=args_payload,
                payload={"tool_call_id": tool_call_id, "action_seq": action_seq},
                trajectory=trajectory,
            )
        )
        if decision and planner._guardrails_enforced():
            if decision.action.value == "STOP":
                failure_payload = {"guardrail": planner._guardrail_decision_payload(decision)}
                error = f"guardrail_stop:{decision.rule_id}"
                planner._emit_event(
                    PlannerEvent(
                        event_type="tool_call_result",
                        ts=planner._time_source(),
                        trajectory_step=len(trajectory.steps),
                        extra={
                            "tool_call_id": tool_call_id,
                            "tool_name": spec.name,
                            "result_json": _safe_json_dumps({"error": error, "failure": failure_payload}),
                            "action_seq": action_seq,
                        },
                    )
                )
                return ToolCallOutcome(
                    error=error,
                    failure=failure_payload,
                    streams=ctx._collect_chunks() or None,
                )
            if decision.action.value == "PAUSE":
                pause_payload = {"guardrail": planner._guardrail_decision_payload(decision)}
                pause = await planner._pause_from_context(
                    "approval_required",
                    pause_payload,
                    trajectory,
                )
                return ToolCallOutcome(
                    pause=pause,
                    streams=ctx._collect_chunks() or None,
                )
            if decision.action.value == "REDACT" and decision.redactions:
                from .guardrails.utils import apply_redactions_to_text

                redacted_json = apply_redactions_to_text(result_json, decision.redactions)
                result_json = redacted_json
                try:
                    redacted_payload = json.loads(redacted_json)
                    llm_obs = redacted_payload
                    observation_json = redacted_payload
                except json.JSONDecodeError:
                    pass
    planner._emit_event(
        PlannerEvent(
            event_type="tool_call_result",
            ts=planner._time_source(),
            trajectory_step=len(trajectory.steps),
            extra={
                "tool_call_id": tool_call_id,
                "tool_name": spec.name,
                "result_json": result_json,
                "action_seq": action_seq,
            },
        )
    )
    planner._record_hint_progress(spec.name, trajectory)

    return ToolCallOutcome(
        observation=observation_json,
        llm_observation=llm_obs,
        streams=ctx._collect_chunks() or None,
    )
