"""MCP tools for BPA classification/catalog operations.

This module provides tools for listing, retrieving, creating, and updating
BPA classifications (catalog data sources used for dropdown fields in forms).

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
"""

from __future__ import annotations

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.tools.large_response import large_response_handler

__all__ = [
    "classification_list",
    "classification_get",
    "classification_search",
    "classification_create",
    "classification_update",
    "classification_delete",
    "classification_export_csv",
    "classification_apply_country_codes",
    "register_classification_tools",
]


def _transform_classification_summary(classification: dict[str, Any]) -> dict[str, Any]:
    """Transform classification to summary format with snake_case keys."""
    return {
        "id": classification.get("id"),
        "name": classification.get("name"),
        "type": classification.get("type") or classification.get("classificationType"),
        "entry_count": (
            classification.get("entryCount") or classification.get("size", 0)
        ),
    }


def _transform_classification_detail(classification: dict[str, Any]) -> dict[str, Any]:
    """Transform classification to detailed format with entries."""
    # Handle entries - may be in 'content' (pageable) or 'entries' field
    raw_entries = classification.get("content") or classification.get("entries") or []
    entries = []
    for entry in raw_entries:
        transformed: dict[str, Any] = {
            "value": entry.get("value") or entry.get("key"),
            "label": entry.get("label") or entry.get("name") or entry.get("value"),
        }
        # Include key if present (custom identifier, distinct from value)
        if entry.get("key"):
            transformed["key"] = entry["key"]
        entries.append(transformed)

    return {
        "id": classification.get("id"),
        "name": classification.get("name"),
        "type": classification.get("type") or classification.get("classificationType"),
        "entries": entries,
        "entry_count": len(entries),
        "created_at": classification.get("createdAt"),
        "updated_at": classification.get("updatedAt"),
    }


async def classification_list(
    limit: int = 50,
    offset: int = 0,
    name_filter: str | None = None,
    include_system_catalogs: bool = True,
    instance: str | None = None,
) -> dict[str, Any]:
    """List all classifications (catalog data sources).

    Args:
        limit: Maximum to return (default: 50).
        offset: Skip count (default: 0).
        name_filter: Filter by name (contains, case-insensitive).
        include_system_catalogs: Include GDB system catalogs (default: true).
        instance: Instance profile name (from instance_list).

    Returns:
        dict with classifications, total, has_more.
    """
    # Normalize pagination parameters
    if limit <= 0:
        limit = 50
    if offset < 0:
        offset = 0

    try:
        async with BPAClient.for_instance(instance) as client:
            # GET /classification returns Spring Data Page — use pageable iterator
            pageable_result = await client.get_all_pageable(
                "/classification",
                sort="name,asc",
                resource_type="classification",
            )
            classifications_data: list[dict[str, Any]] = pageable_result.get(
                "content", []
            )

            # Merge GDB system catalogs (e.g. country_selection) if requested
            if include_system_catalogs:
                gdb_catalogs = await client.get_list(
                    "/classifications",
                    params={"showGdbCatalogs": True},
                    resource_type="classification",
                )
                # Deduplicate by ID — GDB catalogs have id+name only
                existing_ids = {c.get("id") for c in classifications_data}
                for gdb in gdb_catalogs:
                    if gdb.get("id") not in existing_ids:
                        classifications_data.append(
                            {
                                "id": gdb.get("id"),
                                "name": gdb.get("name"),
                                "type": "gdb",
                            }
                        )
    except BPAClientError as e:
        raise translate_error(e, resource_type="classification")

    # Transform to consistent output format with snake_case keys
    all_classifications = [
        _transform_classification_summary(c) for c in classifications_data
    ]

    # Apply name filter if provided
    if name_filter:
        name_filter_lower = name_filter.lower()
        all_classifications = [
            c
            for c in all_classifications
            if name_filter_lower in (c.get("name") or "").lower()
        ]

    # Sort by name for consistent pagination ordering
    all_classifications.sort(key=lambda c: c.get("name") or "")

    # Calculate total before pagination
    total = len(all_classifications)

    # Apply pagination
    paginated = all_classifications[offset : offset + limit]

    # Calculate has_more
    has_more = (offset + limit) < total

    return {
        "classifications": paginated,
        "total": total,
        "has_more": has_more,
    }


