"""Event-driven webhook processor for CRM-triggered scout execution."""

from __future__ import annotations

import hashlib
import json
import os
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

from g_gremlin.scout.engine import ScoutEngine
from g_gremlin.scout.loader import ScoutLoader, load_scan_config

from .approvals import ApprovalTokenStore, default_approval_ttl_seconds
from .contracts import ActorContext
from .control_plane import (
    DEFAULT_DEDUPE_WINDOW_SECONDS,
    ProposalV1,
    compute_proposal_signature,
    control_plane_store,
    resolve_deterministic_action_key,
)
from .control_plane_job_queue import ControlPlaneJobQueue
from .event_queue import CrmEventQueue, event_queue_db_path
from .executor import PlanExecutor
from .pending_actions import build_pending_action, write_pending_action
from .planner import build_plan
from .policy import AutonomyPolicy, apply_policy_overlays
from .receipts import ReceiptWriter
from .scout_config_store import ScoutConfigStore
from .tools import get_default_registry


_DEFAULT_REACTIVE_SCOUTS = ["do_not_contact", "sequence_collision"]
_EVENT_TYPE_TO_SCOUTS: tuple[tuple[tuple[str, ...], list[str]], ...] = (
    (("dnc", "opt_out", "optout", "donotcontact", "do_not_contact", "suppression"), ["do_not_contact", "sequence_collision"]),
    (("sequence", "collision"), ["sequence_collision", "do_not_contact"]),
    (("ticket", "case", "support", "escalation"), ["customer_open_ticket_guardrail"]),
    (("meeting", "followup", "activity", "engagement"), ["meeting_followup_breach"]),
)
_SOBJECT_TO_SCOUTS: dict[str, list[str]] = {
    "lead": ["do_not_contact", "sequence_collision", "unassigned_inbound"],
    "contact": ["do_not_contact", "sequence_collision", "customer_open_ticket_guardrail"],
    "case": ["customer_open_ticket_guardrail"],
    "ticket": ["customer_open_ticket_guardrail"],
    "conversation": ["customer_open_ticket_guardrail"],
    "event": ["meeting_followup_breach"],
    "task": ["meeting_followup_breach"],
    "engagement": ["meeting_followup_breach"],
    "call": ["meeting_followup_breach"],
}


def _utc_now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()


def _stable_hash(payload: Any) -> str:
    encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
    return hashlib.sha256(encoded).hexdigest()


def _window_bucket_timestamp(now: datetime, window_seconds: int) -> str:
    if window_seconds <= 0:
        return now.isoformat()
    epoch = int(now.timestamp())
    bucket = epoch - (epoch % window_seconds)
    return datetime.fromtimestamp(bucket, tz=timezone.utc).isoformat()


def _normalize_text(value: Any) -> str:
    if value is None:
        return ""
    return str(value).strip()


def _as_list(value: Any) -> list[Any]:
    if value is None:
        return []
    if isinstance(value, list):
        return list(value)
    return [value]


_ALLOWED_RECORD_ID_KEYS: set[str] = {
    "id",
    "record_id",
    "recordid",
    "lead_id",
    "leadid",
    "contact_id",
    "contactid",
    "account_id",
    "accountid",
    "case_id",
    "caseid",
    "opportunity_id",
    "opportunityid",
    "task_id",
    "taskid",
    "event_id",
    "eventid",
    "owner_id",
    "ownerid",
    "parent_id",
    "parentid",
    "whoid",
    "whatid",
    "entity_id",
    "entityid",
    "object_id",
    "objectid",
    "sobject_id",
    "sobjectid",
    "related_id",
    "relatedid",
    "prospect_id",
    "prospectid",
    "sequence_id",
    "sequenceid",
    "sequence_state_id",
    "sequencestateid",
}
_ALLOWED_RECORD_ID_STEMS: set[str] = {
    "record",
    "lead",
    "contact",
    "account",
    "case",
    "opportunity",
    "task",
    "event",
    "owner",
    "parent",
    "who",
    "what",
    "entity",
    "object",
    "sobject",
    "related",
    "prospect",
    "sequence",
    "state",
    "sequence_state",
}


