"""Repository / Digital Knowledge Store — domain models (PRD §6, REPO-SP1…SP7)."""

from __future__ import annotations

import uuid

from django.conf import settings
from django.contrib.postgres.fields import ArrayField
from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVectorField
from django.db import models
from django.utils import timezone
from django_fsm import FSMField, transition

from apps.core.authentication.models import Institution
from apps.core.permissions.roles import UserRole
from apps.core.storage import FileValidator
from apps.core.storage.paths import upload_to_repository, upload_to_repository_thumbnails
from apps.repository.documents.managers import DocumentManager

_repo_file_validator = FileValidator(
    module="repository",
    allowed_categories=["document", "data", "image", "archive", "media"],
)


# ── Author registry ─────────────────────────────────────────────────────────


class _ActiveAuthorManager(models.Manager):
    """Default manager — hides archived (merged-away) Author rows."""

    def get_queryset(self):
        return super().get_queryset().filter(is_archived=False)


class Author(models.Model):
    """Centralised author entity — tracks identity across name changes via ORCID
    and via the ``AuthorAlias`` registry (FRREP-MCL004 name-variant linking).

    When two author records turn out to be the same person, ``merge_authors``
    soft-deletes the duplicates (``is_archived=True``, ``merged_into=primary``)
    and snapshots their public name onto the primary as ``AuthorAlias`` rows.
    Historical documents continue to render with the canonical name while a
    full audit trail of name variants survives in ``primary.aliases``.
    """

    first_name = models.CharField(max_length=120)
    last_name = models.CharField(max_length=120)
    orcid = models.CharField(
        max_length=19,
        unique=True,
        null=True,
        blank=True,
        db_index=True,
        help_text="ORCID iD in the form 0000-0000-0000-0000.",
    )
    email = models.EmailField(blank=True)
    institution = models.ForeignKey(
        Institution,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="repository_authors",
    )
    affiliation_text = models.CharField(max_length=255, blank=True)
    verified_at = models.DateTimeField(null=True, blank=True)
    is_archived = models.BooleanField(default=False, db_index=True)
    merged_into = models.ForeignKey(
        "self",
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="merge_aliases",
        help_text="When archived via a merge, points at the canonical primary author.",
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    objects = _ActiveAuthorManager()
    all_objects = models.Manager()

    class Meta:
        base_manager_name = "all_objects"
        ordering = ["last_name", "first_name"]
        indexes = [
            models.Index(fields=["last_name", "first_name"]),
        ]

    def __str__(self) -> str:
        return f"{self.last_name}, {self.first_name}".strip(", ")

    @property
    def full_name(self) -> str:
        return f"{self.first_name} {self.last_name}".strip()


class AuthorAlias(models.Model):
    """Historical name variant attached to a canonical :class:`Author`
    (FRREP-MCL004).

    Created automatically by ``services.merge_authors`` (one row per merged-in
    duplicate, source=MERGE) and ``services.rename_author`` (one row capturing
    the previous name, source=RENAME). Drives the "Also known as" surface on
    author detail pages so historical documents stay discoverable under their
    original byline.
    """

    class Source(models.TextChoices):
        MERGE = "merge", "Merged from duplicate"
        RENAME = "rename", "Renamed in place"
        IMPORT = "import", "Imported from upstream"

    author = models.ForeignKey(
        Author,
        on_delete=models.CASCADE,
        related_name="aliases",
    )
    full_name = models.CharField(max_length=255)
    source = models.CharField(max_length=12, choices=Source.choices)
    note = models.CharField(max_length=255, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        constraints = [
            models.UniqueConstraint(fields=["author", "full_name"], name="uniq_author_alias"),
        ]
        indexes = [models.Index(fields=["full_name"])]
        ordering = ["-created_at"]

    def __str__(self) -> str:
        return f"{self.full_name} → {self.author}"


# ── Metadata schema (per-collection configurable validation) ───────────────


class MetadataSchema(models.Model):
    name = models.CharField(max_length=120, unique=True)
    description = models.TextField(blank=True)
    mandatory_fields = models.JSONField(
        default=list,
        blank=True,
        help_text='List of mandatory field codes, e.g. ["title","abstract","license_type"].',
    )
    controlled_vocabularies = models.JSONField(
        default=dict,
        blank=True,
        help_text='Mapping of field code → list of allowed values.',
    )
    auto_classification_rules = models.JSONField(
        default=list,
        blank=True,
        help_text='List of rules: {"if_keyword": str, "then_collection_slug": str}.',
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ["name"]

    def __str__(self) -> str:
        return self.name


# ── Collection ──────────────────────────────────────────────────────────────


class Collection(models.Model):
    """Standardised taxonomy node managed exclusively by Repository administrators."""

    class Visibility(models.TextChoices):
        PUBLIC_OPEN = "public_open", "Public — Open Access"
        AUTHENTICATED = "authenticated", "Authenticated only"
        RESTRICTED = "restricted", "Restricted (named users / groups)"
        INTERNAL = "internal", "Internal (not publicly searchable)"

    class RetentionAction(models.TextChoices):
        ARCHIVE = "archive", "Archive"
        FLAG = "flag", "Flag for review"
        WITHDRAW = "withdraw", "Withdraw"

    name = models.CharField(max_length=160)
    slug = models.SlugField(max_length=120, unique=True, db_index=True)
    description = models.TextField(blank=True)
    parent = models.ForeignKey(
        "self",
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="children",
    )
    visibility = models.CharField(
        max_length=24,
        choices=Visibility.choices,
        default=Visibility.PUBLIC_OPEN,
        db_index=True,
    )
    qa_workflow_enabled = models.BooleanField(default=True)
    qa_turnaround_days = models.PositiveSmallIntegerField(default=14)
    retention_period_days = models.PositiveIntegerField(null=True, blank=True)
    retention_action = models.CharField(
        max_length=16,
        choices=RetentionAction.choices,
        default=RetentionAction.FLAG,
    )
    # FRREP-VL005 — advance-notice lead time before a retention action fires.
    # `enforce_retention_policies` notifies the collection's managers this many
    # days *before* the retention cutoff, in addition to the day-of action.
    retention_notice_days = models.PositiveIntegerField(
        default=14,
        help_text="Days of advance notice given to collection managers before a "
        "retention action is applied. Set to 0 to disable advance notice.",
    )
    metadata_schema = models.ForeignKey(
        MetadataSchema,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="collections",
    )
    managed_by = models.ManyToManyField(
        settings.AUTH_USER_MODEL,
        blank=True,
        related_name="managed_repository_collections",
        help_text="Collection Managers responsible for QA on this collection.",
    )
    upload_notification_recipients = models.ManyToManyField(
        settings.AUTH_USER_MODEL,
        blank=True,
        related_name="repository_upload_subscriptions",
        help_text="Users notified when a new document is ingested into this collection (PRD REPO-SP1 FRREP-DCI014).",
    )
    # FRREP-DCI014 — role-based option alongside the named-user recipient list
    # above. Every active user holding one of these roles is notified in
    # addition to `upload_notification_recipients`.
    notify_roles = ArrayField(
        models.CharField(max_length=32, choices=UserRole.choices),
        default=list,
        blank=True,
        help_text="Roles notified (in addition to specific recipients above) when a new document is ingested.",
    )

    # FRREP-DCI012 — inbound mail ingestion. When enabled and the IMAP poller
    # finds an UNSEEN message addressed to ``email_ingest_address`` whose
    # sender matches ``email_ingest_allowed_senders``, every supported
    # attachment is run through the standard ingestion pipeline.
    email_ingest_enabled = models.BooleanField(
        default=False,
        help_text="Allow new documents to be submitted by emailing them to this collection's inbox address.",
    )
    email_ingest_address = models.EmailField(
        blank=True,
        unique=False,
        help_text="Mailbox address that maps to this collection — e.g. repo+theses@iilmp.org.",
    )
    email_ingest_allowed_senders = models.TextField(
        blank=True,
        help_text="Newline-separated email addresses or domains (@example.org) allowed to submit. Leave empty to deny all.",
    )

    # FRREP-MCL012 — deprecation. A deprecated Collection remains queryable so
    # historical traffic and existing documents are unaffected, but new
    # submissions trigger a warning surfaced via Django messages and the
    # SubmitForm. ``superseded_by`` is the replacement Collection users should
    # pick instead (per SRS use case A4).
    is_deprecated = models.BooleanField(default=False, db_index=True)
    deprecation_message = models.CharField(
        max_length=255,
        blank=True,
        help_text="Shown to users when they pick this Collection — e.g. why it was retired.",
    )
    deprecated_at = models.DateTimeField(null=True, blank=True)
    superseded_by = models.ForeignKey(
        "self",
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="supersedes",
        help_text="The Collection users should select instead — surfaced in the deprecation warning.",
    )

    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ["name"]
        constraints = [
            models.UniqueConstraint(
                fields=["email_ingest_address"],
                condition=models.Q(email_ingest_enabled=True),
                name="uniq_collection_email_ingest_addr_when_enabled",
            ),
        ]

    def __str__(self) -> str:
        return self.name

    @property
    def is_open_access(self) -> bool:
        return self.visibility == self.Visibility.PUBLIC_OPEN

    def email_ingest_allowed_sender_list(self) -> list[str]:
        """Return the allow-list as lowercased entries (addresses + @domains)."""
        return [
            line.strip().lower()
            for line in (self.email_ingest_allowed_senders or "").splitlines()
            if line.strip()
        ]

    def email_ingest_sender_allowed(self, sender_email: str) -> bool:
        """Match the sender against the allowlist entries."""
        if not sender_email:
            return False
        sender = sender_email.strip().lower()
        domain_part = "@" + sender.rsplit("@", 1)[-1] if "@" in sender else ""
        for entry in self.email_ingest_allowed_sender_list():
            if entry.startswith("@") and entry == domain_part:
                return True
            if entry == sender:
                return True
        return False


# ── Group-based collection access (REPO-SP4 FRREP-AC005) ────────────────────


class CollectionGroupAccess(models.Model):
    """ACL pivot granting an `auth.Group` access to a (typically Restricted) Collection.

    Complements per-document `django-guardian` grants used by Document Sharing —
    this row covers the wholesale "give the Health-Researchers group access to
    everything in the Maternal-Health collection" pattern that was previously
    only doable via Django admin or implicit role gates.

    Honoured by `Document.objects.for_user()` so any document whose
    `effective_visibility` resolves to RESTRICTED becomes visible to members of
    a granted group while the grant is active.
    """

    class PermissionLevel(models.TextChoices):
        VIEW = "view", "View"
        COMMENT = "comment", "View + comment"
        EDIT = "edit", "View + comment + edit metadata"

    collection = models.ForeignKey(
        Collection,
        on_delete=models.CASCADE,
        related_name="group_access",
    )
    group = models.ForeignKey(
        "auth.Group",
        on_delete=models.CASCADE,
        related_name="repository_collection_grants",
    )
    permission_level = models.CharField(
        max_length=12,
        choices=PermissionLevel.choices,
        default=PermissionLevel.VIEW,
    )
    granted_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="repository_collection_grants_issued",
    )
    granted_at = models.DateTimeField(auto_now_add=True)
    expires_at = models.DateTimeField(
        null=True,
        blank=True,
        help_text="Optional expiry. After this datetime the grant is treated as inactive.",
    )
    note = models.CharField(
        max_length=255,
        blank=True,
        help_text="Internal note explaining why this group was granted access.",
    )

    class Meta:
        unique_together = (("collection", "group"),)
        ordering = ["collection__name", "group__name"]
        verbose_name = "Collection group access grant"
        verbose_name_plural = "Collection group access grants"

    def __str__(self) -> str:
        return f"{self.group.name} → {self.collection.name} ({self.get_permission_level_display()})"

    @property
    def is_active(self) -> bool:
        return self.expires_at is None or self.expires_at > timezone.now()


class CollectionSlugAlias(models.Model):
    """Historical slug mapping so old collection URLs survive a rename or merge.

    Created automatically by `rename_collection_slug` and `merge_collections`.
    The collection-detail view resolves an unknown slug against this table and
    issues a 301 redirect to the canonical URL.
    """

    collection = models.ForeignKey(
        Collection,
        on_delete=models.CASCADE,
        related_name="slug_aliases",
    )
    old_slug = models.SlugField(max_length=120, unique=True, db_index=True)
    note = models.CharField(
        max_length=255,
        blank=True,
        help_text="Reason the alias was created — e.g. 'merged from theses-old' or 'renamed 2026-05-04'.",
    )
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["-created_at"]
        verbose_name = "Collection slug alias"
        verbose_name_plural = "Collection slug aliases"

    def __str__(self) -> str:
        return f"{self.old_slug} → {self.collection.slug}"


# ── Keyword vocabulary (REPO-SP2 FRREP-MCL007) ──────────────────────────────


class Region(models.Model):
    """Controlled geographic region for browse/search facets (legacy "Region Focus"
    vocabulary). A document may target several regions (multi-valued), e.g.
    "East Africa" + "Southern Africa" for a regional study.
    """

    name = models.CharField(max_length=80, unique=True)
    slug = models.SlugField(max_length=96, unique=True, db_index=True)
    display_order = models.PositiveSmallIntegerField(
        default=0,
        help_text="Manual sort weight for the browse-by-region list.",
    )
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["display_order", "name"]

    def __str__(self) -> str:
        return self.name


class Keyword(models.Model):
    """Controlled vocabulary tag with an approval workflow.

    Submitters may suggest free-text tags; staff approve or reject before they
    appear in browse facets / search filters.

    ``kind`` distinguishes the legacy AGRIS source vocabularies so the "Subjects"
    browse surface can split broad AGRIS Subject Categories (vid 10) from specific
    AGROVOC terms (vid 16) and free-text additional keywords (vid 20).
    """

    class Status(models.TextChoices):
        PENDING = "pending", "Pending review"
        APPROVED = "approved", "Approved"
        REJECTED = "rejected", "Rejected"

    class Kind(models.TextChoices):
        GENERAL = "general", "General"
        AGRIS_CATEGORY = "agris_category", "AGRIS subject category"
        AGROVOC = "agrovoc", "AGROVOC term"
        ADDITIONAL = "additional", "Additional keyword"

    label = models.CharField(max_length=120, unique=True)
    slug = models.SlugField(max_length=140, unique=True, db_index=True)
    description = models.CharField(max_length=255, blank=True)
    kind = models.CharField(
        max_length=20,
        choices=Kind.choices,
        default=Kind.GENERAL,
        db_index=True,
        help_text="Source vocabulary — drives the Subjects browse grouping.",
    )
    agrovoc_uri = models.URLField(
        blank=True,
        help_text="Canonical AGROVOC concept URI, when this term comes from AGROVOC.",
    )
    status = models.CharField(
        max_length=16,
        choices=Status.choices,
        default=Status.APPROVED,
        db_index=True,
    )
    suggested_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="repository_keywords_suggested",
    )
    reviewed_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="repository_keywords_reviewed",
    )
    reviewed_at = models.DateTimeField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ["label"]

    def __str__(self) -> str:
        return self.label

    @property
    def is_approved(self) -> bool:
        return self.status == self.Status.APPROVED