@large_response_handler(
    navigation={
        "list_entries": "jq '.entries'",
        "find_by_value": "jq '.entries[] | select(.value == \"CODE\")'",
        "find_by_label": "jq '.entries[] | select(.label | contains(\"search\"))'",
        "entry_count": "jq '.entry_count'",
    }
)
async def classification_get(
    classification_id: str | int, instance: str | None = None
) -> dict[str, Any]:
    """Get classification details by ID including entries.

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

    Args:
        classification_id: The classification ID.
        instance: Instance profile name (from instance_list).

    Returns:
        dict with id, name, type, entries, entry_count, created_at, updated_at.
    """
    try:
        async with BPAClient.for_instance(instance) as client:
            try:
                # Use pageable endpoint with full iteration to get ALL entries
                classification_data = await client.get_all_pageable(
                    "/classification/{id}/pageable",
                    path_params={"id": classification_id},
                    resource_type="classification",
                    resource_id=classification_id,
                )
            except BPANotFoundError:
                raise ToolError(
                    f"Classification '{classification_id}' not found. "
                    "Use 'classification_list' to see available classifications."
                )
    except ToolError:
        raise
    except BPAClientError as e:
        raise translate_error(
            e, resource_type="classification", resource_id=classification_id
        )

    return _transform_classification_detail(classification_data)


async def classification_search(
    name: str,
    instance: str | None = None,
) -> dict[str, Any]:
    """Search classifications by name substring. Server-side search.

    Args:
        name: Search term (matches catalog names, case-insensitive).
        instance: Instance profile name (from instance_list).

    Returns:
        dict with classifications, total.
    """
    if not name or not name.strip():
        raise ToolError(
            "Cannot search classifications: 'name' is required. "
            "Provide a search term to find matching catalogs."
        )

    try:
        async with BPAClient.for_instance(instance) as client:
            results = await client.get_list(
                "/search_classification/{name}",
                path_params={"name": name.strip()},
                resource_type="classification",
            )
    except BPAClientError as e:
        raise translate_error(e, resource_type="classification")

    classifications = [_transform_classification_summary(c) for c in results]
    classifications.sort(key=lambda c: c.get("name") or "")

    return {
        "classifications": classifications,
        "total": len(classifications),
    }


def _validate_classification_create_params(
    name: str,
    entries: list[dict[str, str]] | None,
    classification_on_form: bool | None,
    is_rejection_catalog: bool,
) -> dict[str, Any]:
    """Validate classification_create parameters (pre-flight).

    Args:
        name: The classification name.
        entries: Optional list of entries with 'value' (and optional 'key').
        classification_on_form: Whether the catalog appears on forms.
        is_rejection_catalog: Whether this is a rejection catalog.

    Returns:
        dict: Validated type payload + validated entries list.

    Raises:
        ToolError: If validation fails.
    """
    errors = []

    if not name or not name.strip():
        errors.append("'name' is required and cannot be empty")

    if name and len(name.strip()) > 255:
        errors.append("'name' must be 255 characters or less")

    # Validate entry structure if provided
    validated_entries: list[dict[str, Any]] = []
    for i, entry in enumerate(entries or []):
        if not isinstance(entry, dict):
            errors.append(f"Entry {i} must be a dict with at least a 'value' key")
            continue
        if not entry.get("value"):
            errors.append(f"Entry {i} missing required 'value' field")
        else:
            field: dict[str, Any] = {"value": entry["value"]}
            if entry.get("key"):
                field["key"] = entry["key"]
            validated_entries.append(field)

    if errors:
        error_msg = "; ".join(errors)
        raise ToolError(
            f"Cannot create classification: {error_msg}. Check required fields."
        )

    # Build ClassificationType payload matching frontend defaults
    type_payload: dict[str, Any] = {
        "name": name.strip(),
        "useCustomOrder": True,
        "showGroups": False,
        "isRejectionCatalog": is_rejection_catalog,
        "serviceIds": [],
    }
    if classification_on_form is not None:
        type_payload["classificationOnForm"] = classification_on_form

    return {"type_payload": type_payload, "entries": validated_entries}


