"""MCP tools for BPA form operations.

This module provides tools for reading and manipulating Form.io forms in BPA services.
Forms include: applicant forms, guide forms, send file forms, and payment forms.

BPA uses a read-modify-write pattern for forms:
1. GET the complete form schema
2. Modify the components array
3. PUT the entire updated schema

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:
- GET /service/{id}/applicant-form?reusable=false - Get applicant form
- GET /service/{id}/guide-form?reusable=false - Get guide form
- GET /service/{id}/send-file-form?reusable=false - Get send file form
- GET /service/{id}/payment-form?reusable=false - Get payment form
- PUT /applicant-form/{form_id} - Update applicant form
- PUT /guide-form/{form_id} - Update guide form
- PUT /send-file-form/{form_id} - Update send file form
- PUT /payment-form/{form_id} - Update payment form
"""

from __future__ import annotations

from copy import deepcopy
from typing import Any

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.patch.diff import generate_diff
from mcp_eregistrations_bpa.tools._form_shared import (
    build_registration_name_map,
    parse_form_schema,
    resolve_registration_uuids,
    simplify_components,
)
from mcp_eregistrations_bpa.tools.form_errors import FormErrorCode
from mcp_eregistrations_bpa.tools.formio_helpers import (
    collect_registration_ids,
    find_component,
    get_all_component_keys,
    insert_component,
    move_component,
    remove_component,
    update_component,
    validate_component,
    validate_component_key_unique,
)
from mcp_eregistrations_bpa.tools.large_response import large_response_handler

__all__ = [
    "form_get",
    "form_component_get",
    "form_component_add",
    "form_component_update",
    "form_component_remove",
    "form_component_move",
    "form_update",
    "form_patch",
    "register_form_tools",
]


# Form type to endpoint mapping
FORM_TYPES = {
    "applicant": {
        "get_endpoint": "/service/{id}/applicant-form",
        "put_endpoint": "/applicant-form/{form_id}",
        "name": "Applicant Form",
    },
    "guide": {
        "get_endpoint": "/service/{id}/guide-form",
        "put_endpoint": "/guide-form/{form_id}",
        "name": "Guide Form",
    },
    "send_file": {
        "get_endpoint": "/service/{id}/send-file-form",
        "put_endpoint": "/send-file-form/{form_id}",
        "name": "Send File Form",
    },
    "payment": {
        "get_endpoint": "/service/{id}/payment-form",
        "put_endpoint": "/payment-form/{form_id}",
        "name": "Payment Form",
    },
}


def _validate_form_type(form_type: str) -> dict[str, str]:
    """Validate form type and return endpoint config.

    Args:
        form_type: Form type to validate.

    Returns:
        Endpoint configuration dict.

    Raises:
        ToolError: If form type is invalid.
    """
    if form_type not in FORM_TYPES:
        valid_types = ", ".join(sorted(FORM_TYPES.keys()))
        raise ToolError(
            f"[{FormErrorCode.INVALID_FORM_TYPE}] Invalid form type '{form_type}'. "
            f"Valid types: {valid_types}"
        )
    return FORM_TYPES[form_type]


def _parse_form_schema(form_data: dict[str, Any]) -> dict[str, Any]:
    """Parse formSchema from form data. Delegates to shared helper."""
    return parse_form_schema(form_data)


def _build_registration_name_map(
    registrations: list[dict[str, Any]] | None,
) -> dict[str, str | None]:
    """Build UUID to name mapping. Delegates to shared helper."""
    return build_registration_name_map(registrations)


def _resolve_registration_uuids(
    registrations: dict[str, Any] | None,
    name_map: dict[str, str | None],
) -> dict[str, str | None] | None:
    """Resolve registration UUIDs to names. Delegates to shared helper."""
    return resolve_registration_uuids(registrations, name_map)


def _simplify_components(
    components: list[dict[str, Any]],
    path: list[str] | None = None,
    registration_name_map: dict[str, str | None] | None = None,
    **kwargs: Any,
) -> list[dict[str, Any]]:
    """Simplify components for display. Delegates to shared helper."""
    return simplify_components(components, path, registration_name_map, **kwargs)


async def _get_form_data(
    client: BPAClient,
    service_id: str | int,
    form_type: str,
) -> dict[str, Any]:
    """Get raw form data from BPA.

    Args:
        client: BPA client instance.
        service_id: Service ID.
        form_type: Type of form.

    Returns:
        Raw form data from API.

    Raises:
        ToolError: If form not found.
    """
    config = _validate_form_type(form_type)

    try:
        form_data = await client.get(
            config["get_endpoint"],
            path_params={"id": service_id},
            params={"reusable": "false"},
            resource_type="form",
            resource_id=f"{service_id}/{form_type}",
        )
    except BPANotFoundError:
        raise ToolError(
            f"[{FormErrorCode.SERVICE_NOT_FOUND}] {config['name']} not found for "
            f"service '{service_id}'. The service may not have this form type "
            "configured."
        )

    return form_data


