"""Salesforce tool handlers for Autopilot execution."""

from __future__ import annotations

from datetime import datetime, timezone
import os
from pathlib import Path
from typing import Any

from g_gremlin import sfdc as sfdc_helpers
from g_gremlin import sfdc_metadata_pack

from .contracts import PlanStep
from .sequence_pause_budget_store import SequencePauseBudgetStore
from .slack_blocks import build_notify_blocks
from .slack_notify import create_client as slack_create_client
from .tools import AutopilotToolDefinition

_DEFAULT_AUTOPILOT_STATE_DIR = "~/.g_gremlin/autopilot"


def _as_list(value: Any) -> list[str]:
    if value is None:
        return []
    if isinstance(value, list):
        return [str(item) for item in value if item not in (None, "")]
    return [str(value)]


def _extract_targets(step: PlanStep) -> list[str]:
    args = step.args or {}
    for key in ("record_ids", "user_ids", "targets", "ids"):
        value = args.get(key)
        if isinstance(value, list):
            return [str(item) for item in value if item not in (None, "")]
    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 _extract_from_details(step: PlanStep, keys: list[str]) -> list[str]:
    details = step.args.get("action_details") or {}
    if not isinstance(details, dict):
        return []
    values: list[str] = []
    for key in keys:
        values.extend(_as_list(details.get(key)))
    return values


def _task_target_field_name(sobject: str) -> str:
    if (sobject or "").lower() in ("lead", "contact"):
        return "WhoId"
    return "WhatId"


def _extract_task_text(step: PlanStep, key: str) -> str | None:
    value = step.args.get(key)
    if value:
        return str(value)
    details = step.args.get("action_details") or {}
    if isinstance(details, dict):
        raw = details.get(key)
        if raw:
            return str(raw)
    return None


def _summarize_slack_records(records: list[dict[str, Any]]) -> list[str]:
    usernames: list[str] = []
    reason_codes: set[str] = set()
    for row in records:
        if not isinstance(row, dict):
            continue
        fields = row.get("fields") if isinstance(row.get("fields"), dict) else {}
        candidate = fields.get("username") or fields.get("name") or row.get("record_id")
        if candidate:
            value = str(candidate)
            if value not in usernames:
                usernames.append(value)
        reason = row.get("reason_code")
        if reason:
            reason_codes.add(str(reason))
        extra_codes = fields.get("reason_codes")
        if isinstance(extra_codes, list):
            for code in extra_codes:
                if code:
                    reason_codes.add(str(code))

    details: list[str] = []
    if usernames:
        top_users = ", ".join(usernames[:3])
        suffix = " +" if len(usernames) > 3 else ""
        details.append(f"Users: {top_users}{suffix}")
    if reason_codes:
        details.append(f"Reasons: {', '.join(sorted(reason_codes))}")
    return details


def _build_task_values(
    *,
    subject: str,
    description: str | None,
    due_date: str | None,
    owner_id: str | None,
    record_id: str,
    sobject: str,
    row_fields: dict[str, Any] | None = None,
) -> dict[str, Any]:
    values: dict[str, Any] = {
        "Subject": subject,
        "Status": "Not Started",
        "Priority": "Normal",
    }
    if description:
        values["Description"] = description
    if due_date:
        values["ActivityDate"] = due_date
    if owner_id:
        values["OwnerId"] = owner_id
    elif row_fields:
        row_owner = row_fields.get("OwnerId") or row_fields.get("owner_id")
        if row_owner:
            values["OwnerId"] = row_owner

    target_field = _task_target_field_name(sobject)
    values[target_field] = record_id
    return values


def handle_assign_permission_sets(
    step: PlanStep,
    tool_def: AutopilotToolDefinition | None,
) -> dict[str, Any]:
    args = step.args or {}
    user_ids = _as_list(args.get("user_ids") or args.get("record_ids"))
    permission_set_ids = _as_list(args.get("permission_set_ids") or args.get("permission_set_id"))
    if not permission_set_ids:
        permission_set_ids = _as_list(args.get("permission_set_names"))
    if not permission_set_ids:
        permission_set_ids = _extract_from_details(
            step,
            ["permission_set_ids", "permission_set_id", "permission_set_names"],
        )

    if not user_ids or not permission_set_ids:
        raise RuntimeError("Missing user_ids or permission_set_ids for permission set assignment.")

    org_alias = args.get("org_alias")
    successes: list[dict[str, Any]] = []
    failures: list[dict[str, Any]] = []

    for user_id in user_ids:
        for perm_id in permission_set_ids:
            try:
                result = sfdc_helpers.create_record(
                    "PermissionSetAssignment",
                    {"AssigneeId": user_id, "PermissionSetId": perm_id},
                    org_alias=org_alias,
                )
                successes.append({
                    "user_id": user_id,
                    "permission_set_id": perm_id,
                    "result": result,
                })
            except Exception as exc:
                failures.append({
                    "user_id": user_id,
                    "permission_set_id": perm_id,
                    "error": str(exc),
                })

    targets = [f"{user_id}:{perm_id}" for user_id in user_ids for perm_id in permission_set_ids]
    if failures:
        error = RuntimeError(
            f"permission_set_assignment_failed successes={len(successes)} failures={len(failures)}"
        )
        error.successes = successes  # type: ignore[attr-defined]
        error.failures = failures  # type: ignore[attr-defined]
        raise error

    return {
        "targets": targets,
        "before": {"user_ids": user_ids, "permission_set_ids": permission_set_ids},
        "after": {"assignments": successes},
    }


def handle_assign_permission_set_groups(
    step: PlanStep,
    tool_def: AutopilotToolDefinition | None,
) -> dict[str, Any]:
    args = step.args or {}
    user_ids = _as_list(args.get("user_ids") or args.get("record_ids"))
    group_ids = _as_list(args.get("permission_set_group_ids") or args.get("permission_set_group_id"))
    if not group_ids:
        group_ids = _as_list(args.get("permission_set_group_names"))
    if not group_ids:
        group_ids = _extract_from_details(
            step,
            ["permission_set_group_ids", "permission_set_group_id", "permission_set_group_names"],
        )

    if not user_ids or not group_ids:
        raise RuntimeError("Missing user_ids or permission_set_group_ids for group assignment.")

    org_alias = args.get("org_alias")
    successes: list[dict[str, Any]] = []
    failures: list[dict[str, Any]] = []

    for user_id in user_ids:
        for group_id in group_ids:
            try:
                result = sfdc_helpers.create_record(
                    "PermissionSetGroupAssignment",
                    {"AssigneeId": user_id, "PermissionSetGroupId": group_id},
                    org_alias=org_alias,
                )
                successes.append({
                    "user_id": user_id,
                    "permission_set_group_id": group_id,
                    "result": result,
                })
            except Exception as exc:
                failures.append({
                    "user_id": user_id,
                    "permission_set_group_id": group_id,
                    "error": str(exc),
                })

    targets = [f"{user_id}:{group_id}" for user_id in user_ids for group_id in group_ids]
    if failures:
        error = RuntimeError(
            f"permission_set_group_assignment_failed successes={len(successes)} failures={len(failures)}"
        )
        error.successes = successes  # type: ignore[attr-defined]
        error.failures = failures  # type: ignore[attr-defined]
        raise error

    return {
        "targets": targets,
        "before": {"user_ids": user_ids, "permission_set_group_ids": group_ids},
        "after": {"assignments": successes},
    }


def handle_add_to_queue_group(
    step: PlanStep,
    tool_def: AutopilotToolDefinition | None,
) -> dict[str, Any]:
    args = step.args or {}
    user_ids = _as_list(args.get("user_ids") or args.get("record_ids"))
    group_ids = _as_list(args.get("group_ids") or args.get("queue_group_ids"))
    if not group_ids:
        group_ids = _as_list(args.get("group_id") or args.get("queue_group_id"))
    if not group_ids:
        group_ids = _as_list(args.get("group_names") or args.get("queue_names"))
    if not group_ids:
        group_ids = _extract_from_details(
            step,
            ["group_ids", "group_id", "queue_group_ids", "queue_group_id", "group_names", "queue_names"],
        )

    if not user_ids or not group_ids:
        raise RuntimeError("Missing user_ids or group_ids for queue/group membership.")

    org_alias = args.get("org_alias")
    successes: list[dict[str, Any]] = []
    failures: list[dict[str, Any]] = []

    for user_id in user_ids:
        for group_id in group_ids:
            try:
                result = sfdc_helpers.create_record(
                    "GroupMember",
                    {"GroupId": group_id, "UserOrGroupId": user_id},
                    org_alias=org_alias,
                )
                successes.append({
                    "user_id": user_id,
                    "group_id": group_id,
                    "result": result,
                })
            except Exception as exc:
                failures.append({
                    "user_id": user_id,
                    "group_id": group_id,
                    "error": str(exc),
                })

    targets = [f"{user_id}:{group_id}" for user_id in user_ids for group_id in group_ids]
    if failures:
        error = RuntimeError(
            f"group_membership_failed successes={len(successes)} failures={len(failures)}"
        )
        error.successes = successes  # type: ignore[attr-defined]
        error.failures = failures  # type: ignore[attr-defined]
        raise error

    return {
        "targets": targets,
        "before": {"user_ids": user_ids, "group_ids": group_ids},
        "after": {"memberships": successes},
    }