async def classification_create(
    name: str,
    entries: list[dict[str, str]] | None = None,
    classification_on_form: bool | None = None,
    is_rejection_catalog: bool = False,
    instance: str | None = None,
) -> dict[str, Any]:
    """Create a classification catalog. Audited write operation.

    Creates the catalog type, then adds entries one-by-one if provided.

    Args:
        name: The classification name.
        entries: Optional entries, each with 'value' (and optional 'key').
        classification_on_form: Whether usable in forms (default: None).
        is_rejection_catalog: Rejection catalog flag (default: false).
        instance: Instance profile name (from instance_list).

    Returns:
        dict with id, name, type, entries, entry_count, audit_id.
    """
    # Pre-flight validation (no audit record for validation failures)
    validated = _validate_classification_create_params(
        name, entries, classification_on_form, is_rejection_catalog
    )
    type_payload = validated["type_payload"]
    validated_entries: list[dict[str, Any]] = validated["entries"]

    # Get authenticated user for audit
    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:
            # Create audit record BEFORE API call
            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="classification",
                params=type_payload,
            )

            try:
                # Step 1: Create the classification type
                classification_data = await client.post(
                    "/classification",
                    json=type_payload,
                    resource_type="classification",
                )

                created_id = classification_data.get("id")

                # Step 2: Add entries one-by-one if provided
                added_entries = []
                for i, entry in enumerate(validated_entries):
                    field_payload: dict[str, Any] = {
                        "value": entry["value"],
                        "customOrder": i,
                        "translations": [],
                        "classificationGroups": [],
                        "classificationFieldExternalIdList": [],
                        "sayRejectionReason": False,
                    }
                    if entry.get("key"):
                        field_payload["key"] = entry["key"]
                    field_data = await client.post(
                        "/classification/{type_id}/field",
                        path_params={"type_id": str(created_id)},
                        json=field_payload,
                        resource_type="classification_field",
                    )
                    added_entries.append(field_data)

                # Save rollback state (for create, save ID to enable deletion)
                await audit_logger.save_rollback_state(
                    audit_id=audit_id,
                    object_type="classification",
                    object_id=str(created_id),
                    previous_state={
                        "id": created_id,
                        "name": classification_data.get("name"),
                        "_operation": "create",  # Marker for rollback to DELETE
                    },
                )

                # Mark audit as success
                await audit_logger.mark_success(
                    audit_id,
                    result={
                        "classification_id": created_id,
                        "name": classification_data.get("name"),
                        "entries_added": len(added_entries),
                    },
                )

                # Re-fetch to get full state with entries
                if added_entries:
                    classification_data = await client.get(
                        "/classification/{id}",
                        path_params={"id": str(created_id)},
                        resource_type="classification",
                    )

                result = _transform_classification_detail(classification_data)
                result["audit_id"] = audit_id
                return result

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

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