# ── Document ────────────────────────────────────────────────────────────────


class Document(models.Model):
    """Core document record — PRD REPO-SP1…SP6."""

    class Status(models.TextChoices):
        DRAFT = "draft", "Draft"
        SUBMITTED = "submitted", "Submitted"
        UNDER_QA = "under_qa", "Under QA review"
        REVISION_REQUIRED = "revision_required", "Revision required"
        APPROVED = "approved", "Approved"
        PUBLISHED = "published", "Published"
        REJECTED = "rejected", "Rejected"
        WITHDRAWN = "withdrawn", "Withdrawn"

    class DocumentType(models.TextChoices):
        THESIS = "thesis", "Thesis / Dissertation"
        JOURNAL_ARTICLE = "journal_article", "Journal article"
        CONFERENCE_PAPER = "conference_paper", "Conference paper"
        POLICY_BRIEF = "policy_brief", "Policy brief"
        TECHNICAL_REPORT = "technical_report", "Technical report"
        PROJECT_REPORT = "project_report", "Project report"
        DATASET = "dataset", "Dataset"
        INNOVATION_DOCUMENT = "innovation_document", "Innovation technical document"
        PUBLICATION = "publication", "Publication"
        OTHER = "other", "Other"

    class License(models.TextChoices):
        CC_BY = "cc_by", "CC BY 4.0"
        CC_BY_SA = "cc_by_sa", "CC BY-SA 4.0"
        CC_BY_NC = "cc_by_nc", "CC BY-NC 4.0"
        CC_BY_NC_SA = "cc_by_nc_sa", "CC BY-NC-SA 4.0"
        CC0 = "cc0", "CC0 1.0 Universal"
        ALL_RIGHTS_RESERVED = "all_rights_reserved", "All rights reserved"
        OTHER = "other", "Other (see notes)"

    class Visibility(models.TextChoices):
        INHERIT = "inherit", "Inherit from collection"
        PUBLIC_OPEN = "public_open", "Public — Open Access"
        AUTHENTICATED = "authenticated", "Authenticated only"
        RESTRICTED = "restricted", "Restricted"
        INTERNAL = "internal", "Internal"

    document_uid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, db_index=True)
    public_document_id = models.CharField(
        max_length=32,
        unique=True,
        blank=True,
        db_index=True,
        help_text="Human-friendly Document ID assigned on first save.",
    )
    title = models.CharField(max_length=400)
    abstract = models.TextField(blank=True)
    file = models.FileField(
        upload_to=upload_to_repository,
        validators=[_repo_file_validator],
        max_length=512,
    )
    file_hash = models.CharField(
        max_length=64,
        blank=True,
        db_index=True,
        help_text="SHA-256 of the file payload — used for duplicate detection.",
    )
    file_size_bytes = models.PositiveBigIntegerField(default=0)
    document_type = models.CharField(
        max_length=32,
        choices=DocumentType.choices,
        default=DocumentType.OTHER,
        db_index=True,
    )
    doi = models.CharField(max_length=120, blank=True, db_index=True)
    license_type = models.CharField(
        max_length=32,
        choices=License.choices,
        default=License.CC_BY,
    )
    license_notes = models.CharField(max_length=255, blank=True)
    language = models.CharField(max_length=8, default="en")
    year = models.PositiveSmallIntegerField(null=True, blank=True)
    # ── Geographic facets (legacy Country / Region Focus vocabularies) ──────
    country = models.CharField(
        max_length=100,
        blank=True,
        db_index=True,
        help_text="Country the resource relates to — canonical full name (see apps.core.countries).",
    )
    regions = models.ManyToManyField(
        Region,
        blank=True,
        related_name="documents",
        help_text="Geographic region(s) of focus — multi-valued (e.g. East Africa + Southern Africa).",
    )
    collection = models.ForeignKey(
        Collection,
        on_delete=models.PROTECT,
        related_name="documents",
    )
    visibility = models.CharField(
        max_length=24,
        choices=Visibility.choices,
        default=Visibility.INHERIT,
        help_text="Document-level override; INHERIT = use collection.visibility.",
    )
    authors = models.ManyToManyField(
        Author,
        through="DocumentAuthor",
        related_name="documents",
    )
    keywords = models.ManyToManyField(
        Keyword,
        blank=True,
        related_name="documents",
        help_text="Controlled vocabulary tags applied to this document (PRD REPO-SP2 FRREP-MCL007).",
    )
    grant_reference = models.CharField(
        max_length=64,
        blank=True,
        db_index=True,
        help_text="RIMS public_grant_id of the funding grant, when applicable.",
    )
    grant_closed_flag = models.BooleanField(
        default=False,
        help_text="True when the linked RIMS grant has been closed (PRD REPO-SP5).",
    )
    # ── Bibliographic block (AGRIS Application Profile) ─────────────────────
    publisher = models.CharField(max_length=255, blank=True, db_index=True)
    publication_place = models.CharField(max_length=160, blank=True)
    journal_title = models.CharField(max_length=255, blank=True, db_index=True)
    volume = models.CharField(max_length=32, blank=True)
    issue = models.CharField(max_length=32, blank=True)
    pages = models.CharField(max_length=32, blank=True)
    issn = models.CharField(max_length=18, blank=True, db_index=True)
    eissn = models.CharField(max_length=18, blank=True)
    isbn = models.CharField(max_length=20, blank=True, db_index=True)
    edition = models.CharField(max_length=64, blank=True)
    conference_name = models.CharField(max_length=255, blank=True, db_index=True)
    conference_date = models.CharField(max_length=64, blank=True)
    conference_place = models.CharField(max_length=160, blank=True)
    degree = models.CharField(max_length=120, blank=True)
    degree_institution = models.CharField(max_length=255, blank=True)
    external_resource_url = models.URLField(max_length=500, blank=True)
    agris_identifier = models.CharField(max_length=64, blank=True, db_index=True)
    oai_identifier = models.CharField(max_length=255, blank=True)
    legacy_drupal_nid = models.PositiveIntegerField(
        null=True,
        blank=True,
        unique=True,
        db_index=True,
        help_text="Source Drupal node id — idempotency anchor for the legacy migration.",
    )
    submitted_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="repository_documents_submitted",
    )
    submitted_at = models.DateTimeField(null=True, blank=True)
    published_at = models.DateTimeField(null=True, blank=True)
    withdrawn_at = models.DateTimeField(null=True, blank=True)
    withdrawn_reason = models.TextField(blank=True)
    # FRREP-VL006 — retention "archive" action. Deliberately a flag rather than
    # an FSM status: archiving hides a document from active listings without
    # disturbing its lifecycle state (mirrors the Author.is_archived pattern).
    is_archived = models.BooleanField(default=False, db_index=True)
    archived_at = models.DateTimeField(null=True, blank=True)
    status = FSMField(
        max_length=32,
        choices=Status.choices,
        default=Status.DRAFT,
        protected=True,
        db_index=True,
    )
    source_system = models.CharField(
        max_length=24,
        default="manual",
        help_text="manual | rims | bulk | email | api",
    )
    extracted_text = models.TextField(blank=True)
    ai_metadata = models.JSONField(default=dict, blank=True)
    ai_summary = models.TextField(blank=True)
    ai_summary_generated_at = models.DateTimeField(null=True, blank=True)
    preview_supported = models.BooleanField(default=False)
    thumbnail = models.ImageField(
        upload_to=upload_to_repository_thumbnails,
        null=True,
        blank=True,
        max_length=512,
    )
    search_vector = SearchVectorField(null=True, editable=False)
    # FRREP-COL008 — a synchronous "who touched this last" pointer, kept
    # alongside `updated_at`/`document_edit_token`. The audit trail is written
    # asynchronously (via Celery, after commit) so it can't be relied on to
    # identify the *other* editor the instant a concurrent-edit conflict is
    # detected; this field always reflects the winner of the last successful
    # `update_document_metadata` call.
    last_edited_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="repository_documents_last_edited",
    )

    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    objects = DocumentManager()

    class Meta:
        ordering = ["-published_at", "-created_at"]
        indexes = [
            GinIndex(fields=["search_vector"], name="repo_doc_search_gin"),
            models.Index(fields=["status", "collection"]),
            models.Index(fields=["document_type", "year"]),
            models.Index(fields=["grant_reference"]),
        ]
        permissions = [
            ("download_document", "Can download a Repository document"),
            ("comment_document", "Can comment on a Repository document"),
            ("share_document", "Can share a Repository document with others"),
            ("manage_document", "Can manage (withdraw / reinstate / edit) a Repository document"),
            ("ingest_via_rims", "Can ingest documents via the RIMS API endpoint"),
        ]

    def __str__(self) -> str:
        return self.public_document_id or self.title[:80]

    @property
    def effective_visibility(self) -> str:
        if self.visibility != self.Visibility.INHERIT:
            return self.visibility
        return self.collection.visibility if self.collection_id else self.Visibility.AUTHENTICATED

    @property
    def is_open_access(self) -> bool:
        return self.effective_visibility == self.Visibility.PUBLIC_OPEN

    @transition(field=status, source=Status.DRAFT, target=Status.SUBMITTED)
    def submit(self):
        self.submitted_at = timezone.now()

    @transition(field=status, source=Status.SUBMITTED, target=Status.UNDER_QA)
    def start_qa_review(self):
        pass

    @transition(field=status, source=Status.UNDER_QA, target=Status.REVISION_REQUIRED)
    def request_revision(self):
        pass

    @transition(field=status, source=Status.REVISION_REQUIRED, target=Status.SUBMITTED)
    def resubmit(self):
        pass

    @transition(field=status, source=Status.UNDER_QA, target=Status.REJECTED)
    def reject(self):
        pass

    @transition(field=status, source=Status.UNDER_QA, target=Status.APPROVED)
    def approve(self):
        pass

    @transition(field=status, source=Status.APPROVED, target=Status.PUBLISHED)
    def publish(self):
        self.published_at = timezone.now()

    @transition(field=status, source="*", target=Status.WITHDRAWN)
    def withdraw(self):
        self.withdrawn_at = timezone.now()

    @transition(field=status, source=Status.WITHDRAWN, target=Status.APPROVED)
    def reinstate(self):
        self.withdrawn_at = None


