"""Storage service layer.

Public surface:
  * ``store_file``                — single-request upload (validates, persists, returns FileMetadata)
  * ``get_or_create_system_folder`` — idempotent system-folder bootstrap
  * ``create_upload_session``     — initiate a resumable / multi-chunk session
  * ``receive_chunk``             — record a received chunk within a session
  * ``complete_upload_session``   — assemble chunks → FileMetadata
  * ``expire_upload_sessions``    — cleanup task: expire stale sessions
  * ``register_scanner``          — plug in an antivirus scanner

Internal helpers are prefixed ``_``.
"""

from __future__ import annotations

import hashlib
import io
import uuid as _uuid
from collections.abc import Iterable
from datetime import timedelta
from typing import BinaryIO, Protocol

from django.conf import settings
from django.utils import timezone

from simorgh.apps.storage.models import (
    FileMetadata,
    FileProcessingStatus,
    FileUploadStatus,
    UploadSession,
    UploadSessionStatus,
    VirusScanStatus,
)
from simorgh.apps.storage.providers import (
    StorageError,
    get_default_provider,
    safe_tenant_path,
)
from simorgh.core.context import current_request_context

DEFAULT_MAX_BYTES = 25 * 1024 * 1024  # 25 MiB
DEFAULT_ALLOWED_MIMES: frozenset[str] = frozenset()  # empty = allow all
DEFAULT_SESSION_TTL_HOURS = 24


class AntivirusScanner(Protocol):
    def scan(self, content: bytes) -> None: ...  # raises on hit


_scanners: list[AntivirusScanner] = []


def register_scanner(scanner: AntivirusScanner) -> None:
    _scanners.append(scanner)


def _max_bytes() -> int:
    return int(getattr(settings, "STORAGE_MAX_FILE_BYTES", DEFAULT_MAX_BYTES))


def _allowed_mimes() -> Iterable[str]:
    return getattr(settings, "STORAGE_ALLOWED_MIMES", DEFAULT_ALLOWED_MIMES)


def _session_ttl_hours() -> int:
    return int(getattr(settings, "STORAGE_UPLOAD_SESSION_TTL_HOURS", DEFAULT_SESSION_TTL_HOURS))


def _resolve_context(
    tenant_id: int | None,
    organization_node_id: int | None,
    uploaded_by_id: int | None,
) -> tuple[int, int, int | None]:
    """Fill in tenant / org_node / actor from RequestContext when not provided."""
    ctx = current_request_context()
    if tenant_id is None and ctx.tenant is not None:
        tenant_id = ctx.tenant.pk
    if tenant_id is None:
        raise StorageError("requires a tenant (bind RequestContext or pass tenant_id)")
    if organization_node_id is None and ctx.org_node_ids:
        organization_node_id = next(iter(ctx.org_node_ids))
    if organization_node_id is None:
        raise StorageError("requires organization_node_id")
    if uploaded_by_id is None and ctx.actor is not None and getattr(ctx.actor, "pk", None):
        uploaded_by_id = ctx.actor.pk
    return tenant_id, organization_node_id, uploaded_by_id


def _validate_content_type(content_type: str) -> None:
    allowed = _allowed_mimes()
    if allowed and content_type and content_type not in allowed:
        raise StorageError(f"mime type {content_type!r} not allowed")


def _run_scanners(raw: bytes) -> VirusScanStatus:
    """Run all registered AV scanners; return CLEAN or raise StorageError."""
    if not _scanners:
        return VirusScanStatus.SKIPPED
    for scanner in _scanners:
        scanner.scan(raw)  # raises StorageError on detection
    return VirusScanStatus.CLEAN


# ---------------------------------------------------------------------------
# System folder bootstrap
# ---------------------------------------------------------------------------

def get_or_create_system_folder(tenant_id: int, name: str) -> "FileMetadata":
    """Get or create a root-level system folder for a tenant.

    System folders are auto-managed by modules (e.g. ``'Chat'``, ``'Helpdesk'``)
    and live at the root of the folder tree (``parent=None``).
    """
    from simorgh.apps.storage.models import StorageFolder

    folder, _ = StorageFolder.objects.get_or_create(
        tenant_id=tenant_id,
        parent=None,
        name=name,
        defaults={"is_system": True},
    )
    return folder  # type: ignore[return-value]


