"""MCP tools for BPA bot input/output mapping operations.

This module provides tools for managing bot field mappings, cloning bots,
viewing revision history, reverting to previous revisions, and configuring
field visibility.

Write operations follow the audit-before-write pattern:
1. Validate parameters (pre-flight, no audit record if validation fails)
2. Create PENDING audit record
3. Execute BPA API call
4. Update audit record to SUCCESS or FAILED

API Endpoints used:
- POST /bot/{bot_id}/input_mapping - Create input mapping
- POST /bot/{bot_id}/output_mapping - Create output mapping
- PUT /input_mapping/{mapping_id} - Update input mapping
- PUT /output_mapping/{mapping_id} - Update output mapping
- DELETE /input_mapping/{mapping_id} - Delete input mapping
- DELETE /output_mapping/{mapping_id} - Delete output mapping
- PUT /bot/{bot_id}/input_mappings - Bulk replace input mappings
- PUT /bot/{bot_id}/output_mappings - Bulk replace output mappings
- POST /bot/{bot_id}/clone - Clone bot with all mappings
- GET /bot/{bot_id}/pagedhistory - Paginated revision history
- PUT /bot/{bot_id}/history/{revision_number} - Revert to revision
- PUT /bot/{bot_id}/input_visibility - Set input field visibility
- PUT /bot/{bot_id}/output_visibility - Set output field visibility
"""

from __future__ import annotations

import base64
import json as json_module
import re
from difflib import SequenceMatcher
from typing import Any, Literal

from fastmcp.exceptions import ToolError

from mcp_eregistrations_bpa.audit.context import (
    NotAuthenticatedError,
    get_current_user_email,
)
from mcp_eregistrations_bpa.audit.logger import AuditLogger
from mcp_eregistrations_bpa.bpa_client import BPAClient
from mcp_eregistrations_bpa.bpa_client.errors import (
    BPAClientError,
    BPANotFoundError,
    translate_error,
)
from mcp_eregistrations_bpa.config import resolve_db_path
from mcp_eregistrations_bpa.tools.bots import _transform_bot_response
from mcp_eregistrations_bpa.tools.large_response import large_response_handler

__all__ = [
    # Read operations
    "bot_input_mapping_list",
    "bot_output_mapping_list",
    "bot_history_list",
    # Single-mapping CRUD (audited)
    "bot_input_mapping_create",
    "bot_input_mapping_update",
    "bot_input_mapping_delete",
    "bot_output_mapping_create",
    "bot_output_mapping_update",
    "bot_output_mapping_delete",
    # Bulk operations (audited, high-risk)
    "bot_input_mapping_save_all",
    "bot_output_mapping_save_all",
    # Bot-level operations (audited)
    "bot_clone",
    "bot_history_revert",
    # Visibility (audited)
    "bot_input_visibility_update",
    "bot_output_visibility_update",
    # Diagnostics / Intelligence
    "bot_mapping_summary",
    "bot_suggest_mappings",
    # Registration
    "register_bot_mapping_tools",
]


# =============================================================================
# Helpers
# =============================================================================


def _transform_mapping_response(data: dict[str, Any]) -> dict[str, Any]:
    """Transform mapping API response from camelCase to snake_case."""
    return {
        "id": data.get("id"),
        "source_field": data.get("sourceField"),
        "source_type": data.get("sourceType"),
        "source_field_is_multiple": data.get("sourceFieldIsMultiple", False),
        "target_field": data.get("targetField"),
        "target_type": data.get("targetType"),
        "target_field_is_multiple": data.get("targetFieldIsMultiple", False),
        "external_id": data.get("externalId"),
        "is_attribute": data.get("isAttribute", False),
        "is_cert": data.get("isCert", False),
        "format": data.get("format"),
        "input_param_type": data.get("inputParamType"),
        "json_determinant": data.get("jsonDeterminant"),
        "json_determinant_filter": data.get("jsonDeterminantFilter"),
        "target_field_properties": data.get("targetFieldProperties"),
    }


def _validate_mapping_create_params(
    bot_id: str | int,
    source_field: str,
    source_type: str,
    target_field: str,
) -> None:
    """Validate required params for mapping creation. Raises ToolError."""
    errors = []
    if not bot_id:
        errors.append("'bot_id' is required")
    if not source_field or not str(source_field).strip():
        errors.append("'source_field' is required")
    if not source_type or not str(source_type).strip():
        errors.append("'source_type' is required")
    if not target_field or not str(target_field).strip():
        errors.append("'target_field' is required")
    if errors:
        raise ToolError(
            f"Cannot create mapping: {'; '.join(errors)}. Check required fields."
        )


def _build_create_payload(
    source_field: str,
    source_type: str,
    target_field: str,
    target_type: str | None = None,
    source_field_is_multiple: bool = False,
    target_field_is_multiple: bool = False,
    is_attribute: bool = False,
    format: str | None = None,
    input_param_type: str | None = None,
    json_determinant: str | None = None,
) -> dict[str, Any]:
    """Build camelCase payload for mapping create."""
    payload: dict[str, Any] = {
        "sourceField": source_field.strip(),
        "sourceType": source_type.strip(),
        "targetField": target_field.strip(),
    }
    # Only include boolean flags when True — UI omits them (stored as
    # null in DB).  Sending False explicitly causes DataWeave generation
    # issues because the backend treats null vs false differently in
    # RootModel.getIsAttribute() and PrimitiveRowBuilder.requiresStringCasting().
    if source_field_is_multiple:
        payload["sourceFieldIsMultiple"] = True
    if target_field_is_multiple:
        payload["targetFieldIsMultiple"] = True
    if is_attribute:
        payload["isAttribute"] = True
    if target_type is not None:
        payload["targetType"] = target_type.strip()
    if format is not None:
        payload["format"] = format
    if input_param_type is not None:
        payload["inputParamType"] = input_param_type
    if json_determinant is not None:
        payload["jsonDeterminant"] = json_determinant
    return payload


def _build_update_payload(
    external_id: str | None = None,
    format: str | None = None,
    input_param_type: str | None = None,
    is_attribute: bool | None = None,
    is_cert: bool | None = None,
    json_determinant: str | None = None,
    json_determinant_filter: str | None = None,
    target_field_properties: str | None = None,
) -> dict[str, Any]:
    """Build camelCase payload for mapping update. Only non-None fields."""
    payload: dict[str, Any] = {}
    if external_id is not None:
        payload["externalId"] = external_id
    if format is not None:
        payload["format"] = format
    if input_param_type is not None:
        payload["inputParamType"] = input_param_type
    if is_attribute is not None:
        payload["isAttribute"] = is_attribute
    if is_cert is not None:
        payload["isCert"] = is_cert
    if json_determinant is not None:
        payload["jsonDeterminant"] = json_determinant
    if json_determinant_filter is not None:
        payload["jsonDeterminantFilter"] = json_determinant_filter
    if target_field_properties is not None:
        payload["targetFieldProperties"] = target_field_properties
    return payload


def _normalize_mapping_for_bulk(m: dict[str, Any]) -> dict[str, Any]:
    """Normalize a mapping dict (accepts both snake_case and camelCase).

    Only includes non-None optional fields to match the backend's
    @Valid expectations (null values cause validation failures).
    """
    result: dict[str, Any] = {
        "sourceField": m.get("source_field") or m.get("sourceField") or "",
        "sourceType": m.get("source_type") or m.get("sourceType") or "",
        "targetField": m.get("target_field") or m.get("targetField") or "",
    }
    # Only include boolean flags when True — UI omits them (stored as
    # null in DB).  Sending False explicitly causes DataWeave generation
    # issues because the backend treats null vs false differently.
    sfm = m.get("source_field_is_multiple") or m.get("sourceFieldIsMultiple")
    if sfm:
        result["sourceFieldIsMultiple"] = True
    tfm = m.get("target_field_is_multiple") or m.get("targetFieldIsMultiple")
    if tfm:
        result["targetFieldIsMultiple"] = True
    ia = m.get("is_attribute") or m.get("isAttribute")
    if ia:
        result["isAttribute"] = True
    # Only include optional fields when non-None (backend rejects null values)
    target_type = m.get("target_type") or m.get("targetType")
    if target_type is not None:
        result["targetType"] = target_type
    external_id = m.get("external_id") or m.get("externalId")
    if external_id is not None:
        result["externalId"] = external_id
    fmt = m.get("format")
    if fmt is not None:
        result["format"] = fmt
    input_param_type = m.get("input_param_type") or m.get("inputParamType")
    if input_param_type is not None:
        result["inputParamType"] = input_param_type
    target_field_props = m.get("target_field_properties") or m.get(
        "targetFieldProperties"
    )
    if target_field_props is not None:
        result["targetFieldProperties"] = target_field_props
    json_det = m.get("json_determinant") or m.get("jsonDeterminant")
    if json_det is not None:
        result["jsonDeterminant"] = json_det
    json_det_filter = m.get("json_determinant_filter") or m.get("jsonDeterminantFilter")
    if json_det_filter is not None:
        result["jsonDeterminantFilter"] = json_det_filter
    return result