# ── DocumentVersion ─────────────────────────────────────────────────────────


class DocumentVersion(models.Model):
    document = models.ForeignKey(
        Document,
        on_delete=models.CASCADE,
        related_name="versions",
    )
    version_number = models.CharField(
        max_length=16,
        help_text='Sequential, e.g. "1.0", "1.1", "2.0".',
    )
    file = models.FileField(
        upload_to=upload_to_repository,
        validators=[_repo_file_validator],
        max_length=512,
    )
    file_hash = models.CharField(max_length=64, blank=True, db_index=True)
    uploaded_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="repository_document_versions_uploaded",
    )
    uploaded_at = models.DateTimeField(auto_now_add=True)
    changelog = models.TextField(blank=True)
    is_immutable = models.BooleanField(default=True)

    class Meta:
        ordering = ["-uploaded_at"]
        constraints = [
            models.UniqueConstraint(
                fields=["document", "version_number"],
                name="uniq_document_version_number",
            ),
        ]

    def __str__(self) -> str:
        return f"{self.document_id} v{self.version_number}"


# ── DocumentAuthor (M2M with ordering + role) ───────────────────────────────


class DocumentAuthor(models.Model):
    class AuthorRole(models.TextChoices):
        PRIMARY = "primary", "Primary author"
        CO_AUTHOR = "co_author", "Co-author"
        CONTRIBUTOR = "contributor", "Contributor"

    document = models.ForeignKey(Document, on_delete=models.CASCADE)
    author = models.ForeignKey(Author, on_delete=models.CASCADE)
    order = models.PositiveSmallIntegerField(default=0)
    role = models.CharField(
        max_length=16,
        choices=AuthorRole.choices,
        default=AuthorRole.CO_AUTHOR,
    )

    class Meta:
        ordering = ["order", "pk"]
        constraints = [
            models.UniqueConstraint(
                fields=["document", "author"],
                name="uniq_document_author",
            ),
        ]


