"""DMS versioning — service layer.

Public API
----------
checkout_document       Acquire an exclusive lock on a document.
checkin_document        Release a lock, optionally creating a new version.
force_release_lock      Admin: forcefully release any active lock.
release_expired_locks   Bulk-release all auto-expired locks (for scheduler).
request_version_diff    Return an existing diff or create a new PENDING one.
update_version_diff     Populate diff results (called by async worker).
"""

from __future__ import annotations

from typing import TYPE_CHECKING

from django.db import transaction

from simorgh.apps.dms.versioning.models import (
    CheckoutAction,
    CheckoutSession,
    DiffStatus,
    DiffType,
    DocumentLock,
    LockStatus,
    VersionDiff,
)

if TYPE_CHECKING:
    from simorgh.apps.dms.documents.models import Document, DocumentVersion


# ---------------------------------------------------------------------------
# Domain errors
# ---------------------------------------------------------------------------

class VersioningError(Exception):
    """Base error for versioning operations."""


class LockError(VersioningError):
    """Raised for invalid lock state (already locked, wrong token, etc.)."""


class LockConflictError(LockError):
    """Raised when a document is already locked by a different user."""


# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------

def _assert_lock_usable(lock: DocumentLock) -> None:
    """Raise LockError if the lock is no longer actionable."""
    if not lock.is_active or lock.is_deleted:
        raise LockError("This lock is no longer active.")
    if lock.is_expired:
        raise LockError("This checkout lock has expired. Please release it and check out again.")


# ---------------------------------------------------------------------------
# Checkout / check-in
# ---------------------------------------------------------------------------

def checkout_document(
    document: "Document",
    *,
    user,
    tenant_id: int,
    organization_node_id: int,
    expires_at=None,
    notes: str = "",
) -> DocumentLock:
    """Acquire an exclusive checkout lock on *document*.

    Only one active lock may exist per document at a time.  If the document is
    already locked by a *different* user, ``LockConflictError`` is raised.
    If the same user already holds the lock, ``LockError`` is raised.

    Parameters
    ----------
    expires_at
        Optional datetime after which ``release_expired_locks`` will
        automatically release the lock.  None means no auto-expiry.

    Raises
    ------
    DocumentError
        If the document is archived.
    LockConflictError
        If the document is locked by someone else.
    LockError
        If the calling user already holds the lock.
    """
    from simorgh.apps.dms.documents.models import DocumentStatus
    from simorgh.apps.dms.documents.services import DocumentError

    if document.status == DocumentStatus.ARCHIVED:
        raise DocumentError("Cannot check out an archived document.")

    from simorgh.apps.dms.versioning.queries import get_active_lock

    existing = get_active_lock(document)
    if existing is not None:
        if existing.locked_by_id == getattr(user, "pk", None):
            raise LockError("You already have this document checked out.")
        expiry_info = (
            existing.expires_at.isoformat() if existing.expires_at else "never"
        )
        raise LockConflictError(
            f"Document is already checked out by another user. "
            f"Lock expires: {expiry_info}."
        )

    with transaction.atomic():
        lock = DocumentLock.objects.create(
            document=document,
            locked_by=user,
            expires_at=expires_at,
            notes=notes,
            is_active=True,
            status=LockStatus.ACTIVE,
            tenant_id=tenant_id,
            organization_node_id=organization_node_id,
        )
        CheckoutSession.objects.create(
            document=document,
            actor=user,
            lock_holder=user,
            action=CheckoutAction.CHECKOUT,
            lock_token=lock.lock_token,
            notes=notes,
            tenant_id=tenant_id,
            organization_node_id=organization_node_id,
        )

    return lock


def checkin_document(
    lock: DocumentLock,
    *,
    file_asset=None,
    bump: str = "minor",
    label: str = "",
    change_summary: str = "",
    lock_token=None,
) -> tuple[DocumentLock, "DocumentVersion | None"]:
    """Release *lock*, optionally uploading a new document version.

    Parameters
    ----------
    file_asset
        If provided, a new ``DocumentVersion`` is created via
        ``documents.services.add_version``.
    lock_token
        If supplied, must match ``lock.lock_token`` to prevent stale
        check-ins from overwriting another user's work.  Omit for
        admin/service-initiated check-ins.

    Returns
    -------
    (lock, version)  where *version* is None when no file_asset was given.
    """
    _assert_lock_usable(lock)

    if lock_token is not None and str(lock_token) != str(lock.lock_token):
        raise LockError(
            "Lock token mismatch. Your checkout session may be stale. "
            "Re-check the document to obtain a fresh token."
        )

    with transaction.atomic():
        version = None
        if file_asset is not None:
            from simorgh.apps.dms.documents import services as doc_services

            version = doc_services.add_version(
                document=lock.document,
                file_asset=file_asset,
                bump=bump,
                label=label,
                change_summary=change_summary,
                tenant_id=lock.tenant_id,
                organization_node_id=lock.organization_node_id,
            )

        lock.is_active = False
        lock.status = LockStatus.RELEASED
        lock.save(update_fields=["is_active", "status", "updated_at"])

        CheckoutSession.objects.create(
            document=lock.document,
            actor=lock.locked_by,
            lock_holder=lock.locked_by,
            action=CheckoutAction.CHECKIN,
            lock_token=lock.lock_token,
            version_created=version,
            notes=change_summary,
            tenant_id=lock.tenant_id,
            organization_node_id=lock.organization_node_id,
        )

    return lock, version