def handle_forecast_assign_user_to_role(
    step: PlanStep,
    tool_def: AutopilotToolDefinition | None,
) -> dict[str, Any]:
    args = step.args or {}
    user_ids = _as_list(args.get("user_ids") or args.get("record_ids"))
    role_ids = _as_list(args.get("role_ids") or args.get("role_id") or args.get("forecast_role_id"))
    if not role_ids:
        role_ids = _extract_from_details(
            step,
            ["role_ids", "role_id", "forecast_role_id", "forecast_role_ids"],
        )
    assignments = args.get("assignments") or args.get("role_assignments")
    if assignments and isinstance(assignments, list):
        user_ids = [str(item.get("user_id")) for item in assignments if item.get("user_id")]
        role_ids = [str(item.get("role_id")) for item in assignments if item.get("role_id")]

    if not user_ids or not role_ids:
        raise RuntimeError("Missing user_ids or role_ids for forecast assignment.")

    if len(role_ids) not in (1, len(user_ids)):
        raise RuntimeError("role_ids count must be 1 or match user_ids count.")

    org_alias = args.get("org_alias")
    sobject = args.get("sobject") or "User"
    role_field = args.get("role_field") or "ForecastingRoleId"

    successes: list[dict[str, Any]] = []
    failures: list[dict[str, Any]] = []
    for idx, user_id in enumerate(user_ids):
        role_id = role_ids[0] if len(role_ids) == 1 else role_ids[idx]
        try:
            result = sfdc_helpers.update_record(
                sobject,
                user_id,
                {role_field: role_id},
                org_alias=org_alias,
            )
            successes.append({
                "user_id": user_id,
                "role_id": role_id,
                "result": result,
            })
        except Exception as exc:
            failures.append({
                "user_id": user_id,
                "role_id": role_id,
                "error": str(exc),
            })

    targets = [f"{user_id}:{role_ids[0] if len(role_ids) == 1 else role_ids[idx]}" for idx, user_id in enumerate(user_ids)]
    if failures:
        error = RuntimeError(
            f"forecast_role_assignment_failed successes={len(successes)} failures={len(failures)}"
        )
        error.successes = successes  # type: ignore[attr-defined]
        error.failures = failures  # type: ignore[attr-defined]
        raise error

    return {
        "targets": targets,
        "before": {"user_ids": user_ids, "role_ids": role_ids},
        "after": {"assignments": successes},
    }


def handle_sfdc_user_deactivate(
    step: PlanStep,
    tool_def: AutopilotToolDefinition | None,
) -> dict[str, Any]:
    args = step.args or {}
    user_ids = _as_list(args.get("user_ids") or args.get("record_ids"))
    if not user_ids:
        raise RuntimeError("Missing user_ids for user deactivation.")

    org_alias = args.get("org_alias")
    escaped = ", ".join(f"'{sfdc_helpers.escape_soql_value(i)}'" for i in user_ids)
    precheck_query = (
        "SELECT Id, IsActive, Profile.Name "
        "FROM User "
        f"WHERE Id IN ({escaped})"
    )
    rows = sfdc_helpers.run_soql(precheck_query, org_alias=org_alias)

    found_ids = {row.get("Id") for row in rows if row.get("Id")}
    missing = [user_id for user_id in user_ids if user_id not in found_ids]
    if missing:
        raise RuntimeError(f"Missing users for deactivation: {missing}")

    admin_users = []
    for row in rows:
        profile_name = str(row.get("Profile.Name") or "")
        if "system administrator" in profile_name.lower():
            admin_users.append(row.get("Id"))
    if admin_users:
        raise RuntimeError(f"Cannot deactivate System Administrators: {admin_users}")

    successes: list[dict[str, Any]] = []
    failures: list[dict[str, Any]] = []
    for row in rows:
        user_id = row.get("Id")
        is_active = row.get("IsActive")
        if not user_id:
            continue
        if str(is_active).lower() in ("false", "0"):
            successes.append({"user_id": user_id, "result": {"status": "already_inactive"}})
            continue
        try:
            result = sfdc_helpers.update_record(
                "User",
                user_id,
                {"IsActive": False},
                org_alias=org_alias,
            )
            successes.append({"user_id": user_id, "result": result})
        except Exception as exc:
            failures.append({"user_id": user_id, "error": str(exc)})

    if failures:
        error = RuntimeError(
            f"user_deactivation_failed successes={len(successes)} failures={len(failures)}"
        )
        error.successes = successes  # type: ignore[attr-defined]
        error.failures = failures  # type: ignore[attr-defined]
        raise error

    postcheck_query = (
        "SELECT Id, IsActive "
        "FROM User "
        f"WHERE Id IN ({escaped})"
    )
    post_rows = sfdc_helpers.run_soql(postcheck_query, org_alias=org_alias)
    still_active = [row.get("Id") for row in post_rows if str(row.get("IsActive")).lower() in ("true", "1")]
    postcheck_warning = None
    if still_active:
        postcheck_warning = f"Users still active: {still_active}"

    return {
        "targets": list(user_ids),
        "before": {"user_ids": list(user_ids)},
        "after": {
            "deactivated": successes,
            "failed": failures,
            "postcheck_warning": postcheck_warning,
        },
    }


def handle_sfdc_record_update(
    step: PlanStep,
    tool_def: AutopilotToolDefinition | None,
) -> dict[str, Any]:
    args = step.args or {}
    org_alias = args.get("org_alias")
    sobject = args.get("sobject") or args.get("target_sobject") or "Opportunity"

    updates: list[tuple[str, dict[str, Any]]] = []
    record_id = args.get("record_id")
    fields = args.get("fields")
    if record_id and isinstance(fields, dict):
        updates.append((str(record_id), fields))
    elif isinstance(args.get("records"), list):
        for row in args.get("records") or []:
            if not isinstance(row, dict):
                continue
            rid = row.get("record_id")
            row_fields = row.get("fields") if isinstance(row.get("fields"), dict) else {}
            if isinstance(row_fields, dict) and "proposed_diffs" in row_fields:
                row_fields = row_fields.get("proposed_diffs") or {}
            if rid and isinstance(row_fields, dict):
                updates.append((str(rid), row_fields))

    if not updates:
        raise RuntimeError("Missing record updates for sfdc.record.update.")

    successes: list[dict[str, Any]] = []
    failures: list[dict[str, Any]] = []
    targets: list[str] = []

    for rid, fields_map in updates:
        if not fields_map:
            continue
        targets.append(rid)
        before_snapshot = None
        try:
            escaped_id = sfdc_helpers.escape_soql_value(rid)
            select_fields = ", ".join(["Id"] + list(fields_map.keys()))
            query = f"SELECT {select_fields} FROM {sobject} WHERE Id = '{escaped_id}'"
            rows = sfdc_helpers.run_soql(query, org_alias=org_alias)
            before_snapshot = rows[0] if rows else None
        except Exception:
            before_snapshot = None

        try:
            result = sfdc_helpers.update_record(sobject, rid, fields_map, org_alias=org_alias)
            successes.append({
                "record_id": rid,
                "before": before_snapshot,
                "result": result,
            })
        except Exception as exc:
            failures.append({
                "record_id": rid,
                "error": str(exc),
            })

    if failures:
        error = RuntimeError(
            f"sfdc_record_update_failed successes={len(successes)} failures={len(failures)}"
        )
        error.successes = successes  # type: ignore[attr-defined]
        error.failures = failures  # type: ignore[attr-defined]
        raise error

    return {
        "targets": targets,
        "before": {"records": [s.get("before") for s in successes]},
        "after": {"updates": successes},
    }