# ── QA workflow record ──────────────────────────────────────────────────────


class QARecord(models.Model):
    class Decision(models.TextChoices):
        PENDING = "pending", "Pending review"
        APPROVE = "approve", "Approved"
        REJECT = "reject", "Rejected"
        REVISION = "revision", "Revision required"

    # FRREP-QA003 — the 6 structured criteria a reviewer must assess before
    # deciding. (code, label) pairs drive the review-form checkboxes, the
    # server-side "fully checked" gate on approval, and the read-only
    # rendering in the Review history timeline.
    CHECKLIST_CRITERIA = (
        ("relevance", "Content is relevant to this collection"),
        ("oa_compliance", "Open Access / OER licence compliant"),
        ("metadata_complete", "Metadata is complete and accurate"),
        ("abstract_quality", "Abstract is clear and adequately describes the work"),
        ("no_copyright_issue", "No apparent copyright violation"),
        ("no_duplication", "Not a duplicate of an existing entry"),
    )

    document = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="qa_records")
    reviewer = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="repository_qa_records",
    )
    pre_check_passed = models.BooleanField(default=False)
    pre_check_notes = models.JSONField(default=dict, blank=True)
    checklist = models.JSONField(
        default=dict,
        blank=True,
        help_text=(
            "Per-criterion QA checklist booleans (FRREP-QA003), e.g. "
            '{"relevance": true, "oa_compliance": true, "metadata_complete": false, '
            '"abstract_quality": true, "no_copyright_issue": true, "no_duplication": true}.'
        ),
    )
    decision = models.CharField(
        max_length=16,
        choices=Decision.choices,
        default=Decision.PENDING,
    )
    comments = models.TextField(blank=True)
    assigned_at = models.DateTimeField(auto_now_add=True)
    decided_at = models.DateTimeField(null=True, blank=True)

    # ── SP6 two-tier review (2026-07 field interviews) ──────────────────────
    # A Reviewer scores the rubric and records a *recommendation*; the final
    # ``decision`` above stays with the management tier (Editor in Chief et
    # al.). Both stages live on the same record so the review page shows the
    # full picture.
    recommendation = models.CharField(
        max_length=16,
        choices=Decision.choices,
        default=Decision.PENDING,
    )
    recommended_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="repository_qa_recommendations",
    )
    recommended_at = models.DateTimeField(null=True, blank=True)
    recommendation_comments = models.TextField(blank=True)

    class ComplianceStatus(models.TextChoices):
        PENDING = "pending", "Not checked"
        PASSED = "passed", "Compliance passed"
        FLAGGED = "flagged", "Compliance flagged"

    # Publication Assistant's licensing / open-access compliance check —
    # advisory to the final decision, surfaced on the queue and review pages.
    compliance_status = models.CharField(
        max_length=16,
        choices=ComplianceStatus.choices,
        default=ComplianceStatus.PENDING,
    )
    compliance_checked_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="repository_qa_compliance_checks",
    )
    compliance_checked_at = models.DateTimeField(null=True, blank=True)
    compliance_notes = models.TextField(blank=True)

    class Meta:
        ordering = ["-assigned_at"]

    def __str__(self) -> str:
        return f"QA {self.document_id} → {self.decision}"

    @property
    def checklist_fully_passed(self) -> bool:
        """True only when every FRREP-QA003 criterion is explicitly checked."""
        return all(bool(self.checklist.get(code)) for code, _ in self.CHECKLIST_CRITERIA)

    def checklist_items(self):
        """(code, label, checked) triples — used to render the read-only
        checklist state on prior decisions in the Review history timeline."""
        return [(code, label, bool(self.checklist.get(code))) for code, label in self.CHECKLIST_CRITERIA]