async def _update_form_data(
    client: BPAClient,
    form_data: dict[str, Any],
    form_type: str,
) -> None:
    """Update form data in BPA.

    Args:
        client: BPA client instance.
        form_data: Complete form data to PUT.
        form_type: Type of form.
    """
    config = _validate_form_type(form_type)
    form_id = form_data.get("id")

    await client.put(
        config["put_endpoint"],
        path_params={"form_id": form_id},
        json=form_data,
        resource_type="form",
        resource_id=form_id,
    )


# =============================================================================
# Cache Helpers
# =============================================================================


async def _fetch_and_cache_form(
    service_id: str | int, form_type: str, instance: str | None = None
) -> dict[str, Any]:
    """Fetch form from BPA and populate cache. Returns raw form_data."""
    _validate_form_type(form_type)
    async with BPAClient.for_instance(instance) as client:
        form_data = await _get_form_data(client, service_id, form_type)
    try:
        from mcp_eregistrations_bpa.db.form_cache import put_form_cache

        db_path = resolve_db_path(instance)
        await put_form_cache(str(service_id), form_type, form_data, db_path)
    except Exception:
        pass  # Best-effort cache population
    return form_data


async def _invalidate_form_cache(
    service_id: str | int, form_type: str, instance: str | None
) -> None:
    """Invalidate form cache after write. Best-effort, never raises."""
    try:
        from mcp_eregistrations_bpa.db.form_cache import invalidate_form_cache

        db_path = resolve_db_path(instance)
        await invalidate_form_cache(str(service_id), form_type, db_path)
    except Exception:
        pass


# =============================================================================
# Read Operations
# =============================================================================


@large_response_handler(
    navigation={
        "list_keys": "jq '.component_keys'",
        "find_by_type": "jq '.components[] | select(.type == \"textfield\")'",
        "find_by_key": "jq '.components[] | select(.key == \"fieldKey\")'",
        "required_only": "jq '.components[] | select(.required == true)'",
    }
)
async def form_get(
    service_id: str | int,
    form_type: str = "applicant",
    include_raw: bool = False,
    force_refresh: bool = False,
    visible_only: bool = False,
    instance: str | None = None,
) -> dict[str, Any]:
    """Get form schema with simplified component list.

    Large responses (>100KB) are saved to file with navigation hints.

    Args:
        service_id: BPA service UUID.
        form_type: "applicant" (default), "guide", "send_file", or "payment".
        include_raw: Include full raw_schema in response (default: False).
        force_refresh: Bypass cache and fetch from BPA (default: False).
        visible_only: Exclude hidden components and children of hidden
            containers (default: False).
        instance: Instance profile name (from instance_list).

    Returns:
        dict with id, form_type, active, components, component_count,
        component_keys. Includes raw_schema only when include_raw=True.
    """
    config = _validate_form_type(form_type)

    # Try cache first (unless force_refresh)
    form_data = None
    if not force_refresh:
        try:
            from mcp_eregistrations_bpa.db.form_cache import get_cached_form

            db_path = resolve_db_path(instance)
            form_data = await get_cached_form(str(service_id), form_type, db_path)
        except Exception:
            pass  # Cache miss or error, fall through to API

    try:
        async with BPAClient.for_instance(instance) as client:
            if form_data is None:
                form_data = await _get_form_data(client, service_id, form_type)
                # Populate cache (best-effort)
                try:
                    from mcp_eregistrations_bpa.db.form_cache import put_form_cache

                    db_path = resolve_db_path(instance)
                    await put_form_cache(str(service_id), form_type, form_data, db_path)
                except Exception:
                    pass

            # Fetch service registrations for UUID to name resolution (Story 9.3)
            # This allows resolving registration UUIDs in form components to names
            try:
                service_data = await client.get(
                    "/service/{id}",
                    path_params={"id": service_id},
                    resource_type="service",
                    resource_id=str(service_id),
                )
                service_registrations = service_data.get("registrations", [])
            except BPAClientError:
                # If we can't fetch service data, continue without registration names
                service_registrations = []
    except ToolError:
        raise
    except BPAClientError as e:
        raise translate_error(e, resource_type="form", resource_id=service_id)

    # Build registration name map for UUID resolution
    registration_name_map = _build_registration_name_map(service_registrations)

    # Extract form schema
    form_schema = _parse_form_schema(form_data)
    components = form_schema.get("components", [])
    # Handle BPA API returning non-list for components (e.g., integer count)
    if not isinstance(components, list):
        components = []
    all_keys = get_all_component_keys(components)
    simplified = _simplify_components(
        components,
        registration_name_map=registration_name_map,
        visible_only=visible_only,
    )

    result: dict[str, Any] = {
        "id": form_data.get("id"),
        "form_type": form_type,
        "form_name": config["name"],
        "service_id": service_id,
        "active": form_data.get("active", True),
        "components": simplified,
        "component_count": len(all_keys),
        "component_keys": sorted(all_keys),
    }

    # Only include raw_schema when explicitly requested
    if include_raw:
        result["raw_schema"] = form_schema

    return result