def handle_sfdc_record_create(
    step: PlanStep,
    tool_def: AutopilotToolDefinition | None,
) -> dict[str, Any]:
    del tool_def
    args = step.args or {}
    org_alias = args.get("org_alias")
    sobject = args.get("sobject") or args.get("target_sobject")
    if not sobject:
        raise RuntimeError("sobject is required for sfdc.record.create.")

    creates: list[dict[str, Any]] = []
    values = args.get("values")
    if isinstance(values, dict):
        creates.append({"values": dict(values)})
    elif isinstance(args.get("records"), list):
        for row in args.get("records") or []:
            if not isinstance(row, dict):
                continue
            row_values = row.get("values") if isinstance(row.get("values"), dict) else None
            if not isinstance(row_values, dict) or not row_values:
                continue
            creates.append({"values": dict(row_values), "target": row.get("target")})

    if not creates:
        raise RuntimeError("Missing create payload for sfdc.record.create.")

    successes: list[dict[str, Any]] = []
    failures: list[dict[str, Any]] = []
    targets: list[str] = []

    for item in creates:
        values_map = item.get("values") if isinstance(item, dict) else None
        if not isinstance(values_map, dict) or not values_map:
            continue
        try:
            result = sfdc_helpers.create_record(str(sobject), values_map, org_alias=org_alias)
            result_payload = result.get("result") if isinstance(result.get("result"), dict) else result
            created_id = (
                str(result_payload.get("id") or result_payload.get("Id") or "").strip()
                if isinstance(result_payload, dict)
                else ""
            )
            target = str(item.get("target") or created_id or "").strip()
            if target:
                targets.append(target)
            successes.append(
                {
                    "id": created_id or None,
                    "target": target or None,
                    "sobject": str(sobject),
                    "values": dict(values_map),
                    "result": result,
                }
            )
        except Exception as exc:
            failures.append(
                {
                    "target": item.get("target"),
                    "sobject": str(sobject),
                    "values": dict(values_map),
                    "error": str(exc),
                }
            )

    if failures:
        error = RuntimeError(
            f"sfdc_record_create_failed successes={len(successes)} failures={len(failures)}"
        )
        error.successes = successes  # type: ignore[attr-defined]
        error.failures = failures  # type: ignore[attr-defined]
        raise error

    return {
        "targets": targets,
        "before": None,
        "after": {"created": successes},
    }


def handle_sfdc_record_delete(
    step: PlanStep,
    tool_def: AutopilotToolDefinition | None,
) -> dict[str, Any]:
    del tool_def
    args = step.args or {}
    org_alias = args.get("org_alias")
    sobject = args.get("sobject") or args.get("target_sobject")
    if not sobject:
        raise RuntimeError("sobject is required for sfdc.record.delete.")

    deletes: list[str] = []
    record_id = args.get("record_id")
    if record_id:
        deletes.append(str(record_id))
    elif isinstance(args.get("record_ids"), list):
        deletes.extend(str(item) for item in args.get("record_ids") or [] if str(item or "").strip())
    elif isinstance(args.get("records"), list):
        for row in args.get("records") or []:
            if not isinstance(row, dict):
                continue
            rid = str(row.get("record_id") or "").strip()
            if rid:
                deletes.append(rid)

    if not deletes:
        raise RuntimeError("Missing record_id for sfdc.record.delete.")

    successes: list[dict[str, Any]] = []
    failures: list[dict[str, Any]] = []
    targets: list[str] = []

    for rid in deletes:
        targets.append(rid)
        before_snapshot = None
        try:
            escaped_id = sfdc_helpers.escape_soql_value(rid)
            query = f"SELECT Id FROM {sobject} WHERE Id = '{escaped_id}'"
            rows = sfdc_helpers.run_soql(query, org_alias=org_alias)
            before_snapshot = rows[0] if rows else None
        except Exception:
            before_snapshot = None

        try:
            result = sfdc_helpers.delete_record(str(sobject), rid, org_alias=org_alias)
            successes.append(
                {
                    "record_id": rid,
                    "before": before_snapshot,
                    "result": result,
                }
            )
        except Exception as exc:
            failures.append(
                {
                    "record_id": rid,
                    "error": str(exc),
                }
            )

    if failures:
        error = RuntimeError(
            f"sfdc_record_delete_failed successes={len(successes)} failures={len(failures)}"
        )
        error.successes = successes  # type: ignore[attr-defined]
        error.failures = failures  # type: ignore[attr-defined]
        raise error

    return {
        "targets": targets,
        "before": {"records": [s.get("before") for s in successes]},
        "after": {"deleted": successes},
    }


_AUTO_ENRICH_DEFAULT_FIELDS = ("Website", "Industry", "NumberOfEmployees")
_AUTO_ENRICH_REASON_FIELD_MAP = {
    "MISSING_WEBSITE": "Website",
    "MISSING_INDUSTRY": "Industry",
    "MISSING_FIRMOGRAPHICS": "NumberOfEmployees",
}
_AUTO_ENRICH_PROVIDER_CONFIDENCE = {
    "zoominfo": 0.86,
    "apollo": 0.82,
    "clearbit": 0.8,
    "pdl": 0.78,
    "apify": 0.75,
}
_AUTO_ENRICH_FIELD_ALIASES = {
    "Website": ("website", "website_url", "domain", "company_domain"),
    "Industry": ("industry", "company_industry"),
    "NumberOfEmployees": ("employee_count", "employees", "estimated_num_employees", "number_of_employees"),
}


def _normalize_provider_list(value: Any) -> list[str]:
    providers = _as_list(value)
    normalized: list[str] = []
    seen: set[str] = set()
    for provider in providers:
        cleaned = str(provider or "").strip().lower()
        if not cleaned or cleaned in seen:
            continue
        normalized.append(cleaned)
        seen.add(cleaned)
    return normalized


def _row_value(row: dict[str, Any], field_name: str) -> Any:
    if field_name in row:
        return row.get(field_name)
    lowered = field_name.lower()
    for key, value in row.items():
        if str(key).lower() == lowered:
            return value
    return None


def _is_blank(value: Any) -> bool:
    return value in (None, "")


def _normalize_numeric(value: Any) -> int | None:
    if value in (None, ""):
        return None
    if isinstance(value, bool):
        return None
    if isinstance(value, int):
        return value
    if isinstance(value, float):
        return int(value)
    try:
        cleaned = str(value).replace(",", "").strip()
        if not cleaned:
            return None
        return int(float(cleaned))
    except Exception:
        return None


def _extract_enriched_field_value(result: dict[str, Any], target_field: str) -> Any:
    aliases = _AUTO_ENRICH_FIELD_ALIASES.get(target_field, ())
    for alias in aliases:
        for key, value in result.items():
            if str(key).strip().lower() == alias and not _is_blank(value):
                return value
    return None


def _provider_is_configured(provider: str) -> bool:
    try:
        from g_gremlin.credentials import INTEGRATION_REGISTRY, resolve_credentials
    except Exception:
        return False

    spec = INTEGRATION_REGISTRY.get(provider)
    if spec is None:
        return False
    required = list(spec.required_keys or [])
    try:
        resolve_credentials(provider, required_keys=required)
    except Exception:
        return False
    return True


def _auto_enrich_state_dir(args: dict[str, Any], details: dict[str, Any]) -> Path:
    raw_state_dir = (
        args.get("state_dir")
        or details.get("state_dir")
        or os.getenv("G_GREMLIN_STATE_DIR")
        or _DEFAULT_AUTOPILOT_STATE_DIR
    )
    resolved = Path(os.path.expandvars(os.path.expanduser(str(raw_state_dir)))).resolve()
    (resolved / "db").mkdir(parents=True, exist_ok=True)
    return resolved


def _normalize_domain(value: Any) -> str | None:
    raw = str(value or "").strip().lower()
    if not raw:
        return None
    if "://" in raw:
        raw = raw.split("://", 1)[1]
    raw = raw.split("/", 1)[0]
    if raw.startswith("www."):
        raw = raw[4:]
    return raw or None