# ── Access requests (REPO-SP4) ──────────────────────────────────────────────


class AccessRequest(models.Model):
    class Status(models.TextChoices):
        PENDING = "pending", "Pending"
        APPROVED = "approved", "Approved"
        DENIED = "denied", "Denied"

    document = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="access_requests")
    requester = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="repository_access_requests",
    )
    justification = models.TextField()
    status = models.CharField(
        max_length=16,
        choices=Status.choices,
        default=Status.PENDING,
        db_index=True,
    )
    decided_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="repository_access_requests_decided",
    )
    decided_at = models.DateTimeField(null=True, blank=True)
    decision_note = models.TextField(blank=True)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["-created_at"]


# ── Document collaboration (REPO-SP7) ───────────────────────────────────────


class AccessViolation(models.Model):
    """Denied access/download attempt (REPO-SP4 — 2026-07 field interviews).

    The Repository Administrator "reviews access violations and unauthorized
    access attempts"; until now denials only produced a 403 with no reviewable
    trail. One row per denied request, written from the shared denial path in
    ``documents/views.py``.
    """

    class Action(models.TextChoices):
        VIEW = "view", "View metadata"
        READ = "read", "Inline read"
        DOWNLOAD = "download", "Download"

    document = models.ForeignKey(
        Document, on_delete=models.CASCADE, related_name="access_violations"
    )
    user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="repository_access_violations",
    )
    action = models.CharField(max_length=16, choices=Action.choices, db_index=True)
    ip_address = models.GenericIPAddressField(null=True, blank=True)
    user_agent = models.CharField(max_length=512, blank=True)
    path = models.CharField(max_length=500, blank=True)
    timestamp = models.DateTimeField(auto_now_add=True, db_index=True)

    class Meta:
        ordering = ["-timestamp"]

    def __str__(self) -> str:
        who = self.user_id or "anonymous"
        return f"Denied {self.action} of {self.document_id} by {who}"


