from __future__ import annotations

import json
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Dict, Iterable, Mapping, MutableMapping, Optional, Sequence
from urllib.parse import urlparse

from ..activity import extract_resources


def utc_now_iso() -> str:
    return datetime.now(tz=timezone.utc).isoformat()


def safe_text(value: Any, *, max_chars: int = 1200) -> str:
    if value is None:
        return ""
    if isinstance(value, str):
        text = value
    else:
        try:
            text = json.dumps(value, ensure_ascii=False)
        except Exception:
            text = str(value)
    if len(text) <= max_chars:
        return text
    return text[:max_chars].rstrip() + "..."


def safe_json(value: Any, *, max_chars: int = 1200) -> Any:
    if value is None or isinstance(value, (str, int, float, bool)):
        return value
    if isinstance(value, Mapping):
        return {str(k): safe_json(v, max_chars=max_chars) for k, v in value.items()}
    if isinstance(value, (list, tuple, set)):
        return [safe_json(v, max_chars=max_chars) for v in value]
    if hasattr(value, "model_dump"):
        try:
            return safe_json(value.model_dump(), max_chars=max_chars)
        except Exception:
            pass
    if hasattr(value, "dict"):
        try:
            return safe_json(value.dict(), max_chars=max_chars)
        except Exception:
            pass
    return safe_text(value, max_chars=max_chars)


def extract_message_content(message: Any) -> Any:
    content = getattr(message, "content", None)
    if content is None and isinstance(message, Mapping):
        content = message.get("content")
    if content is None and isinstance(message, str):
        content = message
    return content


def extract_message_role(message: Any) -> Optional[str]:
    role = getattr(message, "role", None)
    if role is None and isinstance(message, Mapping):
        role = message.get("role") or message.get("type")
    return str(role) if role else None


def normalize_messages(
    messages: Any,
    *,
    max_messages: int = 5,
    max_chars: int = 400,
) -> Optional[list[str]]:
    if not isinstance(messages, Sequence) or isinstance(messages, (str, bytes, bytearray)):
        return None
    preview: list[str] = []
    for message in list(messages)[-max_messages:]:
        content = extract_message_content(message)
        if content is None:
            content = message
        text = safe_text(content, max_chars=max_chars)
        if text:
            preview.append(text)
    return preview or None


def compute_context_coverage(decision_context: Mapping[str, Any] | None) -> Dict[str, bool]:
    if not isinstance(decision_context, Mapping):
        return {
            "has_user_intent": False,
            "has_assistant_rationale": False,
            "has_routing_choice": False,
            "has_tool_action_intent": False,
            "has_resource_targets": False,
        }
    return {
        "has_user_intent": bool(decision_context.get("user_intent")),
        "has_assistant_rationale": bool(decision_context.get("assistant_rationale")),
        "has_routing_choice": bool(decision_context.get("routing_choice")),
        "has_tool_action_intent": bool(decision_context.get("tool_action_intent")),
        "has_resource_targets": bool(decision_context.get("resource_targets")),
    }


def build_transport_status(
    *,
    ok: bool = True,
    error: str | None = None,
    fallback: bool = False,
) -> Dict[str, Any]:
    return {
        "transport_ok": ok,
        "transport_error": error,
        "used_fallback": fallback,
    }


def _first_string(*values: Any) -> Optional[str]:
    for value in values:
        if value is None:
            continue
        text = str(value).strip()
        if text:
            return text
    return None


def _extract_domain(url: str) -> str:
    if not url:
        return ""
    try:
        return urlparse(url).netloc
    except Exception:
        return ""


def _extract_path(url: str) -> str:
    if not url:
        return ""
    try:
        return urlparse(url).path
    except Exception:
        return ""


def _extract_key_prefix(key: str) -> str:
    if not key or "/" not in key:
        return ""
    return key.rsplit("/", 1)[0] + "/"


def _candidate_mappings(params: Mapping[str, Any]) -> list[Mapping[str, Any]]:
    candidates: list[Mapping[str, Any]] = [params]
    kwargs = params.get("kwargs")
    if isinstance(kwargs, Mapping):
        candidates.append(kwargs)
    args = params.get("args")
    if isinstance(args, Sequence) and not isinstance(args, (str, bytes, bytearray)):
        for arg in args:
            if isinstance(arg, Mapping):
                candidates.append(arg)
            else:
                normalized = safe_json(arg)
                if isinstance(normalized, Mapping):
                    candidates.append(normalized)
    return candidates