def handle_sfdc_record_enrich_missing_fields(
    step: PlanStep,
    tool_def: AutopilotToolDefinition | None,
) -> dict[str, Any]:
    del tool_def
    args = step.args or {}
    details = args.get("action_details") if isinstance(args.get("action_details"), dict) else {}

    org_alias = args.get("org_alias")
    sobject_fallback = str(args.get("sobject") or details.get("sobject") or "Account")
    only_fill_empty = bool(
        args.get("only_fill_empty_fields")
        if args.get("only_fill_empty_fields") is not None
        else details.get("only_fill_empty_fields", True)
    )
    try:
        confidence_floor = float(
            args.get("confidence_floor")
            if args.get("confidence_floor") is not None
            else details.get("confidence_floor", 0.7)
        )
    except Exception:
        confidence_floor = 0.7
    confidence_floor = min(max(confidence_floor, 0.0), 1.0)

    try:
        max_provider_calls = int(
            args.get("max_provider_calls_per_run")
            if args.get("max_provider_calls_per_run") is not None
            else details.get("max_provider_calls_per_run", 100)
        )
    except Exception:
        max_provider_calls = 100
    max_provider_calls = max(1, max_provider_calls)

    target_fields = _as_list(args.get("target_fields") or details.get("target_fields"))
    if not target_fields:
        target_fields = list(_AUTO_ENRICH_DEFAULT_FIELDS)
    target_fields = [field for field in target_fields if field]
    allowed_field_set = {field.lower() for field in target_fields}

    providers = _normalize_provider_list(
        args.get("providers") if args.get("providers") is not None else details.get("providers")
    )
    if not providers:
        providers = ["zoominfo", "apollo", "clearbit"]

    raw_rows = args.get("records") if isinstance(args.get("records"), list) else []
    candidate_rows: list[dict[str, Any]] = [row for row in raw_rows if isinstance(row, dict)]
    if not candidate_rows:
        candidate_rows = [
            {"record_id": record_id, "sobject": sobject_fallback, "fields": {}, "missing_fields": list(target_fields)}
            for record_id in _as_list(args.get("record_ids"))
        ]
    if not candidate_rows:
        raise RuntimeError("Missing record targets for sfdc.record.enrich_missing_fields.")

    grouped: dict[str, dict[str, Any]] = {}
    for row in candidate_rows:
        record_id = str(row.get("record_id") or "").strip()
        if not record_id:
            continue
        fields = row.get("fields") if isinstance(row.get("fields"), dict) else {}
        missing_fields = _as_list(row.get("missing_fields"))
        if not missing_fields and isinstance(fields, dict):
            for key, value in fields.items():
                key_name = str(key or "").strip()
                if not key_name or key_name.lower() not in allowed_field_set:
                    continue
                if _is_blank(value):
                    missing_fields.append(key_name)
        reason_field = _AUTO_ENRICH_REASON_FIELD_MAP.get(str(row.get("reason_code") or "").upper())
        if reason_field:
            missing_fields.append(reason_field)
        missing_fields = [
            field
            for field in dict.fromkeys(missing_fields)
            if str(field).strip() and str(field).strip().lower() in allowed_field_set
        ]
        if not missing_fields:
            continue

        bucket = grouped.setdefault(
            record_id,
            {
                "record_id": record_id,
                "sobject": str(row.get("sobject") or sobject_fallback),
                "fields": {},
                "missing_fields": [],
            },
        )
        bucket_fields = bucket.get("fields") if isinstance(bucket.get("fields"), dict) else {}
        if isinstance(fields, dict):
            bucket_fields.update(fields)
            bucket["fields"] = bucket_fields
        bucket["missing_fields"] = list(
            dict.fromkeys(_as_list(bucket.get("missing_fields")) + missing_fields)
        )

    if not grouped:
        return {
            "targets": [],
            "before": {"records": []},
            "after": {
                "status": "no_targets",
                "providers": providers,
                "configured_providers": [],
                "provider_calls": 0,
                "records": [],
            },
        }

    configured_providers = [provider for provider in providers if _provider_is_configured(provider)]
    if not configured_providers:
        targets = list(grouped.keys())
        return {
            "targets": targets,
            "before": {"records": []},
            "after": {
                "status": "no_providers_configured",
                "providers": providers,
                "configured_providers": [],
                "provider_calls": 0,
                "records": [],
            },
        }

    state_dir = _auto_enrich_state_dir(args, details)
    identity_graph = None
    provenance_store = None
    try:
        from g_gremlin.autopilot.identity_graph import IdentityGraph
        from g_gremlin.autopilot.field_provenance import FieldProvenanceStore

        db_path = state_dir / "db" / "platform.sqlite"
        identity_graph = IdentityGraph(db_path)
        provenance_store = FieldProvenanceStore(db_path)
    except Exception:
        identity_graph = None
        provenance_store = None

    from g_gremlin import enrich as enrich_helpers
    from g_gremlin.scout.field_mapping import FieldMapper

    mapper = FieldMapper("salesforce")

    successes: list[dict[str, Any]] = []
    skipped: list[dict[str, Any]] = []
    failures: list[dict[str, Any]] = []
    before_records: list[dict[str, Any]] = []
    after_records: list[dict[str, Any]] = []
    observations: list[dict[str, Any]] = []
    provider_calls = 0

    for record in grouped.values():
        record_id = str(record.get("record_id") or "").strip()
        sobject = str(record.get("sobject") or sobject_fallback)
        native_sobject = mapper.native_object(sobject) or sobject
        if not record_id:
            continue

        select_fields = ["Id", "Name"] + [field for field in target_fields if field not in ("Id", "Name")]
        select_clause = ", ".join(dict.fromkeys(select_fields))
        escaped_id = sfdc_helpers.escape_soql_value(record_id)
        query = f"SELECT {select_clause} FROM {native_sobject} WHERE Id = '{escaped_id}'"
        rows = sfdc_helpers.run_soql(query, org_alias=org_alias)
        before_row = rows[0] if rows else {}
        if not isinstance(before_row, dict):
            before_row = {}
        before_records.append(before_row)

        requested_missing = _as_list(record.get("missing_fields"))
        if not requested_missing:
            skipped.append({"record_id": record_id, "reason": "no_missing_fields"})
            continue

        missing_fields: list[str] = []
        for field_name in requested_missing:
            if field_name.lower() not in allowed_field_set:
                continue
            current_value = _row_value(before_row, field_name)
            if only_fill_empty and not _is_blank(current_value):
                continue
            missing_fields.append(field_name)
        missing_fields = list(dict.fromkeys(missing_fields))
        if not missing_fields:
            skipped.append({"record_id": record_id, "reason": "no_empty_target_fields"})
            continue

        identifier = (
            _row_value(before_row, "Website")
            or _row_value(before_row, "Name")
            or (record.get("fields") or {}).get("Website")
            or (record.get("fields") or {}).get("Name")
        )
        identifier_text = str(identifier or "").strip()
        if not identifier_text:
            skipped.append({"record_id": record_id, "reason": "missing_lookup_identifier"})
            continue

        updates: dict[str, Any] = {}
        provenance_by_field: dict[str, dict[str, Any]] = {}
        provider_errors: list[dict[str, Any]] = []

        for provider in configured_providers:
            if provider_calls >= max_provider_calls:
                break
            unresolved_fields = [field for field in missing_fields if field not in updates]
            if not unresolved_fields:
                break

            requested_provider_fields = sorted(
                {
                    alias
                    for field in unresolved_fields
                    for alias in _AUTO_ENRICH_FIELD_ALIASES.get(field, ())
                }
            )
            provider_calls += 1
            try:
                provider_result = enrich_helpers._provider_lookup(
                    provider,
                    identifier_text,
                    requested_provider_fields,
                )
            except Exception as exc:
                provider_errors.append({"provider": provider, "error": str(exc)})
                continue
            if not isinstance(provider_result, dict):
                continue

            provider_confidence = float(_AUTO_ENRICH_PROVIDER_CONFIDENCE.get(provider, 0.75))
            if provider_confidence < confidence_floor:
                continue

            for field_name in unresolved_fields:
                value = _extract_enriched_field_value(provider_result, field_name)
                if _is_blank(value):
                    continue
                normalized_value: Any = value
                if field_name == "NumberOfEmployees":
                    normalized_value = _normalize_numeric(value)
                if _is_blank(normalized_value):
                    continue
                updates[field_name] = normalized_value
                provenance_by_field[field_name] = {
                    "provider": provider,
                    "confidence": provider_confidence,
                }

        if provider_calls >= max_provider_calls:
            skipped.append({"record_id": record_id, "reason": "max_provider_calls_reached"})
            break

        if not updates:
            skipped.append(
                {
                    "record_id": record_id,
                    "reason": "no_enrichment_data",
                    "provider_errors": provider_errors,
                }
            )
            continue

        try:
            result = sfdc_helpers.update_record(native_sobject, record_id, updates, org_alias=org_alias)
        except Exception as exc:
            failures.append({"record_id": record_id, "error": str(exc), "updates": updates})
            continue

        success_entry = {
            "record_id": record_id,
            "sobject": native_sobject,
            "updates": updates,
            "result": result,
            "provenance": provenance_by_field,
            "provider_errors": provider_errors,
        }
        successes.append(success_entry)

        entity_id = ""
        if identity_graph is not None:
            try:
                entity_id = identity_graph.link_record(
                    source_system="salesforce",
                    source_object=native_sobject,
                    source_record_id=record_id,
                    source_org_id=str(org_alias or ""),
                    entity_type="account",
                    canonical_name=str(_row_value(before_row, "Name") or ""),
                    canonical_domain=_normalize_domain(updates.get("Website") or _row_value(before_row, "Website")),
                    linked_by="module:auto_enrich_missing_fields",
                )
            except Exception:
                entity_id = ""

        for field_name, field_value in updates.items():
            provider_meta = provenance_by_field.get(field_name, {})
            provider = str(provider_meta.get("provider") or "unknown")
            confidence = float(provider_meta.get("confidence") or 0.0)
            after_records.append(
                {
                    "record_id": record_id,
                    "sobject": native_sobject,
                    "field_name": field_name,
                    "field_value": field_value,
                    "provider": provider,
                    "confidence": confidence,
                }
            )
            observations.append(
                {
                    "source_system": provider,
                    "source_object": native_sobject,
                    "source_record_id": record_id,
                    "field_name": field_name,
                    "field_value": field_value,
                    "entity_id": entity_id,
                    "confidence": confidence,
                    "updated_by": "module:auto_enrich_missing_fields",
                }
            )

    if observations and provenance_store is not None:
        try:
            provenance_store.record_observations_batch(observations)
        except Exception:
            pass

    if failures:
        error = RuntimeError(
            f"sfdc_record_enrich_missing_fields_failed successes={len(successes)} failures={len(failures)}"
        )
        error.successes = successes  # type: ignore[attr-defined]
        error.failures = failures  # type: ignore[attr-defined]
        raise error

    targets = [entry["record_id"] for entry in successes] or list(grouped.keys())
    status = "executed" if successes else "no_changes"
    return {
        "targets": targets,
        "before": {"records": before_records},
        "after": {
            "status": status,
            "providers": providers,
            "configured_providers": configured_providers,
            "provider_calls": provider_calls,
            "records": after_records,
            "updated_records": successes,
            "skipped_records": skipped,
        },
    }


