"""MCP tools for BPA print document operations.

This module provides tools for reading and manipulating print documents in BPA services.
Print documents are Form.io-based templates used to generate printable outputs
(certificates, licenses, receipts, etc.).

BPA uses a read-modify-write pattern for print documents:
1. GET the complete document (includes formSchema)
2. Modify the components array within formSchema
3. PUT the entire updated document back

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}/print-documents - List all print documents
- GET /service/{id}/active-print-documents - List active print documents
- GET /print-documents/{id} - Get single print document
- POST /service/{id}/print-documents - Create new print document
- PUT /print-documents/{id} - Update print document
- DELETE /print-documents/{id} - Delete print document
- PUT /sortprintdocuments - Reorder documents
- GET /available-print-templates - List predefined templates
- GET /service/{id}/print-template/{template_id} - Get template schema
- GET /print-documents/{id}/pagedhistory - Paginated revision history
- PUT /print-documents/{id}/history/{rev} - Revert to revision
"""

from __future__ import annotations

import json
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 (
    parse_form_schema,
    simplify_components,
)
from mcp_eregistrations_bpa.tools.form_errors import FormErrorCode
from mcp_eregistrations_bpa.tools.formio_helpers import (
    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__ = [
    "print_document_list",
    "print_document_get",
    "print_document_create",
    "print_document_update",
    "print_document_delete",
    "print_document_component_get",
    "print_document_component_add",
    "print_document_component_update",
    "print_document_component_remove",
    "print_document_component_move",
    "print_document_sort",
    "print_document_templates",
    "print_document_history",
    "print_document_revert",
    "print_document_patch",
    "register_print_document_tools",
]


# =============================================================================
# Internal helpers
# =============================================================================


async def _get_document(
    client: BPAClient,
    document_id: str | int,
) -> dict[str, Any]:
    """Get a print document by ID.

    Args:
        client: BPA client instance.
        document_id: Print document ID.

    Returns:
        Raw document data from API.

    Raises:
        ToolError: If document not found.
    """
    try:
        doc_data = await client.get(
            "/print-documents/{id}",
            path_params={"id": document_id},
            resource_type="print_document",
            resource_id=str(document_id),
        )
    except BPANotFoundError:
        raise ToolError(
            f"[{FormErrorCode.DOCUMENT_NOT_FOUND}] Print document '{document_id}' "
            "not found. Use print_document_list to see available documents."
        )
    return doc_data


async def _update_document(
    client: BPAClient,
    doc_data: dict[str, Any],
) -> None:
    """PUT the full document object back to BPA.

    Args:
        client: BPA client instance.
        doc_data: Complete document data to PUT.
    """
    document_id = doc_data.get("id")
    await client.put(
        "/print-documents/{id}",
        path_params={"id": document_id},
        json=doc_data,
        resource_type="print_document",
        resource_id=str(document_id),
    )


def _summarize_document(doc: dict[str, Any]) -> dict[str, Any]:
    """Build a summary dict for a print document list item.

    Args:
        doc: Raw document object from API.

    Returns:
        Simplified summary dict.
    """
    return {
        "id": doc.get("id"),
        "name": doc.get("name"),
        "active": doc.get("active"),
        "sort_order": doc.get("sortOrder"),
        "short_name": doc.get("shortName"),
        "is_external": doc.get("isExternal", False),
    }


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


async def print_document_list(
    service_id: str | int,
    active_only: bool = False,
    instance: str | None = None,
) -> dict[str, Any]:
    """List print documents for a service.

    Args:
        service_id: BPA service UUID.
        active_only: Only return active documents (default: False).
        instance: Instance profile name (from instance_list).

    Returns:
        dict with documents (list), count, service_id.
    """
    endpoint = (
        "/service/{id}/active-print-documents"
        if active_only
        else "/service/{id}/print-documents"
    )

    try:
        async with BPAClient.for_instance(instance) as client:
            data = await client.get(
                endpoint,
                path_params={"id": service_id},
                resource_type="print_document",
                resource_id=str(service_id),
            )
    except BPANotFoundError:
        raise ToolError(
            f"[{FormErrorCode.SERVICE_NOT_FOUND}] Service '{service_id}' not found."
        )
    except BPAClientError as e:
        raise translate_error(e, resource_type="print_document", resource_id=service_id)

    # API returns a list of documents
    docs: list[dict[str, Any]] = data if isinstance(data, list) else []
    summaries = [_summarize_document(doc) for doc in docs]

    return {
        "documents": summaries,
        "count": len(summaries),
        "service_id": str(service_id),
        "active_only": active_only,
    }


@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\")'",
    }
)
async def print_document_get(
    document_id: str | int,
    include_raw: bool = False,
    instance: str | None = None,
) -> dict[str, Any]:
    """Get print document with simplified component list.

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

    Args:
        document_id: Print document UUID.
        include_raw: Include full raw_schema in response (default: False).
        instance: Instance profile name (from instance_list).

    Returns:
        dict with id, name, active, component_count, component_keys,
        components. Includes raw_schema only when include_raw=True.
    """
    try:
        async with BPAClient.for_instance(instance) as client:
            doc_data = await _get_document(client, document_id)
    except ToolError:
        raise
    except BPAClientError as e:
        raise translate_error(
            e, resource_type="print_document", resource_id=document_id
        )

    # Parse form schema
    form_schema = parse_form_schema(doc_data)
    components = form_schema.get("components", [])
    if not isinstance(components, list):
        components = []
    all_keys = get_all_component_keys(components)
    simplified = simplify_components(components)

    result: dict[str, Any] = {
        "id": doc_data.get("id"),
        "name": doc_data.get("name"),
        "active": doc_data.get("active"),
        "short_name": doc_data.get("shortName"),
        "is_external": doc_data.get("isExternal", False),
        "sort_order": doc_data.get("sortOrder"),
        "components": simplified,
        "component_count": len(all_keys),
        "component_keys": sorted(all_keys),
    }

    if include_raw:
        result["raw_schema"] = form_schema

    return result