class DocumentShare(models.Model):
    class PermissionLevel(models.TextChoices):
        VIEW = "view", "View only"
        COMMENT = "comment", "Comment"
        EDIT = "edit", "Edit"

    document = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="shares")
    collaborator = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="repository_shared_with_me",
    )
    permission_level = models.CharField(
        max_length=16,
        choices=PermissionLevel.choices,
        default=PermissionLevel.VIEW,
    )
    granted_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="repository_shares_granted",
    )
    granted_at = models.DateTimeField(auto_now_add=True)
    revoked_at = models.DateTimeField(null=True, blank=True)

    class Meta:
        ordering = ["-granted_at"]
        constraints = [
            models.UniqueConstraint(
                fields=["document", "collaborator"],
                name="uniq_document_share",
            ),
        ]


class DocumentComment(models.Model):
    document = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="comments")
    author = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="repository_comments",
    )
    parent = models.ForeignKey(
        "self",
        null=True,
        blank=True,
        on_delete=models.CASCADE,
        related_name="replies",
    )
    section_anchor = models.CharField(
        max_length=120,
        blank=True,
        help_text="Page / section reference, e.g. 'p.4 ¶2'.",
    )
    text = models.TextField()
    is_resolved = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ["created_at"]


class DocumentReviewTask(models.Model):
    class Status(models.TextChoices):
        PENDING = "pending", "Pending"
        IN_PROGRESS = "in_progress", "In progress"
        COMPLETED = "completed", "Completed"

    document = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="review_tasks")
    assignee = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="repository_review_tasks",
    )
    scope = models.TextField()
    instructions = models.TextField(blank=True)
    due_date = models.DateField(null=True, blank=True)
    status = models.CharField(
        max_length=16,
        choices=Status.choices,
        default=Status.PENDING,
        db_index=True,
    )
    created_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="repository_review_tasks_created",
    )
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["-created_at"]