# =============================================================================
# Shared internal logic (DRY for input/output)
# =============================================================================


async def _validate_gdb_field(
    client: BPAClient,
    bot_service_id: str,
    mapping_type: Literal["input", "output"],
    source_field: str,
    target_field: str,
) -> None:
    """Validate that the GDB-side field exists in the external service.

    For input mappings the GDB field is target_field (mapped to GDB inputs).
    For output mappings the GDB field is source_field (read from GDB outputs).

    Raises ToolError with available field names when the field is not found.
    Silently passes if the GDB service cannot be fetched.
    """
    try:
        encoded_id = base64.b64encode(bot_service_id.encode()).decode()
        gdb_service = await client.get(
            "/mule/services/{service_id}",
            path_params={"service_id": encoded_id},
            resource_type="mule_service",
            resource_id=bot_service_id,
        )
    except (BPANotFoundError, BPAClientError):
        return  # Can't validate; proceed without blocking

    if mapping_type == "input":
        raw_fields = gdb_service.get("inputs", [])
        field_to_check = target_field
        label = "target_field"
        direction = "inputs"
    else:
        raw_fields = gdb_service.get("outputs", [])
        field_to_check = source_field
        label = "source_field"
        direction = "outputs"

    # GDB field IDs (e.g. "query_child_NIT") are what mappings reference
    gdb_ids = {f.get("id") for f in raw_fields}
    gdb_ids.discard(None)
    if gdb_ids and field_to_check not in gdb_ids:
        # Show id→name pairs so agents can pick the right one
        id_name_pairs = sorted(
            f"{f.get('id')} ({f.get('name')})" for f in raw_fields if f.get("id")
        )
        raise ToolError(
            f"{label} '{field_to_check}' not found in GDB service "
            f"{direction}. This would create a ghost mapping. "
            f"Available fields: {id_name_pairs}"
        )


async def _validate_form_field(
    client: BPAClient,
    service_id: str | int,
    mapping_type: Literal["input", "output"],
    source_field: str,
    target_field: str,
) -> None:
    """Validate that the form-side field exists in the service.

    For input mappings the form field is source_field.
    For output mappings the form field is target_field.

    Raises ToolError with available field keys when the field is not found.
    Silently passes if fields cannot be fetched.
    """
    try:
        fields_data = await client.get_list(
            "/service/{service_id}/fields",
            path_params={"service_id": service_id},
            resource_type="field",
        )
    except (BPANotFoundError, BPAClientError):
        return  # Can't validate; proceed without blocking

    form_keys: set[str] = {k for f in fields_data if isinstance(k := f.get("key"), str)}
    if not form_keys:
        return

    if mapping_type == "input":
        field_to_check = source_field
        label = "source_field"
    else:
        field_to_check = target_field
        label = "target_field"

    if field_to_check not in form_keys:
        # Show a few similar keys to help the agent pick the right one
        from difflib import get_close_matches

        similar = get_close_matches(field_to_check, sorted(form_keys), n=5, cutoff=0.4)
        hint = f" Similar keys: {similar}" if similar else ""
        raise ToolError(
            f"{label} '{field_to_check}' not found in service form fields. "
            f"This would create a ghost mapping. "
            f"Use field_list(service_id) to see valid keys.{hint}"
        )


async def _mapping_create(
    bot_id: str | int,
    mapping_type: Literal["input", "output"],
    payload: dict[str, Any],
    *,
    service_id: str | int | None = None,
    instance: str | None = None,
) -> dict[str, Any]:
    """Shared create logic for input/output mappings."""
    type_label = f"bot_{mapping_type}_mapping"
    endpoint = f"/bot/{bot_id}/{mapping_type}_mapping"

    try:
        user_email = get_current_user_email(instance=instance)
    except NotAuthenticatedError as e:
        raise ToolError(str(e))

    source = payload["sourceField"]
    target = payload["targetField"]

    try:
        async with BPAClient.for_instance(instance) as client:
            # Pre-flight: verify bot exists and get current state
            try:
                bot_data = await client.get(
                    "/bot/{bot_id}",
                    path_params={"bot_id": bot_id},
                    resource_type="bot",
                    resource_id=bot_id,
                )
            except BPANotFoundError:
                raise ToolError(
                    f"Bot '{bot_id}' not found. "
                    "Use 'bot_list' with service_id to see available bots."
                )

            # Guard: reject duplicate source→target (prevents ghost lines)
            mapping_key = (
                "inputFieldsMapping"
                if mapping_type == "input"
                else "outputFieldsMapping"
            )
            for existing in bot_data.get(mapping_key) or []:
                if (
                    existing.get("sourceField") == source
                    and existing.get("targetField") == target
                ):
                    transformed = _transform_mapping_response(existing)
                    transformed["bot_id"] = str(bot_id)
                    transformed["already_existed"] = True
                    transformed["message"] = (
                        f"Mapping {source} -> {target} already exists "
                        f"(id: {existing.get('id')}). Skipped to avoid "
                        "ghost lines."
                    )
                    return transformed

            # Guard: validate field names against GDB service
            bot_service_id = bot_data.get("botServiceId")
            if bot_service_id:
                await _validate_gdb_field(
                    client, bot_service_id, mapping_type, source, target
                )

            # Guard: validate form-side field exists in the service
            # Bot API excludes serviceId (@JsonIgnore), so caller must provide it
            if service_id:
                await _validate_form_field(
                    client, service_id, mapping_type, source, target
                )

            audit_logger = AuditLogger(db_path=resolve_db_path(instance))
            audit_id = await audit_logger.record_pending(
                user_email=user_email,
                operation_type="create",
                object_type=type_label,
                object_id=str(bot_id),
                params=payload,
            )

            try:
                result = await client.post(
                    endpoint,
                    json=payload,
                    resource_type=type_label,
                )

                created_id = result.get("id")
                await audit_logger.save_rollback_state(
                    audit_id=audit_id,
                    object_type=type_label,
                    object_id=str(created_id),
                    previous_state={
                        "id": created_id,
                        "_operation": "create",
                    },
                )

                await audit_logger.mark_success(
                    audit_id,
                    result={"mapping_id": created_id, "bot_id": str(bot_id)},
                )

                transformed = _transform_mapping_response(result)
                transformed["bot_id"] = str(bot_id)
                transformed["audit_id"] = audit_id
                return transformed

            except BPAClientError as e:
                await audit_logger.mark_failed(audit_id, str(e))
                raise translate_error(e, resource_type=type_label)

    except ToolError:
        raise
    except BPAClientError as e:
        raise translate_error(e, resource_type=type_label)


async def _mapping_update(
    mapping_id: str | int,
    mapping_type: Literal["input", "output"],
    payload: dict[str, Any],
    instance: str | None = None,
) -> dict[str, Any]:
    """Shared update logic for input/output mappings."""
    type_label = f"bot_{mapping_type}_mapping"
    endpoint = f"/{mapping_type}_mapping/{mapping_id}"

    if not payload:
        raise ToolError(
            f"Cannot update {mapping_type} mapping: at least one field must be "
            "provided (external_id, format, is_attribute, json_determinant, etc.)."
        )

    try:
        user_email = get_current_user_email(instance=instance)
    except NotAuthenticatedError as e:
        raise ToolError(str(e))

    try:
        async with BPAClient.for_instance(instance) as client:
            audit_logger = AuditLogger(db_path=resolve_db_path(instance))
            audit_id = await audit_logger.record_pending(
                user_email=user_email,
                operation_type="update",
                object_type=type_label,
                object_id=str(mapping_id),
                params=payload,
            )

            try:
                # PUT returns void — no response body
                await client.put(
                    endpoint,
                    json=payload,
                    resource_type=type_label,
                    resource_id=mapping_id,
                )

                updated_fields = list(payload.keys())
                await audit_logger.mark_success(
                    audit_id,
                    result={
                        "mapping_id": str(mapping_id),
                        "updated_fields": updated_fields,
                    },
                )

                return {
                    "mapping_id": str(mapping_id),
                    "updated_fields": updated_fields,
                    "audit_id": audit_id,
                }

            except BPAClientError as e:
                await audit_logger.mark_failed(audit_id, str(e))
                raise translate_error(
                    e, resource_type=type_label, resource_id=mapping_id
                )

    except ToolError:
        raise
    except BPAClientError as e:
        raise translate_error(e, resource_type=type_label, resource_id=mapping_id)