def _normalize_key(key: Any) -> str:
    raw = str(key or "").strip().lower()
    if not raw:
        return ""
    if raw.endswith("__c") or raw.endswith("__r"):
        raw = raw[:-3]
    normalized_chars: list[str] = []
    for char in raw:
        normalized_chars.append(char if char.isalnum() else "_")
    normalized = "".join(normalized_chars)
    while "__" in normalized:
        normalized = normalized.replace("__", "_")
    return normalized.strip("_")


def _is_record_id_key(key: Any) -> bool:
    normalized = _normalize_key(key)
    if not normalized:
        return False
    if normalized in _ALLOWED_RECORD_ID_KEYS:
        return True
    if not normalized.endswith("_id"):
        return False
    stem = normalized[:-3].strip("_")
    if not stem:
        return False
    if stem in _ALLOWED_RECORD_ID_STEMS:
        return True
    stem_parts = [part for part in stem.split("_") if part]
    if not stem_parts:
        return False
    return stem_parts[-1] in _ALLOWED_RECORD_ID_STEMS


def _extract_source_event_id(event: dict[str, Any], payload: dict[str, Any]) -> str:
    candidates: list[Any] = []
    event_meta = event.get("event") if isinstance(event.get("event"), dict) else {}
    for key in ("source_event_id", "event_id", "eventId", "ReplayId", "replayId"):
        candidates.append(event.get(key))
    for key in ("replayId", "replay_id", "ReplayId", "id", "eventUuid", "eventId"):
        candidates.append(event_meta.get(key))
    for key in ("ReplayId", "replayId", "event_id", "eventId", "EventUuid", "EventUUID", "Id"):
        candidates.append(payload.get(key))
    for candidate in candidates:
        normalized = _normalize_text(candidate)
        if normalized:
            return normalized
    return f"hash:{_stable_hash({'event': event, 'payload': payload})}"


def _extract_event_type(event: dict[str, Any], payload: dict[str, Any]) -> str:
    candidates: list[Any] = []
    event_meta = event.get("event") if isinstance(event.get("event"), dict) else {}
    for key in ("event_type", "eventType", "type", "Type"):
        candidates.append(event.get(key))
    for key in ("type", "eventType"):
        candidates.append(event_meta.get(key))
    for key in ("EventType__c", "event_type", "eventType", "changeType", "ChangeType"):
        candidates.append(payload.get(key))
    for candidate in candidates:
        normalized = _normalize_text(candidate)
        if normalized:
            return normalized
    return "platform_event"


def _extract_record_ids(payload: dict[str, Any]) -> list[str]:
    candidates: list[str] = []

    def _visit(node: Any, depth: int = 0) -> None:
        if depth > 3:
            return
        if isinstance(node, dict):
            for key, value in node.items():
                if _is_record_id_key(key):
                    for token in _as_list(value):
                        normalized = _normalize_text(token)
                        if normalized:
                            candidates.append(normalized)
                if isinstance(value, (dict, list)):
                    _visit(value, depth + 1)
        elif isinstance(node, list):
            for item in node:
                _visit(item, depth + 1)

    _visit(payload)
    deduped: list[str] = []
    seen: set[str] = set()
    for candidate in candidates:
        token = candidate.strip()
        if not token:
            continue
        lower = token.lower()
        if lower in seen:
            continue
        seen.add(lower)
        deduped.append(token)
        if len(deduped) >= 50:
            break
    return deduped