async def form_component_get(
    service_id: str | int,
    component_key: str,
    form_type: str = "applicant",
    instance: str | None = None,
) -> dict[str, Any]:
    """Get details of a form component, including nested components.

    Args:
        service_id: BPA service UUID.
        component_key: Component's key property.
        form_type: "applicant" (default), "guide", "send_file", or "payment".
        instance: Instance profile name (from instance_list).

    Returns:
        dict with key, type, label, validate, data, determinant_ids, path, raw.
        See docs/mcp-tools-guide.md for path hierarchy examples.
    """
    config = _validate_form_type(form_type)

    try:
        async with BPAClient.for_instance(instance) as client:
            form_data = await _get_form_data(client, service_id, form_type)

            # Fetch service registrations for UUID to name resolution (Story 9.3)
            try:
                service_data = await client.get(
                    "/service/{id}",
                    path_params={"id": service_id},
                    resource_type="service",
                    resource_id=str(service_id),
                )
                service_registrations = service_data.get("registrations", [])
            except BPAClientError:
                # If we can't fetch service data, continue without registration names
                service_registrations = []
    except ToolError:
        raise
    except BPAClientError as e:
        raise translate_error(e, resource_type="form", resource_id=service_id)

    # Build registration name map for UUID resolution
    registration_name_map = _build_registration_name_map(service_registrations)

    # Extract form schema
    form_schema = _parse_form_schema(form_data)
    components = form_schema.get("components", [])

    # Find component
    result = find_component(components, component_key)
    if result is None:
        all_keys = get_all_component_keys(components)
        key_preview = list(sorted(all_keys))[:10]
        raise ToolError(
            f"[{FormErrorCode.COMPONENT_NOT_FOUND}] Component '{component_key}' not "
            f"found in {config['name']}. Available keys include: "
            f"{', '.join(key_preview)}... Use form_get to see all "
            f"{len(all_keys)} components."
        )

    comp, path = result

    # Build detailed response
    response: dict[str, Any] = {
        "key": comp.get("key"),
        "type": comp.get("type"),
        "label": comp.get("label"),
        "form_type": form_type,
        "service_id": service_id,
        "path": path,
    }

    # Add validation info
    validate = comp.get("validate", {})
    if validate:
        response["validate"] = validate

    # Add data source info (for selects)
    data = comp.get("data", {})
    if data:
        response["data"] = data

    # Add BPA-specific properties
    if comp.get("determinantIds"):
        response["determinant_ids"] = comp["determinantIds"]
    # Resolve registration UUIDs to names (Story 9.3)
    raw_registrations = comp.get("registrations")
    if raw_registrations:
        resolved_registrations = _resolve_registration_uuids(
            raw_registrations, registration_name_map
        )
        if resolved_registrations:
            response["registrations"] = resolved_registrations
    if comp.get("componentActionId"):
        response["component_action_id"] = comp["componentActionId"]
    if comp.get("componentFormulaId"):
        response["component_formula_id"] = comp["componentFormulaId"]

    # Add common properties
    if comp.get("hidden"):
        response["hidden"] = True
    if comp.get("disabled"):
        response["disabled"] = True
    if comp.get("defaultValue") is not None:
        response["default_value"] = comp["defaultValue"]

    # Include raw component for advanced use
    response["raw"] = comp

    return response


# =============================================================================
# Write Operations
# =============================================================================


def _validate_component_add_params(
    component: dict[str, Any],
) -> list[str]:
    """Validate component for add operation.

    Args:
        component: Component to validate.

    Returns:
        List of validation errors (empty if valid).
    """
    return validate_component(component)