# ── Document bookmarks (FRREP-VL007 — "saved documents") ────────────────────


class DocumentBookmark(models.Model):
    """A user's personal bookmark on a single document.

    Distinct from ``SavedSearch``, which tracks a saved *query*. Bookmarks are
    used both to power a "my saved documents" list and — per the FRREP-VL007
    audit finding — so that withdrawal notifications reach anyone who saved
    the specific document, not just its original submitter.
    """

    user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="repository_bookmarks",
    )
    document = models.ForeignKey(Document, on_delete=models.CASCADE, related_name="bookmarks")
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["-created_at"]
        constraints = [
            models.UniqueConstraint(
                fields=["user", "document"], name="uniq_document_bookmark_user_document"
            ),
        ]


# ── Saved searches with email alerts (REPO-SP3) ─────────────────────────────


class SavedSearch(models.Model):
    user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="repository_saved_searches",
    )
    name = models.CharField(max_length=120)
    query = models.JSONField(
        default=dict,
        help_text='{"q": "<query>", "filters": {...}, "sort": "..."}',
    )
    alert_enabled = models.BooleanField(default=False)
    last_alert_at = models.DateTimeField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["-created_at"]


# ── Outbox for outbound integration events (REPO-SP8) ───────────────────────


class IntegrationOutbox(models.Model):
    class Target(models.TextChoices):
        MEL = "mel", "M&EL"
        RIMS = "rims", "RIMS"
        REP = "rep", "REP"
        SMEHUB = "smehub", "SME-Hub"

    class Status(models.TextChoices):
        PENDING = "pending", "Pending"
        SENT = "sent", "Sent"
        FAILED = "failed", "Failed"

    target = models.CharField(max_length=12, choices=Target.choices, db_index=True)
    event_type = models.CharField(max_length=64)
    payload = models.JSONField(default=dict)
    status = models.CharField(
        max_length=12,
        choices=Status.choices,
        default=Status.PENDING,
        db_index=True,
    )
    retry_count = models.PositiveSmallIntegerField(default=0)
    error = models.TextField(blank=True)
    sent_at = models.DateTimeField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["-created_at"]
        indexes = [
            models.Index(fields=["target", "status"]),
        ]