async def print_document_component_get(
    document_id: str | int,
    component_key: str,
    instance: str | None = None,
) -> dict[str, Any]:
    """Get details of a print document component.

    Args:
        document_id: Print document UUID.
        component_key: Component's key property.
        instance: Instance profile name (from instance_list).

    Returns:
        dict with key, type, label, path, validate, raw.
    """
    try:
        async with BPAClient.for_instance(instance) as client:
            doc_data = await _get_document(client, document_id)
    except ToolError:
        raise
    except BPAClientError as e:
        raise translate_error(
            e, resource_type="print_document", resource_id=document_id
        )

    form_schema = parse_form_schema(doc_data)
    components = form_schema.get("components", [])

    found = find_component(components, component_key)
    if found 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 print document '{document_id}'. Available keys include: "
            f"{', '.join(key_preview)}... Use print_document_get to see all "
            f"{len(all_keys)} components."
        )

    comp, path = found

    response: dict[str, Any] = {
        "key": comp.get("key"),
        "type": comp.get("type"),
        "label": comp.get("label"),
        "document_id": str(document_id),
        "path": path,
    }

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

    data = comp.get("data", {})
    if data:
        response["data"] = data

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

    # Add BPA-specific properties
    if comp.get("determinantIds"):
        response["determinant_ids"] = comp["determinantIds"]
    if comp.get("registrations"):
        response["registrations"] = comp["registrations"]
    if comp.get("componentActionId"):
        response["component_action_id"] = comp["componentActionId"]
    if comp.get("componentFormulaId"):
        response["component_formula_id"] = comp["componentFormulaId"]

    response["raw"] = comp
    return response