async def form_component_add(
    service_id: str | int,
    component: dict[str, Any],
    form_type: str = "applicant",
    parent_key: str | None = None,
    position: int | None = None,
    column_index: int | str | None = None,
    row_index: int | str | None = None,
    cell_index: int | str | None = None,
    instance: str | None = None,
    dry_run: bool = False,
) -> dict[str, Any]:
    """Add component to form. Audited write operation.

    Args:
        service_id: BPA service UUID.
        component: Form.io component with key, type, label.
        form_type: "applicant" (default), "guide", "send_file", "payment".
        parent_key: Parent container key for nesting, or None for root.
        position: Insert position (0-indexed), or None for end.
        column_index: For columns parents, which column to add to. Use int
            (0-indexed) for specific column, or "all" to add to every column.
        row_index: For table parents, which row to add to. Use int (0-indexed)
            for specific row, or "all" to add to every row.
        cell_index: For table parents, which cell in the row to add to. Use int
            (0-indexed) for specific cell, or "all" to add to every cell.
        instance: Instance profile name (from instance_list).
        dry_run: If True, return diff without writing to BPA (default: False).

    Returns:
        dict with added, component_key, position, column_index, row_index,
        cell_index, dry_run, diff, audit_id (omitted when dry_run=True).
        See docs/mcp-tools-guide.md for nesting examples.
    """
    config = _validate_form_type(form_type)

    # Pre-flight validation
    errors = _validate_component_add_params(component)
    if errors:
        raise ToolError(
            f"[{FormErrorCode.MISSING_REQUIRED_PROPERTY}] Invalid component: "
            f"{'; '.join(errors)}. Ensure 'key', 'type', and 'label' are provided."
        )

    component_key = str(component.get("key", ""))

    # Get authenticated user
    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:
            # Get current form
            form_data = await _get_form_data(client, service_id, form_type)

            # Parse form schema
            form_schema = _parse_form_schema(form_data)
            components = form_schema.get("components", [])

            # Check key uniqueness
            if not validate_component_key_unique(components, component_key):
                raise ToolError(
                    f"[{FormErrorCode.DUPLICATE_KEY}] Component key '{component_key}' "
                    "already exists in form. Use a unique key or use "
                    "form_component_update to modify existing."
                )

            # Validate parent if specified
            if parent_key:
                parent_result = find_component(components, parent_key)
                if parent_result is None:
                    raise ToolError(
                        f"[{FormErrorCode.INVALID_PARENT}] Parent component "
                        f"'{parent_key}' not found. Use form_get to see "
                        "available components."
                    )

            # Auto-assign registrations if not provided (matches frontend behavior).
            # The frontend auto-assigns ALL service registrations to new components.
            if not component.get("registrations"):
                existing_regs = collect_registration_ids(components)
                if existing_regs:
                    component["registrations"] = existing_regs

            # Deepcopy for diff baseline before mutation
            original_components = deepcopy(components)

            # Insert component (single call for both dry_run and normal paths)
            try:
                new_components = insert_component(
                    components,
                    component,
                    parent_key,
                    position,
                    column_index,
                    row_index,
                    cell_index,
                )
            except ValueError as e:
                raise ToolError(f"[{FormErrorCode.INVALID_POSITION}] {e}")

            # Generate diff
            diff = generate_diff(original_components, new_components)
            diff_dict = diff.to_dict()

            # Dry run — return diff only, skip audit + PUT
            if dry_run:
                return {
                    "added": True,
                    "dry_run": True,
                    "component_key": component_key,
                    "component_type": component.get("type"),
                    "form_type": form_type,
                    "service_id": service_id,
                    "parent_key": parent_key,
                    "position": position,
                    "diff": diff_dict,
                }

            # Create audit record BEFORE modification
            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="form_component",
                params={
                    "service_id": str(service_id),
                    "form_type": form_type,
                    "form_id": form_data.get("id"),
                    "component_key": component_key,
                    "parent_key": parent_key,
                    "position": position,
                    "column_index": column_index,
                    "row_index": row_index,
                    "cell_index": cell_index,
                },
            )

            # Save rollback state (entire form before modification)
            await audit_logger.save_rollback_state(
                audit_id=audit_id,
                object_type="form",
                object_id=str(form_data.get("id")),
                previous_state=form_data,
            )

            operation_error: Exception | None = None
            try:
                # Update form schema with computed components
                form_schema["components"] = new_components
                form_data["formSchema"] = form_schema

                # PUT updated form
                await _update_form_data(client, form_data, form_type)

            except BPAClientError as e:
                operation_error = translate_error(e, resource_type="form")
            finally:
                # Always update audit status, even if this fails
                try:
                    if operation_error:
                        await audit_logger.mark_failed(audit_id, str(operation_error))
                    else:
                        await audit_logger.mark_success(
                            audit_id,
                            result={
                                "component_key": component_key,
                                "parent_key": parent_key,
                                "position": position,
                                "column_index": column_index,
                                "row_index": row_index,
                                "cell_index": cell_index,
                            },
                        )
                except Exception:
                    pass  # Don't mask the original error

            if operation_error:
                raise operation_error

    except ToolError:
        raise
    except BPAClientError as e:
        raise translate_error(e, resource_type="form", resource_id=service_id)

    await _invalidate_form_cache(service_id, form_type, instance)

    return {
        "added": True,
        "dry_run": False,
        "component_key": component_key,
        "component_type": component.get("type"),
        "form_type": form_type,
        "form_name": config["name"],
        "service_id": service_id,
        "parent_key": parent_key,
        "position": position,
        "column_index": column_index,
        "row_index": row_index,
        "cell_index": cell_index,
        "diff": diff_dict,
        "audit_id": audit_id,
    }