# ---------------------------------------------------------------------------
# Single-request upload
# ---------------------------------------------------------------------------

def store_file(
    *,
    filename: str,
    content: BinaryIO,
    content_type: str = "",
    organization_node_id: int | None = None,
    tenant_id: int | None = None,
    uploaded_by_id: int | None = None,
    folder_id: str | None = None,
    app_context: str = "",
) -> FileMetadata:
    """Persist ``content`` and create the matching :class:`FileMetadata` row.

    Tenant + org_node default to the bound ``RequestContext``. Files exceeding
    ``STORAGE_MAX_FILE_BYTES`` or whose MIME is not in ``STORAGE_ALLOWED_MIMES``
    (when set) are rejected before any disk write.

    The storage path is UUID-based (via :func:`safe_tenant_path`) so concurrent
    uploads of identically-named files never collide.

    The returned record has ``upload_status=READY`` and
    ``virus_scan_status`` set to the result of registered AV scanners (or
    ``SKIPPED`` if none are registered).
    """
    tenant_id, organization_node_id, uploaded_by_id = _resolve_context(
        tenant_id, organization_node_id, uploaded_by_id
    )
    _validate_content_type(content_type)

    raw = content.read()
    if len(raw) > _max_bytes():
        raise StorageError(f"file exceeds max size of {_max_bytes()} bytes")

    scan_result = _run_scanners(raw)

    checksum = hashlib.sha256(raw).hexdigest()
    path = safe_tenant_path(tenant_id, filename)
    provider = get_default_provider()
    provider.save(path, io.BytesIO(raw))

    return FileMetadata.objects.create(
        tenant_id=tenant_id,
        organization_node_id=organization_node_id,
        storage_backend=provider.name,
        path=path,
        filename=filename,
        content_type=content_type,
        size_bytes=len(raw),
        checksum_sha256=checksum,
        uploaded_by_id=uploaded_by_id,
        folder_id=folder_id,
        app_context=app_context,
        upload_status=FileUploadStatus.READY,
        virus_scan_status=scan_result,
        virus_scan_at=timezone.now() if scan_result != VirusScanStatus.SKIPPED else None,
        processing_status=FileProcessingStatus.PENDING,
    )


# ---------------------------------------------------------------------------
# Resumable / multi-chunk upload
# ---------------------------------------------------------------------------

def create_upload_session(
    *,
    filename: str,
    content_type: str = "",
    total_size_bytes: int,
    total_chunks: int = 1,
    expected_checksum_sha256: str = "",
    organization_node_id: int | None = None,
    tenant_id: int | None = None,
    uploaded_by_id: int | None = None,
    folder_id: str | None = None,
    app_context: str = "",
    ttl_hours: int | None = None,
) -> UploadSession:
    """Create and return a new :class:`UploadSession`.

    The caller receives a session ``public_id`` which must be passed with each
    subsequent chunk upload.  ``storage_key`` is pre-computed from a UUID so
    the assembled file lands at a collision-free path.
    """
    tenant_id, organization_node_id, uploaded_by_id = _resolve_context(
        tenant_id, organization_node_id, uploaded_by_id
    )
    _validate_content_type(content_type)

    ttl = ttl_hours if ttl_hours is not None else _session_ttl_hours()
    storage_key = _uuid.uuid4().hex  # used as sub_key in safe_tenant_path later

    return UploadSession.objects.create(
        tenant_id=tenant_id,
        organization_node_id=organization_node_id,
        filename=filename,
        content_type=content_type,
        total_size_bytes=total_size_bytes,
        total_chunks=total_chunks,
        received_chunks=0,
        status=UploadSessionStatus.INITIATED,
        storage_key=storage_key,
        expected_checksum_sha256=expected_checksum_sha256,
        uploaded_by_id=uploaded_by_id,
        folder_id=folder_id,
        app_context=app_context,
        expires_at=timezone.now() + timedelta(hours=ttl),
    )