async def print_document_templates(instance: str | None = None) -> dict[str, Any]:
    """List available predefined print templates.

    Args:
        instance: Instance profile name (from instance_list).

    Returns:
        dict with templates (list), count.
    """
    try:
        async with BPAClient.for_instance(instance) as client:
            data = await client.get(
                "/available-print-templates",
                resource_type="print_template",
            )
    except BPAClientError as e:
        raise translate_error(e, resource_type="print_template")

    templates: list[dict[str, Any]] = data if isinstance(data, list) else []
    return {
        "templates": templates,
        "count": len(templates),
    }


async def print_document_history(
    document_id: str | int,
    page: int | None = None,
    size: int | None = None,
    instance: str | None = None,
) -> dict[str, Any]:
    """Get paginated revision history for a print document.

    Args:
        document_id: Print document UUID.
        page: Page number (0-indexed).
        size: Page size.
        instance: Instance profile name (from instance_list).

    Returns:
        dict with revisions (list), total_elements, total_pages, document_id.
    """
    params: dict[str, Any] = {}
    if page is not None:
        params["page"] = page
    if size is not None:
        params["size"] = size

    try:
        async with BPAClient.for_instance(instance) as client:
            data = await client.get(
                "/print-documents/{id}/pagedhistory",
                path_params={"id": document_id},
                params=params if params else None,
                resource_type="print_document_history",
                resource_id=str(document_id),
            )
    except BPANotFoundError:
        raise ToolError(
            f"[{FormErrorCode.DOCUMENT_NOT_FOUND}] Print document '{document_id}' "
            "not found. Use print_document_list to see available documents."
        )
    except BPAClientError as e:
        raise translate_error(
            e, resource_type="print_document_history", resource_id=document_id
        )

    # BPA paginated response structure
    if isinstance(data, dict):
        revisions = data.get("content", [])
        total_elements = data.get("totalElements", len(revisions))
        total_pages = data.get("totalPages", 1)
    else:
        revisions = data if isinstance(data, list) else []
        total_elements = len(revisions)
        total_pages = 1

    return {
        "revisions": revisions,
        "total_elements": total_elements,
        "total_pages": total_pages,
        "document_id": str(document_id),
    }


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


async def print_document_create(
    service_id: str | int,
    name: str,
    template_id: str | None = None,
    short_name: str | None = None,
    instance: str | None = None,
) -> dict[str, Any]:
    """Create a new print document. Audited write operation.

    Args:
        service_id: BPA service UUID.
        name: Document name.
        template_id: Predefined template ID to initialize from (optional).
        short_name: Short name for the document (optional).
        instance: Instance profile name (from instance_list).

    Returns:
        dict with created, document_id, name, audit_id.
    """
    if not name or not name.strip():
        raise ToolError(
            f"[{FormErrorCode.MISSING_REQUIRED_PROPERTY}] Document name is required."
        )

    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:
            # If template_id provided, fetch template schema first
            template_schema = None
            if template_id:
                try:
                    template_schema = await client.get(
                        "/service/{id}/print-template/{template_id}",
                        path_params={"id": service_id, "template_id": template_id},
                        resource_type="print_template",
                        resource_id=str(template_id),
                    )
                except BPANotFoundError:
                    raise ToolError(
                        f"Template '{template_id}' not found. "
                        "Use print_document_templates to see available templates."
                    )

            # Build payload
            payload: dict[str, Any] = {"name": name.strip()}
            if short_name is not None:
                payload["shortName"] = short_name.strip()
            if template_schema is not None:
                payload["formSchema"] = template_schema

            # 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="create",
                object_type="print_document",
                params={
                    "service_id": str(service_id),
                    "name": name,
                    "template_id": template_id,
                    "short_name": short_name,
                },
            )

            operation_error: Exception | None = None
            result_data: dict[str, Any] | None = None
            try:
                result_data = await client.post(
                    "/service/{id}/print-documents",
                    path_params={"id": service_id},
                    json=payload,
                    resource_type="print_document",
                )
            except BPAClientError as e:
                operation_error = translate_error(e, resource_type="print_document")
            finally:
                try:
                    if operation_error:
                        await audit_logger.mark_failed(audit_id, str(operation_error))
                    else:
                        await audit_logger.mark_success(
                            audit_id,
                            result={
                                "document_id": (
                                    result_data.get("id") if result_data else None
                                ),
                                "name": name,
                            },
                        )
                except Exception:
                    pass

            if operation_error:
                raise operation_error

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

    new_id = result_data.get("id") if result_data else None
    return {
        "created": True,
        "document_id": new_id,
        "name": name,
        "service_id": str(service_id),
        "template_id": template_id,
        "audit_id": audit_id,
    }