def _validate_classification_update_params(
    classification_id: str | int,
    name: str | None,
    entries: list[dict[str, str]] | None,
    use_custom_key: bool | None = None,
    use_custom_order: bool | None = None,
    classification_on_form: bool | None = None,
    use_groups_for_sub_catalogs: bool | None = None,
) -> dict[str, Any]:
    """Validate classification_update parameters (pre-flight).

    Args:
        classification_id: The classification ID to update.
        name: New name (optional).
        entries: New entries list (optional).
        use_custom_key: Enable custom keys (optional).
        use_custom_order: Enable custom ordering (optional).
        classification_on_form: Show classification on form (optional).
        use_groups_for_sub_catalogs: Enable groups for sub-catalogs (optional).

    Returns:
        dict: Validated parameters ready for API call.

    Raises:
        ToolError: If validation fails.
    """
    errors = []

    if not classification_id:
        errors.append("'classification_id' is required")

    if name is not None and not name.strip():
        errors.append("'name' cannot be empty when provided")

    if name and len(name.strip()) > 255:
        errors.append("'name' must be 255 characters or less")

    # Validate entry structure if provided
    if entries is not None:
        if not entries:
            errors.append("'entries' cannot be empty when provided")
        for i, entry in enumerate(entries):
            if not isinstance(entry, dict):
                errors.append(f"Entry {i} must be a dict with 'value' and 'label'")
                continue
            if not entry.get("value"):
                errors.append(f"Entry {i} missing required 'value' field")
            if not entry.get("label"):
                errors.append(f"Entry {i} missing required 'label' field")

    # At least one field must be provided
    has_metadata = any(
        v is not None
        for v in [
            use_custom_key,
            use_custom_order,
            classification_on_form,
            use_groups_for_sub_catalogs,
        ]
    )
    if name is None and entries is None and not has_metadata:
        errors.append("At least one field must be provided")

    if errors:
        error_msg = "; ".join(errors)
        raise ToolError(
            f"Cannot update classification: {error_msg}. Check required fields."
        )

    # Build API payload with only provided fields
    params: dict[str, Any] = {}
    if name is not None:
        params["name"] = name.strip()
    if entries is not None:
        params["entries"] = [
            {"value": e["value"], "label": e["label"]} for e in entries
        ]

    # Map snake_case metadata params to camelCase API keys
    if use_custom_key is not None:
        params["useCustomKey"] = use_custom_key
    if use_custom_order is not None:
        params["useCustomOrder"] = use_custom_order
    if classification_on_form is not None:
        params["classificationOnForm"] = classification_on_form
    if use_groups_for_sub_catalogs is not None:
        params["useGroupsForSubCatalogs"] = use_groups_for_sub_catalogs

    return params