async def _mapping_delete(
    mapping_id: str | int,
    mapping_type: Literal["input", "output"],
    instance: str | None = None,
) -> dict[str, Any]:
    """Shared delete logic for input/output mappings."""
    type_label = f"bot_{mapping_type}_mapping"
    endpoint = f"/{mapping_type}_mapping/{mapping_id}"

    if not mapping_id:
        raise ToolError(
            f"'mapping_id' is required. "
            f"Use 'bot_{mapping_type}_mapping_list' to find mapping IDs."
        )

    try:
        user_email = get_current_user_email(instance=instance)
    except NotAuthenticatedError as e:
        raise ToolError(str(e))

    try:
        async with BPAClient.for_instance(instance) as client:
            audit_logger = AuditLogger(db_path=resolve_db_path(instance))
            audit_id = await audit_logger.record_pending(
                user_email=user_email,
                operation_type="delete",
                object_type=type_label,
                object_id=str(mapping_id),
                params={},
            )

            try:
                # DELETE returns void — no response body
                await client.delete(
                    endpoint,
                    resource_type=type_label,
                    resource_id=mapping_id,
                )

                await audit_logger.mark_success(
                    audit_id,
                    result={"deleted": True, "mapping_id": str(mapping_id)},
                )

                return {
                    "deleted": True,
                    "mapping_id": str(mapping_id),
                    "audit_id": audit_id,
                }

            except BPAClientError as e:
                await audit_logger.mark_failed(audit_id, str(e))
                raise translate_error(
                    e, resource_type=type_label, resource_id=mapping_id
                )

    except ToolError:
        raise
    except BPAClientError as e:
        raise translate_error(e, resource_type=type_label, resource_id=mapping_id)


async def _mapping_list(
    bot_id: str | int,
    mapping_type: Literal["input", "output"],
    instance: str | None = None,
) -> dict[str, Any]:
    """Shared list logic for input/output mappings."""
    if not bot_id:
        raise ToolError(
            "'bot_id' is required. Use 'bot_list' with service_id to find bot IDs."
        )

    mapping_key = (
        "inputFieldsMapping" if mapping_type == "input" else "outputFieldsMapping"
    )
    result_key = f"{mapping_type}_mappings"

    try:
        async with BPAClient.for_instance(instance) as client:
            try:
                bot_data = await client.get(
                    "/bot/{bot_id}",
                    path_params={"bot_id": bot_id},
                    resource_type="bot",
                    resource_id=bot_id,
                )
            except BPANotFoundError:
                raise ToolError(
                    f"Bot '{bot_id}' not found. "
                    "Use 'bot_list' with service_id to see available bots."
                )
    except ToolError:
        raise
    except BPAClientError as e:
        raise translate_error(e, resource_type="bot", resource_id=bot_id)

    raw_mappings = bot_data.get(mapping_key) or []
    mappings = [_transform_mapping_response(m) for m in raw_mappings]

    return {
        result_key: mappings,
        "bot_id": str(bot_id),
        "total": len(mappings),
    }


async def _mapping_save_all(
    bot_id: str | int,
    mapping_type: Literal["input", "output"],
    mappings: list[dict[str, Any]],
    instance: str | None = None,
) -> dict[str, Any]:
    """Shared bulk-save logic for input/output mappings.

    For input mappings, uses the bulk PUT endpoint directly.
    For output mappings, uses individual delete+create operations to work
    around a backend validation bug (validateOutputMappings checks sourceField
    against form fields, but output sourceField is a GDB field).
    """
    type_label = f"bot_{mapping_type}_mapping"
    mapping_key = (
        "inputFieldsMapping" if mapping_type == "input" else "outputFieldsMapping"
    )

    if not bot_id:
        raise ToolError(
            "'bot_id' is required. Use 'bot_list' with service_id to find bot IDs."
        )

    if not mappings:
        raise ToolError(
            f"Cannot save {mapping_type} mappings: 'mappings' list is empty. "
            f"To delete all mappings, use bot_{mapping_type}_mapping_delete "
            "individually. Bulk save with an empty list is not allowed."
        )

    # Validate each mapping has required fields
    for i, m in enumerate(mappings):
        sf = m.get("source_field") or m.get("sourceField")
        st = m.get("source_type") or m.get("sourceType")
        tf = m.get("target_field") or m.get("targetField")
        if not sf or not st or not tf:
            raise ToolError(
                f"Mapping at index {i} is missing required fields. "
                "Each mapping needs source_field, source_type, target_field."
            )

    try:
        user_email = get_current_user_email(instance=instance)
    except NotAuthenticatedError as e:
        raise ToolError(str(e))

    # Normalize all mappings to camelCase
    mappings_payload = [_normalize_mapping_for_bulk(m) for m in mappings]

    try:
        async with BPAClient.for_instance(instance) as client:
            # Capture current mappings for rollback
            try:
                bot_data = await client.get(
                    "/bot/{bot_id}",
                    path_params={"bot_id": bot_id},
                    resource_type="bot",
                    resource_id=bot_id,
                )
            except BPANotFoundError:
                raise ToolError(
                    f"Bot '{bot_id}' not found. "
                    "Use 'bot_list' with service_id to see available bots."
                )

            previous_mappings = bot_data.get(mapping_key) or []
            previous_count = len(previous_mappings)

            audit_logger = AuditLogger(db_path=resolve_db_path(instance))
            audit_id = await audit_logger.record_pending(
                user_email=user_email,
                operation_type="bulk_replace",
                object_type=type_label,
                object_id=str(bot_id),
                params={
                    "new_mapping_count": len(mappings_payload),
                    "previous_mapping_count": previous_count,
                },
            )

            # Save ALL previous mappings for rollback
            await audit_logger.save_rollback_state(
                audit_id=audit_id,
                object_type=type_label,
                object_id=str(bot_id),
                previous_state={
                    "mappings": previous_mappings,
                    "_operation": "bulk_replace",
                },
            )

            try:
                # Use individual delete+create for both input and output
                # mappings.  The bulk PUT endpoint (/bot/{id}/input_mappings)
                # runs through BotController.addBotInputMappings() which has
                # side-effects (fieldsCacheController, validateInputMappings)
                # that differ from the individual POST path used by the UI.
                # This causes DataWeave compilation issues at publish time.
                # The individual POST endpoint (InputMappingController) is
                # the same code path the BPA UI uses — simple, no validation
                # side-effects, and produces correct DataWeave templates.
                for prev in previous_mappings:
                    mid = prev.get("id")
                    if mid:
                        await client.delete(
                            f"/{mapping_type}_mapping/{mid}",
                            resource_type=type_label,
                            resource_id=mid,
                        )
                for mp in mappings_payload:
                    await client.post(
                        f"/bot/{bot_id}/{mapping_type}_mapping",
                        json=mp,
                        resource_type=type_label,
                    )

                await audit_logger.mark_success(
                    audit_id,
                    result={
                        "bot_id": str(bot_id),
                        "mapping_count": len(mappings_payload),
                        "previous_mapping_count": previous_count,
                    },
                )

                return {
                    "bot_id": str(bot_id),
                    "mapping_count": len(mappings_payload),
                    "previous_mapping_count": previous_count,
                    "audit_id": audit_id,
                }

            except BPAClientError as e:
                await audit_logger.mark_failed(audit_id, str(e))
                raise translate_error(e, resource_type=type_label)

    except ToolError:
        raise
    except BPAClientError as e:
        raise translate_error(e, resource_type=type_label)


# =============================================================================
# Public tools: Mapping List
# =============================================================================


@large_response_handler(
    threshold_bytes=50 * 1024,
    navigation={
        "list_all": "jq '.input_mappings'",
        "find_by_source": (
            "jq '.input_mappings[] | select(.source_field | contains(\"search\"))'"
        ),
        "find_by_target": (
            "jq '.input_mappings[] | select(.target_field | contains(\"search\"))'"
        ),
    },
)
async def bot_input_mapping_list(
    bot_id: str | int, instance: str | None = None
) -> dict[str, Any]:
    """List input mappings for a bot.

    Args:
        bot_id: Bot ID.
        instance: Instance profile name (from instance_list).

    Returns:
        dict with input_mappings, bot_id, total.
    """
    return await _mapping_list(bot_id, "input", instance=instance)


@large_response_handler(
    threshold_bytes=50 * 1024,
    navigation={
        "list_all": "jq '.output_mappings'",
        "find_by_source": (
            "jq '.output_mappings[] | select(.source_field | contains(\"search\"))'"
        ),
        "find_by_target": (
            "jq '.output_mappings[] | select(.target_field | contains(\"search\"))'"
        ),
    },
)
async def bot_output_mapping_list(
    bot_id: str | int, instance: str | None = None
) -> dict[str, Any]:
    """List output mappings for a bot.

    Args:
        bot_id: Bot ID.
        instance: Instance profile name (from instance_list).

    Returns:
        dict with output_mappings, bot_id, total.
    """
    return await _mapping_list(bot_id, "output", instance=instance)


