"""Confirmed SRS gap fixes — REPO-SP1 (DCI) + REPO-SP2 (MCL), 2026-07 audit.

Covers:
  * FRREP-DCI007 — a ClamAV positive is audited + Repository Managers notified.
  * FRREP-DCI008 — structurally corrupt PDF/DOCX uploads are rejected.
  * FRREP-DCI014 — role-based upload-notification recipients.
  * FRREP-DCI017 — ingest audit log carries filename/method/validation outcome.
  * FRREP-MCL002 — Author.verified_at only set when the ORCID lookup succeeds.
  * FRREP-MCL006 — MetadataSchema admin form validates the JSON rule fields.
"""
from __future__ import annotations

from io import BytesIO

import pytest
from django.core.exceptions import ValidationError as DjValidationError
from django.core.files.base import ContentFile

from apps.core.audit import tasks as audit_tasks
from apps.core.audit.models import AuditLog
from apps.core.notifications.models import Notification
from apps.core.permissions.roles import UserRole
from apps.repository.documents import services
from apps.repository.documents.admin import MetadataSchemaAdminForm
from apps.repository.documents.models import Author, Document, MetadataSchema

PDF_BYTES = (
    b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n1 0 obj<</Type/Catalog>>endobj\n"
    b"trailer<<>>\n%%EOF\n"
)


def _valid_pdf(name="paper.pdf") -> ContentFile:
    return ContentFile(PDF_BYTES, name=name)


@pytest.fixture(autouse=True)
def sync_audit(monkeypatch):
    """Make `write_audit_log.delay(...)` write synchronously inside tests.

    Production routes audit writes through Celery (log_audit ->
    transaction.on_commit -> write_audit_log.delay), and this test environment
    doesn't run with CELERY_TASK_ALWAYS_EAGER, so without this the row never
    lands in the test DB in time for the assertion. Same pattern as
    test_collection_group_access.py / test_auto_classification.py.
    """

    def _eager_delay(**kwargs):
        return audit_tasks.persist_audit_log(**kwargs)

    monkeypatch.setattr(audit_tasks.write_audit_log, "delay", _eager_delay)


# ── FRREP-DCI007 — malware scan positive is audited + admins notified ──────


@pytest.mark.django_db
def test_malware_positive_is_audited_and_admins_notified(
    monkeypatch, settings, django_capture_on_commit_callbacks,
    manager_user, submitter_user, public_collection,
):
    # The clamd scan only runs at all when a host is configured — see
    # apps/core/storage/validators.py's CLAMD_TCP_HOST gate.
    settings.CLAMD_TCP_HOST = "clamd.test.local"

    def _fake_scan(stream):
        raise DjValidationError("Uploaded file failed virus scan.")

    monkeypatch.setattr(
        "apps.core.storage.validators.scan_stream_optional", _fake_scan
    )

    with django_capture_on_commit_callbacks(execute=True):
        with pytest.raises(DjValidationError):
            services.ingest_document(
                file=_valid_pdf("infected.pdf"),
                title="Infected upload",
                collection=public_collection,
                document_type=Document.DocumentType.THESIS,
                submitted_by=submitter_user,
                actor=submitter_user,
            )

    audit = AuditLog.objects.filter(
        target_model="Document", changes__malware_scan="positive"
    ).first()
    assert audit is not None
    assert audit.changes["filename"] == "infected.pdf"

    assert Notification.objects.filter(
        recipient=manager_user,
        verb=Notification.Verb.REPO_UPLOAD_MALWARE_DETECTED,
    ).exists()


@pytest.mark.django_db
def test_clean_upload_does_not_trigger_malware_audit_or_notification(
    manager_user, submitter_user, public_collection
):
    services.ingest_document(
        file=_valid_pdf("clean.pdf"),
        title="Clean upload",
        collection=public_collection,
        document_type=Document.DocumentType.THESIS,
        submitted_by=submitter_user,
        actor=submitter_user,
    )
    assert not AuditLog.objects.filter(changes__malware_scan="positive").exists()
    assert not Notification.objects.filter(
        verb=Notification.Verb.REPO_UPLOAD_MALWARE_DETECTED
    ).exists()


# ── FRREP-DCI008 — structural corruption detection ─────────────────────────


@pytest.mark.django_db
def test_direct_upload_rejects_truncated_pdf():
    corrupt = ContentFile(b"%PDF-1.4 not really a pdf", name="corrupt.pdf")
    with pytest.raises(DjValidationError, match="truncated or corrupted"):
        services._validate_repository_upload(corrupt)


@pytest.mark.django_db
def test_direct_upload_accepts_well_formed_pdf(sample_pdf):
    # No exception — a genuinely valid PDF passes the structural probe too.
    services._validate_repository_upload(sample_pdf)


@pytest.mark.django_db
def test_direct_upload_rejects_corrupt_docx():
    # A .docx is a zip container — a garbage internal layout must be rejected
    # by the python-docx parse. Exercises `_validate_file_structure` directly
    # (rather than the full `_validate_repository_upload` pipeline) since the
    # magic-bytes/MIME gate ahead of it already rejects a bare zip as "not a
    # .docx" — this test targets FRREP-DCI008's own structural probe.
    import zipfile

    buf = BytesIO()
    with zipfile.ZipFile(buf, "w") as zf:
        zf.writestr("not_a_real_docx.txt", "nothing useful here")
    corrupt = ContentFile(buf.getvalue(), name="corrupt.docx")
    with pytest.raises(DjValidationError, match="corrupted"):
        services._validate_file_structure(corrupt)