async def form_component_update(
    service_id: str | int,
    component_key: str,
    updates: dict[str, Any],
    form_type: str = "applicant",
    instance: str | None = None,
    dry_run: bool = False,
) -> dict[str, Any]:
    """Update component properties. Audited write operation.

    Args:
        service_id: BPA service UUID.
        component_key: Component to update.
        updates: Properties to merge (e.g., {"label": "New", "hidden": True}).
        form_type: "applicant" (default), "guide", "send_file", or "payment".
        instance: Instance profile name (from instance_list).
        dry_run: If True, return diff without writing to BPA (default: False).

    Returns:
        dict with updated, component_key, updates_applied, previous_state, diff,
        dry_run, audit_id (omitted when dry_run=True).
    """
    config = _validate_form_type(form_type)

    if not updates:
        raise ToolError(
            f"[{FormErrorCode.NO_UPDATES_PROVIDED}] No updates provided. "
            "Specify properties to update."
        )

    # Prevent key changes
    if "key" in updates and updates["key"] != component_key:
        raise ToolError(
            f"[{FormErrorCode.KEY_CHANGE_NOT_ALLOWED}] Cannot change component key. "
            "To rename, remove and re-add the component."
        )

    # Get authenticated user
    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:
            # Get current form
            form_data = await _get_form_data(client, service_id, form_type)

            # Parse form schema
            form_schema = _parse_form_schema(form_data)
            components = form_schema.get("components", [])

            # Check component exists
            found = find_component(components, component_key)
            if found is None:
                all_keys = get_all_component_keys(components)
                raise ToolError(
                    f"[{FormErrorCode.COMPONENT_NOT_FOUND}] Component "
                    f"'{component_key}' not found. Use form_get to see "
                    f"{len(all_keys)} available components."
                )

            # Deepcopy for diff baseline before mutation
            original_components = deepcopy(components)

            # Update component (single call for both paths)
            try:
                new_components, previous_state = update_component(
                    components, component_key, updates
                )
            except ValueError as e:
                raise ToolError(f"[{FormErrorCode.COMPONENT_NOT_FOUND}] {e}")

            # Generate diff
            diff = generate_diff(original_components, new_components)
            diff_dict = diff.to_dict()

            # Dry run — return diff only, skip audit + PUT
            if dry_run:
                return {
                    "updated": True,
                    "dry_run": True,
                    "component_key": component_key,
                    "form_type": form_type,
                    "service_id": service_id,
                    "updates_applied": list(updates.keys()),
                    "diff": diff_dict,
                }

            # Create audit record
            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="form_component",
                params={
                    "service_id": str(service_id),
                    "form_type": form_type,
                    "form_id": form_data.get("id"),
                    "component_key": component_key,
                    "updates": updates,
                },
            )

            # Save rollback state
            await audit_logger.save_rollback_state(
                audit_id=audit_id,
                object_type="form",
                object_id=str(form_data.get("id")),
                previous_state=form_data,
            )

            operation_error: Exception | None = None
            try:
                # Update form schema with computed components
                form_schema["components"] = new_components
                form_data["formSchema"] = form_schema

                # PUT updated form
                await _update_form_data(client, form_data, form_type)

            except BPAClientError as e:
                operation_error = translate_error(e, resource_type="form")
            finally:
                # Always update audit status, even if this fails
                try:
                    if operation_error:
                        await audit_logger.mark_failed(audit_id, str(operation_error))
                    else:
                        await audit_logger.mark_success(
                            audit_id,
                            result={
                                "component_key": component_key,
                                "updates_applied": list(updates.keys()),
                            },
                        )
                except Exception:
                    pass  # Don't mask the original error

            if operation_error:
                raise operation_error

    except ToolError:
        raise
    except BPAClientError as e:
        raise translate_error(e, resource_type="form", resource_id=service_id)

    await _invalidate_form_cache(service_id, form_type, instance)

    # Simplify previous state for response
    prev_summary = {
        "label": previous_state.get("label"),
        "type": previous_state.get("type"),
    }
    if previous_state.get("validate"):
        prev_summary["validate"] = previous_state["validate"]
    if previous_state.get("hidden"):
        prev_summary["hidden"] = previous_state["hidden"]
    if previous_state.get("disabled"):
        prev_summary["disabled"] = previous_state["disabled"]

    return {
        "updated": True,
        "dry_run": False,
        "component_key": component_key,
        "form_type": form_type,
        "form_name": config["name"],
        "service_id": service_id,
        "updates_applied": list(updates.keys()),
        "previous_state": prev_summary,
        "diff": diff_dict,
        "audit_id": audit_id,
    }