# =============================================================================
# Public tools: Mapping Create
# =============================================================================


async def bot_input_mapping_create(
    bot_id: str | int,
    source_field: str,
    source_type: str,
    target_field: str,
    target_type: str | None = None,
    source_field_is_multiple: bool = False,
    target_field_is_multiple: bool = False,
    is_attribute: bool = False,
    format: str | None = None,
    input_param_type: str | None = None,
    json_determinant: str | None = None,
    service_id: str | int | None = None,
    instance: str | None = None,
) -> dict[str, Any]:
    """Create an input mapping for a bot. Audited write operation.

    Maps a service form field (source) to an external GDB field (target).
    Validates fields exist before creating to prevent ghost mappings.

    Args:
        bot_id: Bot ID to add mapping to.
        source_field: Service form field key (use field_list to verify).
        source_type: Source field data type.
        target_field: External service (GDB) field ID (use muleservice_get).
        target_type: Target field data type (optional).
        source_field_is_multiple: Source is a collection (default: False).
        target_field_is_multiple: Target is a collection (default: False).
        is_attribute: Mark as attribute mapping (default: False).
        format: Data format, e.g. "JSON" (optional).
        input_param_type: Parameter type: HEADER, QUERY, BODY, PATH (optional).
        json_determinant: Condition logic JSON (optional).
        service_id: BPA service ID for form field validation (recommended).
        instance: Instance profile name (from instance_list).

    Returns:
        dict with id, source_field, source_type, target_field, target_type,
        bot_id, audit_id.
    """
    _validate_mapping_create_params(bot_id, source_field, source_type, target_field)
    payload = _build_create_payload(
        source_field=source_field,
        source_type=source_type,
        target_field=target_field,
        target_type=target_type,
        source_field_is_multiple=source_field_is_multiple,
        target_field_is_multiple=target_field_is_multiple,
        is_attribute=is_attribute,
        format=format,
        input_param_type=input_param_type,
        json_determinant=json_determinant,
    )
    return await _mapping_create(
        bot_id, "input", payload, service_id=service_id, instance=instance
    )


async def bot_output_mapping_create(
    bot_id: str | int,
    source_field: str,
    source_type: str,
    target_field: str,
    target_type: str | None = None,
    source_field_is_multiple: bool = False,
    target_field_is_multiple: bool = False,
    is_attribute: bool = False,
    format: str | None = None,
    json_determinant: str | None = None,
    service_id: str | int | None = None,
    instance: str | None = None,
) -> dict[str, Any]:
    """Create an output mapping for a bot. Audited write operation.

    Maps an external GDB response field (source) to a service form field (target).
    Validates fields exist before creating to prevent ghost mappings.

    Args:
        bot_id: Bot ID to add mapping to.
        source_field: External service (GDB) response field ID.
        source_type: Source field data type.
        target_field: Service form field key to populate.
        target_type: Target field data type (optional).
        source_field_is_multiple: Source is a collection (default: False).
        target_field_is_multiple: Target is a collection (default: False).
        is_attribute: Mark as attribute mapping (default: False).
        format: Data format, e.g. "JSON" (optional).
        json_determinant: Condition logic JSON (optional).
        service_id: BPA service ID for form field validation (recommended).
        instance: Instance profile name (from instance_list).

    Returns:
        dict with id, source_field, source_type, target_field, target_type,
        bot_id, audit_id.
    """
    _validate_mapping_create_params(bot_id, source_field, source_type, target_field)
    payload = _build_create_payload(
        source_field=source_field,
        source_type=source_type,
        target_field=target_field,
        target_type=target_type,
        source_field_is_multiple=source_field_is_multiple,
        target_field_is_multiple=target_field_is_multiple,
        is_attribute=is_attribute,
        format=format,
        json_determinant=json_determinant,
    )
    return await _mapping_create(
        bot_id, "output", payload, service_id=service_id, instance=instance
    )


# =============================================================================
# Public tools: Mapping Update
# =============================================================================


async def bot_input_mapping_update(
    mapping_id: str | int,
    external_id: str | None = None,
    format: str | None = None,
    input_param_type: str | None = None,
    is_attribute: bool | None = None,
    is_cert: bool | None = None,
    json_determinant: str | None = None,
    json_determinant_filter: str | None = None,
    target_field_properties: str | None = None,
    instance: str | None = None,
) -> dict[str, Any]:
    """Update an input mapping's properties. Audited write operation.

    Only metadata fields can be updated (not source/target). To change
    source/target fields, delete and recreate the mapping.

    Args:
        mapping_id: Input mapping ID.
        external_id: External system ID (optional).
        format: Data format (optional).
        input_param_type: HEADER, QUERY, BODY, PATH (optional).
        is_attribute: Attribute flag (optional).
        is_cert: Certificate flag (optional).
        json_determinant: Condition logic JSON (optional).
        json_determinant_filter: Filter logic JSON (optional).
        target_field_properties: Target field properties JSON (optional).
        instance: Instance profile name (from instance_list).

    Returns:
        dict with mapping_id, updated_fields, audit_id.
    """
    if not mapping_id:
        raise ToolError(
            "'mapping_id' is required. "
            "Use 'bot_input_mapping_list' to find mapping IDs."
        )
    payload = _build_update_payload(
        external_id=external_id,
        format=format,
        input_param_type=input_param_type,
        is_attribute=is_attribute,
        is_cert=is_cert,
        json_determinant=json_determinant,
        json_determinant_filter=json_determinant_filter,
        target_field_properties=target_field_properties,
    )
    return await _mapping_update(mapping_id, "input", payload, instance=instance)


async def bot_output_mapping_update(
    mapping_id: str | int,
    external_id: str | None = None,
    format: str | None = None,
    is_attribute: bool | None = None,
    is_cert: bool | None = None,
    json_determinant: str | None = None,
    json_determinant_filter: str | None = None,
    target_field_properties: str | None = None,
    instance: str | None = None,
) -> dict[str, Any]:
    """Update an output mapping's properties. Audited write operation.

    Only metadata fields can be updated (not source/target). To change
    source/target fields, delete and recreate the mapping.

    Args:
        mapping_id: Output mapping ID.
        external_id: External system ID (optional).
        format: Data format (optional).
        is_attribute: Attribute flag (optional).
        is_cert: Certificate flag (optional).
        json_determinant: Condition logic JSON (optional).
        json_determinant_filter: Filter logic JSON (optional).
        target_field_properties: Target field properties JSON (optional).
        instance: Instance profile name (from instance_list).

    Returns:
        dict with mapping_id, updated_fields, audit_id.
    """
    if not mapping_id:
        raise ToolError(
            "'mapping_id' is required. "
            "Use 'bot_output_mapping_list' to find mapping IDs."
        )
    payload = _build_update_payload(
        external_id=external_id,
        format=format,
        is_attribute=is_attribute,
        is_cert=is_cert,
        json_determinant=json_determinant,
        json_determinant_filter=json_determinant_filter,
        target_field_properties=target_field_properties,
    )
    return await _mapping_update(mapping_id, "output", payload, instance=instance)


# =============================================================================
# Public tools: Mapping Delete
# =============================================================================


async def bot_input_mapping_delete(
    mapping_id: str | int, instance: str | None = None
) -> dict[str, Any]:
    """Delete an input mapping. Audited write operation.

    Args:
        mapping_id: Input mapping ID to delete.
        instance: Instance profile name (from instance_list).

    Returns:
        dict with deleted (bool), mapping_id, audit_id.
    """
    return await _mapping_delete(mapping_id, "input", instance=instance)


async def bot_output_mapping_delete(
    mapping_id: str | int, instance: str | None = None
) -> dict[str, Any]:
    """Delete an output mapping. Audited write operation.

    Args:
        mapping_id: Output mapping ID to delete.
        instance: Instance profile name (from instance_list).

    Returns:
        dict with deleted (bool), mapping_id, audit_id.
    """
    return await _mapping_delete(mapping_id, "output", instance=instance)


# =============================================================================
# Public tools: Bulk Save
# =============================================================================


async def bot_input_mapping_save_all(
    bot_id: str | int,
    mappings: list[dict[str, Any]],
    instance: str | None = None,
) -> dict[str, Any]:
    """Replace ALL input mappings for a bot. Audited write operation.

    WARNING: This REPLACES all existing input mappings. Any mappings not
    included in the list will be deleted. Use bot_input_mapping_list first
    to get current mappings, then modify and pass the complete list.

    Args:
        bot_id: Bot ID.
        mappings: Complete list of input mappings. Each mapping needs
            source_field, source_type, target_field, target_type.
        instance: Instance profile name (from instance_list).

    Returns:
        dict with bot_id, mapping_count, previous_mapping_count, audit_id.
    """
    return await _mapping_save_all(bot_id, "input", mappings, instance=instance)