async def print_document_update(
    document_id: str | int,
    name: str | None = None,
    active: bool | None = None,
    short_name: str | None = None,
    external_url: str | None = None,
    instance: str | None = None,
) -> dict[str, Any]:
    """Update print document metadata. Audited write operation.

    Args:
        document_id: Print document UUID.
        name: New document name (optional).
        active: Set active status (optional).
        short_name: New short name (optional).
        external_url: External URL for external documents (optional).
        instance: Instance profile name (from instance_list).

    Returns:
        dict with updated, document_id, updates_applied, audit_id.
    """
    if name is None and active is None and short_name is None and external_url is None:
        raise ToolError(
            f"[{FormErrorCode.NO_UPDATES_PROVIDED}] No updates provided. "
            "Specify name, active, short_name, or external_url."
        )

    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 state
            doc_data = await _get_document(client, document_id)

            # 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="print_document",
                object_id=str(document_id),
                params={
                    "document_id": str(document_id),
                    "name": name,
                    "active": active,
                    "short_name": short_name,
                    "external_url": external_url,
                },
            )

            # Save rollback state
            await audit_logger.save_rollback_state(
                audit_id=audit_id,
                object_type="print_document",
                object_id=str(document_id),
                previous_state=doc_data,
            )

            # Merge updates
            updates_applied: list[str] = []
            if name is not None:
                doc_data["name"] = name.strip()
                updates_applied.append("name")
            if active is not None:
                doc_data["active"] = active
                updates_applied.append("active")
            if short_name is not None:
                doc_data["shortName"] = short_name.strip()
                updates_applied.append("short_name")
            if external_url is not None:
                doc_data["externalUrl"] = external_url
                updates_applied.append("external_url")

            operation_error: Exception | None = None
            try:
                await _update_document(client, doc_data)
            except BPAClientError as e:
                operation_error = translate_error(e, resource_type="print_document")
            finally:
                try:
                    if operation_error:
                        await audit_logger.mark_failed(audit_id, str(operation_error))
                    else:
                        await audit_logger.mark_success(
                            audit_id,
                            result={
                                "document_id": str(document_id),
                                "updates_applied": updates_applied,
                            },
                        )
                except Exception:
                    pass

            if operation_error:
                raise operation_error

    except ToolError:
        raise
    except BPAClientError as e:
        raise translate_error(
            e, resource_type="print_document", resource_id=document_id
        )

    return {
        "updated": True,
        "document_id": str(document_id),
        "updates_applied": updates_applied,
        "audit_id": audit_id,
    }