def handle_sfdc_record_reassign_owner(
    step: PlanStep,
    tool_def: AutopilotToolDefinition | None,
) -> dict[str, Any]:
    args = step.args or {}
    record_ids = _as_list(args.get("record_ids") or args.get("targets") or args.get("ids"))
    sobject = args.get("sobject") or args.get("target_sobject")
    new_owner_id = args.get("new_owner_id") or args.get("owner_id")
    if not record_ids or not sobject or not new_owner_id:
        details = args.get("action_details") or {}
        if not new_owner_id and isinstance(details, dict):
            new_owner_id = details.get("new_owner_id") or details.get("default_owner_id")
        if not sobject and isinstance(details, dict):
            sobject = details.get("sobject")
    if not record_ids or not sobject or not new_owner_id:
        raise RuntimeError("Missing sobject, record_ids, or new_owner_id for reassignment.")

    from g_gremlin.scout.field_mapping import FieldMapper

    org_alias = args.get("org_alias")
    mapper = FieldMapper("salesforce")
    native_sobject = mapper.native_object(str(sobject)) or str(sobject)

    owner_query = (
        "SELECT Id, IsActive, Profile.Name "
        "FROM User "
        f"WHERE Id = '{sfdc_helpers.escape_soql_value(new_owner_id)}'"
    )
    owner_rows = sfdc_helpers.run_soql(owner_query, org_alias=org_alias)
    if not owner_rows:
        queue_query = (
            "SELECT Id, Type "
            "FROM Group "
            f"WHERE Id = '{sfdc_helpers.escape_soql_value(new_owner_id)}' AND Type = 'Queue'"
        )
        queue_rows = sfdc_helpers.run_soql(queue_query, org_alias=org_alias)
        if not queue_rows:
            raise RuntimeError(f"Owner {new_owner_id} not found (user or queue).")
    else:
        if str(owner_rows[0].get("IsActive")).lower() in ("false", "0"):
            raise RuntimeError(f"User {new_owner_id} is inactive.")

    original_owners: dict[str, str] = {}
    if len(record_ids) <= 100:
        escaped = ", ".join(f"'{sfdc_helpers.escape_soql_value(i)}'" for i in record_ids)
        snapshot_query = (
            f"SELECT Id, OwnerId FROM {native_sobject} WHERE Id IN ({escaped})"
        )
        snapshot_rows = sfdc_helpers.run_soql(snapshot_query, org_alias=org_alias)
        original_owners = {
            str(row.get("Id")): str(row.get("OwnerId"))
            for row in snapshot_rows
            if row.get("Id") and row.get("OwnerId")
        }

    successes: list[dict[str, Any]] = []
    failures: list[dict[str, Any]] = []
    for record_id in record_ids:
        try:
            result = sfdc_helpers.update_record(
                native_sobject,
                record_id,
                {"OwnerId": new_owner_id},
                org_alias=org_alias,
            )
            successes.append({"record_id": record_id, "result": result})
        except Exception as exc:
            failures.append({"record_id": record_id, "error": str(exc)})

    if failures:
        error = RuntimeError(
            f"record_reassignment_failed successes={len(successes)} failures={len(failures)}"
        )
        error.successes = successes  # type: ignore[attr-defined]
        error.failures = failures  # type: ignore[attr-defined]
        raise error

    escaped = ", ".join(f"'{sfdc_helpers.escape_soql_value(i)}'" for i in record_ids)
    post_query = f"SELECT Id, OwnerId FROM {native_sobject} WHERE Id IN ({escaped})"
    post_rows = sfdc_helpers.run_soql(post_query, org_alias=org_alias)
    not_reassigned = [
        row.get("Id")
        for row in post_rows
        if row.get("Id") and str(row.get("OwnerId")) != str(new_owner_id)
    ]
    postcheck_warning = None
    if not_reassigned:
        postcheck_warning = f"Records not reassigned: {not_reassigned}"

    return {
        "targets": list(record_ids),
        "before": {"record_ids": list(record_ids), "original_owners": original_owners},
        "after": {
            "reassigned": successes,
            "failed": failures,
            "postcheck_warning": postcheck_warning,
            "rollback_data": {"original_owners": original_owners, "sobject": native_sobject}
            if original_owners
            else None,
        },
    }


def handle_tasks_create(
    step: PlanStep,
    tool_def: AutopilotToolDefinition | None,
) -> dict[str, Any]:
    args = step.args or {}
    org_alias = args.get("org_alias")
    subject = _extract_task_text(step, "subject") or "Autopilot follow-up"
    description = _extract_task_text(step, "description")
    due_date = _extract_task_text(step, "due_date")
    owner_id = _extract_task_text(step, "owner_id")

    records = args.get("records") if isinstance(args.get("records"), list) else []
    record_ids = _as_list(args.get("record_ids"))
    sobject = args.get("sobject") or args.get("target_sobject") or ""

    if not records and not record_ids:
        raise RuntimeError("Missing record targets for task creation.")

    if records:
        targets = [str(row.get("record_id")) for row in records if row.get("record_id")]
    else:
        targets = list(record_ids)

    successes: list[dict[str, Any]] = []
    failures: list[dict[str, Any]] = []

    if records:
        for row in records:
            record_id = row.get("record_id")
            row_sobject = row.get("sobject") or sobject
            if not record_id:
                continue
            values = _build_task_values(
                subject=subject,
                description=description,
                due_date=due_date,
                owner_id=owner_id,
                record_id=record_id,
                sobject=str(row_sobject),
                row_fields=row.get("fields") if isinstance(row.get("fields"), dict) else None,
            )

            try:
                result = sfdc_helpers.create_record("Task", values, org_alias=org_alias)
                successes.append({"record_id": record_id, "sobject": row_sobject, "result": result})
            except Exception as exc:
                failures.append({"record_id": record_id, "sobject": row_sobject, "error": str(exc)})
    else:
        for record_id in record_ids:
            values = _build_task_values(
                subject=subject,
                description=description,
                due_date=due_date,
                owner_id=owner_id,
                record_id=record_id,
                sobject=str(sobject),
                row_fields=None,
            )
            try:
                result = sfdc_helpers.create_record("Task", values, org_alias=org_alias)
                successes.append({"record_id": record_id, "sobject": sobject, "result": result})
            except Exception as exc:
                failures.append({"record_id": record_id, "sobject": sobject, "error": str(exc)})

    if failures:
        error = RuntimeError(f"task_create_failed successes={len(successes)} failures={len(failures)}")
        error.successes = successes  # type: ignore[attr-defined]
        error.failures = failures  # type: ignore[attr-defined]
        raise error

    return {
        "targets": targets,
        "before": None,
        "after": {"created": successes},
    }


def _is_opportunity_id(value: Any) -> bool:
    raw = str(value or "").strip()
    return len(raw) >= 3 and raw[:3] == "006"


