"""DMS preview — service layer.

All mutation logic for PreviewRecord lives here.

Public surface
--------------
request_preview     — create a PENDING preview record (hook for external converter)
mark_processing     — PENDING → PROCESSING (converter acknowledged the job)
complete_preview    — → READY (converter delivered the rendition)
fail_preview        — → FAILED (converter reported an error)
mark_unavailable    — → UNAVAILABLE (format not supported for this file type)
invalidate_previews — soft-delete all previews for a version (e.g. after rollback)

Domain errors
-------------
PreviewError        — base domain error for this bounded context
"""

from __future__ import annotations

from django.db import transaction

from simorgh.apps.dms.preview.models import PreviewFormat, PreviewRecord, PreviewStatus


# ---------------------------------------------------------------------------
# Domain errors
# ---------------------------------------------------------------------------

class PreviewError(Exception):
    """Raised for invalid preview pipeline operations."""


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

_TERMINAL_STATUSES = frozenset(
    {PreviewStatus.READY, PreviewStatus.FAILED, PreviewStatus.UNAVAILABLE}
)


def _assert_mutable(record: PreviewRecord) -> None:
    """Raise if the record has already reached a terminal status."""
    if record.status in _TERMINAL_STATUSES:
        raise PreviewError(
            f"PreviewRecord {record.public_id} is already in terminal status "
            f"'{record.status}' and cannot be mutated."
        )


# ---------------------------------------------------------------------------
# Service functions
# ---------------------------------------------------------------------------

def request_preview(
    *,
    document_version,          # dms.DocumentVersion
    preview_format: str,
    tenant_id: int,
    organization_node_id: int,
    page_number: int | None = None,
    width: int | None = None,
    height: int | None = None,
    provider: str = "",
    is_primary: bool = False,
) -> PreviewRecord:
    """Create a PENDING preview record and return it.

    The caller (or an async task) is responsible for dispatching the actual
    conversion job to the external provider.  This function only registers
    intent and assigns a stable ``public_id`` for the converter callback.

    If ``is_primary`` is True any existing primary preview is demoted
    (is_primary → False) so uniqueness is maintained.
    """
    if preview_format not in PreviewFormat.values:
        raise PreviewError(
            f"Unknown preview format: {preview_format!r}. "
            f"Allowed: {PreviewFormat.values}"
        )

    # Check for an existing non-deleted record for the same (version, format, page).
    existing = PreviewRecord.objects.filter(
        document_version=document_version,
        preview_format=preview_format,
        page_number=page_number,
        is_deleted=False,
    ).first()
    if existing is not None:
        raise PreviewError(
            f"A PreviewRecord already exists for format={preview_format!r}, "
            f"page={page_number} on this version (id={existing.public_id}).  "
            f"Soft-delete it first or call invalidate_previews()."
        )

    with transaction.atomic():
        if is_primary:
            # Demote any existing primary previews for this version.
            PreviewRecord.objects.filter(
                document_version=document_version,
                is_primary=True,
                is_deleted=False,
            ).update(is_primary=False)

        record = PreviewRecord.objects.create(
            document_version=document_version,
            preview_format=preview_format,
            page_number=page_number,
            width=width,
            height=height,
            status=PreviewStatus.PENDING,
            provider=provider,
            is_primary=is_primary,
            tenant_id=tenant_id,
            organization_node_id=organization_node_id,
        )

    return record


def mark_processing(record: PreviewRecord, *, provider_job_id: str = "") -> PreviewRecord:
    """Transition PENDING → PROCESSING.

    Called when the external converter acknowledges the job.
    Optionally records the provider's job reference for future polling.
    """
    if record.status != PreviewStatus.PENDING:
        raise PreviewError(
            f"Only PENDING records can be moved to PROCESSING "
            f"(current: {record.status!r})."
        )
    record.status = PreviewStatus.PROCESSING
    if provider_job_id:
        record.provider_job_id = provider_job_id
    record.save(update_fields=["status", "provider_job_id", "updated_at"])
    return record


def complete_preview(
    record: PreviewRecord,
    *,
    file_asset,           # storage.FileMetadata — the produced rendition
    width: int | None = None,
    height: int | None = None,
) -> PreviewRecord:
    """Transition → READY and attach the produced rendition file.

    Called by the external converter (or its webhook handler) when the
    rendition file has been uploaded to the storage layer.

    Mirrors ``file_size_bytes`` and ``content_type`` from the file asset for
    cheap listing queries.
    """
    _assert_mutable(record)
    if record.status not in (PreviewStatus.PENDING, PreviewStatus.PROCESSING):
        raise PreviewError(
            f"Cannot complete a preview in status {record.status!r}."
        )

    record.file_asset = file_asset
    record.file_size_bytes = getattr(file_asset, "size_bytes", None)
    record.content_type = getattr(file_asset, "content_type", "")
    record.status = PreviewStatus.READY
    if width is not None:
        record.width = width
    if height is not None:
        record.height = height
    record.failure_reason = ""
    record.save(
        update_fields=[
            "file_asset", "file_size_bytes", "content_type",
            "status", "width", "height", "failure_reason", "updated_at",
        ]
    )
    return record


def fail_preview(record: PreviewRecord, *, reason: str = "") -> PreviewRecord:
    """Transition → FAILED.

    Called by the external converter when conversion fails.
    Records the human-readable reason for observability.
    """
    _assert_mutable(record)
    record.status = PreviewStatus.FAILED
    record.failure_reason = reason or "Unknown error."
    record.save(update_fields=["status", "failure_reason", "updated_at"])
    return record


def mark_unavailable(record: PreviewRecord, *, reason: str = "") -> PreviewRecord:
    """Transition → UNAVAILABLE.

    Used when the requested format is not supported for this file type
    (e.g. thumbnail requested for a zero-byte file, or HTML preview for
    a binary blob that has no converter).
    """
    _assert_mutable(record)
    record.status = PreviewStatus.UNAVAILABLE
    record.failure_reason = reason or "Format unavailable for this file type."
    record.save(update_fields=["status", "failure_reason", "updated_at"])
    return record


def invalidate_previews(document_version) -> int:
    """Soft-delete all non-deleted previews for a version.

    Returns the count of invalidated records.
    Used after a rollback or when the version's file asset changes.
    """
    updated = PreviewRecord.objects.filter(
        document_version=document_version, is_deleted=False
    ).update(is_deleted=True)
    return updated