def _extract_sobjects(payload: dict[str, Any]) -> list[str]:
    names: list[str] = []

    def _push(value: Any) -> None:
        normalized = _normalize_text(value)
        if not normalized:
            return
        key = normalized.lower()
        if key not in {item.lower() for item in names}:
            names.append(normalized)

    for key in ("sobject", "sObject", "sobject_name", "object", "entity", "SObject__c", "ObjectName__c"):
        if key in payload:
            for value in _as_list(payload.get(key)):
                _push(value)
    return names


def _extract_explicit_scout_targets(event: dict[str, Any], payload: dict[str, Any]) -> list[str]:
    values: list[str] = []
    for source in (event, payload):
        for key in ("target_scouts", "scout_targets", "scout_names", "scouts"):
            raw = source.get(key) if isinstance(source, dict) else None
            if raw is None:
                continue
            if isinstance(raw, str):
                values.extend([part.strip() for part in raw.split(",") if part.strip()])
            elif isinstance(raw, list):
                values.extend([_normalize_text(item) for item in raw if _normalize_text(item)])
    deduped: list[str] = []
    seen: set[str] = set()
    for value in values:
        lower = value.lower()
        if lower in seen:
            continue
        seen.add(lower)
        deduped.append(value)
    return deduped


def _normalize_crm_provider(value: Any, default: str = "salesforce") -> str:
    normalized = str(value or "").strip().lower()
    if normalized in {"salesforce", "sfdc"}:
        return "salesforce"
    if normalized in {"hubspot", "dynamics"}:
        return normalized
    return str(default or "salesforce").strip().lower() or "salesforce"


def _hubspot_event_items(payload: dict[str, Any] | list[Any]) -> list[dict[str, Any]]:
    if isinstance(payload, list):
        return [item for item in payload if isinstance(item, dict)]
    if isinstance(payload, dict):
        for key in ("events", "notifications"):
            raw_items = payload.get(key)
            if isinstance(raw_items, list):
                return [item for item in raw_items if isinstance(item, dict)]
        return [payload]
    return []


def _hubspot_identity_payload(notification: dict[str, Any]) -> dict[str, Any]:
    normalized: dict[str, Any] = {}
    for key, value in notification.items():
        normalized_key = _normalize_key(key)
        if normalized_key in {"attemptnumber", "attempt_number", "attemptid", "attempt_id"}:
            continue
        normalized[str(key)] = value
    return normalized


def _extract_hubspot_source_event_id(notification: dict[str, Any]) -> str:
    message_id = _normalize_text(notification.get("messageId") or notification.get("message_id"))
    if message_id:
        return message_id
    return f"hash:{_stable_hash(_hubspot_identity_payload(notification))}"


def _extract_hubspot_event_type(notification: dict[str, Any]) -> str:
    base_type = _normalize_text(
        notification.get("subscriptionType")
        or notification.get("subscription_type")
        or notification.get("eventType")
        or notification.get("event_type")
        or notification.get("type")
    )
    property_name = _normalize_text(notification.get("propertyName") or notification.get("property_name"))
    if base_type and property_name:
        return f"{base_type}:{property_name}"
    return base_type or "webhook_event"


def _extract_hubspot_record_ids(notification: dict[str, Any]) -> list[str]:
    record_ids: list[str] = []
    for key in ("objectId", "object_id", "primaryObjectId", "primary_object_id"):
        candidate = _normalize_text(notification.get(key))
        if candidate:
            record_ids.append(candidate)
    for key in ("mergedObjectIds", "merged_object_ids"):
        for value in _as_list(notification.get(key)):
            candidate = _normalize_text(value)
            if candidate:
                record_ids.append(candidate)

    deduped: list[str] = []
    seen: set[str] = set()
    for candidate in record_ids:
        lower = candidate.lower()
        if lower in seen:
            continue
        seen.add(lower)
        deduped.append(candidate)
    return deduped