class ResumableUpload(models.Model):
    """Server-side state for chunked / resumable file uploads (FRREP-DCI004).

    A client opens a session via ``start_resumable_upload`` (POST), sends bytes
    one chunk at a time via ``append_resumable_chunk`` (PATCH chunk_<index>),
    and closes it with ``finalize_resumable_upload`` (POST) which assembles
    the chunks and runs them through the standard ``UploadHandler`` validators
    — so magic-bytes, SCORM manifest, and SVG-sanitiser guarantees still apply.

    Chunk files live under ``MEDIA_ROOT/resumable/<token>/chunk_<index>``.
    On finalize, the assembled file is moved into the same staging directory
    used by ``StagedUploadView`` so the existing submission wizard consumes
    the resulting token transparently.
    """

    class Status(models.TextChoices):
        IN_PROGRESS = "in_progress", "In progress"
        COMPLETED = "completed", "Completed"
        ABANDONED = "abandoned", "Abandoned"

    token = models.CharField(max_length=64, unique=True, db_index=True)
    user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="repo_resumable_uploads",
    )
    filename = models.CharField(max_length=512)
    total_bytes = models.PositiveBigIntegerField()
    chunk_size = models.PositiveIntegerField()
    expected_chunks = models.PositiveIntegerField()
    chunks_received = models.PositiveIntegerField(default=0)
    received_chunk_indexes = models.JSONField(default=list)
    sha256_hex = models.CharField(max_length=64, blank=True)
    status = models.CharField(
        max_length=16,
        choices=Status.choices,
        default=Status.IN_PROGRESS,
        db_index=True,
    )
    expires_at = models.DateTimeField()
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ["-created_at"]
        indexes = [models.Index(fields=["status", "expires_at"])]

    def __str__(self):
        return f"resumable {self.token[:8]} {self.filename} ({self.status})"


class EmailIngestMailbox(models.Model):
    """Per-mailbox IMAP configuration for inbound document submissions
    (FRREP-DCI012). The poller iterates every enabled mailbox; each can map to
    a different default Collection and use distinct credentials so multiple
    institutions can share one deployment.

    The legacy single-mailbox env-var path is auto-imported as a "Default
    (env)" row on first poll when no mailbox exists yet — see
    ``apps.repository.integration.mail_ingest._ensure_env_mailbox``.
    """

    name = models.CharField(max_length=120, unique=True)
    imap_host = models.CharField(max_length=255)
    imap_port = models.PositiveIntegerField(default=993)
    use_ssl = models.BooleanField(default=True)
    username = models.CharField(max_length=255)
    password_signed = models.BinaryField(
        null=True,
        blank=True,
        help_text="Set/read via the `password` property — never assign directly.",
    )
    inbox_folder = models.CharField(max_length=120, default="INBOX")
    processed_folder = models.CharField(max_length=120, default="Processed")
    failed_folder = models.CharField(max_length=120, default="Failed")
    default_collection = models.ForeignKey(
        Collection,
        on_delete=models.PROTECT,
        related_name="email_ingest_mailboxes",
        help_text="Mailbox-wide default when no Collection.email_ingest_address matches the recipient.",
    )
    default_license = models.CharField(
        max_length=32,
        default="cc_by",
        help_text="Default license assigned to documents ingested through this mailbox.",
    )
    submitter = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="email_ingest_mailboxes",
        help_text="User credited as submitter when the sender's email doesn't match a CustomUser.",
    )
    parsing_rules = models.JSONField(
        default=dict,
        blank=True,
        help_text='e.g. {"title_from": "subject", "abstract_from": "body"}',
    )
    is_enabled = models.BooleanField(default=True, db_index=True)
    last_polled_at = models.DateTimeField(null=True, blank=True)
    last_error = models.TextField(blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ["name"]

    def __str__(self) -> str:
        return self.name

    @property
    def password(self) -> str:
        """Return the plain-text password by un-signing the stored bytes.

        Returns "" when no password is set. Uses Django's signing framework
        (HMAC + SECRET_KEY) — adequate for dev/staging. In production, swap
        this for a Fernet-based field or an external secret manager.
        """
        from django.core.signing import BadSignature, loads

        if not self.password_signed:
            return ""
        try:
            return loads(bytes(self.password_signed).decode("utf-8"))
        except (BadSignature, UnicodeDecodeError):
            return ""

    @password.setter
    def password(self, raw: str) -> None:
        from django.core.signing import dumps

        if not raw:
            self.password_signed = None
        else:
            self.password_signed = dumps(raw).encode("utf-8")


class EmailIngestEvent(models.Model):
    """Per-message audit row for inbound mail ingestion (FRREP-DCI017 audit
    trail). Created idempotently keyed on (mailbox, message_uid) so re-polling
    the same message does not double-ingest."""

    class Status(models.TextChoices):
        INGESTED = "ingested", "Ingested"
        SKIPPED_DUPLICATE = "skipped_dup", "Skipped (duplicate)"
        REJECTED_SENDER = "rejected_sender", "Rejected (sender)"
        REJECTED_RECIPIENT = "rejected_recipient", "Rejected (no collection)"
        REJECTED_VALIDATION = "rejected_validation", "Rejected (validation)"
        ERROR = "error", "Error"

    mailbox = models.ForeignKey(
        EmailIngestMailbox,
        on_delete=models.CASCADE,
        related_name="events",
    )
    message_uid = models.CharField(max_length=120)
    subject = models.CharField(max_length=500, blank=True)
    sender = models.CharField(max_length=255, blank=True)
    processed_at = models.DateTimeField(auto_now_add=True)
    status = models.CharField(
        max_length=24,
        choices=Status.choices,
        db_index=True,
    )
    error_message = models.TextField(blank=True)
    document = models.ForeignKey(
        "Document",
        on_delete=models.SET_NULL,
        null=True,
        blank=True,
        related_name="email_ingest_events",
    )

    class Meta:
        constraints = [
            models.UniqueConstraint(fields=["mailbox", "message_uid"], name="uniq_email_ingest_msg"),
        ]
        indexes = [models.Index(fields=["mailbox", "status"])]
        ordering = ["-processed_at"]

    def __str__(self) -> str:
        return f"{self.mailbox_id}/{self.message_uid} → {self.status}"