async def print_document_delete(
    document_id: str | int,
    instance: str | None = None,
) -> dict[str, Any]:
    """Delete a print document. Audited write operation.

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

    Args:
        document_id: Print document UUID to delete.
        instance: Instance profile name (from instance_list).

    Returns:
        dict with deleted, document_id, deleted_document, audit_id.
    """
    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 state for rollback
            doc_data = await _get_document(client, document_id)

            # 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="delete",
                object_type="print_document",
                object_id=str(document_id),
                params={"document_id": str(document_id)},
            )

            # Save rollback state
            await audit_logger.save_rollback_state(
                audit_id=audit_id,
                object_type="print_document",
                object_id=str(document_id),
                previous_state=doc_data,
            )

            operation_error: Exception | None = None
            try:
                await client.delete(
                    "/print-documents/{id}",
                    path_params={"id": document_id},
                    resource_type="print_document",
                    resource_id=str(document_id),
                )
            except BPAClientError as e:
                operation_error = translate_error(e, resource_type="print_document")
            finally:
                try:
                    if operation_error:
                        await audit_logger.mark_failed(audit_id, str(operation_error))
                    else:
                        await audit_logger.mark_success(
                            audit_id,
                            result={
                                "document_id": str(document_id),
                                "name": doc_data.get("name"),
                            },
                        )
                except Exception:
                    pass

            if operation_error:
                raise operation_error

    except ToolError:
        raise
    except BPAClientError as e:
        raise translate_error(
            e, resource_type="print_document", resource_id=document_id
        )

    return {
        "deleted": True,
        "document_id": str(document_id),
        "deleted_document": {
            "name": doc_data.get("name"),
            "active": doc_data.get("active"),
            "short_name": doc_data.get("shortName"),
        },
        "audit_id": audit_id,
    }


# =============================================================================
# Component Manipulation
# =============================================================================