@pytest.mark.django_db
def test_ingest_document_rejects_corrupt_pdf(submitter_user, public_collection):
    corrupt = ContentFile(b"%PDF-1.4 not really a pdf", name="corrupt.pdf")
    with pytest.raises(DjValidationError):
        services.ingest_document(
            file=corrupt,
            title="Corrupt upload",
            collection=public_collection,
            document_type=Document.DocumentType.THESIS,
            submitted_by=submitter_user,
            actor=submitter_user,
        )
    assert not Document.objects.filter(title="Corrupt upload").exists()


# ── FRREP-DCI014 — role-based upload notifications ──────────────────────────


@pytest.mark.django_db
def test_notify_roles_reaches_users_without_explicit_recipient_link(
    submitter_user, public_collection
):
    from django.contrib.auth import get_user_model

    User = get_user_model()
    khub = User.objects.create_user(email="khub@test.example", password="pw-test-1234")
    khub.role = UserRole.KHUB_MANAGER
    khub.save()

    public_collection.notify_roles = [UserRole.KHUB_MANAGER]
    public_collection.save(update_fields=["notify_roles"])

    services.ingest_document(
        file=_valid_pdf("role-notified.pdf"),
        title="Role-notified upload",
        collection=public_collection,
        document_type=Document.DocumentType.THESIS,
        submitted_by=submitter_user,
        actor=submitter_user,
    )

    assert Notification.objects.filter(
        recipient=khub, verb=Notification.Verb.REPO_DOC_INGESTED
    ).exists()


@pytest.mark.django_db
def test_notify_roles_does_not_double_notify_explicit_recipient(
    manager_user, submitter_user, public_collection
):
    public_collection.upload_notification_recipients.add(manager_user)
    public_collection.notify_roles = [UserRole.REPO_MANAGER]
    public_collection.save(update_fields=["notify_roles"])

    services.ingest_document(
        file=_valid_pdf("no-dup.pdf"),
        title="No duplicate notification",
        collection=public_collection,
        document_type=Document.DocumentType.THESIS,
        submitted_by=submitter_user,
        actor=submitter_user,
    )

    assert Notification.objects.filter(
        recipient=manager_user, verb=Notification.Verb.REPO_DOC_INGESTED
    ).count() == 1


# ── FRREP-DCI017 — richer ingest audit entry ────────────────────────────────


@pytest.mark.django_db
def test_ingest_audit_log_carries_filename_method_and_validation(
    django_capture_on_commit_callbacks, submitter_user, public_collection
):
    with django_capture_on_commit_callbacks(execute=True):
        doc = services.ingest_document(
            file=_valid_pdf("audit-me.pdf"),
            title="Audit trail check",
            collection=public_collection,
            document_type=Document.DocumentType.THESIS,
            submitted_by=submitter_user,
            actor=submitter_user,
            source_system="manual",
        )
    audit = AuditLog.objects.get(
        target_model="Document", object_id=str(doc.pk), action=AuditLog.Action.CREATE
    )
    assert audit.changes["filename"] == "audit-me.pdf"
    assert audit.changes["ingestion_method"] == "manual"
    assert audit.changes["validation"] == "passed"
    assert audit.changes["file_size_bytes"] == doc.file_size_bytes


# ── FRREP-MCL002 — ORCID verified_at only set on a successful lookup ───────


@pytest.mark.django_db
def test_register_author_does_not_verify_when_orcid_lookup_fails(monkeypatch):
    monkeypatch.setattr(
        "apps.repository.documents.ingestion.lookup_orcid", lambda orcid, **kw: None
    )
    author = services.register_author(
        first_name="Jane", last_name="Doe", orcid="0000-0001-2345-6789"
    )
    assert author.orcid == "0000-0001-2345-6789"
    assert author.verified_at is None


@pytest.mark.django_db
def test_register_author_verifies_when_orcid_lookup_succeeds(monkeypatch):
    monkeypatch.setattr(
        "apps.repository.documents.ingestion.lookup_orcid",
        lambda orcid, **kw: {"first_name": "Jane", "last_name": "Doe", "email": ""},
    )
    author = services.register_author(orcid="0000-0001-2345-6780")
    assert author.verified_at is not None


# ── FRREP-MCL006 — admin JSON validation for auto_classification_rules ─────


@pytest.mark.django_db
def test_metadata_schema_admin_rejects_malformed_rule_shape():
    form = MetadataSchemaAdminForm(
        data={
            "name": "Bad schema",
            "description": "",
            "mandatory_fields": "[]",
            "controlled_vocabularies": "{}",
            "auto_classification_rules": '[{"if_keyword": "climate"}]',
        }
    )
    assert not form.is_valid()
    assert "auto_classification_rules" in form.errors


@pytest.mark.django_db
def test_metadata_schema_admin_rejects_non_list_rules():
    form = MetadataSchemaAdminForm(
        data={
            "name": "Bad schema 2",
            "description": "",
            "mandatory_fields": "[]",
            "controlled_vocabularies": "{}",
            "auto_classification_rules": '{"if_keyword": "climate", "then_collection_slug": "x"}',
        }
    )
    assert not form.is_valid()
    assert "auto_classification_rules" in form.errors


@pytest.mark.django_db
def test_metadata_schema_admin_accepts_well_formed_rules():
    form = MetadataSchemaAdminForm(
        data={
            "name": "Good schema",
            "description": "",
            "mandatory_fields": '["title", "abstract"]',
            "controlled_vocabularies": '{"language": ["en", "fr"]}',
            "auto_classification_rules": (
                '[{"if_keyword": "climate", "then_collection_slug": "climate-research"}]'
            ),
        }
    )
    assert form.is_valid(), form.errors
    schema = form.save()
    assert schema.auto_classification_rules == [
        {"if_keyword": "climate", "then_collection_slug": "climate-research"}
    ]