def _extract_hubspot_sobjects(notification: dict[str, Any]) -> list[str]:
    candidates: list[str] = []
    subscription_type = _normalize_text(notification.get("subscriptionType") or notification.get("subscription_type"))
    if subscription_type:
        prefix = subscription_type.split(".", 1)[0].strip()
        if prefix:
            candidates.append(prefix)
    object_type = _normalize_text(notification.get("objectType") or notification.get("object_type"))
    if object_type:
        candidates.append(object_type)

    deduped: list[str] = []
    seen: set[str] = set()
    for candidate in candidates:
        lower = candidate.lower()
        if lower in seen:
            continue
        seen.add(lower)
        deduped.append(candidate)
    return deduped


def _write_scan_payload(state_dir: Path, *, run_id: str, payload: dict[str, Any]) -> Path:
    run_dir = Path(state_dir) / "receipts" / run_id
    run_dir.mkdir(parents=True, exist_ok=True)
    scan_path = run_dir / "scan.json"
    tmp_path = run_dir / f"scan.{os.getpid()}.tmp"
    tmp_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
    os.replace(tmp_path, scan_path)
    return scan_path


def _get_user_credentials() -> set[str]:
    try:
        from g_gremlin.credentials.provider import CredentialProvider

        provider = CredentialProvider()
        return set(provider.list_configured_integrations())
    except Exception:
        return set()