def _dedupe_targets(targets: Iterable[Any]) -> list[Dict[str, Any]]:
    result: list[Dict[str, Any]] = []
    seen: set[str] = set()
    for target in targets:
        normalized = safe_json(target)
        if not isinstance(normalized, Mapping):
            continue
        key = json.dumps(normalized, sort_keys=True, ensure_ascii=False)
        if key in seen:
            continue
        seen.add(key)
        result.append(dict(normalized))
    return result


def extract_resource_targets(
    action: str,
    params: Mapping[str, Any] | None,
    *,
    resources: Optional[Sequence[Mapping[str, Any]]] = None,
) -> list[Dict[str, Any]]:
    normalized_params = dict(safe_json(params or {}) or {})
    targets: list[Any] = []
    if resources:
        targets.extend(resources)
    else:
        targets.extend(extract_resources(action, normalized_params))

    for candidate in _candidate_mappings(normalized_params):
        url = _first_string(candidate.get("url"), candidate.get("uri"))
        if url:
            targets.append(
                {
                    "type": "http",
                    "url": url,
                    "method": _first_string(candidate.get("method"), "GET"),
                    "domain": _extract_domain(url),
                    "path": _extract_path(url),
                }
            )
        bucket = _first_string(candidate.get("bucket"))
        if bucket:
            key = _first_string(candidate.get("key"), "*") or "*"
            targets.append(
                {
                    "type": "s3",
                    "bucket": bucket,
                    "key": key,
                    "key_prefix": _extract_key_prefix(key),
                }
            )
        if any(candidate.get(key) is not None for key in ("query", "table", "query_id")):
            query = safe_text(candidate.get("query"), max_chars=300).upper()
            if query.startswith("SELECT"):
                operation = "read"
            elif query.startswith(("INSERT", "UPDATE", "DELETE")):
                operation = "write"
            elif query.startswith(("CREATE", "DROP", "ALTER")):
                operation = "admin"
            else:
                operation = "unknown"
            targets.append(
                {
                    "type": "database",
                    "query_id": candidate.get("query_id"),
                    "table": candidate.get("table"),
                    "operation": operation,
                }
            )
        service = _first_string(candidate.get("service"))
        if service:
            targets.append(
                {
                    "type": "api",
                    "service": service,
                    "path": candidate.get("path"),
                    "method": _first_string(candidate.get("method"), "GET"),
                }
            )
        amount = candidate.get("amount")
        if amount is not None:
            targets.append(
                {
                    "type": "payment",
                    "amount": amount,
                    "currency": _first_string(candidate.get("currency"), "USD") or "USD",
                }
            )
        account_id = _first_string(candidate.get("account_id"), candidate.get("customer_id"))
        if account_id:
            targets.append({"type": "account", "account_id": account_id})
        recipient = _first_string(candidate.get("to"), candidate.get("email"), candidate.get("recipient"))
        if recipient:
            targets.append({"type": "email", "recipient": recipient})
        file_path = _first_string(candidate.get("path"), candidate.get("file_path"), candidate.get("artifact_path"))
        if file_path and action.startswith("tool."):
            targets.append({"type": "file", "path": file_path})

    return _dedupe_targets(targets)


