"""Celery tasks for the Repository documents app."""

from __future__ import annotations

import io
import logging
from datetime import timedelta

from celery import shared_task
from django.contrib.auth import get_user_model
from django.db.models import Q
from django.utils import timezone

logger = logging.getLogger(__name__)
User = get_user_model()


@shared_task
def extract_text_from_file(document_id: int) -> int:
    """Extract plain text from PDF / DOCX / TXT for indexing and AI."""
    from apps.repository.documents.models import Document

    try:
        document = Document.objects.get(pk=document_id)
    except Document.DoesNotExist:
        return 0
    if not document.file:
        return 0

    name = document.file.name.lower()
    text = ""
    try:
        if name.endswith(".pdf"):
            from pypdf import PdfReader

            with document.file.open("rb") as fh:
                reader = PdfReader(fh)
                text = "\n".join((page.extract_text() or "") for page in reader.pages)
        elif name.endswith(".docx"):
            from docx import Document as DocxDocument

            with document.file.open("rb") as fh:
                doc = DocxDocument(io.BytesIO(fh.read()))
                text = "\n".join(p.text for p in doc.paragraphs)
        elif name.endswith((".txt", ".md")):
            with document.file.open("rb") as fh:
                text = fh.read().decode("utf-8", errors="ignore")
    except Exception:  # pragma: no cover — extraction is best-effort
        logger.warning("text extraction failed for document %s", document_id, exc_info=True)
        return 0

    Document.objects.filter(pk=document.pk).update(extracted_text=text[:1_000_000])
    return len(text)


@shared_task
def generate_thumbnail(document_id: int) -> bool:
    """Best-effort thumbnail generation. Quietly skip if dependencies missing."""
    from apps.repository.documents.models import Document

    try:
        document = Document.objects.get(pk=document_id)
    except Document.DoesNotExist:
        return False
    if not document.file:
        return False
    name = document.file.name.lower()
    try:
        from PIL import Image
    except ImportError:
        return False

    try:
        if name.endswith((".png", ".jpg", ".jpeg", ".webp", ".tiff", ".bmp")):
            with document.file.open("rb") as fh:
                img = Image.open(fh)
                img.thumbnail((600, 800))
                buf = io.BytesIO()
                img.convert("RGB").save(buf, format="JPEG", quality=82)
            from django.core.files.base import ContentFile

            document.thumbnail.save(
                f"{document.public_document_id or document.pk}.jpg",
                ContentFile(buf.getvalue()),
                save=True,
            )
            return True
    except Exception:  # pragma: no cover
        logger.warning("thumbnail generation failed for %s", document_id, exc_info=True)
    return False


@shared_task
def index_document_for_search(document_id: int) -> bool:
    from apps.repository.search.indexes import update_search_vector

    return update_search_vector(document_id)


@shared_task
def notify_collection_managers_qa(document_id: int) -> int:
    from apps.core.notifications.models import Notification
    from apps.core.notifications.services import bulk_notify
    from apps.repository.documents.models import Document

    try:
        document = Document.objects.get(pk=document_id)
    except Document.DoesNotExist:
        return 0
    managers = document.collection.managed_by.filter(is_active=True)
    if not managers.exists():
        return 0
    bulk_notify(
        list(managers),
        f'New submission awaiting QA review: "{document.title[:80]}".',
        verb=Notification.Verb.REPO_QA_ASSIGNED,
    )
    return managers.count()


@shared_task
def verify_orcid(author_id: int) -> bool:
    from apps.repository.documents.ingestion import lookup_orcid
    from apps.repository.documents.models import Author

    try:
        author = Author.objects.get(pk=author_id)
    except Author.DoesNotExist:
        return False
    if not author.orcid:
        return False
    lookup = lookup_orcid(author.orcid)
    if not lookup:
        return False
    dirty = []
    for key in ("first_name", "last_name", "email"):
        if lookup.get(key) and not getattr(author, key):
            setattr(author, key, lookup[key])
            dirty.append(key)
    author.verified_at = timezone.now()
    dirty.append("verified_at")
    author.save(update_fields=dirty + ["updated_at"])
    return True