async def form_component_remove(
    service_id: str | int,
    component_key: str,
    form_type: str = "applicant",
    dry_run: bool = False,
    instance: str | None = None,
) -> dict[str, Any]:
    """Remove component from form. Audited write operation.

    Warning: May break determinant references. Check determinant_list first.

    Args:
        service_id: BPA service UUID.
        component_key: Component to remove.
        form_type: "applicant" (default), "guide", "send_file", or "payment".
        dry_run: If True, return diff without saving (default: False).
        instance: Instance profile name (from instance_list).

    Returns:
        dict with removed, dry_run, component_key, deleted_component, diff,
        audit_id (omitted for dry_run).
    """
    config = _validate_form_type(form_type)

    # Get authenticated user
    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:
            # Get current form
            form_data = await _get_form_data(client, service_id, form_type)

            # Parse form schema
            form_schema = _parse_form_schema(form_data)
            components = form_schema.get("components", [])

            # Check component exists
            found = find_component(components, component_key)
            if found is None:
                all_keys = get_all_component_keys(components)
                raise ToolError(
                    f"[{FormErrorCode.COMPONENT_NOT_FOUND}] Component "
                    f"'{component_key}' not found. Use form_get to see "
                    f"{len(all_keys)} available components."
                )

            # Deepcopy for diff baseline before mutation
            original_components = deepcopy(components)

            # Remove component (single call for both dry_run and normal paths)
            try:
                new_components, removed = remove_component(components, component_key)
            except ValueError as e:
                raise ToolError(f"[{FormErrorCode.COMPONENT_NOT_FOUND}] {e}")

            # Generate diff
            diff = generate_diff(original_components, new_components)
            diff_dict = diff.to_dict()

            # Dry run — return diff only, skip audit + PUT
            if dry_run:
                removed_summary = {
                    "key": removed.get("key"),
                    "type": removed.get("type"),
                    "label": removed.get("label"),
                }
                return {
                    "removed": True,
                    "dry_run": True,
                    "component_key": component_key,
                    "form_type": form_type,
                    "service_id": service_id,
                    "deleted_component": removed_summary,
                    "diff": diff_dict,
                }

            # Create audit record BEFORE modification
            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="form_component",
                params={
                    "service_id": str(service_id),
                    "form_type": form_type,
                    "form_id": form_data.get("id"),
                    "component_key": component_key,
                },
            )

            # Save rollback state
            await audit_logger.save_rollback_state(
                audit_id=audit_id,
                object_type="form",
                object_id=str(form_data.get("id")),
                previous_state=form_data,
            )

            operation_error: Exception | None = None
            try:
                # Update form schema with computed components
                form_schema["components"] = new_components
                form_data["formSchema"] = form_schema

                # PUT updated form
                await _update_form_data(client, form_data, form_type)

            except BPAClientError as e:
                operation_error = translate_error(e, resource_type="form")
            finally:
                # Always update audit status, even if this fails
                try:
                    if operation_error:
                        await audit_logger.mark_failed(audit_id, str(operation_error))
                    else:
                        await audit_logger.mark_success(
                            audit_id,
                            result={
                                "component_key": component_key,
                                "component_type": removed.get("type"),
                            },
                        )
                except Exception:
                    pass  # Don't mask the original error

            if operation_error:
                raise operation_error

    except ToolError:
        raise
    except BPAClientError as e:
        raise translate_error(e, resource_type="form", resource_id=service_id)

    await _invalidate_form_cache(service_id, form_type, instance)

    # Simplify removed component for response
    removed_summary = {
        "key": removed.get("key"),
        "type": removed.get("type"),
        "label": removed.get("label"),
    }

    return {
        "removed": True,
        "dry_run": False,
        "component_key": component_key,
        "form_type": form_type,
        "form_name": config["name"],
        "service_id": service_id,
        "deleted_component": removed_summary,
        "diff": diff_dict,
        "audit_id": audit_id,
    }