@dataclass
class DecisionAccumulator:
    """Local, callback-safe collector for decision-time evidence."""

    fields: Dict[str, Any] = field(default_factory=dict)

    def merge(self, **fields: Any) -> None:
        for key, value in fields.items():
            if value is None:
                continue
            if key == "timestamps":
                current = self.fields.setdefault("timestamps", {})
                if isinstance(current, MutableMapping) and isinstance(value, Mapping):
                    current.update(safe_json(value))
                continue
            if key == "recent_messages":
                messages = normalize_messages(value)
                if messages:
                    self.fields["recent_messages"] = messages
                continue
            self.fields[key] = safe_json(value)

    def record_prompt(self, *, prompt: Any = None, messages: Any = None, metadata: Any = None) -> None:
        prompt_excerpt = safe_text(prompt, max_chars=1200) if prompt is not None else None
        if not prompt_excerpt and isinstance(messages, Sequence):
            for message in reversed(list(messages)):
                role = extract_message_role(message)
                if role in ("user", "human", None):
                    content = extract_message_content(message)
                    prompt_excerpt = safe_text(content, max_chars=1200)
                    if prompt_excerpt:
                        break
        self.merge(
            prompt_excerpt=prompt_excerpt,
            recent_messages=messages,
            timestamps={"last_prompt_at": utc_now_iso()},
            callback_metadata=metadata,
        )

    def record_response(
        self,
        *,
        response: Any = None,
        assistant_name: Optional[str] = None,
        usage: Any = None,
        model: Any = None,
        tool_calls: Any = None,
    ) -> None:
        response_excerpt = safe_text(extract_message_content(response) if response is not None else response, max_chars=1200)
        payload_tool_calls = tool_calls
        if payload_tool_calls is None and response is not None:
            payload_tool_calls = getattr(response, "tool_calls", None)
            if payload_tool_calls is None and isinstance(response, Mapping):
                payload_tool_calls = response.get("tool_calls")
        self.merge(
            assistant_response_excerpt=response_excerpt or None,
            assistant_name=assistant_name,
            tool_call_payload=payload_tool_calls,
            token_usage=usage,
            model_metadata=model,
            timestamps={"last_response_at": utc_now_iso()},
        )

    def record_manager_decision(
        self,
        *,
        reasoning: Any = None,
        routing_choice: Any = None,
        required_tool_names: Any = None,
    ) -> None:
        self.merge(
            manager_output={
                "reasoning": reasoning,
                "routing_choice": routing_choice,
                "required_tool_names": required_tool_names or [],
            },
            routing_choice=routing_choice,
            timestamps={"last_manager_decision_at": utc_now_iso()},
        )

    def snapshot(self) -> Dict[str, Any]:
        return dict(safe_json(self.fields))


class AgendexCallbackHandler:
    """Optional callback collector that only harvests local metadata."""

    def __init__(self, accumulator: DecisionAccumulator) -> None:
        self.accumulator = accumulator

    def on_chat_model_start(
        self,
        *,
        messages: Any = None,
        prompts: Any = None,
        metadata: Any = None,
        **_: Any,
    ) -> None:
        prompt = None
        if isinstance(prompts, Sequence) and not isinstance(prompts, (str, bytes, bytearray)) and prompts:
            prompt = prompts[-1]
        self.accumulator.record_prompt(prompt=prompt, messages=messages, metadata=metadata)

    def on_llm_start(
        self,
        serialized: Any = None,
        prompts: Any = None,
        **kwargs: Any,
    ) -> None:
        self.on_chat_model_start(prompts=prompts, metadata=serialized, **kwargs)

    def on_llm_end(self, response: Any, **kwargs: Any) -> None:
        usage = kwargs.get("usage")
        model = kwargs.get("model")
        if usage is None and hasattr(response, "llm_output"):
            llm_output = getattr(response, "llm_output", None)
            if isinstance(llm_output, Mapping):
                usage = llm_output.get("token_usage") or llm_output.get("usage")
                model = model or llm_output.get("model_name") or llm_output.get("model")
        generations = getattr(response, "generations", None)
        candidate = response
        if isinstance(generations, Sequence) and generations and isinstance(generations[0], Sequence) and generations[0]:
            generation = generations[0][0]
            candidate = getattr(generation, "message", None) or getattr(generation, "text", None) or generation
        self.accumulator.record_response(response=candidate, usage=usage, model=model)

    def on_tool_start(self, serialized: Any = None, input_str: Any = None, **_: Any) -> None:
        name = None
        if isinstance(serialized, Mapping):
            name = serialized.get("name")
        self.accumulator.merge(
            tool_name=name,
            tool_call_payload={"tool_name": name, "input": safe_json(input_str)},
            timestamps={"last_tool_start_at": utc_now_iso()},
        )