async def bot_output_mapping_save_all(
    bot_id: str | int,
    mappings: list[dict[str, Any]],
    instance: str | None = None,
) -> dict[str, Any]:
    """Replace ALL output mappings for a bot. Audited write operation.

    WARNING: This REPLACES all existing output mappings. Any mappings not
    included in the list will be deleted. Use bot_output_mapping_list first
    to get current mappings, then modify and pass the complete list.

    Uses individual delete+create operations internally (not the bulk PUT
    endpoint) to work around a backend validation bug.

    Args:
        bot_id: Bot ID.
        mappings: Complete list of output mappings. Each mapping needs
            source_field, source_type, target_field, target_type.
        instance: Instance profile name (from instance_list).

    Returns:
        dict with bot_id, mapping_count, previous_mapping_count, audit_id.
    """
    return await _mapping_save_all(bot_id, "output", mappings, instance=instance)


# =============================================================================
# Public tools: Bot Clone
# =============================================================================


async def bot_clone(bot_id: str | int, instance: str | None = None) -> dict[str, Any]:
    """Clone a bot with all mappings. Audited write operation.

    Creates a copy with auto-incremented name. All input and output
    mappings are duplicated. Output mappings are copied manually to
    work around a backend validation bug.

    Args:
        bot_id: Bot ID to clone.
        instance: Instance profile name (from instance_list).

    Returns:
        dict with cloned_bot (full bot object), original_bot_id,
        input_mapping_count, output_mapping_count, audit_id.
    """
    if not bot_id:
        raise ToolError(
            "'bot_id' is required. Use 'bot_list' with service_id to find bot IDs."
        )

    try:
        user_email = get_current_user_email(instance=instance)
    except NotAuthenticatedError as e:
        raise ToolError(str(e))

    try:
        async with BPAClient.for_instance(instance) as client:
            # Pre-flight: verify bot exists and capture output mappings
            try:
                original_bot = await client.get(
                    "/bot/{bot_id}",
                    path_params={"bot_id": bot_id},
                    resource_type="bot",
                    resource_id=bot_id,
                )
            except BPANotFoundError:
                raise ToolError(
                    f"Bot '{bot_id}' not found. "
                    "Use 'bot_list' with service_id to see available bots."
                )

            # Capture original output mappings before clone (backend's
            # validateOutputMappings bug drops them during clone).
            original_outputs = original_bot.get("outputFieldsMapping") or []

            audit_logger = AuditLogger(db_path=resolve_db_path(instance))
            audit_id = await audit_logger.record_pending(
                user_email=user_email,
                operation_type="clone",
                object_type="bot",
                object_id=str(bot_id),
                params={"source_bot_id": str(bot_id)},
            )

            try:
                result = await client.post(
                    f"/bot/{bot_id}/clone",
                    resource_type="bot",
                )

                cloned_id = result.get("id")

                # Manually copy output mappings to cloned bot (backend
                # clone drops them due to validateOutputMappings bug).
                output_count = 0
                for om in original_outputs:
                    payload = _normalize_mapping_for_bulk(om)
                    await client.post(
                        f"/bot/{cloned_id}/output_mapping",
                        json=payload,
                        resource_type="bot_output_mapping",
                    )
                    output_count += 1

                # Count cloned input mappings from bot object
                cloned_bot = await client.get(
                    "/bot/{bot_id}",
                    path_params={"bot_id": cloned_id},
                    resource_type="bot",
                    resource_id=cloned_id,
                )
                input_count = len(cloned_bot.get("inputFieldsMapping") or [])

                await audit_logger.save_rollback_state(
                    audit_id=audit_id,
                    object_type="bot",
                    object_id=str(cloned_id),
                    previous_state={
                        "id": cloned_id,
                        "_operation": "clone",
                    },
                )

                await audit_logger.mark_success(
                    audit_id,
                    result={
                        "cloned_bot_id": cloned_id,
                        "original_bot_id": str(bot_id),
                    },
                )

                return {
                    "cloned_bot": _transform_bot_response(result),
                    "original_bot_id": str(bot_id),
                    "input_mapping_count": input_count,
                    "output_mapping_count": output_count,
                    "audit_id": audit_id,
                }

            except BPAClientError as e:
                await audit_logger.mark_failed(audit_id, str(e))
                raise translate_error(e, resource_type="bot")

    except ToolError:
        raise
    except BPAClientError as e:
        raise translate_error(e, resource_type="bot", resource_id=bot_id)


# =============================================================================
# Public tools: Bot History
# =============================================================================


@large_response_handler(
    threshold_bytes=50 * 1024,
    navigation={
        "list_all": "jq '.revisions'",
        "latest": "jq '.revisions[0]'",
    },
)
async def bot_history_list(
    bot_id: str | int,
    page: int = 0,
    page_size: int = 10,
    instance: str | None = None,
) -> dict[str, Any]:
    """List bot revision history (paginated).

    Args:
        bot_id: Bot ID.
        page: Page number, zero-indexed (default: 0).
        page_size: Items per page (default: 10).
        instance: Instance profile name (from instance_list).

    Returns:
        dict with revisions, bot_id, page, page_size, total, total_pages.
    """
    if not bot_id:
        raise ToolError(
            "'bot_id' is required. Use 'bot_list' with service_id to find bot IDs."
        )

    try:
        async with BPAClient.for_instance(instance) as client:
            try:
                result = await client.get(
                    f"/bot/{bot_id}/pagedhistory",
                    params={"pageNo": page, "pageSize": page_size},
                    resource_type="bot_history",
                    resource_id=bot_id,
                )
            except BPANotFoundError:
                raise ToolError(
                    f"Bot '{bot_id}' not found. "
                    "Use 'bot_list' with service_id to see available bots."
                )
    except ToolError:
        raise
    except BPAClientError as e:
        raise translate_error(e, resource_type="bot_history", resource_id=bot_id)

    publish_list = result.get("publishList") or []
    revisions = [
        {
            "revision_number": item.get("revisionNumber"),
            "created_by": item.get("createdBy"),
            "created_when": item.get("createdWhen"),
            "last_changed_by": item.get("lastChangedBy"),
            "last_changed_when": item.get("lastChangedWhen"),
        }
        for item in publish_list
    ]

    return {
        "revisions": revisions,
        "bot_id": str(bot_id),
        "page": result.get("currentPage", page),
        "page_size": page_size,
        "total": result.get("totalItems", 0),
        "total_pages": result.get("totalPages", 0),
    }


async def bot_history_revert(
    bot_id: str | int,
    revision_number: int,
    instance: str | None = None,
) -> dict[str, Any]:
    """Revert bot to a previous revision. Audited write operation.

    WARNING: Replaces ALL current bot configuration and mappings with the
    state from the specified revision. This is destructive and cannot be
    partially applied.

    Args:
        bot_id: Bot ID to revert.
        revision_number: Target revision number (from bot_history_list).
        instance: Instance profile name (from instance_list).

    Returns:
        dict with reverted (bool), bot_id, revision_number,
        restored_bot, audit_id.
    """
    if not bot_id:
        raise ToolError(
            "'bot_id' is required. Use 'bot_list' with service_id to find bot IDs."
        )
    if not isinstance(revision_number, int) or revision_number <= 0:
        raise ToolError(
            "'revision_number' must be a positive integer. "
            "Use 'bot_history_list' to see available revisions."
        )

    try:
        user_email = get_current_user_email(instance=instance)
    except NotAuthenticatedError as e:
        raise ToolError(str(e))

    try:
        async with BPAClient.for_instance(instance) as client:
            # Capture COMPLETE current state before revert (bot + all mappings)
            try:
                current_bot = await client.get(
                    "/bot/{bot_id}",
                    path_params={"bot_id": bot_id},
                    resource_type="bot",
                    resource_id=bot_id,
                )
            except BPANotFoundError:
                raise ToolError(
                    f"Bot '{bot_id}' not found. "
                    "Use 'bot_list' with service_id to see available bots."
                )

            current_input_mappings = current_bot.get("inputFieldsMapping") or []
            current_output_mappings = current_bot.get("outputFieldsMapping") or []

            audit_logger = AuditLogger(db_path=resolve_db_path(instance))
            audit_id = await audit_logger.record_pending(
                user_email=user_email,
                operation_type="revert",
                object_type="bot",
                object_id=str(bot_id),
                params={"revision_number": revision_number},
            )

            # Save FULL rollback state (bot + all mappings)
            await audit_logger.save_rollback_state(
                audit_id=audit_id,
                object_type="bot",
                object_id=str(bot_id),
                previous_state={
                    "bot": current_bot,
                    "input_mappings": current_input_mappings,
                    "output_mappings": current_output_mappings,
                    "_operation": "revert",
                },
            )

            try:
                await client.put(
                    f"/bot/{bot_id}/history/{revision_number}",
                    resource_type="bot",
                    resource_id=bot_id,
                )

                # PUT returns empty body — re-fetch reverted bot state
                result = await client.get(
                    f"/bot/{bot_id}",
                    resource_type="bot",
                    resource_id=bot_id,
                )

                await audit_logger.mark_success(
                    audit_id,
                    result={
                        "bot_id": str(bot_id),
                        "revision_number": revision_number,
                        "reverted": True,
                    },
                )

                return {
                    "reverted": True,
                    "bot_id": str(bot_id),
                    "revision_number": revision_number,
                    "restored_bot": _transform_bot_response(result),
                    "audit_id": audit_id,
                }

            except BPAClientError as e:
                await audit_logger.mark_failed(audit_id, str(e))
                raise translate_error(e, resource_type="bot", resource_id=bot_id)

    except ToolError:
        raise
    except BPAClientError as e:
        raise translate_error(e, resource_type="bot", resource_id=bot_id)