def handle_sfdc_followup_enforce(
    step: PlanStep,
    tool_def: AutopilotToolDefinition | None,
) -> dict[str, Any]:
    del tool_def
    args = step.args or {}
    details = args.get("action_details") if isinstance(args.get("action_details"), dict) else {}
    org_alias = args.get("org_alias")

    subject = _extract_task_text(step, "subject") or "Autopilot follow-up"
    description = _extract_task_text(step, "description")
    due_date = _extract_task_text(step, "due_date")
    if not due_date:
        due_at = details.get("due_at") if isinstance(details, dict) else None
        if due_at not in (None, ""):
            due_date = str(due_at)[:10]
    if not due_date:
        due_date = datetime.now(timezone.utc).date().isoformat()
    owner_id = _extract_task_text(step, "owner_id")

    apply_stage_hold = bool(
        args.get("apply_stage_hold")
        if args.get("apply_stage_hold") is not None
        else details.get("apply_stage_hold", False)
    )
    hold_stage_name = str(
        args.get("hold_stage_name")
        or details.get("hold_stage_name")
        or "Follow-up Required"
    )
    notify_slack = bool(
        args.get("notify_slack")
        if args.get("notify_slack") is not None
        else details.get("notify_slack", False)
    )
    slack_channel = str(args.get("slack_channel") or details.get("slack_channel") or "").strip()
    slack_severity = str(args.get("slack_severity") or details.get("slack_severity") or "high").strip()

    records = args.get("records") if isinstance(args.get("records"), list) else []
    record_ids = _as_list(args.get("record_ids"))
    if not records and not record_ids:
        raise RuntimeError("Missing record targets for follow-up enforcement.")

    if not records:
        records = [{"record_id": record_id, "sobject": "Event", "fields": {}} for record_id in record_ids]

    targets: list[str] = []
    created_tasks: list[dict[str, Any]] = []
    task_failures: list[dict[str, Any]] = []
    stage_updates: list[dict[str, Any]] = []
    stage_failures: list[dict[str, Any]] = []
    stage_skipped: list[dict[str, Any]] = []

    for row in records:
        if not isinstance(row, dict):
            continue
        record_id = str(row.get("record_id") or "").strip()
        if not record_id:
            continue
        targets.append(record_id)
        row_sobject = str(row.get("sobject") or "Event")
        row_fields = row.get("fields") if isinstance(row.get("fields"), dict) else {}
        per_row_owner = owner_id or (row_fields.get("OwnerId") if isinstance(row_fields, dict) else None)

        values = _build_task_values(
            subject=subject,
            description=description,
            due_date=due_date,
            owner_id=str(per_row_owner) if per_row_owner not in (None, "") else None,
            record_id=record_id,
            sobject=row_sobject,
            row_fields=row_fields if isinstance(row_fields, dict) else None,
        )
        try:
            result = sfdc_helpers.create_record("Task", values, org_alias=org_alias)
            created_tasks.append(
                {
                    "event_id": record_id,
                    "sobject": row_sobject,
                    "task": result,
                }
            )
        except Exception as exc:
            task_failures.append({"event_id": record_id, "sobject": row_sobject, "error": str(exc)})
            continue

        if not apply_stage_hold:
            continue
        what_id = ""
        if isinstance(row_fields, dict):
            what_id = str(row_fields.get("WhatId") or row_fields.get("what_id") or "").strip()
        if not what_id:
            stage_skipped.append({"event_id": record_id, "reason": "missing_what_id"})
            continue
        if not _is_opportunity_id(what_id):
            stage_skipped.append({"event_id": record_id, "what_id": what_id, "reason": "not_opportunity"})
            continue
        try:
            stage_result = sfdc_helpers.update_record(
                "Opportunity",
                what_id,
                {"StageName": hold_stage_name},
                org_alias=org_alias,
            )
            stage_updates.append(
                {
                    "event_id": record_id,
                    "opportunity_id": what_id,
                    "stage_name": hold_stage_name,
                    "result": stage_result,
                }
            )
        except Exception as exc:
            stage_failures.append(
                {
                    "event_id": record_id,
                    "opportunity_id": what_id,
                    "error": str(exc),
                }
            )

    if task_failures:
        error = RuntimeError(
            f"followup_enforcement_failed successes={len(created_tasks)} failures={len(task_failures)}"
        )
        error.successes = created_tasks  # type: ignore[attr-defined]
        error.failures = task_failures  # type: ignore[attr-defined]
        raise error

    slack_result: dict[str, Any] | None = None
    slack_error: str | None = None
    if notify_slack:
        if not slack_channel:
            slack_error = "notify_slack enabled but slack_channel missing"
        else:
            try:
                slack_step = PlanStep(
                    step_id=f"{step.step_id}:slack",
                    tool="slack.notify",
                    args={
                        "channel": slack_channel,
                        "severity": slack_severity or "high",
                        "title": "Autopilot Exception: MEETING_FOLLOWUP_BREACH",
                        "message": (
                            f"Meeting follow-up enforcement created {len(created_tasks)} task(s)"
                            f"{' with opportunity stage hold' if apply_stage_hold else ''}."
                        ),
                        "records": records,
                        "scout_name": args.get("scout_name") or "meeting_followup_breach",
                    },
                    risk_level=1,
                )
                slack_payload = handle_slack_notify(slack_step, None)
                slack_result = slack_payload.get("after") if isinstance(slack_payload, dict) else None
            except Exception as exc:
                slack_error = str(exc)

    return {
        "targets": targets,
        "before": None,
        "after": {
            "created_tasks": created_tasks,
            "stage_updates": stage_updates,
            "stage_failures": stage_failures,
            "stage_skipped": stage_skipped,
            "slack_result": slack_result,
            "slack_error": slack_error,
        },
    }


def _load_outreach_client() -> Any | None:
    try:
        from g_gremlin.outreach import OutreachClient

        return OutreachClient()
    except Exception:
        return None


def _extract_sequence_state_ids_from_row(row: dict[str, Any]) -> list[str]:
    fields = row.get("fields") if isinstance(row.get("fields"), dict) else {}
    if not isinstance(fields, dict):
        fields = {}

    def _collect(keys: tuple[str, ...]) -> list[str]:
        values: list[str] = []
        for key in keys:
            if key not in fields:
                continue
            values.extend(_as_list(fields.get(key)))
        seen: set[str] = set()
        ordered: list[str] = []
        for value in values:
            if value in seen:
                continue
            seen.add(value)
            ordered.append(value)
        return ordered

    # Prefer explicit pause candidates to avoid pausing keeper states.
    for keys in (
        ("pause_sequence_state_ids", "pauseSequenceStateIds"),
        ("sequence_state_ids", "sequenceStateIds"),
        ("active_sequence_state_ids", "activeSequenceStateIds"),
    ):
        ids = _collect(keys)
        if ids:
            return ids
    return []


def _extract_prospect_id_from_row(row: dict[str, Any]) -> str | None:
    fields = row.get("fields") if isinstance(row.get("fields"), dict) else {}
    for key in ("outreach_prospect_id", "prospect_id", "prospectId"):
        value = fields.get(key) if isinstance(fields, dict) else None
        if value not in (None, ""):
            return str(value)
    return None


def _has_pause_tag(existing_value: Any, tag_prefix: str, reason_code: str) -> bool:
    if isinstance(existing_value, list):
        values = [str(item) for item in existing_value if item not in (None, "")]
    elif isinstance(existing_value, str):
        values = [part.strip() for part in existing_value.split(";") if part.strip()]
    elif existing_value not in (None, ""):
        values = [str(existing_value)]
    else:
        values = []

    required_prefix = f"{tag_prefix}:{reason_code}:".lower()
    for value in values:
        lowered = value.lower()
        if lowered.startswith(required_prefix):
            return True
    return False


def _append_pause_tag(existing_value: Any, new_tag: str) -> str:
    if isinstance(existing_value, list):
        values = [str(item).strip() for item in existing_value if str(item).strip()]
    elif isinstance(existing_value, str):
        values = [part.strip() for part in existing_value.split(";") if part.strip()]
    elif existing_value not in (None, ""):
        values = [str(existing_value).strip()]
    else:
        values = []
    if new_tag not in values:
        values.append(new_tag)
    return ";".join(values)


def _fetch_current_tag_value(
    *,
    sobject: str,
    record_id: str,
    tag_field: str,
    org_alias: str | None,
) -> Any:
    safe_id = sfdc_helpers.escape_soql_value(record_id)
    query = f"SELECT {tag_field} FROM {sobject} WHERE Id = '{safe_id}' LIMIT 1"
    rows = sfdc_helpers.run_soql(query, org_alias=org_alias)
    if not rows:
        return None
    row = rows[0] if isinstance(rows[0], dict) else {}
    if not isinstance(row, dict):
        return None
    if tag_field in row:
        return row.get(tag_field)
    lowered = tag_field.lower()
    for key, value in row.items():
        if str(key).lower() == lowered:
            return value
    return None