@dataclass(frozen=True)
class DecisionCapture:
    decision_context: Dict[str, Any]
    supporting_context: Dict[str, Any]
    context_coverage: Dict[str, bool]
    resource_targets: list[Dict[str, Any]]

    def as_context(self) -> Dict[str, Any]:
        manager_output = self.supporting_context.get("manager_output")
        manager_reasoning = None
        if isinstance(manager_output, Mapping):
            manager_reasoning = manager_output.get("reasoning")
        context = {
            "decision_context": self.decision_context,
            "supporting_context": self.supporting_context,
            "context_coverage": self.context_coverage,
            "transport_status": self.supporting_context.get("transport_status"),
            "user_id": self.decision_context.get("user_id"),
            "session_id": self.decision_context.get("session_id"),
            "tenant_id": self.decision_context.get("tenant_id"),
            "task": self.decision_context.get("task"),
            "agent_id": self.decision_context.get("agent_id"),
            "trace_id": self.decision_context.get("trace_id"),
            "span_id": self.decision_context.get("span_id"),
            "parent_span_id": self.decision_context.get("parent_span_id"),
            "actor": self.decision_context.get("actor"),
            "mode": self.decision_context.get("mode"),
            "user_intent": self.decision_context.get("user_intent"),
            "assistant_rationale": self.decision_context.get("assistant_rationale"),
            "assistant_reasoning": self.decision_context.get("assistant_rationale"),
            "reasoning": self.decision_context.get("assistant_rationale"),
            "manager_reasoning": manager_reasoning,
            "routing_choice": self.decision_context.get("routing_choice"),
            "tool_action_intent": self.decision_context.get("tool_action_intent"),
            "resource_targets": self.resource_targets,
            "user_prompt": self.supporting_context.get("prompt_excerpt"),
            "prompt_excerpt": self.supporting_context.get("prompt_excerpt"),
            "assistant_response_excerpt": self.supporting_context.get("assistant_response_excerpt"),
            "manager_output": self.supporting_context.get("manager_output"),
            "tool_call_payload": self.supporting_context.get("tool_call_payload"),
            "tool_args": self.supporting_context.get("tool_args"),
            "node_name": self.supporting_context.get("node_name"),
            "assistant_name": self.supporting_context.get("assistant_name"),
            "tool_name": self.supporting_context.get("tool_name"),
            "timestamps": self.supporting_context.get("timestamps"),
            "token_usage": self.supporting_context.get("token_usage"),
            "memory_summaries": self.supporting_context.get("memory_summaries"),
            "retrieval_context": self.supporting_context.get("retrieval_context"),
            "all_created_files": self.supporting_context.get("all_created_files"),
            "artifact_paths": self.supporting_context.get("artifact_paths"),
            "extra_state": self.supporting_context.get("extra_state"),
            "recent_messages": self.supporting_context.get("recent_messages"),
        }
        return {key: value for key, value in context.items() if value is not None}

    def as_event_fields(self) -> Dict[str, Any]:
        return {
            "decision_context": self.decision_context,
            "supporting_context": self.supporting_context,
            "context_coverage": self.context_coverage,
            "transport_status": self.supporting_context.get("transport_status"),
            "resources": self.resource_targets,
        }