@shared_task
def enforce_retention_policies() -> int:
    """Daily job — apply retention actions + give advance notice (PRD REPO-SP5).

    FRREP-VL005 — notifies collection managers ``retention_notice_days`` days
    *before* the retention cutoff, separate from the day-of action below.
    FRREP-VL006 — actually branches on ``Collection.retention_action``:
    ARCHIVE hides the document, WITHDRAW pulls it, FLAG is notify-only (the
    pre-existing behaviour). FRREP-VL010 — every action taken is audited and
    escalated to administrators.
    """
    from apps.core.notifications.models import Notification
    from apps.core.notifications.services import bulk_notify
    from apps.core.permissions.roles import UserRole
    from apps.repository.documents import services
    from apps.repository.documents.audit_helper import action_update, audit_repository
    from apps.repository.documents.models import Collection, Document

    today = timezone.now()
    affected = 0

    administrators = list(
        User.objects.filter(role=UserRole.ADMIN, is_active=True)
    ) + list(User.objects.filter(is_superuser=True, is_active=True))
    seen_admin_ids: set[int] = set()
    deduped_admins = []
    for u in administrators:
        if u.pk not in seen_admin_ids:
            deduped_admins.append(u)
            seen_admin_ids.add(u.pk)

    for collection in Collection.objects.filter(retention_period_days__isnull=False):
        managers = list(collection.managed_by.filter(is_active=True))
        live_docs = Document.objects.filter(collection=collection).filter(
            ~Q(status=Document.Status.WITHDRAWN)
        ).filter(is_archived=False)

        # ── FRREP-VL005 — advance notice, ahead of the cutoff ───────────────
        notice_days = collection.retention_notice_days or 0
        if notice_days and collection.retention_period_days > notice_days:
            notice_cutoff = today - timedelta(
                days=collection.retention_period_days - notice_days
            )
            window_start = notice_cutoff - timedelta(days=1)
            upcoming = live_docs.filter(
                published_at__lt=notice_cutoff, published_at__gte=window_start
            )
            upcoming_count = upcoming.count()
            if upcoming_count and managers:
                bulk_notify(
                    managers,
                    f'{upcoming_count} document(s) in "{collection.name}" will reach '
                    f"retention in {notice_days} day(s) — scheduled action: "
                    f"{collection.get_retention_action_display()}.",
                    verb=Notification.Verb.REPO_RETENTION_NOTICE,
                )

        # ── FRREP-VL006 — day-of retention action ───────────────────────────
        cutoff = today - timedelta(days=collection.retention_period_days)
        candidates = list(live_docs.filter(published_at__lt=cutoff))
        if not candidates:
            continue

        if managers:
            bulk_notify(
                managers,
                f'{len(candidates)} document(s) in "{collection.name}" have reached retention.',
                verb=Notification.Verb.REPO_RETENTION_DUE,
            )

        action = collection.retention_action
        acted_on = 0
        for document in candidates:
            if action == Collection.RetentionAction.ARCHIVE:
                services.archive_document(
                    document,
                    reason=f'Automatic retention policy ("{collection.name}").',
                )
                acted_on += 1
            elif action == Collection.RetentionAction.WITHDRAW:
                services.withdraw_document(
                    document,
                    reason=f'Automatic retention policy withdrawal ("{collection.name}").',
                )
                acted_on += 1
            # FLAG — notify-only; the bulk_notify above already covers it.

        if acted_on and deduped_admins:
            bulk_notify(
                deduped_admins,
                f'Retention policy "{collection.get_retention_action_display()}" was '
                f'applied to {acted_on} document(s) in "{collection.name}".',
                verb=Notification.Verb.REPO_RETENTION_DUE,
            )

        audit_repository(
            actor=None,
            action=action_update(),
            target_model="Collection",
            object_id=collection.pk,
            object_repr=collection.name,
            changes={
                "retention_action": action,
                "documents_matched": len(candidates),
                "documents_acted_on": acted_on,
            },
        )
        affected += len(candidates)
    return affected


@shared_task
def dispatch_outbox_pending() -> int:
    """Process pending IntegrationOutbox rows by delegating to per-target handlers."""
    from apps.repository.documents.models import IntegrationOutbox
    from apps.repository.integration import dispatch as outbox_dispatch

    pending = IntegrationOutbox.objects.filter(status=IntegrationOutbox.Status.PENDING)[:50]
    handled = 0
    for row in pending:
        try:
            outbox_dispatch.handle(row)
            handled += 1
        except Exception as exc:  # noqa: BLE001
            row.retry_count += 1
            row.error = str(exc)[:1000]
            row.status = (
                IntegrationOutbox.Status.FAILED
                if row.retry_count >= 5
                else IntegrationOutbox.Status.PENDING
            )
            row.save(update_fields=["retry_count", "error", "status"])
    return handled