async def form_component_move(
    service_id: str | int,
    component_key: str,
    new_parent_key: str | None = None,
    new_position: int | None = None,
    column_index: int | str | None = None,
    row_index: int | str | None = None,
    cell_index: int | str | None = None,
    form_type: str = "applicant",
    dry_run: bool = False,
    instance: str | None = None,
) -> dict[str, Any]:
    """Move component to new position. Audited write operation.

    Args:
        service_id: BPA service UUID.
        component_key: Component to move.
        new_parent_key: Target parent container, or None for root.
        new_position: Position in target, or None for end.
        column_index: For columns parents, which column to move into. Use int
            (0-indexed) for specific column, or "all" to copy into every column.
        row_index: For table parents, which row to move into. Use int (0-indexed)
            for specific row, or "all" to copy into every row.
        cell_index: For table parents, which cell in the row to move into. Use int
            (0-indexed) for specific cell, or "all" to copy into every cell.
        form_type: "applicant" (default), "guide", "send_file", or "payment".
        dry_run: If True, return diff without writing to BPA (default: False).
        instance: Instance profile name (from instance_list).

    Returns:
        dict with moved, dry_run, old_parent, old_position, new_parent,
        new_position, column_index, row_index, cell_index, diff,
        audit_id (omitted when dry_run=True).
    """
    config = _validate_form_type(form_type)

    # Get authenticated user
    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:
            # Get current form
            form_data = await _get_form_data(client, service_id, form_type)

            # Parse form schema
            form_schema = _parse_form_schema(form_data)
            components = form_schema.get("components", [])

            # Check component exists
            found = find_component(components, component_key)
            if found is None:
                raise ToolError(
                    f"[{FormErrorCode.COMPONENT_NOT_FOUND}] Component "
                    f"'{component_key}' not found. Use form_get to see "
                    "available components."
                )

            # Validate new parent if specified
            if new_parent_key:
                parent_result = find_component(components, new_parent_key)
                if parent_result is None:
                    raise ToolError(
                        f"[{FormErrorCode.INVALID_PARENT}] Target parent "
                        f"'{new_parent_key}' not found. Use form_get to see "
                        "available components."
                    )

            original_components = deepcopy(components)

            # Move component (single call for both dry_run and normal paths)
            try:
                new_components, move_info = move_component(
                    components,
                    component_key,
                    new_parent_key,
                    new_position,
                    column_index,
                    row_index,
                    cell_index,
                )
            except ValueError as e:
                raise ToolError(f"[{FormErrorCode.INVALID_PARENT}] {e}")

            # Generate diff
            diff = generate_diff(original_components, new_components)
            diff_dict = diff.to_dict()

            # Dry run — return diff only, skip audit + PUT
            if dry_run:
                return {
                    "moved": True,
                    "dry_run": True,
                    "component_key": component_key,
                    "form_type": form_type,
                    "service_id": service_id,
                    "old_parent": move_info.get("old_parent"),
                    "old_position": move_info.get("old_position"),
                    "new_parent": move_info.get("new_parent"),
                    "new_position": move_info.get("new_position"),
                    "column_index": move_info.get("column_index"),
                    "row_index": move_info.get("row_index"),
                    "cell_index": move_info.get("cell_index"),
                    "diff": diff_dict,
                }

            # Create audit record
            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="form_component",
                params={
                    "service_id": str(service_id),
                    "form_type": form_type,
                    "form_id": form_data.get("id"),
                    "component_key": component_key,
                    "new_parent_key": new_parent_key,
                    "new_position": new_position,
                    "column_index": column_index,
                    "row_index": row_index,
                    "cell_index": cell_index,
                    "operation": "move",
                },
            )

            # Save rollback state
            await audit_logger.save_rollback_state(
                audit_id=audit_id,
                object_type="form",
                object_id=str(form_data.get("id")),
                previous_state=form_data,
            )

            operation_error: Exception | None = None
            try:
                # Update form schema
                form_schema["components"] = new_components
                form_data["formSchema"] = form_schema

                # PUT updated form
                await _update_form_data(client, form_data, form_type)

            except BPAClientError as e:
                operation_error = translate_error(e, resource_type="form")
            finally:
                # Always update audit status, even if this fails
                try:
                    if operation_error:
                        await audit_logger.mark_failed(audit_id, str(operation_error))
                    else:
                        await audit_logger.mark_success(
                            audit_id,
                            result={
                                "component_key": component_key,
                                "move_info": move_info,
                            },
                        )
                except Exception:
                    pass  # Don't mask the original error

            if operation_error:
                raise operation_error

    except ToolError:
        raise
    except BPAClientError as e:
        raise translate_error(e, resource_type="form", resource_id=service_id)

    await _invalidate_form_cache(service_id, form_type, instance)

    return {
        "moved": True,
        "dry_run": False,
        "component_key": component_key,
        "form_type": form_type,
        "form_name": config["name"],
        "service_id": service_id,
        "old_parent": move_info.get("old_parent"),
        "old_position": move_info.get("old_position"),
        "new_parent": move_info.get("new_parent"),
        "new_position": move_info.get("new_position"),
        "column_index": move_info.get("column_index"),
        "row_index": move_info.get("row_index"),
        "cell_index": move_info.get("cell_index"),
        "diff": diff_dict,
        "audit_id": audit_id,
    }


async def form_update(
    service_id: str | int,
    form_type: str = "applicant",
    components: list[dict[str, Any]] | None = None,
    active: bool | None = None,
    tutorials: dict[str, Any] | None = None,
    instance: str | None = None,
) -> dict[str, Any]:
    """Update form schema. Audited write operation.

    Warning: components replaces ALL existing. Use form_component_* for targeted
    changes.

    Args:
        service_id: BPA service UUID.
        form_type: "applicant" (default), "guide", "send_file", or "payment".
        components: New components array (replaces existing).
        active: Set active status.
        tutorials: Update tutorials.
        instance: Instance profile name (from instance_list).

    Returns:
        dict with updated, form_id, components_replaced, active_updated, audit_id.
    """
    config = _validate_form_type(form_type)

    if components is None and active is None and tutorials is None:
        raise ToolError(
            f"[{FormErrorCode.NO_UPDATES_PROVIDED}] No updates provided. "
            "Specify components, active, or tutorials to update."
        )

    # Get authenticated user
    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:
            # Get current form
            form_data = await _get_form_data(client, service_id, form_type)

            # Create audit record
            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="form",
                params={
                    "service_id": str(service_id),
                    "form_type": form_type,
                    "form_id": form_data.get("id"),
                    "updates": {
                        "components": components is not None,
                        "active": active,
                        "tutorials": tutorials is not None,
                    },
                },
            )

            # Save rollback state
            await audit_logger.save_rollback_state(
                audit_id=audit_id,
                object_type="form",
                object_id=str(form_data.get("id")),
                previous_state=form_data,
            )

            try:
                # Apply updates
                if components is not None:
                    form_schema = _parse_form_schema(form_data)
                    form_schema["components"] = components
                    form_data["formSchema"] = form_schema

                if active is not None:
                    form_data["active"] = active

                if tutorials is not None:
                    form_data["tutorials"] = tutorials

                # PUT updated form
                await _update_form_data(client, form_data, form_type)

                # Mark audit as success
                await audit_logger.mark_success(
                    audit_id,
                    result={
                        "form_id": form_data.get("id"),
                        "form_type": form_type,
                    },
                )

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

    except ToolError:
        raise
    except BPAClientError as e:
        raise translate_error(e, resource_type="form", resource_id=service_id)

    await _invalidate_form_cache(service_id, form_type, instance)

    return {
        "updated": True,
        "form_id": form_data.get("id"),
        "form_type": form_type,
        "form_name": config["name"],
        "service_id": service_id,
        "components_replaced": components is not None,
        "active_updated": active is not None,
        "tutorials_updated": tutorials is not None,
        "audit_id": audit_id,
    }