async def print_document_component_add(
    document_id: str | int,
    component: dict[str, Any],
    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 print document. Audited write operation.

    Args:
        document_id: Print document UUID.
        component: Form.io component with key, type, label.
        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, dry_run, diff,
        audit_id (omitted when dry_run=True).
    """
    # Pre-flight validation
    errors = validate_component(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", ""))

    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:
            doc_data = await _get_document(client, document_id)

            form_schema = parse_form_schema(doc_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. Use a unique key or use "
                    "print_document_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 print_document_get to see "
                        "available components."
                    )

            # 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"),
                    "document_id": str(document_id),
                    "parent_key": parent_key,
                    "position": position,
                    "column_index": column_index,
                    "row_index": row_index,
                    "cell_index": 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="create",
                object_type="print_document_component",
                params={
                    "document_id": str(document_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
            await audit_logger.save_rollback_state(
                audit_id=audit_id,
                object_type="print_document",
                object_id=str(document_id),
                previous_state=doc_data,
            )

            operation_error: Exception | None = None
            try:
                form_schema["components"] = new_components
                doc_data["formSchema"] = form_schema

                await _update_document(client, doc_data)

            except BPAClientError as e:
                operation_error = translate_error(e, resource_type="print_document")
            finally:
                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,
                                "diff_summary": diff_dict["summary"],
                            },
                        )
                except Exception:
                    pass

            if operation_error:
                raise operation_error

    except ToolError:
        raise
    except BPAClientError as e:
        raise translate_error(
            e, resource_type="print_document", resource_id=document_id
        )

    return {
        "added": True,
        "dry_run": False,
        "component_key": component_key,
        "component_type": component.get("type"),
        "document_id": str(document_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 print_document_component_update(
    document_id: str | int,
    component_key: str,
    updates: dict[str, Any],
    instance: str | None = None,
    dry_run: bool = False,
) -> dict[str, Any]:
    """Update component properties in print document. Audited write operation.

    Args:
        document_id: Print document UUID.
        component_key: Component to update.
        updates: Properties to merge (e.g., {"label": "New", "hidden": True}).
        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,
        dry_run, diff, audit_id (omitted when dry_run=True).
    """
    if not updates:
        raise ToolError(
            f"[{FormErrorCode.NO_UPDATES_PROVIDED}] No updates provided. "
            "Specify properties to update."
        )

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

    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:
            doc_data = await _get_document(client, document_id)

            form_schema = parse_form_schema(doc_data)
            components = form_schema.get("components", [])

            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 print_document_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 dry_run and normal 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:
                prev_summary = {
                    "label": previous_state.get("label"),
                    "type": previous_state.get("type"),
                }
                return {
                    "updated": True,
                    "dry_run": True,
                    "component_key": component_key,
                    "document_id": str(document_id),
                    "updates_applied": list(updates.keys()),
                    "previous_state": prev_summary,
                    "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="print_document_component",
                params={
                    "document_id": str(document_id),
                    "component_key": component_key,
                    "updates": updates,
                },
            )

            # Save rollback state
            await audit_logger.save_rollback_state(
                audit_id=audit_id,
                object_type="print_document",
                object_id=str(document_id),
                previous_state=doc_data,
            )

            operation_error: Exception | None = None
            try:
                form_schema["components"] = new_components
                doc_data["formSchema"] = form_schema

                await _update_document(client, doc_data)

            except BPAClientError as e:
                operation_error = translate_error(e, resource_type="print_document")
            finally:
                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()),
                                "diff_summary": diff_dict["summary"],
                            },
                        )
                except Exception:
                    pass

            if operation_error:
                raise operation_error

    except ToolError:
        raise
    except BPAClientError as e:
        raise translate_error(
            e, resource_type="print_document", resource_id=document_id
        )

    # 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,
        "document_id": str(document_id),
        "updates_applied": list(updates.keys()),
        "previous_state": prev_summary,
        "diff": diff_dict,
        "audit_id": audit_id,
    }


async def print_document_component_remove(
    document_id: str | int,
    component_key: str,
    dry_run: bool = False,
    instance: str | None = None,
) -> dict[str, Any]:
    """Remove component from print document. Audited write operation.

    Args:
        document_id: Print document UUID.
        component_key: Component to remove.
        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).
    """
    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:
            doc_data = await _get_document(client, document_id)

            form_schema = parse_form_schema(doc_data)
            components = form_schema.get("components", [])

            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 print_document_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,
                    "document_id": str(document_id),
                    "deleted_component": removed_summary,
                    "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="delete",
                object_type="print_document_component",
                params={
                    "document_id": str(document_id),
                    "component_key": component_key,
                },
            )

            # Save rollback state
            await audit_logger.save_rollback_state(
                audit_id=audit_id,
                object_type="print_document",
                object_id=str(document_id),
                previous_state=doc_data,
            )

            operation_error: Exception | None = None
            try:
                form_schema["components"] = new_components
                doc_data["formSchema"] = form_schema

                await _update_document(client, doc_data)

            except BPAClientError as e:
                operation_error = translate_error(e, resource_type="print_document")
            finally:
                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"),
                                "diff_summary": diff_dict["summary"],
                            },
                        )
                except Exception:
                    pass

            if operation_error:
                raise operation_error

    except ToolError:
        raise
    except BPAClientError as e:
        raise translate_error(
            e, resource_type="print_document", resource_id=document_id
        )

    removed_summary = {
        "key": removed.get("key"),
        "type": removed.get("type"),
        "label": removed.get("label"),
    }

    return {
        "removed": True,
        "dry_run": False,
        "component_key": component_key,
        "document_id": str(document_id),
        "deleted_component": removed_summary,
        "diff": diff_dict,
        "audit_id": audit_id,
    }


async def print_document_component_move(
    document_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,
    dry_run: bool = False,
    instance: str | None = None,
) -> dict[str, Any]:
    """Move component to new position in print document. Audited write operation.

    Args:
        document_id: Print document 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.
        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).
    """
    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:
            doc_data = await _get_document(client, document_id)

            form_schema = parse_form_schema(doc_data)
            components = form_schema.get("components", [])

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

            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 print_document_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,
                    "document_id": str(document_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="print_document_component",
                params={
                    "document_id": str(document_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="print_document",
                object_id=str(document_id),
                previous_state=doc_data,
            )

            operation_error: Exception | None = None
            try:
                form_schema["components"] = new_components
                doc_data["formSchema"] = form_schema

                await _update_document(client, doc_data)

            except BPAClientError as e:
                operation_error = translate_error(e, resource_type="print_document")
            finally:
                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,
                                "diff_summary": diff_dict["summary"],
                            },
                        )
                except Exception:
                    pass

            if operation_error:
                raise operation_error

    except ToolError:
        raise
    except BPAClientError as e:
        raise translate_error(
            e, resource_type="print_document", resource_id=document_id
        )

    return {
        "moved": True,
        "dry_run": False,
        "component_key": component_key,
        "document_id": str(document_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,
    }


# =============================================================================
# Organization
# =============================================================================


async def print_document_sort(
    service_id: str | int,
    document_ids: list[str],
    instance: str | None = None,
) -> dict[str, Any]:
    """Reorder print documents for a service. Audited write operation.

    The document_ids list defines the new order. All document IDs for the
    service must be included.

    Args:
        service_id: BPA service UUID.
        document_ids: Ordered list of document IDs defining new sort order.
        instance: Instance profile name (from instance_list).

    Returns:
        dict with sorted, document_count, audit_id.
    """
    if not document_ids:
        raise ToolError(
            f"[{FormErrorCode.MISSING_REQUIRED_PROPERTY}] "
            "document_ids list is required."
        )

    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 all documents to validate IDs and build payload
            data = await client.get(
                "/service/{id}/print-documents",
                path_params={"id": service_id},
                resource_type="print_document",
                resource_id=str(service_id),
            )

            docs: list[dict[str, Any]] = data if isinstance(data, list) else []
            doc_map = {str(doc.get("id")): doc for doc in docs}

            # Validate that all provided IDs exist
            provided_ids = [str(did) for did in document_ids]
            unknown_ids = [did for did in provided_ids if did not in doc_map]
            if unknown_ids:
                raise ToolError(
                    f"Unknown document IDs: {', '.join(unknown_ids)}. "
                    "Use print_document_list to see valid IDs."
                )

            # Validate all existing docs are included
            existing_ids = set(doc_map.keys())
            provided_set = set(provided_ids)
            missing_ids = existing_ids - provided_set
            if missing_ids:
                raise ToolError(
                    f"Missing document IDs: {', '.join(missing_ids)}. "
                    "All documents must be included in the sort order."
                )

            # Build sorted payload with updated sortOrder
            sorted_docs = []
            for idx, did in enumerate(provided_ids):
                doc = doc_map[did].copy()
                doc["sortOrder"] = idx
                sorted_docs.append(doc)

            # 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="print_document_sort",
                params={
                    "service_id": str(service_id),
                    "document_ids": provided_ids,
                },
            )

            # Save rollback state (all docs before sort)
            await audit_logger.save_rollback_state(
                audit_id=audit_id,
                object_type="print_document_sort",
                object_id=str(service_id),
                previous_state={"documents": docs},
            )

            operation_error: Exception | None = None
            try:
                await client.put(
                    "/sortprintdocuments",
                    content=json.dumps(sorted_docs),
                    resource_type="print_document",
                )
            except BPAClientError as e:
                operation_error = translate_error(e, resource_type="print_document")
            finally:
                try:
                    if operation_error:
                        await audit_logger.mark_failed(audit_id, str(operation_error))
                    else:
                        await audit_logger.mark_success(
                            audit_id,
                            result={
                                "service_id": str(service_id),
                                "document_count": len(sorted_docs),
                            },
                        )
                except Exception:
                    pass

            if operation_error:
                raise operation_error

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

    return {
        "sorted": True,
        "service_id": str(service_id),
        "document_count": len(sorted_docs),
        "new_order": provided_ids,
        "audit_id": audit_id,
    }


# =============================================================================
# History
# =============================================================================


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

    Args:
        document_id: Print document UUID.
        revision_number: Revision number to revert to.
        instance: Instance profile name (from instance_list).

    Returns:
        dict with reverted, document_id, revision_number, audit_id.
    """
    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 state for rollback
            doc_data = await _get_document(client, document_id)

            # 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="print_document",
                object_id=str(document_id),
                params={
                    "document_id": str(document_id),
                    "revision_number": revision_number,
                    "operation": "revert",
                },
            )

            # Save rollback state (current state before revert)
            await audit_logger.save_rollback_state(
                audit_id=audit_id,
                object_type="print_document",
                object_id=str(document_id),
                previous_state=doc_data,
            )

            operation_error: Exception | None = None
            try:
                await client.put(
                    "/print-documents/{id}/history/{rev}",
                    path_params={"id": document_id, "rev": revision_number},
                    resource_type="print_document",
                    resource_id=str(document_id),
                )
            except BPANotFoundError:
                operation_error = ToolError(
                    f"Revision {revision_number} not found for document "
                    f"'{document_id}'. Use print_document_history to see "
                    "available revisions."
                )
            except BPAClientError as e:
                operation_error = translate_error(e, resource_type="print_document")
            finally:
                try:
                    if operation_error:
                        await audit_logger.mark_failed(audit_id, str(operation_error))
                    else:
                        await audit_logger.mark_success(
                            audit_id,
                            result={
                                "document_id": str(document_id),
                                "revision_number": revision_number,
                            },
                        )
                except Exception:
                    pass

            if operation_error:
                raise operation_error

    except ToolError:
        raise
    except BPAClientError as e:
        raise translate_error(
            e, resource_type="print_document", resource_id=document_id
        )

    return {
        "reverted": True,
        "document_id": str(document_id),
        "revision_number": revision_number,
        "audit_id": audit_id,
    }


