"""DMS AI bounded context — service layer.

All business logic for OCR, AI classification, and entity extraction lives
here.  Views and tasks must only call these services; they must never
manipulate the models directly.

Provider abstraction
--------------------
The services accept a ``provider`` slug string.  The DMS layer stores it
but does NOT call any external provider.  Actual provider communication is
the responsibility of Celery tasks or external adapters that call back into
these services via ``record_ocr_result`` / ``record_ai_classification`` /
``add_extracted_entities``.

This design means the DMS AI module is always async-ready without coupling
to any specific AI/OCR SDK.
"""

from __future__ import annotations

from typing import Any, Optional

from django.utils import timezone

from simorgh.apps.dms.ai.models import (
    AIClassification,
    AIClassificationType,
    EntityType,
    ExtractedEntity,
    OCRResult,
    OCRStatus,
)


# ---------------------------------------------------------------------------
# Custom exceptions
# ---------------------------------------------------------------------------

class OCRError(Exception):
    """Raised when an OCR service operation cannot be completed."""


class AIClassificationError(Exception):
    """Raised when an AI classification operation cannot be completed."""


# ---------------------------------------------------------------------------
# OCR services
# ---------------------------------------------------------------------------

def submit_ocr(
    *,
    tenant_id: int,
    organization_node_id: int,
    document_version_id: int,
    provider: str = "default",
    language_code: str = "",
    submitted_by,
) -> OCRResult:
    """Create an OCRResult record in PENDING state.

    This represents queueing an OCR job.  The actual provider call happens
    in a Celery task that will invoke ``record_ocr_result`` or ``fail_ocr``
    upon completion.

    Multiple pending/processing jobs for the same version+provider are
    allowed (e.g. retry scenario).
    """
    return OCRResult.objects.create(
        tenant_id=tenant_id,
        organization_node_id=organization_node_id,
        document_version_id=document_version_id,
        provider=provider,
        language_code=language_code,
        status=OCRStatus.PENDING,
        created_by=submitted_by,
        updated_by=submitted_by,
    )


def record_ocr_result(
    *,
    ocr_result: OCRResult,
    full_text: str,
    page_count: int = 0,
    confidence_score: Optional[float] = None,
    language_code: str = "",
    processing_metadata: Optional[dict] = None,
    updated_by,
) -> OCRResult:
    """Persist the output of a completed OCR run.

    Transitions status to COMPLETED and stores the extracted text.
    Raises ``OCRError`` if the result is already completed or failed.
    """
    if ocr_result.status not in (OCRStatus.PENDING, OCRStatus.PROCESSING):
        raise OCRError(
            f"Cannot record result for OCR job in status {ocr_result.status!r}."
        )

    ocr_result.status = OCRStatus.COMPLETED
    ocr_result.full_text = full_text
    ocr_result.page_count = page_count
    if confidence_score is not None:
        ocr_result.confidence_score = confidence_score
    if language_code:
        ocr_result.language_code = language_code
    if processing_metadata is not None:
        ocr_result.processing_metadata = processing_metadata
    ocr_result.completed_at = timezone.now()
    ocr_result.updated_by = updated_by
    ocr_result.save()
    return ocr_result


def fail_ocr(
    *,
    ocr_result: OCRResult,
    error_message: str,
    updated_by,
) -> OCRResult:
    """Mark an OCR job as failed.

    Raises ``OCRError`` if the result is already completed or failed.
    """
    if ocr_result.status not in (OCRStatus.PENDING, OCRStatus.PROCESSING):
        raise OCRError(
            f"Cannot fail OCR job in status {ocr_result.status!r}."
        )

    ocr_result.status = OCRStatus.FAILED
    ocr_result.error_message = error_message
    ocr_result.completed_at = timezone.now()
    ocr_result.updated_by = updated_by
    ocr_result.save()
    return ocr_result


# ---------------------------------------------------------------------------
# AI Classification services
# ---------------------------------------------------------------------------

def submit_ai_classification(
    *,
    tenant_id: int,
    organization_node_id: int,
    document_version_id: int,
    classification_type: str = AIClassificationType.DOCUMENT_TYPE,
    provider: str = "default",
    created_by,
) -> AIClassification:
    """Create an AIClassification record.

    The record starts with an empty label.  The actual classification result
    is recorded via ``record_ai_classification`` after the provider responds.
    """
    return AIClassification.objects.create(
        tenant_id=tenant_id,
        organization_node_id=organization_node_id,
        document_version_id=document_version_id,
        classification_type=classification_type,
        provider=provider,
        created_by=created_by,
        updated_by=created_by,
    )


def record_ai_classification(
    *,
    classification: AIClassification,
    label: str,
    confidence_score: Optional[float] = None,
    summary: str = "",
    tags: Optional[list] = None,
    processing_metadata: Optional[dict] = None,
    updated_by,
) -> AIClassification:
    """Persist AI classification results onto an existing record.

    Can be called multiple times to overwrite a previous result (e.g. when
    the AI model is re-run with a better prompt or updated model version).
    """
    classification.label = label
    if confidence_score is not None:
        classification.confidence_score = confidence_score
    if summary:
        classification.summary = summary
    if tags is not None:
        classification.tags = tags
    if processing_metadata is not None:
        classification.processing_metadata = processing_metadata
    classification.updated_by = updated_by
    classification.save()
    return classification


# ---------------------------------------------------------------------------
# Extracted entity services
# ---------------------------------------------------------------------------

def add_extracted_entities(
    *,
    tenant_id: int,
    organization_node_id: int,
    document_version_id: int,
    entities_data: list[dict[str, Any]],
    ocr_result: Optional[OCRResult] = None,
    created_by,
) -> list[ExtractedEntity]:
    """Bulk-create extracted entities for a document version.

    Each item in ``entities_data`` must contain:
      - ``entity_type``: an ``EntityType`` value
      - ``value``: the raw extracted string

    Optional keys:
      - ``normalized_value``: canonical form
      - ``confidence_score``: float in [0, 1]
      - ``provider``: provider slug
      - ``position_metadata``: dict with location info

    Returns the list of created ExtractedEntity instances.
    """
    instances = [
        ExtractedEntity(
            tenant_id=tenant_id,
            organization_node_id=organization_node_id,
            document_version_id=document_version_id,
            source=ocr_result,
            entity_type=item.get("entity_type", EntityType.CUSTOM),
            value=item["value"],
            normalized_value=item.get("normalized_value", ""),
            confidence_score=item.get("confidence_score"),
            provider=item.get("provider", ""),
            position_metadata=item.get("position_metadata", {}),
            created_by=created_by,
            updated_by=created_by,
        )
        for item in entities_data
    ]
    return ExtractedEntity.objects.bulk_create(instances)


def clear_extracted_entities(
    *,
    tenant_id: int,
    document_version_id: int,
    updated_by,
) -> int:
    """Soft-delete all extracted entities for a document version.

    Returns the count of entities soft-deleted.  Use before re-extraction
    to avoid duplicate entity accumulation.
    """
    qs = ExtractedEntity.objects.filter(
        tenant_id=tenant_id,
        document_version_id=document_version_id,
        is_deleted=False,
    )
    count = qs.count()
    qs.update(is_deleted=True, updated_by=updated_by)
    return count