async def classification_update(
    classification_id: str | int,
    name: str | None = None,
    entries: list[dict[str, str]] | None = None,
    use_custom_key: bool | None = None,
    use_custom_order: bool | None = None,
    classification_on_form: bool | None = None,
    use_groups_for_sub_catalogs: bool | None = None,
    instance: str | None = None,
) -> dict[str, Any]:
    """Update a classification catalog. Audited write operation.

    Args:
        classification_id: The classification ID to update.
        name: New display name -- NOT the classification_id (e.g.
            "all countries", not "country_selection"). Omit to keep current.
        entries: New entries list (optional).
        use_custom_key: Enable custom keys for entries (optional).
        use_custom_order: Enable custom ordering of entries (optional).
        classification_on_form: Show classification on form (optional).
        use_groups_for_sub_catalogs: Enable groups for sub-catalogs (optional).
        instance: Instance profile name (from instance_list).

    Returns:
        dict with id, name, type, entries, entry_count, audit_id.
    """
    # Pre-flight validation
    validated_params = _validate_classification_update_params(
        classification_id,
        name,
        entries,
        use_custom_key=use_custom_key,
        use_custom_order=use_custom_order,
        classification_on_form=classification_on_form,
        use_groups_for_sub_catalogs=use_groups_for_sub_catalogs,
    )

    # Get authenticated user for audit
    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 current state for rollback
            try:
                current_data = await client.get(
                    "/classification/{id}/pageable",
                    path_params={"id": classification_id},
                    resource_type="classification",
                    resource_id=classification_id,
                )
            except BPANotFoundError:
                raise ToolError(
                    f"Classification '{classification_id}' not found. "
                    "Use 'classification_list' to see available classifications."
                )

            # Create audit record BEFORE API call
            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="classification",
                object_id=str(classification_id),
                params=validated_params,
            )

            try:
                # PUT /classification (no {id} in path, id in JSON body)
                # Include name from current data if not being updated
                # (backend @Valid requires non-empty name)
                previous_name = current_data.get("name") or ""
                update_payload = {"id": classification_id, **validated_params}
                if "name" not in update_payload:
                    if not previous_name:
                        raise ToolError(
                            f"Classification '{classification_id}' has no name "
                            "in API response. Cannot update without a name "
                            "(backend @Valid requires it). Check the catalog "
                            "in the BPA UI."
                        )
                    update_payload["name"] = previous_name

                classification_data = await client.put(
                    "/classification",
                    json=update_payload,
                    resource_type="classification",
                    resource_id=classification_id,
                )

                # Save rollback state (previous state for restore)
                await audit_logger.save_rollback_state(
                    audit_id=audit_id,
                    object_type="classification",
                    object_id=str(classification_id),
                    previous_state={
                        "id": current_data.get("id"),
                        "name": previous_name,
                        "type": current_data.get("type"),
                        "entries": current_data.get("content")
                        or current_data.get("entries"),
                        "_operation": "update",
                    },
                )

                # Mark audit as success
                await audit_logger.mark_success(
                    audit_id,
                    result={
                        "classification_id": classification_id,
                        "name": classification_data.get("name"),
                    },
                )

                result = _transform_classification_detail(classification_data)
                result["audit_id"] = audit_id

                # Warn if the name was changed (compare against API response)
                actual_name = classification_data.get("name", "")
                if actual_name != previous_name:
                    result["name_change_warning"] = (
                        f"Catalog name changed from "
                        f'"{previous_name}" to "{actual_name}". '
                        f"If unintentional, use classification_update with "
                        f'name="{previous_name}" to restore, or rollback '
                        f"with audit_id={audit_id!r}."
                    )

                return result

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

    except ToolError:
        raise
    except BPAClientError as e:
        raise translate_error(
            e, resource_type="classification", resource_id=classification_id
        )


def _validate_classification_delete_params(
    classification_id: str | int,
) -> None:
    """Validate classification_delete parameters (pre-flight).

    Raises ToolError if validation fails.

    Args:
        classification_id: Classification ID to delete (required).

    Raises:
        ToolError: If validation fails.
    """
    if not classification_id:
        raise ToolError(
            "Cannot delete classification: 'classification_id' is required. "
            "Use 'classification_list' to find valid classification IDs."
        )