@dataclass
class DecisionContextBuilder:
    user_id_keys: tuple[str, ...] = ("user_id", "user_email", "operator_id")
    session_id_keys: tuple[str, ...] = ("session_id", "chat_id", "thread_id", "conversation_id")
    user_intent_keys: tuple[str, ...] = (
        "user_intent",
        "user_query",
        "user_prompt",
        "prompt",
        "input",
        "query",
        "user_input",
    )
    assistant_rationale_keys: tuple[str, ...] = (
        "assistant_rationale",
        "assistant_reasoning",
        "reasoning",
        "manager_reasoning",
    )
    routing_choice_keys: tuple[str, ...] = (
        "routing_choice",
        "manager_routing_decision",
        "next_assistant",
        "last_assistant_routed",
    )
    assistant_name_keys: tuple[str, ...] = ("assistant_name", "last_assistant_routed")
    node_name_keys: tuple[str, ...] = ("node_name", "current_node", "active_node")
    memory_summary_keys: tuple[str, ...] = ("memory_summaries", "memory_summary", "memory")
    retrieval_context_keys: tuple[str, ...] = ("retrieval_context", "retrieved_context", "retrieval")
    created_files_keys: tuple[str, ...] = ("all_created_files", "created_files")
    artifact_paths_keys: tuple[str, ...] = ("artifact_paths", "artifacts")
    extra_state_max_chars: int = 2000
    message_preview_chars: int = 400

    def _mapping_get(self, mapping: Mapping[str, Any] | None, key: str) -> Any:
        if isinstance(mapping, Mapping):
            return mapping.get(key)
        return None

    def _first_from_sources(self, keys: Sequence[str], *sources: Mapping[str, Any] | None) -> Any:
        for key in keys:
            for source in sources:
                value = self._mapping_get(source, key)
                if value is not None:
                    return value
        return None

    def _first_text(self, keys: Sequence[str], *sources: Mapping[str, Any] | None) -> Optional[str]:
        return _first_string(self._first_from_sources(keys, *sources))

    def _normalize_extra_state(
        self,
        *,
        state: Any,
        context: Mapping[str, Any] | None,
        accumulator_data: Mapping[str, Any] | None,
        known_keys: set[str],
    ) -> Optional[Dict[str, Any]]:
        extra: Dict[str, Any] = {}
        existing = self._mapping_get(accumulator_data, "extra_state")
        if isinstance(existing, Mapping):
            extra.update(dict(safe_json(existing)))
        existing_context = self._mapping_get(context, "extra_state")
        if isinstance(existing_context, Mapping):
            extra.update(dict(safe_json(existing_context)))
        if not isinstance(state, MutableMapping):
            return extra or None
        for key, value in state.items():
            if key in known_keys or key.startswith("_agendex"):
                continue
            if isinstance(value, (str, int, float, bool)):
                extra.setdefault(key, value)
            elif isinstance(value, Mapping):
                text = safe_text(value, max_chars=self.extra_state_max_chars)
                if len(text) <= self.extra_state_max_chars:
                    extra.setdefault(key, dict(safe_json(value)))
        return extra or None

    def _recent_messages(
        self,
        *,
        context: Mapping[str, Any] | None,
        accumulator_data: Mapping[str, Any] | None,
        messages: Any = None,
    ) -> Optional[list[str]]:
        existing = self._mapping_get(context, "recent_messages")
        if isinstance(existing, Sequence) and not isinstance(existing, (str, bytes, bytearray)):
            return list(existing)
        existing = self._mapping_get(accumulator_data, "recent_messages")
        if isinstance(existing, Sequence) and not isinstance(existing, (str, bytes, bytearray)):
            return list(existing)
        return normalize_messages(messages, max_chars=self.message_preview_chars)

    def build(
        self,
        *,
        decision_type: str,
        action: Optional[str],
        task: Optional[str],
        context: Mapping[str, Any] | None = None,
        state: Any = None,
        params: Mapping[str, Any] | None = None,
        resources: Optional[Sequence[Mapping[str, Any]]] = None,
        accumulator: DecisionAccumulator | None = None,
        trace_id: Optional[str] = None,
        span_id: Optional[str] = None,
        parent_span_id: Optional[str] = None,
        actor: Optional[str] = None,
        agent_id: Optional[str] = None,
        tenant_id: Optional[str] = None,
        mode: Optional[str] = None,
        assistant_name: Optional[str] = None,
        tool_name: Optional[str] = None,
        node_name: Optional[str] = None,
        messages: Any = None,
        assistant_response: Any = None,
        manager_output: Any = None,
        tool_call_payload: Any = None,
        tool_action_intent: Optional[str] = None,
        transport_status: Optional[Dict[str, Any]] = None,
    ) -> DecisionCapture:
        accumulator_data = accumulator.snapshot() if accumulator else {}
        context = dict(safe_json(context or {}))
        state_mapping = state if isinstance(state, MutableMapping) else {}

        user_id = self._first_text(self.user_id_keys, context, accumulator_data, state_mapping)
        session_id = self._first_text(self.session_id_keys, context, accumulator_data, state_mapping)
        user_intent = self._first_text(self.user_intent_keys, context, accumulator_data, state_mapping)
        assistant_rationale = self._first_text(
            self.assistant_rationale_keys, context, accumulator_data, state_mapping
        )
        if not assistant_rationale and assistant_response is not None:
            assistant_rationale = _first_string(safe_text(extract_message_content(assistant_response), max_chars=1200))
        routing_choice = self._first_text(self.routing_choice_keys, context, accumulator_data, state_mapping)
        resolved_assistant_name = self._first_text(
            self.assistant_name_keys, context, accumulator_data, state_mapping
        ) or assistant_name
        resolved_tool_name = _first_string(
            tool_name,
            self._mapping_get(context, "tool_name"),
            self._mapping_get(accumulator_data, "tool_name"),
            action,
        )
        resolved_node_name = self._first_text(self.node_name_keys, context, accumulator_data, state_mapping) or node_name

        prompt_excerpt = _first_string(
            self._mapping_get(context, "prompt_excerpt"),
            self._mapping_get(accumulator_data, "prompt_excerpt"),
            self._mapping_get(context, "user_prompt"),
            user_intent,
        )
        if not user_intent:
            user_intent = prompt_excerpt
        assistant_response_excerpt = _first_string(
            self._mapping_get(context, "assistant_response_excerpt"),
            self._mapping_get(accumulator_data, "assistant_response_excerpt"),
            assistant_rationale,
        )
        if not assistant_rationale:
            assistant_rationale = assistant_response_excerpt
        recent_messages = self._recent_messages(
            context=context,
            accumulator_data=accumulator_data,
            messages=messages or state_mapping.get("assistant_messages_current_turn") or state_mapping.get("messages"),
        )
        resource_targets = extract_resource_targets(action or "", params, resources=resources)

        decision_context = {
            "decision_type": decision_type,
            "user_intent": user_intent,
            "assistant_rationale": assistant_rationale,
            "routing_choice": routing_choice,
            "tool_action_intent": tool_action_intent if tool_action_intent is not None else (action if decision_type == "tool_call" else None),
            "resource_targets": resource_targets,
            "user_id": user_id,
            "session_id": session_id,
            "tenant_id": _first_string(tenant_id, self._mapping_get(context, "tenant_id")),
            "task": _first_string(task, self._mapping_get(context, "task")),
            "agent_id": _first_string(agent_id, self._mapping_get(context, "agent_id")),
            "trace_id": _first_string(trace_id, self._mapping_get(context, "trace_id")),
            "span_id": _first_string(span_id, self._mapping_get(context, "span_id")),
            "parent_span_id": _first_string(parent_span_id, self._mapping_get(context, "parent_span_id")),
            "actor": _first_string(actor, self._mapping_get(context, "actor")),
            "mode": _first_string(mode, self._mapping_get(context, "mode")),
        }

        known_keys = {
            *self.user_id_keys,
            *self.session_id_keys,
            *self.user_intent_keys,
            *self.assistant_rationale_keys,
            *self.routing_choice_keys,
            *self.assistant_name_keys,
            *self.node_name_keys,
            *self.memory_summary_keys,
            *self.retrieval_context_keys,
            *self.created_files_keys,
            *self.artifact_paths_keys,
            "trace_id",
            "span_id",
            "parent_span_id",
            "actor",
            "mode",
            "task",
            "tenant_id",
            "tool_name",
        }
        supporting_context = {
            "recent_messages": recent_messages,
            "prompt_excerpt": prompt_excerpt,
            "assistant_response_excerpt": assistant_response_excerpt,
            "manager_output": safe_json(
                manager_output
                or self._mapping_get(context, "manager_output")
                or self._mapping_get(accumulator_data, "manager_output")
            ),
            "tool_call_payload": safe_json(
                tool_call_payload
                or self._mapping_get(context, "tool_call_payload")
                or self._mapping_get(accumulator_data, "tool_call_payload")
            ),
            "tool_args": safe_json(params),
            "node_name": resolved_node_name,
            "assistant_name": resolved_assistant_name,
            "tool_name": resolved_tool_name,
            "timestamps": safe_json(
                self._mapping_get(context, "timestamps") or self._mapping_get(accumulator_data, "timestamps")
            ),
            "token_usage": safe_json(
                self._mapping_get(context, "token_usage")
                or self._mapping_get(accumulator_data, "token_usage")
                or self._mapping_get(accumulator_data, "usage")
            ),
            "memory_summaries": safe_json(
                self._first_from_sources(self.memory_summary_keys, context, accumulator_data, state_mapping)
            ),
            "retrieval_context": safe_json(
                self._first_from_sources(self.retrieval_context_keys, context, accumulator_data, state_mapping)
            ),
            "all_created_files": safe_json(
                self._first_from_sources(self.created_files_keys, context, accumulator_data, state_mapping)
            ),
            "artifact_paths": safe_json(
                self._first_from_sources(self.artifact_paths_keys, context, accumulator_data, state_mapping)
            ),
            "extra_state": self._normalize_extra_state(
                state=state_mapping,
                context=context,
                accumulator_data=accumulator_data,
                known_keys=known_keys,
            ),
            "transport_status": safe_json(
                transport_status
                or self._mapping_get(context, "transport_status")
                or self._mapping_get(accumulator_data, "transport_status")
                or build_transport_status()
            ),
        }
        supporting_context["context_coverage"] = compute_context_coverage(decision_context)

        return DecisionCapture(
            decision_context=decision_context,
            supporting_context=supporting_context,
            context_coverage=supporting_context["context_coverage"],
            resource_targets=resource_targets,
        )