# =============================================================================
# Public tools: Field Visibility
# =============================================================================


def _build_visibility_item(
    item: dict[str, Any],
    visibility_type: Literal["input", "output"],
) -> dict[str, Any]:
    """Build a backend-compatible visibility object from user-friendly config.

    Backend expects Form.io component-like structure with nested 'field' object.
    Key is constructed as "{form}-{parentKey}-{key}" in the backend.
    """
    key = item.get("key") or ""
    form = item.get("form") or ""
    parent_key = item.get("parent_key") or item.get("parentKey")
    component_type = item.get("type") or "textfield"

    # Determine hidden flags from user input
    if visibility_type == "input":
        input_hidden = item.get("input_hidden", item.get("inputHidden", True))
        output_hidden = item.get("output_hidden", item.get("outputHidden", False))
    else:
        input_hidden = item.get("input_hidden", item.get("inputHidden", False))
        output_hidden = item.get("output_hidden", item.get("outputHidden", True))

    # Also accept simplified "hidden" bool
    if "hidden" in item:
        if visibility_type == "input":
            input_hidden = item["hidden"]
        else:
            output_hidden = item["hidden"]

    result: dict[str, Any] = {
        "key": key,
        "form": form,
        "type": component_type,
        "field": {
            "fieldKey": key,
            "formTitle": form,
            "type": component_type,
            "inputHidden": input_hidden,
            "outputHidden": output_hidden,
        },
    }
    if parent_key is not None:
        result["parentKey"] = parent_key
        result["field"]["parentKey"] = parent_key
    return result


async def _visibility_update(
    bot_id: str | int,
    visibility_type: Literal["input", "output"],
    visibility_config: list[dict[str, Any]],
    instance: str | None = None,
) -> dict[str, Any]:
    """Shared visibility update logic.

    Backend expects a single JSON object (not array). Multiple items are
    wrapped in an {"items": [...]} structure. Each item needs a nested
    'field' object with inputHidden/outputHidden flags.
    """
    type_label = f"bot_{visibility_type}_visibility"
    endpoint = f"/bot/{bot_id}/{visibility_type}_visibility"

    if not bot_id:
        raise ToolError(
            "'bot_id' is required. Use 'bot_list' with service_id to find bot IDs."
        )

    if not visibility_config:
        raise ToolError("'visibility_config' is required and cannot be empty.")

    # Validate required fields
    for i, item in enumerate(visibility_config):
        if not item.get("key"):
            raise ToolError(
                f"Item at index {i} missing 'key'. Each item needs at minimum "
                "'key' (field key) and 'form' (form title). Use field_list to "
                "find valid field keys."
            )
        if not item.get("form"):
            raise ToolError(
                f"Item at index {i} missing 'form'. Provide the form title "
                "(e.g. 'Applicant form'). Use field_list to find form titles."
            )

    try:
        user_email = get_current_user_email(instance=instance)
    except NotAuthenticatedError as e:
        raise ToolError(str(e))

    # Build backend-compatible payload
    items = [
        _build_visibility_item(item, visibility_type) for item in visibility_config
    ]
    if len(items) == 1:
        payload = items[0]
    else:
        payload = {"items": items}

    try:
        async with BPAClient.for_instance(instance) as client:
            # Pre-flight: verify bot exists and capture current state
            try:
                bot_data = await client.get(
                    "/bot/{bot_id}",
                    path_params={"bot_id": bot_id},
                    resource_type="bot",
                    resource_id=bot_id,
                )
            except BPANotFoundError:
                raise ToolError(
                    f"Bot '{bot_id}' not found. "
                    "Use 'bot_list' with service_id to see available bots."
                )

            audit_logger = AuditLogger(db_path=resolve_db_path(instance))
            audit_id = await audit_logger.record_pending(
                user_email=user_email,
                operation_type="update",
                object_type=type_label,
                object_id=str(bot_id),
                params={"field_count": len(visibility_config)},
            )

            # Save previous field properties for rollback
            await audit_logger.save_rollback_state(
                audit_id=audit_id,
                object_type=type_label,
                object_id=str(bot_id),
                previous_state={
                    "field_properties": bot_data.get("fieldProperties"),
                    "_operation": "visibility_update",
                },
            )

            try:
                # Backend expects @RequestBody String data - send as
                # raw JSON string content, not json= (which sends a
                # JSON object that Spring can't deserialize to String).
                raw_body = json_module.dumps(payload)
                await client.put(
                    endpoint,
                    content=raw_body,
                    resource_type=type_label,
                    resource_id=bot_id,
                )

                await audit_logger.mark_success(
                    audit_id,
                    result={
                        "bot_id": str(bot_id),
                        "field_count": len(visibility_config),
                    },
                )

                return {
                    "bot_id": str(bot_id),
                    "field_count": len(visibility_config),
                    "audit_id": audit_id,
                }

            except BPAClientError as e:
                await audit_logger.mark_failed(audit_id, str(e))
                raise translate_error(e, resource_type=type_label)

    except ToolError:
        raise
    except BPAClientError as e:
        raise translate_error(e, resource_type=type_label)


async def bot_input_visibility_update(
    bot_id: str | int,
    visibility_config: list[dict[str, Any]],
    instance: str | None = None,
) -> dict[str, Any]:
    """Update input field visibility for a bot. Audited write operation.

    Controls which fields are hidden from the bot's input mapping interface.

    Args:
        bot_id: Bot ID.
        visibility_config: List of field visibility items. Each needs:
            key (field key from field_list), form (form title),
            parent_key (optional), input_hidden (bool, default: true).
        instance: Instance profile name (from instance_list).

    Returns:
        dict with bot_id, field_count, audit_id.
    """
    return await _visibility_update(
        bot_id, "input", visibility_config, instance=instance
    )


async def bot_output_visibility_update(
    bot_id: str | int,
    visibility_config: list[dict[str, Any]],
    instance: str | None = None,
) -> dict[str, Any]:
    """Update output field visibility for a bot. Audited write operation.

    Controls which fields are hidden from the bot's output mapping interface.

    Args:
        bot_id: Bot ID.
        visibility_config: List of field visibility items. Each needs:
            key (field key from field_list), form (form title),
            parent_key (optional), output_hidden (bool, default: true).
        instance: Instance profile name (from instance_list).

    Returns:
        dict with bot_id, field_count, audit_id.
    """
    return await _visibility_update(
        bot_id, "output", visibility_config, instance=instance
    )


# =============================================================================
# Public tools: Bot Mapping Summary (Diagnostic)
# =============================================================================


def _summarize_mapping(m: dict[str, Any]) -> dict[str, Any]:
    """Build a compact input mapping summary dict."""
    return {
        "source_field": m.get("sourceField"),
        "source_type": m.get("sourceType"),
        "target_field": m.get("targetField"),
        "target_type": m.get("targetType"),
        "has_determinant": bool(m.get("jsonDeterminant")),
    }


def _check_type_mismatch(source_type: str | None, target_type: str | None) -> bool:
    """Return True if source and target types are clearly incompatible."""
    if not source_type or not target_type:
        return False
    s, t = source_type.lower(), target_type.lower()
    if s == t:
        return False

    _string_types = {"string", "textfield", "textarea", "text", "email", "url"}
    _number_types = {
        "number",
        "integer",
        "int",
        "float",
        "double",
        "decimal",
        "long",
    }
    _bool_types = {"boolean", "checkbox", "radio"}

    for family in (_string_types, _number_types, _bool_types):
        if s in family and t in family:
            return False
        if s in family:
            for other_family in (_string_types, _number_types, _bool_types):
                if other_family is not family and t in other_family:
                    return True
        if t in family:
            for other_family in (_string_types, _number_types, _bool_types):
                if other_family is not family and s in other_family:
                    return True
    return False