async def print_document_patch(
    document_id: str | int,
    operations: list[dict[str, Any]],
    instance: str | None = None,
    dry_run: bool = False,
) -> dict[str, Any]:
    """Apply batch patch operations to a print document form. Audited write operation.

    Args:
        document_id: Print document UUID.
        operations: List of operation dicts (each must have 'op' key).
        instance: Instance profile name (from instance_list).
        dry_run: Preview changes without saving (default: False).

    Returns:
        dict with success, diff, document_id, operations_count, audit_id.
    """
    # Deferred imports to break circular dependency
    from mcp_eregistrations_bpa.patch.adapters.print_document import (
        PrintDocumentAdapter,
    )
    from mcp_eregistrations_bpa.patch.engine import FormPatchEngine
    from mcp_eregistrations_bpa.patch.operations import Operation

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

    # 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 = PrintDocumentAdapter(client)
            try:
                components = await adapter.fetch(str(document_id))
            except BPAClientError as e:
                raise translate_error(e, resource_type="print_document")

            # 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,
                    "document_id": str(document_id),
                    "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="print_document",
                object_id=str(document_id),
                params={
                    "operations_count": len(ops),
                    "diff_summary": diff_dict["summary"],
                },
            )

            # Save rollback state (previous print document envelope)
            previous_state = deepcopy(adapter.envelope) or {}
            await audit_logger.save_rollback_state(
                audit_id=audit_id,
                object_type="print_document",
                object_id=str(document_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={
                        "document_id": str(document_id),
                        "diff_summary": diff_dict["summary"],
                    },
                )

                return {
                    "success": True,
                    "dry_run": False,
                    "document_id": str(document_id),
                    "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="print_document")

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


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


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

    # Read operations
    mcp.tool(annotations=READ)(print_document_list)
    mcp.tool(annotations=READ)(print_document_get)
    mcp.tool(annotations=READ)(print_document_component_get)
    mcp.tool(annotations=READ)(print_document_templates)
    mcp.tool(annotations=READ)(print_document_history)
    # Write operations
    mcp.tool(annotations=WRITE)(print_document_create)
    mcp.tool(annotations=WRITE)(print_document_update)
    mcp.tool(annotations=DESTRUCTIVE)(print_document_delete)
    mcp.tool(annotations=WRITE)(print_document_component_add)
    mcp.tool(annotations=WRITE)(print_document_component_update)
    mcp.tool(annotations=DESTRUCTIVE)(print_document_component_remove)
    mcp.tool(annotations=WRITE)(print_document_component_move)
    mcp.tool(annotations=WRITE)(print_document_sort)
    mcp.tool(annotations=DESTRUCTIVE)(print_document_revert)
    mcp.tool(annotations=WRITE)(print_document_patch)