@shared_task
def escalate_overdue_qa_reviews() -> int:
    """Notify Repository Administrators when a QARecord exceeds the collection's
    qa_turnaround_days (PRD REPO-SP6 FRREP-QA008)."""
    from apps.core.notifications.models import Notification
    from apps.core.notifications.services import bulk_notify
    from apps.core.permissions.roles import UserRole
    from apps.repository.documents.audit_helper import action_update, audit_repository
    from apps.repository.documents.models import QARecord

    now = timezone.now()
    pending = QARecord.objects.filter(
        decision=QARecord.Decision.PENDING
    ).select_related("document", "document__collection", "reviewer")
    administrators = list(
        User.objects.filter(role=UserRole.ADMIN, is_active=True)
    ) + list(User.objects.filter(is_superuser=True, is_active=True))
    seen_admin_ids: set[int] = set()
    deduped_admins = []
    for u in administrators:
        if u.pk not in seen_admin_ids:
            deduped_admins.append(u)
            seen_admin_ids.add(u.pk)
    escalated = 0
    for record in pending:
        cutoff_days = record.document.collection.qa_turnaround_days or 14
        age_days = (now - record.assigned_at).days
        if age_days <= cutoff_days:
            continue
        message = (
            f'QA review for "{record.document.title[:80]}" is overdue '
            f"({age_days} days, target {cutoff_days})."
        )
        # Notify the assigned reviewer + administrators.
        recipients = []
        if record.reviewer_id:
            recipients.append(record.reviewer)
        recipients.extend(deduped_admins)
        if recipients:
            bulk_notify(
                recipients,
                message,
                verb=Notification.Verb.REPO_QA_OVERDUE,
            )
        audit_repository(
            actor=None,
            action=action_update(),
            target_model="QARecord",
            object_id=record.pk,
            object_repr=str(record.document),
            changes={"escalated_overdue": age_days},
        )
        escalated += 1
    return escalated


@shared_task
def send_review_task_reminders() -> int:
    """Notify assignees of review tasks due within 48 hours (PRD REPO-SP7 FRREP-COL006)."""
    from apps.core.notifications.models import Notification
    from apps.core.notifications.services import send_notification
    from apps.repository.documents.models import DocumentReviewTask

    today = timezone.now().date()
    soon = today + timedelta(days=2)
    upcoming = DocumentReviewTask.objects.filter(
        due_date__isnull=False,
        due_date__lte=soon,
        status__in=[
            DocumentReviewTask.Status.PENDING,
            DocumentReviewTask.Status.IN_PROGRESS,
        ],
    ).select_related("assignee", "document")
    sent = 0
    for task in upcoming:
        if not task.assignee_id:
            continue
        is_overdue = task.due_date < today
        if is_overdue:
            label = "is overdue"
        elif task.due_date == today:
            label = "is due today"
        else:
            label = f"is due on {task.due_date.isoformat()}"
        send_notification(
            task.assignee,
            f'Review task "{task.scope[:60]}" on "{task.document.title[:60]}" {label}.',
            verb=Notification.Verb.REPO_REVIEW_TASK_DUE,
        )
        sent += 1
        # FRREP-COL006 — the SRS requires notifying the collaborator *and*
        # the document owner, not just the assignee, once a task is overdue.
        owner_id = task.document.submitted_by_id
        if is_overdue and owner_id and owner_id != task.assignee_id:
            send_notification(
                task.document.submitted_by,
                f'Review task "{task.scope[:60]}" assigned to '
                f'{task.assignee.get_full_name() or task.assignee.email} on your document '
                f'"{task.document.title[:60]}" is overdue.',
                verb=Notification.Verb.REPO_REVIEW_TASK_DUE,
            )
    return sent


@shared_task
def dispatch_saved_search_alerts() -> int:
    """Send digests to SavedSearch users with new matches since their last alert
    (PRD REPO-SP3 FRREP-SR010)."""
    from apps.core.notifications.models import Notification
    from apps.core.notifications.services import send_notification
    from apps.repository.documents.models import SavedSearch
    from apps.repository.search.services import search_documents

    sent = 0
    for saved in SavedSearch.objects.filter(alert_enabled=True).select_related("user"):
        query_payload = saved.query or {}
        q = query_payload.get("q", "")
        filters = query_payload.get("filters", {}) or {}
        cutoff = saved.last_alert_at or saved.created_at
        try:
            qs = search_documents(query=q, filters=filters, sort="recent", user=saved.user)
        except Exception:
            logger.warning(
                "saved-search %s could not be re-run", saved.pk, exc_info=True
            )
            continue
        # Only notify on documents published after the last alert window.
        new_matches = list(
            qs.filter(published_at__gt=cutoff).values_list(
                "title", "public_document_id"
            )[:10]
        )
        if not new_matches:
            continue
        first_titles = ", ".join(f'"{t[:40]}"' for t, _ in new_matches[:3])
        send_notification(
            saved.user,
            f'{len(new_matches)} new document(s) match your saved search '
            f'"{saved.name}": {first_titles}.',
            verb=Notification.Verb.REPO_SAVED_SEARCH_ALERT,
        )
        SavedSearch.objects.filter(pk=saved.pk).update(last_alert_at=timezone.now())
        sent += 1
    return sent


@shared_task
def cleanup_resumable_uploads() -> int:
    """Periodic cleanup of abandoned + expired resumable upload sessions
    (FRREP-DCI004). Wired in ``config/celery.py`` beat schedule, runs hourly.
    """
    from apps.repository.documents.services import (
        cleanup_expired_resumable_uploads,
    )
    return cleanup_expired_resumable_uploads()