# =============================================================================
# Batch patch (FormPatchEngine)
# =============================================================================

# Map tool-facing form_type names to ServiceFormAdapter keys
_FORM_TYPE_TO_ADAPTER: dict[str, str] = {
    "applicant": "applicant",
    "guide": "guide",
    "send_file": "send-file",
    "payment": "payment",
}


async def form_patch(
    service_id: str | int,
    operations: list[dict[str, Any]],
    form_type: str = "applicant",
    instance: str | None = None,
    dry_run: bool = False,
) -> dict[str, Any]:
    """Apply batch patch operations to a service form. Audited write operation.

    Does NOT support column/row/cell indices. For container placement,
    use form_component_add instead.

    Args:
        service_id: BPA service UUID.
        operations: List of operation dicts (each must have 'op' key).
        form_type: "applicant" (default), "guide", "send_file", or "payment".
        instance: Instance profile name (from instance_list).
        dry_run: Preview changes without saving (default: False).

    Returns:
        dict with success, diff, service_id, operations_count, audit_id.
    """
    # Deferred imports to break circular dependency
    from mcp_eregistrations_bpa.patch.adapters.service_form import ServiceFormAdapter
    from mcp_eregistrations_bpa.patch.engine import FormPatchEngine
    from mcp_eregistrations_bpa.patch.operations import Operation

    if not operations:
        raise ToolError("No operations provided.")

    # Validate form_type
    adapter_form_type = _FORM_TYPE_TO_ADAPTER.get(form_type)
    if adapter_form_type is None:
        valid_types = ", ".join(sorted(_FORM_TYPE_TO_ADAPTER.keys()))
        raise ToolError(
            f"[{FormErrorCode.INVALID_FORM_TYPE}] Invalid form type '{form_type}'. "
            f"Valid types: {valid_types}"
        )

    # Parse operations
    ops = [Operation.from_dict(d) for d in operations]

    # Auth check
    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:
            # Fetch form via adapter
            adapter = ServiceFormAdapter(
                client, service_id, form_type=adapter_form_type
            )
            try:
                components = await adapter.fetch(str(service_id))
            except BPAClientError as e:
                raise translate_error(e, resource_type="form")

            # Apply patch
            engine = FormPatchEngine()
            result = engine.apply(components, ops, dry_run=dry_run)

            if not result.success:
                raise ToolError(f"Patch failed: {'; '.join(result.errors)}")

            diff = result.diff
            diff_dict = diff.to_dict()

            # Dry run -- return diff only
            if dry_run:
                return {
                    "success": True,
                    "dry_run": True,
                    "service_id": str(service_id),
                    "form_type": form_type,
                    "diff": diff_dict,
                    "operations_count": len(ops),
                }

            # Audit-before-write
            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="form",
                object_id=str(service_id),
                params={
                    "form_type": form_type,
                    "operations_count": len(ops),
                    "diff_summary": diff_dict["summary"],
                },
            )

            # Save rollback state (previous form envelope)
            previous_state = deepcopy(adapter.envelope) or {}
            await audit_logger.save_rollback_state(
                audit_id=audit_id,
                object_type="form",
                object_id=str(service_id),
                previous_state=previous_state,
            )

            # Save modified tree
            try:
                assert result.modified_tree is not None
                await adapter.save(result.modified_tree)

                # Mark success
                await audit_logger.mark_success(
                    audit_id,
                    result={
                        "service_id": str(service_id),
                        "form_type": form_type,
                        "diff_summary": diff_dict["summary"],
                    },
                )

                return {
                    "success": True,
                    "dry_run": False,
                    "service_id": str(service_id),
                    "form_type": form_type,
                    "diff": diff_dict,
                    "operations_count": len(ops),
                    "audit_id": audit_id,
                }

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

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


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


def register_form_tools(mcp: Any) -> None:
    """Register form tools with the MCP server.

    Args:
        mcp: The FastMCP server instance.
    """
    from mcp_eregistrations_bpa.tools.annotations import DESTRUCTIVE, READ, WRITE

    # Read operations
    mcp.tool(annotations=READ)(form_get)
    mcp.tool(annotations=READ)(form_component_get)
    # Write operations (audit-before-write pattern)
    mcp.tool(annotations=WRITE)(form_component_add)
    mcp.tool(annotations=WRITE)(form_component_update)
    mcp.tool(annotations=DESTRUCTIVE)(form_component_remove)
    mcp.tool(annotations=WRITE)(form_component_move)
    mcp.tool(annotations=WRITE)(form_update)
    mcp.tool(annotations=WRITE)(form_patch)