def _find_duplicate_mappings(
    mappings: list[dict[str, Any]],
) -> list[dict[str, str]]:
    """Find duplicate source+target pairs in a mapping list."""
    seen: set[tuple[str, str]] = set()
    duplicates: list[dict[str, str]] = []
    for m in mappings:
        key = (m.get("sourceField", ""), m.get("targetField", ""))
        if key in seen:
            duplicates.append({"source_field": key[0], "target_field": key[1]})
        else:
            seen.add(key)
    return duplicates


async def bot_mapping_summary(
    bot_id: str | int, instance: str | None = None
) -> dict[str, Any]:
    """Diagnose bot mapping health: coverage, mismatches, ghosts, duplicates.

    Args:
        bot_id: Bot ID to analyze.
        instance: Instance profile name (from instance_list).

    Returns:
        dict with bot_metadata, gdb_service, input_mappings, output_mappings,
        health_checks, issue_count, coverage.
    """
    if not bot_id:
        raise ToolError(
            "'bot_id' is required. Use 'bot_list' with service_id to find bot IDs."
        )

    try:
        async with BPAClient.for_instance(instance) as client:
            # Fetch bot data
            try:
                bot_data = await client.get(
                    "/bot/{bot_id}",
                    path_params={"bot_id": bot_id},
                    resource_type="bot",
                    resource_id=bot_id,
                )
            except BPANotFoundError:
                raise ToolError(
                    f"Bot '{bot_id}' not found. "
                    "Use 'bot_list' with service_id to see available bots."
                )

            # Bot metadata
            bot_metadata = {
                "id": bot_data.get("id"),
                "name": bot_data.get("name"),
                "bot_type": bot_data.get("botType"),
                "category": bot_data.get("category"),
                "bot_service_id": bot_data.get("botServiceId"),
                "enabled": bot_data.get("enabled", True),
            }

            # Extract mappings
            raw_inputs = bot_data.get("inputFieldsMapping") or []
            raw_outputs = bot_data.get("outputFieldsMapping") or []

            input_summaries = [_summarize_mapping(m) for m in raw_inputs]
            output_summaries = [
                {
                    "source_field": m.get("sourceField"),
                    "source_type": m.get("sourceType"),
                    "target_field": m.get("targetField"),
                    "target_type": m.get("targetType"),
                }
                for m in raw_outputs
            ]

            # Health checks
            health: dict[str, Any] = {
                "type_mismatches": [],
                "missing_required_inputs": [],
                "missing_required_outputs": [],
                "ghost_input_mappings": [],
                "ghost_output_mappings": [],
                "duplicate_input_mappings": _find_duplicate_mappings(raw_inputs),
                "duplicate_output_mappings": _find_duplicate_mappings(raw_outputs),
            }

            # Check type mismatches across all mappings
            for m in raw_inputs:
                if _check_type_mismatch(m.get("sourceType"), m.get("targetType")):
                    health["type_mismatches"].append(
                        {
                            "direction": "input",
                            "source_field": m.get("sourceField"),
                            "source_type": m.get("sourceType"),
                            "target_field": m.get("targetField"),
                            "target_type": m.get("targetType"),
                        }
                    )
            for m in raw_outputs:
                if _check_type_mismatch(m.get("sourceType"), m.get("targetType")):
                    health["type_mismatches"].append(
                        {
                            "direction": "output",
                            "source_field": m.get("sourceField"),
                            "source_type": m.get("sourceType"),
                            "target_field": m.get("targetField"),
                            "target_type": m.get("targetType"),
                        }
                    )

            # GDB service analysis (if botServiceId is set)
            gdb_service: dict[str, Any] | None = None
            coverage: dict[str, Any] = {
                "input_mapped": len(raw_inputs),
                "input_total": None,
                "output_mapped": len(raw_outputs),
                "output_total": None,
            }

            bot_service_id = bot_data.get("botServiceId")
            if bot_service_id:
                try:
                    encoded_id = base64.b64encode(bot_service_id.encode()).decode()
                    svc_data = await client.get(
                        "/mule/services/{service_id}",
                        path_params={"service_id": encoded_id},
                        resource_type="mule_service",
                        resource_id=bot_service_id,
                    )

                    gdb_service = {
                        "id": svc_data.get("id"),
                        "name": svc_data.get("name"),
                        "method": svc_data.get("method"),
                    }

                    gdb_inputs = svc_data.get("inputs") or []
                    gdb_outputs = svc_data.get("outputs") or []

                    coverage["input_total"] = len(gdb_inputs)
                    coverage["output_total"] = len(gdb_outputs)

                    # Build lookup sets for GDB field names
                    gdb_input_names = {
                        f.get("name") for f in gdb_inputs if f.get("name")
                    }
                    gdb_output_names = {
                        f.get("name") for f in gdb_outputs if f.get("name")
                    }

                    # Required GDB inputs not mapped
                    mapped_input_targets = {m.get("targetField") for m in raw_inputs}
                    for f in gdb_inputs:
                        fname = f.get("name")
                        if f.get("required") and fname not in mapped_input_targets:
                            health["missing_required_inputs"].append(fname)

                    # Required GDB outputs not mapped
                    mapped_output_sources = {m.get("sourceField") for m in raw_outputs}
                    for f in gdb_outputs:
                        fname = f.get("name")
                        if f.get("required") and fname not in mapped_output_sources:
                            health["missing_required_outputs"].append(fname)

                    # Ghost input mappings (target not in GDB inputs)
                    for m in raw_inputs:
                        tgt = m.get("targetField")
                        if tgt and tgt not in gdb_input_names:
                            health["ghost_input_mappings"].append(tgt)

                    # Ghost output mappings (source not in GDB outputs)
                    for m in raw_outputs:
                        src = m.get("sourceField")
                        if src and src not in gdb_output_names:
                            health["ghost_output_mappings"].append(src)

                except (BPANotFoundError, BPAClientError):
                    # GDB service not found — skip GDB-specific analysis
                    gdb_service = {
                        "id": bot_service_id,
                        "name": None,
                        "method": None,
                        "error": "GDB service not found or inaccessible",
                    }

            # Count total issues
            issue_count = (
                len(health["type_mismatches"])
                + len(health["missing_required_inputs"])
                + len(health["missing_required_outputs"])
                + len(health["ghost_input_mappings"])
                + len(health["ghost_output_mappings"])
                + len(health["duplicate_input_mappings"])
                + len(health["duplicate_output_mappings"])
            )

            return {
                "bot_metadata": bot_metadata,
                "gdb_service": gdb_service,
                "input_mappings": input_summaries,
                "output_mappings": output_summaries,
                "health_checks": health,
                "issue_count": issue_count,
                "coverage": coverage,
            }

    except ToolError:
        raise
    except BPAClientError as e:
        raise translate_error(e, resource_type="bot", resource_id=bot_id)


# =============================================================================
# Intelligence: Suggest Mappings
# =============================================================================

# GDB field prefixes to strip for name comparison (3-panel services)
_GDB_FIELD_PREFIXES = re.compile(
    r"^(query_child_|write_child_|read_child_|"
    r"content_child_|response_child_|result_child_)"
)


def _normalize_name(name: str) -> str:
    """Normalize a field name for fuzzy comparison.

    Splits camelCase, replaces separators with spaces, lowercases.
    Example: "applicantBusinessName" -> "applicant business name"
    Example: "query_child_business_name" -> "business name"
    """
    if not name:
        return ""
    # Strip known GDB prefixes (repeatedly, they can nest)
    normalized = name
    for _ in range(5):
        stripped = _GDB_FIELD_PREFIXES.sub("", normalized)
        if stripped == normalized:
            break
        normalized = stripped
    # Split camelCase: insert space before uppercase letters
    normalized = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", normalized)
    # Replace underscores, hyphens, dots with spaces
    normalized = re.sub(r"[_\-\.]+", " ", normalized)
    return normalized.lower().strip()


# Type compatibility scores for matching
_TYPE_COMPAT: dict[tuple[str, str], float] = {
    ("string", "string"): 1.0,
    ("string", "textfield"): 1.0,
    ("textfield", "string"): 1.0,
    ("number", "number"): 1.0,
    ("integer", "number"): 0.9,
    ("number", "integer"): 0.9,
    ("date", "date"): 1.0,
    ("datetime", "date"): 0.8,
    ("date", "datetime"): 0.8,
    ("boolean", "boolean"): 1.0,
    ("boolean", "checkbox"): 0.9,
    ("checkbox", "boolean"): 0.9,
    ("string", "number"): 0.5,
    ("number", "string"): 0.5,
    ("string", "select"): 0.7,
    ("select", "string"): 0.7,
    ("date", "string"): 0.3,
    ("string", "date"): 0.3,
    ("string", "textarea"): 0.9,
    ("textarea", "string"): 0.9,
    ("string", "email"): 0.8,
    ("email", "string"): 0.8,
}


def _type_compatibility(type_a: str | None, type_b: str | None) -> float:
    """Compute type compatibility score between two field types."""
    if not type_a or not type_b:
        return 0.4  # Unknown types get a neutral score
    a = type_a.lower().strip()
    b = type_b.lower().strip()
    if a == b:
        return 1.0
    return _TYPE_COMPAT.get((a, b), 0.4)