def _active_state_ids(sequence_states: Any) -> list[str]:
    if not isinstance(sequence_states, list):
        return []
    active_names = {"active", "in_progress", "pending", "scheduled", "running"}
    state_ids: list[str] = []
    for item in sequence_states:
        if not isinstance(item, dict):
            continue
        attrs = item.get("attributes") if isinstance(item.get("attributes"), dict) else {}
        state_name = str(attrs.get("state") or "").strip().lower()
        if state_name not in active_names:
            continue
        state_id = item.get("id")
        if state_id:
            state_ids.append(str(state_id))
    return state_ids


def _sequence_pause_state_dir(args: dict[str, Any], details: dict[str, Any]) -> Path:
    raw_state_dir = (
        args.get("state_dir")
        or details.get("state_dir")
        or os.getenv("G_GREMLIN_STATE_DIR")
        or _DEFAULT_AUTOPILOT_STATE_DIR
    )
    resolved = Path(os.path.expandvars(os.path.expanduser(str(raw_state_dir)))).resolve()
    (resolved / "db").mkdir(parents=True, exist_ok=True)
    return resolved


def _sequence_pause_budget_store(args: dict[str, Any], details: dict[str, Any]) -> SequencePauseBudgetStore:
    state_dir = _sequence_pause_state_dir(args, details)
    return SequencePauseBudgetStore(state_dir / "db" / "sequence_pause_budgets.sqlite")


def _extract_entity_key(row: dict[str, Any], record_id: str) -> str:
    fields = row.get("fields") if isinstance(row.get("fields"), dict) else {}
    entity_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),
    ]
    for entity_type, value in entity_candidates:
        if value in (None, ""):
            continue
        normalized = str(value).strip()
        if not normalized:
            continue
        if entity_type == "email":
            return f"email:{normalized.lower()}"
        return f"entity:{normalized.lower()}"
    return f"record:{record_id.lower()}"


def handle_sequence_pause(
    step: PlanStep,
    tool_def: AutopilotToolDefinition | None,
) -> dict[str, Any]:
    del tool_def
    args = step.args or {}
    details = args.get("action_details") if isinstance(args.get("action_details"), dict) else {}
    org_alias = args.get("org_alias")
    reason_code = str(args.get("reason_code") or details.get("reason_code") or "DNC_IN_ACTIVE_SEQUENCE")
    tag_prefix = str(args.get("tag_prefix") or details.get("tag_prefix") or "gremlin:paused").strip()
    tag_field = str(args.get("tag_field") or details.get("tag_field") or "Gremlin_Pause_Tag__c").strip()
    raw_cap = (
        args.get("blast_radius_max_records_per_run")
        or details.get("blast_radius_max_records_per_run")
        or 50
    )
    try:
        max_records_per_run = max(1, int(raw_cap))
    except Exception:
        max_records_per_run = 50
    raw_daily_cap = (
        args.get("blast_radius_max_records_per_day")
        or details.get("blast_radius_max_records_per_day")
        or 200
    )
    try:
        max_records_per_day = max(1, int(raw_daily_cap))
    except Exception:
        max_records_per_day = 200
    raw_entity_cap = (
        args.get("max_actions_per_entity_per_day")
        or details.get("max_actions_per_entity_per_day")
        or 3
    )
    try:
        max_actions_per_entity_per_day = max(1, int(raw_entity_cap))
    except Exception:
        max_actions_per_entity_per_day = 3

    rows = args.get("records") if isinstance(args.get("records"), list) else []
    if rows:
        candidate_rows = [row for row in rows if isinstance(row, dict)]
    else:
        candidate_rows = [
            {
                "record_id": rid,
                "sobject": args.get("sobject") or "Lead",
                "fields": {},
            }
            for rid in _as_list(args.get("record_ids"))
        ]
    if not candidate_rows:
        raise RuntimeError("Missing record targets for sequence.pause.")

    budget_store = _sequence_pause_budget_store(args, details)

    outreach_client = _load_outreach_client()
    warnings: list[str] = []
    if outreach_client is None:
        warnings.append("Outreach not configured; skipped sequence API pause and applied CRM tag only.")

    now_utc = datetime.now(timezone.utc)
    timestamp = now_utc.isoformat()
    day_key = now_utc.date().isoformat()
    run_cap_reached = False
    daily_cap_reached = False
    entity_budget_exhausted_entities: set[str] = set()
    pause_tag = f"{tag_prefix}:{reason_code}:{timestamp}"

    targets: list[str] = []
    before_rows: list[dict[str, Any]] = []
    after_rows: list[dict[str, Any]] = []
    rollback_rows: list[dict[str, Any]] = []
    skipped_rows: list[dict[str, Any]] = []
    failed_rows: list[dict[str, Any]] = []
    processed = 0

    for row in candidate_rows:
        if processed >= max_records_per_run:
            run_cap_reached = True
            break
        record_id = str(row.get("record_id") or "").strip()
        if not record_id:
            continue
        sobject = str(row.get("sobject") or args.get("sobject") or "Lead")
        fields = row.get("fields") if isinstance(row.get("fields"), dict) else {}
        existing_tag_value = fields.get(tag_field) if isinstance(fields, dict) else None
        if existing_tag_value in (None, "", []):
            try:
                existing_tag_value = _fetch_current_tag_value(
                    sobject=sobject,
                    record_id=record_id,
                    tag_field=tag_field,
                    org_alias=org_alias,
                )
            except Exception as exc:
                warnings.append(
                    f"Unable to read existing pause tag for {sobject}:{record_id}: {exc}"
                )
        if _has_pause_tag(existing_tag_value, tag_prefix, reason_code):
            skipped_rows.append(
                {
                    "record_id": record_id,
                    "sobject": sobject,
                    "reason": "already_paused_tag_present",
                }
            )
            continue

        entity_key = _extract_entity_key(row, record_id)
        budget_decision = budget_store.consume(
            day_key=day_key,
            entity_key=entity_key,
            daily_limit=max_records_per_day,
            entity_limit=max_actions_per_entity_per_day,
        )
        if not budget_decision.allowed and budget_decision.reason == "daily_budget_exhausted":
            daily_cap_reached = True
            break
        if not budget_decision.allowed and budget_decision.reason == "entity_budget_exhausted":
            entity_budget_exhausted_entities.add(entity_key)
            skipped_rows.append(
                {
                    "record_id": record_id,
                    "sobject": sobject,
                    "entity_key": entity_key,
                    "reason": "entity_budget_exhausted",
                }
            )
            continue
        processed += 1
        before_entry = {
            "record_id": record_id,
            "sobject": sobject,
            "sequence_status": "active",
            "tag_field": tag_field,
            "original_tag_value": existing_tag_value,
        }
        before_rows.append(before_entry)

        paused_states: list[dict[str, Any]] = []
        pause_errors: list[str] = []
        sequence_state_ids = _extract_sequence_state_ids_from_row(row)
        prospect_id = _extract_prospect_id_from_row(row)
        if not sequence_state_ids:
            sequence_state_ids = _as_list(
                details.get("pause_sequence_state_ids") or details.get("sequence_state_ids")
            )
        if outreach_client is not None:
            try:
                if not sequence_state_ids:
                    if not prospect_id:
                        detail_prospect = details.get("prospect_id") or details.get("outreach_prospect_id")
                        if detail_prospect not in (None, ""):
                            prospect_id = str(detail_prospect)
                    if prospect_id:
                        sequence_states = outreach_client.get_sequence_states_for_prospect(prospect_id)
                        sequence_state_ids = _active_state_ids(sequence_states)
                for state_id in sequence_state_ids:
                    try:
                        pause_result = outreach_client.pause_prospect_sequence(state_id)
                        paused_states.append(
                            {
                                "sequence_state_id": state_id,
                                "result": pause_result,
                            }
                        )
                    except Exception as exc:
                        pause_errors.append(str(exc))
            except Exception as exc:
                pause_errors.append(str(exc))

        tag_error = None
        tag_written = False
        try:
            new_value = _append_pause_tag(existing_tag_value, pause_tag)
            sfdc_helpers.update_record(
                sobject,
                record_id,
                {tag_field: new_value},
                org_alias=org_alias,
            )
            tag_written = True
        except Exception as exc:
            tag_error = str(exc)

        if not tag_written and not paused_states:
            failed_rows.append(
                {
                    "record_id": record_id,
                    "sobject": sobject,
                    "error": tag_error or "sequence pause failed",
                }
            )
            continue

        targets.append(record_id)
        paused_sequence_state_ids = [
            str(item.get("sequence_state_id"))
            for item in paused_states
            if isinstance(item, dict) and item.get("sequence_state_id")
        ]
        after_rows.append(
            {
                "record_id": record_id,
                "sobject": sobject,
                "sequence_status": "paused" if paused_states else "pause_requested",
                "tag_written": tag_written,
                "tag_field": tag_field,
                "tag_value": pause_tag,
                "paused_sequence_states": paused_states,
                "paused_sequence_state_ids": paused_sequence_state_ids,
                "pause_errors": pause_errors or None,
                "tag_error": tag_error,
            }
        )
        rollback_rows.append(
            {
                "record_id": record_id,
                "sobject": sobject,
                "tag_field": tag_field,
                "tag_written": tag_written,
                "original_tag_value": existing_tag_value,
                "paused_sequence_state_ids": paused_sequence_state_ids,
                "prospect_id": prospect_id,
            }
        )

    candidate_count = len([row for row in candidate_rows if row.get("record_id")])
    if run_cap_reached:
        warnings.append(
            f"Blast radius reached; processed {processed} record(s) "
            f"out of {candidate_count}."
        )
    if daily_cap_reached:
        warnings.append(
            f"Daily blast radius reached for {day_key}; cap={max_records_per_day}, "
            f"used={budget_store.daily_usage(day_key)}."
        )
    if entity_budget_exhausted_entities:
        warnings.append(
            f"Per-entity action budget reached for {len(entity_budget_exhausted_entities)} "
            f"entity key(s); cap={max_actions_per_entity_per_day}."
        )

    return {
        "targets": targets,
        "before": {"records": before_rows, "sequence_status": "active"},
        "after": {
            "records": after_rows,
            "skipped": skipped_rows,
            "failed": failed_rows,
            "warnings": warnings,
            "outreach_connected": outreach_client is not None,
            "reason_code": reason_code,
            "processed": processed,
            "blast_radius_max_records_per_run": max_records_per_run,
            "blast_radius_max_records_per_day": max_records_per_day,
            "max_actions_per_entity_per_day": max_actions_per_entity_per_day,
            "daily_usage_date": day_key,
            "daily_usage_count": budget_store.daily_usage(day_key),
            "entity_budget_exhausted_count": len(entity_budget_exhausted_entities),
            "rollback_data": {
                "tag_prefix": tag_prefix,
                "reason_code": reason_code,
                "records": rollback_rows,
            },
        },
    }