async def classification_delete(
    classification_id: str | int,
    instance: str | None = None,
) -> dict[str, Any]:
    """Delete a BPA classification. Audited write operation.

    Saves state before deletion; use rollback with audit_id to restore.

    Args:
        classification_id: Classification ID to delete.
        instance: Instance profile name (from instance_list).

    Returns:
        dict with deleted (bool), classification_id,
        deleted_classification, audit_id.
    """
    # Pre-flight validation (no audit record for validation failures)
    _validate_classification_delete_params(classification_id)

    # Get authenticated user for audit
    try:
        user_email = get_current_user_email(instance=instance)
    except NotAuthenticatedError as e:
        raise ToolError(str(e))

    # Capture current state for rollback BEFORE making changes
    try:
        async with BPAClient.for_instance(instance) as client:
            try:
                previous_state = await client.get(
                    "/classification/{id}/pageable",
                    path_params={"id": classification_id},
                    resource_type="classification",
                    resource_id=classification_id,
                )
            except BPANotFoundError:
                raise ToolError(
                    f"Classification '{classification_id}' not found. "
                    "Use 'classification_list' to see available classifications."
                )
    except ToolError:
        raise
    except BPAClientError as e:
        raise translate_error(
            e, resource_type="classification", resource_id=classification_id
        )

    # Build rollback state (full classification data for restore)
    rollback_previous_state = {
        "id": previous_state.get("id"),
        "name": previous_state.get("name"),
        "type": previous_state.get("type") or previous_state.get("classificationType"),
        "entries": previous_state.get("content") or previous_state.get("entries"),
    }

    # Normalize previous_state to snake_case for response
    normalized_previous_state = {
        "id": previous_state.get("id"),
        "name": previous_state.get("name"),
        "type": rollback_previous_state["type"],
        "entry_count": len(rollback_previous_state.get("entries") or []),
    }

    # Create audit record BEFORE API call (audit-before-write pattern)
    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="classification",
        object_id=str(classification_id),
        params={"classification_id": str(classification_id)},
    )

    # Save rollback state for undo capability (recreate on rollback)
    await audit_logger.save_rollback_state(
        audit_id=audit_id,
        object_type="classification",
        object_id=str(classification_id),
        previous_state=rollback_previous_state,
    )

    try:
        async with BPAClient.for_instance(instance) as client:
            await client.delete(
                "/classification/{id}",
                path_params={"id": classification_id},
                resource_type="classification",
                resource_id=classification_id,
            )

        # Mark audit as success
        await audit_logger.mark_success(
            audit_id,
            result={
                "deleted": True,
                "classification_id": str(classification_id),
            },
        )

        return {
            "deleted": True,
            "classification_id": classification_id,
            "deleted_classification": {
                "id": normalized_previous_state["id"],
                "name": normalized_previous_state["name"],
                "type": normalized_previous_state["type"],
            },
            "audit_id": audit_id,
        }

    except BPAClientError as e:
        # Mark audit as failed
        await audit_logger.mark_failed(audit_id, str(e))
        raise translate_error(
            e, resource_type="classification", resource_id=classification_id
        )


async def classification_export_csv(
    classification_id: str | int, instance: str | None = None
) -> dict[str, Any]:
    """Export classification entries as CSV content.

    Args:
        classification_id: The classification ID to export.
        instance: Instance profile name (from instance_list).

    Returns:
        dict with classification_id, name, csv_content, entry_count.
    """
    try:
        async with BPAClient.for_instance(instance) as client:
            try:
                # First get classification details for metadata
                classification_data = await client.get(
                    "/classification/{id}/pageable",
                    path_params={"id": classification_id},
                    resource_type="classification",
                    resource_id=classification_id,
                )
            except BPANotFoundError:
                raise ToolError(
                    f"Classification '{classification_id}' not found. "
                    "Use 'classification_list' to see available classifications."
                )

            # Get CSV export (endpoint returns text/csv, not JSON)
            try:
                csv_content = await client.get_text(
                    "/classification/{id}/exportCsv",
                    path_params={"id": classification_id},
                    resource_type="classification",
                    resource_id=classification_id,
                )
            except BPAClientError as e:
                raise translate_error(
                    e, resource_type="classification", resource_id=classification_id
                )

            # Count entries from original data
            raw_entries = (
                classification_data.get("content")
                or classification_data.get("entries")
                or []
            )

            return {
                "classification_id": classification_id,
                "name": classification_data.get("name"),
                "csv_content": csv_content,
                "entry_count": len(raw_entries),
            }

    except ToolError:
        raise
    except BPAClientError as e:
        raise translate_error(
            e, resource_type="classification", resource_id=classification_id
        )