def receive_chunk(session: UploadSession) -> UploadSession:
    """Increment the received-chunk counter and transition status to UPLOADING.

    The actual chunk bytes are written to the provider by the API layer before
    calling this function.  This function only updates the session record.

    Raises ``StorageError`` if the session is expired or already completed.
    """
    if session.is_expired:
        raise StorageError(f"UploadSession {session.public_id} has expired")
    if session.status in (UploadSessionStatus.COMPLETED, UploadSessionStatus.FAILED):
        raise StorageError(
            f"UploadSession {session.public_id} is already in terminal state '{session.status}'"
        )

    session.received_chunks += 1
    session.status = UploadSessionStatus.UPLOADING
    session.save(update_fields=["received_chunks", "status", "updated_at"])
    return session


def complete_upload_session(
    session: UploadSession,
    *,
    assembled_content: BinaryIO,
) -> FileMetadata:
    """Assemble chunks into a :class:`FileMetadata` record.

    ``assembled_content`` is the fully assembled file stream.  The caller is
    responsible for concatenating chunk data from the provider before calling
    this function.

    Raises ``StorageError`` if the session is expired, not all chunks were
    received, or (when ``expected_checksum_sha256`` is set) the checksum
    doesn't match.
    """
    if session.is_expired:
        raise StorageError(f"UploadSession {session.public_id} has expired")
    if not session.all_chunks_received:
        raise StorageError(
            f"UploadSession {session.public_id}: expected {session.total_chunks} chunks, "
            f"received {session.received_chunks}"
        )

    session.status = UploadSessionStatus.ASSEMBLING
    session.save(update_fields=["status", "updated_at"])

    raw = assembled_content.read()
    checksum = hashlib.sha256(raw).hexdigest()

    if session.expected_checksum_sha256 and checksum != session.expected_checksum_sha256:
        session.status = UploadSessionStatus.FAILED
        session.save(update_fields=["status", "updated_at"])
        raise StorageError(
            f"Checksum mismatch: expected {session.expected_checksum_sha256!r}, "
            f"got {checksum!r}"
        )

    scan_result = _run_scanners(raw)

    path = safe_tenant_path(
        session.tenant_id, session.filename, sub_key=session.storage_key
    )
    provider = get_default_provider()
    provider.save(path, io.BytesIO(raw))

    file_record = FileMetadata.objects.create(
        tenant_id=session.tenant_id,
        organization_node_id=session.organization_node_id,
        storage_backend=provider.name,
        path=path,
        filename=session.filename,
        content_type=session.content_type,
        size_bytes=len(raw),
        checksum_sha256=checksum,
        uploaded_by_id=session.uploaded_by_id,
        folder_id=session.folder_id,
        app_context=session.app_context,
        upload_status=FileUploadStatus.READY,
        virus_scan_status=scan_result,
        virus_scan_at=timezone.now() if scan_result != VirusScanStatus.SKIPPED else None,
        processing_status=FileProcessingStatus.PENDING,
    )

    session.status = UploadSessionStatus.COMPLETED
    session.completed_file = file_record
    session.save(update_fields=["status", "completed_file", "updated_at"])

    return file_record


# ---------------------------------------------------------------------------
# Cleanup
# ---------------------------------------------------------------------------

def expire_upload_sessions() -> int:
    """Mark all timed-out sessions as EXPIRED and return the count.

    Intended to be called from a periodic Celery task.  Partial chunk data
    on the storage provider is NOT cleaned up here — a separate deep-clean
    task handles provider-side orphan removal to avoid long-running DB locks.
    """
    expired_qs = UploadSession.objects.filter(
        status__in=(UploadSessionStatus.INITIATED, UploadSessionStatus.UPLOADING),
        expires_at__lt=timezone.now(),
    )
    count = expired_qs.update(status=UploadSessionStatus.EXPIRED)
    return count