def _compute_confidence(
    gdb_name: str,
    form_name: str,
    gdb_type: str | None,
    form_type: str | None,
) -> float:
    """Compute combined confidence: 0.7 * name_similarity + 0.3 * type_compat."""
    norm_gdb = _normalize_name(gdb_name)
    norm_form = _normalize_name(form_name)
    name_sim = SequenceMatcher(None, norm_gdb, norm_form).ratio()
    type_compat = _type_compatibility(gdb_type, form_type)
    return round(0.7 * name_sim + 0.3 * type_compat, 3)


def _find_best_match(
    gdb_name: str,
    gdb_type: str,
    form_fields: list[dict[str, str]],
    min_confidence: float,
    mapped_gdb_fields: set[str],
) -> dict[str, Any] | None:
    """Find the best form field match for a GDB field."""
    best: dict[str, Any] | None = None
    for ff in form_fields:
        conf = _compute_confidence(gdb_name, ff["key"], gdb_type, ff["type"])
        if conf < min_confidence:
            continue
        if best is None or conf > best["confidence"]:
            best = {
                "gdb_field": gdb_name,
                "gdb_type": gdb_type,
                "suggested_form_field": ff["key"],
                "form_type": ff["type"],
                "form_label": ff.get("label", ""),
                "confidence": conf,
                "already_mapped": gdb_name in mapped_gdb_fields,
            }
    return best


async def bot_suggest_mappings(
    bot_id: str | int,
    service_id: str | int | None = None,
    direction: str = "both",
    min_confidence: float = 0.3,
    instance: str | None = None,
) -> dict[str, Any]:
    """Suggest field mappings by comparing form fields to GDB service fields.

    Requires bot to have botServiceId set. Uses fuzzy name matching and
    type compatibility to rank suggestions.

    Args:
        bot_id: Bot ID (must have botServiceId configured).
        service_id: BPA service ID (optional; needed when bot API returns
            null serviceId — use the service_id from bot_list).
        direction: "input", "output", or "both" (default: "both").
        min_confidence: Minimum confidence threshold 0.0-1.0 (default: 0.3).
        instance: Instance profile name (from instance_list).

    Returns:
        dict with bot_id, service_id, bot_service_id, input_suggestions,
        output_suggestions, total_suggestions.
    """
    if not bot_id:
        raise ToolError(
            "'bot_id' is required. Use 'bot_list' with service_id to find bot IDs."
        )
    if direction not in ("input", "output", "both"):
        raise ToolError("'direction' must be 'input', 'output', or 'both'.")
    if not (0.0 <= min_confidence <= 1.0):
        raise ToolError("'min_confidence' must be between 0.0 and 1.0.")

    try:
        async with BPAClient.for_instance(instance) as client:
            # 1. Get bot details
            try:
                bot_data = await client.get(
                    "/bot/{bot_id}",
                    path_params={"bot_id": bot_id},
                    resource_type="bot",
                    resource_id=bot_id,
                )
            except BPANotFoundError:
                raise ToolError(
                    f"Bot '{bot_id}' not found. "
                    "Use 'bot_list' with service_id to see available bots."
                )

            bot_service_id = bot_data.get("botServiceId")
            if not bot_service_id:
                raise ToolError(
                    f"Bot '{bot_id}' has no botServiceId set. "
                    "Use 'bot_update' to assign a GDB service first, or use "
                    "'muleservice_list' to find available services."
                )

            resolved_service_id = service_id or bot_data.get("serviceId")
            if not resolved_service_id:
                raise ToolError(
                    f"Bot '{bot_id}' has no serviceId. Pass service_id parameter "
                    "(from bot_list) to provide the parent service."
                )

            # 2. Get GDB service spec (inputs/outputs)
            encoded_svc_id = base64.b64encode(bot_service_id.encode()).decode()
            try:
                gdb_service = await client.get(
                    "/mule/services/{service_id}",
                    path_params={"service_id": encoded_svc_id},
                    resource_type="mule_service",
                    resource_id=bot_service_id,
                )
            except BPANotFoundError:
                raise ToolError(
                    f"GDB service '{bot_service_id}' not found. "
                    "Use 'muleservice_list' to see available services."
                )

            gdb_inputs: list[dict[str, Any]] = gdb_service.get("inputs") or []
            gdb_outputs: list[dict[str, Any]] = gdb_service.get("outputs") or []

            # 3. Get service form fields (flat list)
            form_fields_raw = await client.get_list(
                "/service/{service_id}/fields",
                path_params={"service_id": resolved_service_id},
                resource_type="field",
            )

            form_fields = [
                {
                    "key": f.get("key", ""),
                    "type": f.get("type", ""),
                    "label": f.get("label", ""),
                }
                for f in form_fields_raw
                if f.get("key")
            ]

            # 4. Collect existing mapped fields to flag already_mapped
            existing_input_targets: set[str] = set()
            existing_output_sources: set[str] = set()

            for m in bot_data.get("inputFieldsMapping") or []:
                existing_input_targets.add(m.get("targetField", ""))
            for m in bot_data.get("outputFieldsMapping") or []:
                existing_output_sources.add(m.get("sourceField", ""))

            # 5. Generate suggestions
            result: dict[str, Any] = {
                "bot_id": str(bot_id),
                "service_id": str(resolved_service_id),
                "bot_service_id": bot_service_id,
            }

            if direction in ("input", "both"):
                # Input: form field (source) -> GDB field (target)
                input_suggestions = []
                for gdb_field in gdb_inputs:
                    gdb_name = gdb_field.get("name", "")
                    gdb_type = gdb_field.get("type", "")
                    if not gdb_name:
                        continue
                    match = _find_best_match(
                        gdb_name,
                        gdb_type,
                        form_fields,
                        min_confidence,
                        existing_input_targets,
                    )
                    if match:
                        input_suggestions.append(match)

                input_suggestions.sort(key=lambda s: s["confidence"], reverse=True)
                result["input_suggestions"] = input_suggestions

            if direction in ("output", "both"):
                # Output: GDB response field (source) -> form field (target)
                output_suggestions = []
                for gdb_field in gdb_outputs:
                    gdb_name = gdb_field.get("name", "")
                    gdb_type = gdb_field.get("type", "")
                    if not gdb_name:
                        continue
                    match = _find_best_match(
                        gdb_name,
                        gdb_type,
                        form_fields,
                        min_confidence,
                        existing_output_sources,
                    )
                    if match:
                        output_suggestions.append(match)

                output_suggestions.sort(key=lambda s: s["confidence"], reverse=True)
                result["output_suggestions"] = output_suggestions

            # Total count
            total = 0
            if "input_suggestions" in result:
                total += len(result["input_suggestions"])
            if "output_suggestions" in result:
                total += len(result["output_suggestions"])
            result["total_suggestions"] = total

            return result

    except ToolError:
        raise
    except BPAClientError as e:
        raise translate_error(e, resource_type="bot", resource_id=bot_id)


# =============================================================================
# Registration
# =============================================================================


def register_bot_mapping_tools(mcp: Any) -> None:
    """Register bot mapping tools with the MCP server."""
    from mcp_eregistrations_bpa.tools.annotations import DESTRUCTIVE, READ, WRITE

    # Read operations
    mcp.tool(annotations=READ)(bot_input_mapping_list)
    mcp.tool(annotations=READ)(bot_output_mapping_list)
    mcp.tool(annotations=READ)(bot_history_list)
    # Single-mapping CRUD
    mcp.tool(annotations=WRITE)(bot_input_mapping_create)
    mcp.tool(annotations=WRITE)(bot_input_mapping_update)
    mcp.tool(annotations=DESTRUCTIVE)(bot_input_mapping_delete)
    mcp.tool(annotations=WRITE)(bot_output_mapping_create)
    mcp.tool(annotations=WRITE)(bot_output_mapping_update)
    mcp.tool(annotations=DESTRUCTIVE)(bot_output_mapping_delete)
    # Bulk operations (destructive: replaces all mappings)
    mcp.tool(annotations=DESTRUCTIVE)(bot_input_mapping_save_all)
    mcp.tool(annotations=DESTRUCTIVE)(bot_output_mapping_save_all)
    # Bot-level operations
    mcp.tool(annotations=WRITE)(bot_clone)
    mcp.tool(annotations=DESTRUCTIVE)(bot_history_revert)
    # Visibility
    mcp.tool(annotations=WRITE)(bot_input_visibility_update)
    mcp.tool(annotations=WRITE)(bot_output_visibility_update)
    # Diagnostics / Intelligence
    mcp.tool(annotations=READ)(bot_mapping_summary)
    mcp.tool(annotations=READ)(bot_suggest_mappings)