class CrmEventProcessor:
    """Queue-backed event processor that drives scout->plan->receipt."""

    def __init__(
        self,
        *,
        state_dir: Path | str,
        policy: AutonomyPolicy,
        queue: CrmEventQueue | None = None,
        job_queue: ControlPlaneJobQueue | None = None,
    ) -> None:
        self.state_dir = Path(state_dir).resolve()
        self.policy = policy.model_copy(deep=True)
        self.queue = queue or CrmEventQueue(event_queue_db_path(self.state_dir))
        self.job_queue = job_queue
        self.registry = get_default_registry()
        self._control_store = None

    def enqueue_salesforce_platform_events(
        self,
        payload: dict[str, Any] | list[Any],
    ) -> list[dict[str, Any]]:
        raw_events: list[Any]
        if isinstance(payload, list):
            raw_events = payload
        elif isinstance(payload, dict) and isinstance(payload.get("events"), list):
            raw_events = payload.get("events", [])
        else:
            raw_events = [payload]

        queued_rows: list[dict[str, Any]] = []
        for item in raw_events:
            event = item if isinstance(item, dict) else {}
            event_payload = event.get("payload") if isinstance(event.get("payload"), dict) else event
            source_event_id = _extract_source_event_id(event, event_payload)
            event_type = _extract_event_type(event, event_payload)
            record_ids = _extract_record_ids(event_payload)
            sobjects = _extract_sobjects(event_payload)
            metadata = {
                "record_ids": record_ids,
                "sobjects": sobjects,
                "received_kind": "salesforce_platform_event",
            }
            queued = self.queue.enqueue_event(
                provider="salesforce",
                source_kind="platform_event",
                source_event_id=source_event_id,
                event_type=event_type,
                payload=event_payload,
                metadata=metadata,
            )
            queued_rows.append(queued)
        return queued_rows

    def enqueue_hubspot_webhook_events(
        self,
        payload: dict[str, Any] | list[Any],
    ) -> list[dict[str, Any]]:
        queued_rows: list[dict[str, Any]] = []
        for notification in _hubspot_event_items(payload):
            source_event_id = _extract_hubspot_source_event_id(notification)
            event_type = _extract_hubspot_event_type(notification)
            record_ids = _extract_hubspot_record_ids(notification)
            sobjects = _extract_hubspot_sobjects(notification)
            property_name = _normalize_text(notification.get("propertyName") or notification.get("property_name"))
            metadata = {
                "record_ids": record_ids,
                "sobjects": sobjects,
                "portal_id": _normalize_text(notification.get("portalId") or notification.get("portal_id")),
                "app_id": _normalize_text(notification.get("appId") or notification.get("app_id")),
                "subscription_id": _normalize_text(
                    notification.get("subscriptionId") or notification.get("subscription_id")
                ),
                "property_names": [property_name] if property_name else [],
                "received_kind": "hubspot_webhook",
            }
            queued = self.queue.enqueue_event(
                provider="hubspot",
                source_kind="webhook_event",
                source_event_id=source_event_id,
                event_type=event_type,
                payload=notification,
                metadata=metadata,
            )
            queued_rows.append(queued)
        return queued_rows

    def process_event(self, event_id: str) -> dict[str, Any] | None:
        leased = self.queue.lease_event(event_id)
        if leased is None:
            existing = self.queue.get_event(event_id)
            if existing is None:
                return None
            return {
                "event_id": existing["event_id"],
                "status": existing["status"],
                "processing_outcome": existing.get("processing_outcome"),
                "finding_run_id": existing.get("finding_run_id"),
                "receipt_run_id": existing.get("receipt_run_id"),
                "deduped": True,
            }
        return self._process_leased_event(leased)

    def process_pending(self, *, limit: int = 10) -> list[dict[str, Any]]:
        results: list[dict[str, Any]] = []
        bounded_limit = max(1, int(limit))
        for _index in range(bounded_limit):
            leased = self.queue.lease_next()
            if leased is None:
                break
            results.append(self._process_leased_event(leased))
        return results

    def _process_leased_event(self, event: dict[str, Any]) -> dict[str, Any]:
        event_id = str(event.get("event_id") or "").strip()
        payload = event.get("payload") if isinstance(event.get("payload"), dict) else {}
        metadata = event.get("metadata") if isinstance(event.get("metadata"), dict) else {}
        event_type = _normalize_text(event.get("event_type")).lower()

        try:
            scan_cfg = load_scan_config()
            crm_provider = _normalize_crm_provider(
                event.get("provider"),
                default=_normalize_crm_provider(getattr(scan_cfg, "crm", None), default="salesforce"),
            )
            definitions = ScoutLoader().list_scouts()
            available_names = {definition.name for definition in definitions}
            configured_names = (
                set(scan_cfg.scouts_enabled)
                if isinstance(scan_cfg.scouts_enabled, list) and scan_cfg.scouts_enabled
                else set()
            )
            scout_names = self._target_scout_names(
                event=event,
                payload=payload,
                metadata=metadata,
                event_type=event_type,
                available_names=available_names,
                configured_names=configured_names,
            )

            if not scout_names:
                self.queue.mark_processed(
                    event_id=event_id,
                    processing_outcome="no_target_scouts",
                    finding_run_id=None,
                    receipt_run_id=None,
                )
                return {
                    "event_id": event_id,
                    "status": "processed",
                    "processing_outcome": "no_target_scouts",
                    "scout_names": [],
                }

            merged_per_scout_params = self._merged_scout_params(
                scout_names=scout_names,
                scan_cfg_per_scout=scan_cfg.per_scout_params if isinstance(scan_cfg.per_scout_params, dict) else {},
                event_context={
                    "event_id": event_id,
                    "provider": crm_provider,
                    "source_event_id": event.get("source_event_id"),
                    "event_type": event_type,
                    "record_ids": metadata.get("record_ids") if isinstance(metadata.get("record_ids"), list) else [],
                    "sobjects": metadata.get("sobjects") if isinstance(metadata.get("sobjects"), list) else [],
                },
            )

            top_n_raw = payload.get("top_n", scan_cfg.top_n if scan_cfg.top_n else 20)
            try:
                top_n = int(top_n_raw)
            except Exception:
                top_n = int(scan_cfg.top_n or 20)
            top_n = min(max(top_n, 1), 500)

            runtime_policy = apply_policy_overlays(self.policy, self.state_dir)
            org_alias = runtime_policy.org_alias or scan_cfg.org_alias
            engine = ScoutEngine(
                user_credentials=_get_user_credentials(),
                state_dir=self.state_dir,
            )
            scan_result = engine.run_scan(
                scout_names,
                top_n=top_n,
                per_scout_params=merged_per_scout_params,
                org_alias=org_alias,
                crm=crm_provider,
                hubspot_profile=scan_cfg.hubspot_profile,
                dynamics_profile=scan_cfg.dynamics_profile,
            )
            if not scan_result.ended_at:
                scan_result.ended_at = _utc_now_iso()

            scan_payload = scan_result.to_dict(include_fields=True, include_reason=True)
            scan_payload["trigger"] = {
                "kind": "crm_webhook",
                "crm": crm_provider,
                "provider": event.get("provider"),
                "source_kind": event.get("source_kind"),
                "event_type": event.get("event_type"),
                "queue_event_id": event_id,
                "source_event_id": event.get("source_event_id"),
                "received_at": event.get("created_at"),
            }
            _write_scan_payload(self.state_dir, run_id=scan_result.run_id, payload=scan_payload)

            plan = build_plan(scan_result, self.registry)
            executor = PlanExecutor(runtime_policy, self.registry, self.state_dir)
            receipt = executor.execute(plan)
            writer = ReceiptWriter(self.state_dir)
            writer.write(receipt, plan)

            findings_total = sum(int(finding.findings_count) for finding in scan_result.findings)
            pending_token_issued = False
            if receipt.outcome == "approval_required":
                pending_dir = self.state_dir / "pending" / plan.run_id
                pending_dir.mkdir(parents=True, exist_ok=True)
                pending_plan_path = pending_dir / "plan.json"
                pending_plan_path.write_text(json.dumps(plan.to_dict(), indent=2), encoding="utf-8")
                write_pending_action(
                    self.state_dir,
                    build_pending_action(
                        plan=plan,
                        actor=ActorContext(
                            actor_id=f"webhook:{str(event.get('provider') or 'unknown')}",
                            roles=["revops"],
                            auth_source="webhook",
                        ),
                    ),
                )
                store = ApprovalTokenStore(self.state_dir / "db" / "approval_tokens.sqlite")
                store.create_token(
                    plan_hash=plan.plan_hash or "",
                    policy_hash=plan.policy_hash or "",
                    run_id=plan.run_id,
                    ttl_seconds=default_approval_ttl_seconds(),
                )
                pending_token_issued = True
            compile_job: dict[str, Any] | None = None
            if self.job_queue is not None and receipt.outcome == "approval_required":
                compile_job = self._enqueue_compile_job(
                    event=event,
                    event_type=event_type,
                    scan_run_id=scan_result.run_id,
                    plan=plan,
                    findings_total=findings_total,
                )

            self.queue.mark_processed(
                event_id=event_id,
                processing_outcome=receipt.outcome,
                finding_run_id=scan_result.run_id,
                receipt_run_id=plan.run_id,
            )

            response_payload = {
                "event_id": event_id,
                "status": "processed",
                "processing_outcome": receipt.outcome,
                "finding_run_id": scan_result.run_id,
                "receipt_run_id": plan.run_id,
                "scout_names": scout_names,
                "findings_total": findings_total,
                "errors": list(scan_result.errors),
                "pending_token_issued": pending_token_issued,
            }
            if compile_job is not None:
                response_payload["compile_enqueued"] = True
                response_payload["compile_job_id"] = compile_job.get("job_id")
                response_payload["proposal_id"] = compile_job.get("proposal_id")
            return response_payload
        except Exception as exc:
            next_status = self.queue.mark_failed(
                event_id=event_id,
                error=str(exc),
                retryable=True,
            )
            return {
                "event_id": event_id,
                "status": next_status,
                "processing_outcome": "failed",
                "error": str(exc),
            }

    def _control_plane_store(self):
        if self._control_store is None:
            self._control_store = control_plane_store(self.state_dir)
        return self._control_store

    def _enqueue_compile_job(
        self,
        *,
        event: dict[str, Any],
        event_type: str,
        scan_run_id: str,
        plan: Any,
        findings_total: int,
    ) -> dict[str, Any] | None:
        if self.job_queue is None:
            return None
        action_candidates = self._action_candidates_from_plan(plan)
        if not action_candidates:
            return None

        actor = f"webhook:{str(event.get('provider') or 'unknown')}"
        scope = str(event_type or event.get("source_kind") or "crm_event").strip() or "crm_event"
        action_family = ",".join(
            sorted(
                {
                    str((candidate or {}).get("deterministic_action_key") or "").strip()
                    for candidate in action_candidates
                    if str((candidate or {}).get("deterministic_action_key") or "").strip()
                }
            )
        ) or "custom"
        evidence_ids = [
            f"event:{str(event.get('event_id') or '').strip()}",
            f"scan:{str(scan_run_id or '').strip()}",
        ]
        dedupe_window = max(DEFAULT_DEDUPE_WINDOW_SECONDS, 0)
        proposal_signature = compute_proposal_signature(
            scope=scope,
            action_family=action_family,
            evidence_ids=evidence_ids,
            window_bucket=_window_bucket_timestamp(datetime.now(timezone.utc), dedupe_window),
        )
        risk_payload = {"tier": "low"}
        plan_risk = getattr(plan, "risk", None)
        if plan_risk is not None and hasattr(plan_risk, "model_dump"):
            maybe_risk = plan_risk.model_dump(exclude_none=False)
            if isinstance(maybe_risk, dict):
                risk_payload = maybe_risk

        proposal = ProposalV1(
            tenant_id="default",
            source_kind="scout",
            source_refs=[
                f"event_queue:{str(event.get('event_id') or '').strip()}",
                f"scan:{str(scan_run_id or '').strip()}",
            ],
            title=f"Webhook compile for {scope}",
            objective="Compile event-triggered proposal through durable control-plane jobs.",
            action_candidates=action_candidates,
            confidence={"overall": 1.0 if findings_total > 0 else 0.5},
            risk=risk_payload,
            evidence_ids=evidence_ids,
            why_summary=f"approval_required outcome for webhook event {str(event.get('event_id') or '').strip()}",
            proposal_signature=proposal_signature,
            dedupe_window_seconds=dedupe_window,
            created_by=actor,
        )
        store = self._control_plane_store()
        saved, _was_deduped = store.save_proposal(proposal)
        job = store.create_job(
            "compile",
            {"proposal_id": saved.proposal_id, "emit_pending": True},
            actor=actor,
        )
        self.job_queue.enqueue_job(
            job_id=job.job_id,
            job_type=job.job_type,
            payload=job.request,
            actor=job.actor,
        )
        return {"job_id": job.job_id, "proposal_id": saved.proposal_id}

    def _action_candidates_from_plan(self, plan: Any) -> list[dict[str, Any]]:
        candidates: list[dict[str, Any]] = []
        for step in getattr(plan, "steps", []):
            tool_name = str(getattr(step, "tool", "") or "").strip()
            args = getattr(step, "args", None)
            if not isinstance(args, dict):
                args = {}
            action_key = resolve_deterministic_action_key(
                explicit_action_key=str(args.get("deterministic_action_key") or "").strip(),
                tool_name=tool_name,
                action_type=str(args.get("action_type") or "").strip(),
                scout_name=str(args.get("scout_name") or "").strip(),
                action_details=args.get("action_details") if isinstance(args.get("action_details"), dict) else None,
            )
            if not action_key:
                continue
            record_ids: list[str] = []
            raw_record_ids = args.get("record_ids")
            if isinstance(raw_record_ids, list):
                for item in raw_record_ids:
                    candidate = str(item or "").strip()
                    if candidate:
                        record_ids.append(candidate)
            raw_record_id = str(args.get("record_id") or "").strip()
            if raw_record_id:
                record_ids.append(raw_record_id)
            records = args.get("records")
            if isinstance(records, list):
                for row in records:
                    if not isinstance(row, dict):
                        continue
                    candidate = str(row.get("record_id") or "").strip()
                    if candidate:
                        record_ids.append(candidate)
            deduped_record_ids: list[str] = []
            seen: set[str] = set()
            for item in record_ids:
                if item in seen:
                    continue
                seen.add(item)
                deduped_record_ids.append(item)
            if not deduped_record_ids:
                continue

            params: dict[str, Any] = {
                str(key): value
                for key, value in args.items()
                if str(key).strip()
                and str(key).strip() not in {"proposal_id", "source_kind", "deterministic_action_key"}
            }
            params["record_ids"] = deduped_record_ids

            candidates.append(
                {
                    "candidate_id": str(getattr(step, "step_id", "") or uuid.uuid4()),
                    "deterministic_action_key": action_key,
                    "target_selector": {
                        "entity_type": str(args.get("entity_type") or "record").strip() or "record",
                    },
                    "params": params,
                }
            )
        return candidates

    def _target_scout_names(
        self,
        *,
        event: dict[str, Any],
        payload: dict[str, Any],
        metadata: dict[str, Any],
        event_type: str,
        available_names: set[str],
        configured_names: set[str],
    ) -> list[str]:
        explicit = _extract_explicit_scout_targets(event, payload)
        target_names: list[str] = []
        if explicit:
            target_names = list(explicit)

        if not target_names:
            lowered_event_type = str(event_type or "").lower()
            for tokens, mapped in _EVENT_TYPE_TO_SCOUTS:
                if any(token in lowered_event_type for token in tokens):
                    target_names.extend(mapped)

            sobjects = metadata.get("sobjects") if isinstance(metadata.get("sobjects"), list) else []
            for sobject in sobjects:
                for mapped in _SOBJECT_TO_SCOUTS.get(str(sobject).strip().lower(), []):
                    target_names.append(mapped)

            if not target_names:
                target_names.extend(_DEFAULT_REACTIVE_SCOUTS)

        deduped: list[str] = []
        seen: set[str] = set()
        for name in target_names:
            candidate = str(name or "").strip()
            if not candidate:
                continue
            lower = candidate.lower()
            if lower in seen:
                continue
            if candidate not in available_names:
                continue
            seen.add(lower)
            deduped.append(candidate)

        if configured_names:
            deduped = [name for name in deduped if name in configured_names]

        if not deduped and configured_names:
            fallback = [name for name in _DEFAULT_REACTIVE_SCOUTS if name in available_names and name in configured_names]
            deduped = list(dict.fromkeys(fallback))

        return deduped

    def _merged_scout_params(
        self,
        *,
        scout_names: list[str],
        scan_cfg_per_scout: dict[str, Any],
        event_context: dict[str, Any],
    ) -> dict[str, dict[str, Any]]:
        stored = ScoutConfigStore(self.state_dir).load_all()
        merged_per_scout: dict[str, dict[str, Any]] = {}
        for scout_name in scout_names:
            merged: dict[str, Any] = {}
            if isinstance(stored.get(scout_name), dict):
                merged.update(stored.get(scout_name) or {})
            if isinstance(scan_cfg_per_scout.get(scout_name), dict):
                merged.update(scan_cfg_per_scout.get(scout_name) or {})
            # Unknown fields are allowed by the scout engine, so this is a safe
            # channel for future event-scoped filtering support.
            merged["event_context"] = dict(event_context)
            if event_context.get("record_ids"):
                merged["event_record_ids"] = list(event_context["record_ids"])
            if event_context.get("sobjects"):
                merged["event_sobjects"] = list(event_context["sobjects"])
            merged_per_scout[scout_name] = merged
        return merged_per_scout


__all__ = ["CrmEventProcessor"]