def _match_entries(
    existing_entries: list[dict[str, Any]],
    requested_entries: list[dict[str, str]],
) -> tuple[list[dict[str, Any]], list[dict[str, str]]]:
    """Match AI-provided entries to existing classification field objects by label.

    In the BPA ClassificationField model:
    - ``value`` is the display name (e.g. "Afghanistan")
    - ``key`` is the custom identifier (e.g. "AFG")

    Matching is case-insensitive and strips whitespace. Matches against the
    field's ``value`` (display name). On match, sets the ``key`` field.
    Only matched fields are returned (for per-field POST).

    Args:
        existing_entries: Raw ClassificationField objects from BPA pageable endpoint.
        requested_entries: AI-provided entries with key (code) and label (display name).

    Returns:
        Tuple of (matched_fields, unmatched) where matched_fields contains only
        the existing entries that matched with their key updated, and unmatched
        is the list of requested entries that did not match any existing entry.
    """
    # Build lookup from normalized label -> requested key
    label_to_key: dict[str, str] = {}
    for entry in requested_entries:
        normalized = entry["label"].strip().lower()
        label_to_key[normalized] = entry["key"]

    matched_fields: list[dict[str, Any]] = []
    matched_labels: set[str] = set()

    for existing in existing_entries:
        # BPA field 'value' is the display name
        existing_display = (
            existing.get("value") or existing.get("label") or existing.get("name") or ""
        )
        normalized = existing_display.strip().lower()

        if normalized in label_to_key:
            # Match found: set the 'key' field to the ISO code
            updated_field = {**existing, "key": label_to_key[normalized]}
            matched_fields.append(updated_field)
            matched_labels.add(normalized)

    # Determine which requested entries were not matched
    unmatched = [
        entry
        for entry in requested_entries
        if entry["label"].strip().lower() not in matched_labels
    ]

    return matched_fields, unmatched