def force_release_lock(
    lock: DocumentLock,
    *,
    released_by,
    reason: str = "",
) -> DocumentLock:
    """Forcefully release a lock held by any user.

    This is an administrative action and must only be exposed to users with
    the ``dms.versioning.release`` IAM permission.
    """
    if not lock.is_active or lock.is_deleted:
        raise LockError("Cannot release a lock that is already inactive.")

    with transaction.atomic():
        lock.is_active = False
        lock.status = LockStatus.FORCE_RELEASED
        lock.save(update_fields=["is_active", "status", "updated_at"])

        CheckoutSession.objects.create(
            document=lock.document,
            actor=released_by,
            lock_holder=lock.locked_by,
            action=CheckoutAction.FORCE_RELEASE,
            lock_token=lock.lock_token,
            notes=reason,
            tenant_id=lock.tenant_id,
            organization_node_id=lock.organization_node_id,
        )

    return lock


def release_expired_locks() -> list[DocumentLock]:
    """Release all active locks that have passed their ``expires_at`` time.

    Intended to be called from a periodic Celery beat task.
    Returns the list of locks that were released.
    """
    from simorgh.apps.dms.versioning.queries import get_expired_active_locks

    expired = list(get_expired_active_locks())
    if not expired:
        return []

    with transaction.atomic():
        for lock in expired:
            lock.is_active = False
            lock.status = LockStatus.EXPIRED
            lock.save(update_fields=["is_active", "status", "updated_at"])

            CheckoutSession.objects.create(
                document=lock.document,
                actor=None,
                lock_holder=lock.locked_by,
                action=CheckoutAction.EXPIRED_RELEASE,
                lock_token=lock.lock_token,
                notes="Automatically released due to expiry.",
                tenant_id=lock.tenant_id,
                organization_node_id=lock.organization_node_id,
            )

    return expired


# ---------------------------------------------------------------------------
# Version diff
# ---------------------------------------------------------------------------

def request_version_diff(
    from_version: "DocumentVersion",
    to_version: "DocumentVersion",
    *,
    tenant_id: int,
    organization_node_id: int,
    diff_type: str = DiffType.FULL,
) -> VersionDiff:
    """Return an existing diff record or create a new PENDING one.

    Idempotent: calling with the same (from_version, to_version, diff_type)
    twice returns the same record.  Actual diff computation is intentionally
    out-of-scope — trigger it asynchronously after this call returns.

    Raises
    ------
    VersioningError
        If the versions belong to different documents, are identical, or
        diff_type is invalid.
    """
    if from_version.document_id != to_version.document_id:
        raise VersioningError("Cannot compare versions from different documents.")
    if from_version.pk == to_version.pk:
        raise VersioningError("Cannot compare a version with itself.")
    if diff_type not in DiffType.values:
        raise VersioningError(f"Invalid diff type: {diff_type!r}.")

    from simorgh.apps.dms.versioning.queries import get_version_diff_for_versions

    existing = get_version_diff_for_versions(from_version, to_version, diff_type)
    if existing is not None:
        return existing

    return VersionDiff.objects.create(
        from_version=from_version,
        to_version=to_version,
        diff_type=diff_type,
        status=DiffStatus.PENDING,
        tenant_id=tenant_id,
        organization_node_id=organization_node_id,
    )


def update_version_diff(
    diff: VersionDiff,
    *,
    diff_summary: str = "",
    diff_data: dict | None = None,
    status: str = DiffStatus.READY,
) -> VersionDiff:
    """Populate the result of a version diff computation.

    Called by the async worker after the diff engine completes.
    """
    if status not in DiffStatus.values:
        raise VersioningError(f"Invalid diff status: {status!r}.")

    fields = ["status", "updated_at"]
    diff.status = status
    if diff_summary:
        diff.diff_summary = diff_summary
        fields.append("diff_summary")
    if diff_data is not None:
        diff.diff_data = diff_data
        fields.append("diff_data")
    diff.save(update_fields=fields)
    return diff