def handle_slack_notify(
    step: PlanStep,
    tool_def: AutopilotToolDefinition | None,
) -> dict[str, Any]:
    args = step.args or {}
    details = args.get("action_details") if isinstance(args.get("action_details"), dict) else {}

    channel = args.get("channel") or details.get("channel") or os.getenv("G_GREMLIN_SLACK_CHANNEL")
    if not channel:
        raise RuntimeError("Missing Slack channel for notification.")

    message = args.get("message") or details.get("message")
    severity = args.get("severity") or details.get("severity") or "info"
    title = args.get("title") or details.get("title") or "Integration Health Alert"

    records = args.get("records") if isinstance(args.get("records"), list) else []
    if not message:
        scout_name = args.get("scout_name") or "autopilot"
        count = len([row for row in records if isinstance(row, dict)])
        message = f"{scout_name} alert: {count} finding(s) flagged."

    details_lines = _summarize_slack_records(records)
    blocks = build_notify_blocks(
        title=str(title),
        message=str(message),
        severity=str(severity),
        details=details_lines,
    )

    bot_token = args.get("bot_token") or os.getenv("G_GREMLIN_SLACK_BOT_TOKEN")
    if not bot_token:
        raise RuntimeError("Missing Slack bot token for notification.")

    client = slack_create_client(str(bot_token))
    response = client.chat_postMessage(channel=channel, text=str(message), **blocks)

    return {
        "targets": [str(channel)],
        "before": None,
        "after": {
            "status": "sent",
            "channel": channel,
            "ts": response.get("ts") if isinstance(response, dict) else None,
        },
    }


def handle_scout_user_provisioning_parity(
    step: PlanStep,
    tool_def: AutopilotToolDefinition | None,
) -> dict[str, Any]:
    return {
        "targets": _extract_targets(step),
        "before": None,
        "after": {"status": "executed"},
    }


def handle_scout_forecast_hierarchy_drift(
    step: PlanStep,
    tool_def: AutopilotToolDefinition | None,
) -> dict[str, Any]:
    return {
        "targets": _extract_targets(step),
        "before": None,
        "after": {"status": "executed"},
    }


def handle_scout_automation_failure_impact(
    step: PlanStep,
    tool_def: AutopilotToolDefinition | None,
) -> dict[str, Any]:
    return {
        "targets": _extract_targets(step),
        "before": None,
        "after": {"status": "executed"},
    }


def handle_scout_integration_health(
    step: PlanStep,
    tool_def: AutopilotToolDefinition | None,
) -> dict[str, Any]:
    return {
        "targets": _extract_targets(step),
        "before": None,
        "after": {"status": "executed"},
    }


def handle_scout_lead_to_account_match_health(
    step: PlanStep,
    tool_def: AutopilotToolDefinition | None,
) -> dict[str, Any]:
    return {
        "targets": _extract_targets(step),
        "before": None,
        "after": {"status": "executed"},
    }


def handle_scout_workflow_impact(
    step: PlanStep,
    tool_def: AutopilotToolDefinition | None,
) -> dict[str, Any]:
    return {
        "targets": _extract_targets(step),
        "before": None,
        "after": {"status": "executed"},
    }


def handle_scout_license_utilization(
    step: PlanStep,
    tool_def: AutopilotToolDefinition | None,
) -> dict[str, Any]:
    return {
        "targets": _extract_targets(step),
        "before": None,
        "after": {"status": "executed"},
    }


def handle_architect_metadata_pack_plan(
    step: PlanStep,
    tool_def: AutopilotToolDefinition | None,
) -> dict[str, Any]:
    args = step.args or {}
    pack_dir = args.get("pack_dir")
    if not pack_dir:
        raise RuntimeError("Missing pack_dir for metadata pack plan.")
    org_alias = args.get("org_alias")
    plan = sfdc_metadata_pack.plan_pack(Path(pack_dir), org_alias=org_alias)
    return {
        "targets": [],
        "before": None,
        "after": _safe_to_dict(plan),
    }


def handle_architect_metadata_pack_apply(
    step: PlanStep,
    tool_def: AutopilotToolDefinition | None,
) -> dict[str, Any]:
    args = step.args or {}
    pack_dir = args.get("pack_dir")
    if not pack_dir:
        raise RuntimeError("Missing pack_dir for metadata pack apply.")
    org_alias = args.get("org_alias")
    yes = bool(args.get("yes", True))
    check_only = bool(args.get("check_only", False))
    replan = bool(args.get("replan", False))
    plan_path = args.get("plan_path")
    plan_path_value = Path(plan_path) if plan_path else None
    result = sfdc_metadata_pack.apply_pack(
        Path(pack_dir),
        org_alias=org_alias,
        yes=yes,
        check_only=check_only,
        replan=replan,
        plan_path=plan_path_value,
    )
    return {
        "targets": [],
        "before": None,
        "after": _safe_to_dict(result),
    }


def handle_architect_metadata_pack_verify(
    step: PlanStep,
    tool_def: AutopilotToolDefinition | None,
) -> dict[str, Any]:
    args = step.args or {}
    pack_dir = args.get("pack_dir")
    if not pack_dir:
        raise RuntimeError("Missing pack_dir for metadata pack verify.")
    org_alias = args.get("org_alias")
    report = sfdc_metadata_pack.verify_pack(Path(pack_dir), org_alias=org_alias)
    return {
        "targets": [],
        "before": None,
        "after": _safe_to_dict(report),
    }


def _safe_to_dict(value: Any) -> Any:
    if hasattr(value, "to_dict") and callable(getattr(value, "to_dict")):
        try:
            return value.to_dict()
        except Exception:
            return value
    return value


__all__ = [
    "handle_assign_permission_sets",
    "handle_assign_permission_set_groups",
    "handle_add_to_queue_group",
    "handle_forecast_assign_user_to_role",
    "handle_sfdc_record_create",
    "handle_sfdc_record_delete",
    "handle_sfdc_record_enrich_missing_fields",
    "handle_sfdc_followup_enforce",
    "handle_sfdc_record_update",
    "handle_sfdc_record_reassign_owner",
    "handle_sfdc_user_deactivate",
    "handle_scout_forecast_hierarchy_drift",
    "handle_scout_automation_failure_impact",
    "handle_scout_integration_health",
    "handle_scout_lead_to_account_match_health",
    "handle_scout_workflow_impact",
    "handle_scout_license_utilization",
    "handle_scout_user_provisioning_parity",
    "handle_sequence_pause",
    "handle_architect_metadata_pack_plan",
    "handle_architect_metadata_pack_apply",
    "handle_architect_metadata_pack_verify",
    "handle_slack_notify",
    "handle_tasks_create",
]