async def classification_apply_country_codes(
    classification_id: str | int,
    entries: list[dict[str, str]],
    enable_custom_key: bool = True,
    dry_run: bool = False,
    instance: str | None = None,
) -> dict[str, Any]:
    """Apply custom keys to classification entries by label matching. Audited write.

    Use classification_get first to retrieve entries, then provide label-to-code
    mappings. Matches by label (case-insensitive) against the entry's display
    name, then sets the entry's key via POST /classification/{id}/field per
    field. Optionally enables useCustomKey via PUT /classification metadata.

    Args:
        classification_id: Classification ID.
        entries: Mapped entries, each with 'key' (code) and 'label'.
        enable_custom_key: Set useCustomKey=true (default: True).
        dry_run: Preview matches without applying (default: False).
        instance: Instance profile name (from instance_list).

    Returns:
        dict with matched_count, unmatched, total_entries, dry_run,
        audit_id (if applied).
    """
    # Validate entries
    if not entries:
        raise ToolError(
            "Cannot apply country codes: 'entries' must contain at least one entry. "
            "Use classification_get to retrieve entries first, then map each label "
            "to its code."
        )

    errors = []
    for i, entry in enumerate(entries):
        if not isinstance(entry, dict):
            errors.append(f"Entry {i} must be a dict with 'key' and 'label'")
            continue
        if not entry.get("key"):
            errors.append(f"Entry {i} missing 'key' (the code, e.g. 'AFG')")
        if not entry.get("label"):
            errors.append(f"Entry {i} missing 'label' (the display name)")
    if errors:
        raise ToolError(f"Invalid entries: {'; '.join(errors)}")

    # Fetch current classification with ALL field objects (paginated)
    try:
        async with BPAClient.for_instance(instance) as client:
            try:
                classification_data = await client.get_all_pageable(
                    "/classification/{id}/pageable",
                    path_params={"id": classification_id},
                    resource_type="classification",
                    resource_id=classification_id,
                )
            except BPANotFoundError:
                raise ToolError(
                    f"Classification '{classification_id}' not found. "
                    "Use 'classification_list' to see available classifications."
                )
    except ToolError:
        raise
    except BPAClientError as e:
        raise translate_error(
            e, resource_type="classification", resource_id=classification_id
        )

    raw_entries = (
        classification_data.get("content") or classification_data.get("entries") or []
    )

    # Match AI-provided entries to existing fields by display name (value)
    matched_fields, unmatched = _match_entries(raw_entries, entries)
    matched_count = len(matched_fields)

    # Dry run: return preview without modifying
    if dry_run:
        preview_matched = [
            {"label": f.get("value", ""), "key": f["key"]} for f in matched_fields[:20]
        ]
        return {
            "dry_run": True,
            "classification_id": classification_id,
            "classification_name": classification_data.get("name"),
            "total_entries": len(raw_entries),
            "matched_count": matched_count,
            "unmatched": [{"key": u["key"], "label": u["label"]} for u in unmatched],
            "enable_custom_key": enable_custom_key,
            "preview": preview_matched,
        }

    # Apply changes with audit-before-write
    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="classification",
                object_id=str(classification_id),
                params={
                    "action": "apply_custom_keys",
                    "enable_custom_key": enable_custom_key,
                    "matched_count": matched_count,
                    "unmatched_count": len(unmatched),
                },
            )

            try:
                # Fail fast if the catalog has no name (broken state)
                catalog_name = classification_data.get("name")
                if not catalog_name:
                    raise ToolError(
                        f"Classification '{classification_id}' has no name "
                        "in API response. Cannot safely proceed without a "
                        "name (backend @Valid requires it). Check the "
                        "catalog in the BPA UI."
                    )

                # Step 1: Enable custom keys on the classification metadata
                if enable_custom_key:
                    await client.put(
                        "/classification",
                        json={
                            "id": classification_id,
                            "name": catalog_name,
                            "useCustomKey": True,
                        },
                        resource_type="classification",
                        resource_id=classification_id,
                    )

                # Step 2: POST each matched field individually
                succeeded: list[str] = []
                failed: list[dict[str, str]] = []

                for field in matched_fields:
                    try:
                        await client.post(
                            "/classification/{classification_type_id}/field",
                            path_params={
                                "classification_type_id": classification_id,
                            },
                            json=field,
                            resource_type="classification_field",
                        )
                        succeeded.append(field.get("value", ""))
                    except BPAClientError as field_err:
                        failed.append(
                            {
                                "value": field.get("value", ""),
                                "key": field.get("key", ""),
                                "error": str(field_err),
                            }
                        )

                # Save rollback state (previous entries for restore)
                await audit_logger.save_rollback_state(
                    audit_id=audit_id,
                    object_type="classification",
                    object_id=str(classification_id),
                    previous_state={
                        "id": classification_data.get("id"),
                        "name": classification_data.get("name"),
                        "type": classification_data.get("type"),
                        "entries": raw_entries,
                        "useCustomKey": classification_data.get("useCustomKey"),
                        "_operation": "update",
                    },
                )

                await audit_logger.mark_success(
                    audit_id,
                    result={
                        "classification_id": classification_id,
                        "matched_count": matched_count,
                        "succeeded_count": len(succeeded),
                        "failed_count": len(failed),
                    },
                )

                return {
                    "dry_run": False,
                    "classification_id": classification_id,
                    "classification_name": classification_data.get("name"),
                    "total_entries": len(raw_entries),
                    "matched_count": matched_count,
                    "succeeded_count": len(succeeded),
                    "failed": failed,
                    "unmatched": [
                        {"key": u["key"], "label": u["label"]} for u in unmatched
                    ],
                    "custom_key_enabled": enable_custom_key,
                    "audit_id": audit_id,
                }

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

    except ToolError:
        raise
    except BPAClientError as e:
        raise translate_error(
            e, resource_type="classification", resource_id=classification_id
        )


def register_classification_tools(mcp: Any) -> None:
    """Register classification 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)(classification_list)
    mcp.tool(annotations=READ)(classification_get)
    mcp.tool(annotations=READ)(classification_search)
    mcp.tool(annotations=READ)(classification_export_csv)
    # Write operations
    mcp.tool(annotations=WRITE)(classification_create)
    mcp.tool(annotations=WRITE)(classification_update)
    mcp.tool(annotations=DESTRUCTIVE)(classification_delete)
    mcp.tool(annotations=WRITE)(classification_apply_country_codes)
