"""
Unit tests for Document Vault (INDL-05).

Coverage:
  - Schema validation (Pydantic, no DB required)
  - Service logic (boto3 + DB mocked with unittest.mock)
"""

import uuid
from datetime import datetime, timezone
from typing import Optional
from unittest.mock import AsyncMock, MagicMock, patch, call

import pytest
from pydantic import ValidationError

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

TENANT_ID = uuid.uuid4()
USER_ID = uuid.uuid4()
ENTITY_ID = uuid.uuid4()
DOC_ID = uuid.uuid4()


def _make_document(
    *,
    id: uuid.UUID = DOC_ID,
    tenant_id: uuid.UUID = TENANT_ID,
    entity_type: str = "record",
    entity_id: uuid.UUID = ENTITY_ID,
    s3_key: str = "tenant/x/documents/record/y/z.pdf",
    filename: str = "cert.pdf",
    file_size_bytes: Optional[int] = 1024,
    mime_type: str = "application/pdf",
    category: Optional[str] = "death_certificate",
    version: int = 1,
    uploaded_by: uuid.UUID = USER_ID,
    upload_confirmed: bool = False,
    deleted_at: Optional[datetime] = None,
):
    """
    Build a lightweight Document-like MagicMock.

    We deliberately avoid Document.__new__ because SQLAlchemy's instrumented
    attributes require `_sa_instance_state` to be present — that state is
    normally set up by the metaclass during __init__ / mapper configuration.
    Using a MagicMock with spec-free attribute assignment sidesteps this and
    lets us simulate ORM objects without touching a real DB session.
    """
    doc = MagicMock()
    doc.id = id
    doc.tenant_id = tenant_id
    doc.entity_type = entity_type
    doc.entity_id = entity_id
    doc.s3_key = s3_key
    doc.filename = filename
    doc.file_size_bytes = file_size_bytes
    doc.mime_type = mime_type
    doc.category = category
    doc.version = version
    doc.uploaded_by = uploaded_by
    doc.upload_confirmed = upload_confirmed
    doc.deleted_at = deleted_at
    doc.created_at = datetime.now(timezone.utc)
    doc.updated_at = datetime.now(timezone.utc)

    # Make is_deleted behave like the real property
    type(doc).is_deleted = property(lambda self: self.deleted_at is not None)

    # Make soft_delete() mutate deleted_at as the real method does
    def _soft_delete():
        doc.deleted_at = datetime.now(timezone.utc)

    doc.soft_delete = _soft_delete
    return doc


# ---------------------------------------------------------------------------
# 1. Schema Validation — DocumentUploadUrlRequest
# ---------------------------------------------------------------------------

class TestDocumentUploadUrlRequestSchema:
    """Pydantic schema validation — no DB or S3 required."""

    def test_valid_pdf_payload(self):
        from src.apps.documents.schemas.requests import DocumentUploadUrlRequest

        req = DocumentUploadUrlRequest(
            filename="death_certificate.pdf",
            mime_type="application/pdf",
            file_size_bytes=512_000,
            category="death_certificate",
        )
        assert req.mime_type == "application/pdf"
        assert req.file_size_bytes == 512_000
        assert req.category == "death_certificate"

    def test_invalid_mime_type_raises(self):
        from src.apps.documents.schemas.requests import DocumentUploadUrlRequest

        with pytest.raises(ValidationError) as exc_info:
            DocumentUploadUrlRequest(
                filename="notes.txt",
                mime_type="text/plain",
            )
        errors = exc_info.value.errors()
        assert any("mime_type" in str(e.get("loc")) for e in errors)

    def test_invalid_category_raises(self):
        from src.apps.documents.schemas.requests import DocumentUploadUrlRequest

        with pytest.raises(ValidationError) as exc_info:
            DocumentUploadUrlRequest(
                filename="doc.pdf",
                mime_type="application/pdf",
                category="mystery",
            )
        errors = exc_info.value.errors()
        assert any("category" in str(e.get("loc")) for e in errors)

    def test_empty_filename_raises(self):
        from src.apps.documents.schemas.requests import DocumentUploadUrlRequest

        with pytest.raises(ValidationError):
            DocumentUploadUrlRequest(
                filename="",
                mime_type="application/pdf",
            )

    def test_negative_file_size_raises(self):
        from src.apps.documents.schemas.requests import DocumentUploadUrlRequest

        with pytest.raises(ValidationError):
            DocumentUploadUrlRequest(
                filename="doc.pdf",
                mime_type="application/pdf",
                file_size_bytes=-1,
            )

    @pytest.mark.parametrize("category", [
        "death_certificate",
        "burial_permit",
        "contract",
        "other",
    ])
    def test_all_valid_categories(self, category):
        from src.apps.documents.schemas.requests import DocumentUploadUrlRequest

        req = DocumentUploadUrlRequest(
            filename="doc.pdf",
            mime_type="application/pdf",
            category=category,
        )
        assert req.category == category

    def test_none_category_is_allowed(self):
        from src.apps.documents.schemas.requests import DocumentUploadUrlRequest

        req = DocumentUploadUrlRequest(
            filename="scan.png",
            mime_type="image/png",
        )
        assert req.category is None

    @pytest.mark.parametrize("mime_type", [
        "application/pdf",
        "image/jpeg",
        "image/png",
        "image/tiff",
    ])
    def test_all_valid_mime_types(self, mime_type):
        from src.apps.documents.schemas.requests import DocumentUploadUrlRequest

        req = DocumentUploadUrlRequest(filename="doc", mime_type=mime_type)
        assert req.mime_type == mime_type

    def test_zero_file_size_raises(self):
        """file_size_bytes has gt=0, so zero must be rejected."""
        from src.apps.documents.schemas.requests import DocumentUploadUrlRequest

        with pytest.raises(ValidationError):
            DocumentUploadUrlRequest(
                filename="doc.pdf",
                mime_type="application/pdf",
                file_size_bytes=0,
            )


# ---------------------------------------------------------------------------
# 2. Service Logic — DocumentService (mocked boto3 + mocked AsyncSession)
# ---------------------------------------------------------------------------

def _make_mock_db():
    """Return a MagicMock posing as AsyncSession with async methods."""
    db = MagicMock()
    db.add = MagicMock()
    db.flush = AsyncMock()
    db.execute = AsyncMock()
    return db


def _make_presign_response(url="https://s3.example.com/upload", fields=None):
    return {
        "url": url,
        "fields": fields or {"Content-Type": "application/pdf", "key": "tenant/x/y/z.pdf"},
    }


class TestDocumentServiceGetUploadUrl:
    """Tests for DocumentService.get_upload_url — boto3 + DB fully mocked."""

    @pytest.mark.asyncio
    async def test_valid_inputs_returns_expected_keys(self):
        from src.apps.documents.services.document_service import DocumentService

        db = _make_mock_db()
        presign = _make_presign_response()

        # SQLAlchemy column `default=uuid.uuid4` only fires at flush/INSERT time,
        # not at Python __init__ time — so doc.id is None until the DB assigns it.
        # We intercept db.add() to grab the Document instance, then in flush_side_effect
        # we simulate the DB assigning a UUID pk.
        captured_docs: list = []

        def add_side_effect(obj):
            captured_docs.append(obj)

        db.add.side_effect = add_side_effect

        async def flush_side_effect():
            # Simulate the DB assigning the PK after INSERT
            if captured_docs:
                from sqlalchemy.orm.attributes import set_attribute
                set_attribute(captured_docs[0], "id", uuid.uuid4())

        db.flush.side_effect = flush_side_effect

        with patch(
            "src.apps.documents.services.document_service._make_s3_client"
        ) as mock_client_factory:
            mock_s3 = MagicMock()
            mock_s3.generate_presigned_post.return_value = presign
            mock_client_factory.return_value = mock_s3

            service = DocumentService(db)
            result = await service.get_upload_url(
                tenant_id=TENANT_ID,
                entity_type="record",
                entity_id=ENTITY_ID,
                filename="cert.pdf",
                mime_type="application/pdf",
                file_size_bytes=500_000,
                category="death_certificate",
                uploaded_by=USER_ID,
            )

        assert "document_id" in result
        assert "upload_url" in result
        assert "upload_fields" in result
        assert result["upload_url"] == presign["url"]
        assert result["upload_fields"] == presign["fields"]
        # document_id must be a valid UUID string
        uuid.UUID(result["document_id"])  # raises if invalid
        db.add.assert_called_once()
        db.flush.assert_awaited_once()

    @pytest.mark.asyncio
    async def test_unsupported_mime_type_raises_validation_error(self):
        from src.apps.documents.services.document_service import DocumentService
        from src.core.exceptions import ValidationError as AppValidationError

        db = _make_mock_db()
        service = DocumentService(db)

        with pytest.raises(AppValidationError) as exc_info:
            await service.get_upload_url(
                tenant_id=TENANT_ID,
                entity_type="record",
                entity_id=ENTITY_ID,
                filename="notes.txt",
                mime_type="text/plain",
                file_size_bytes=1024,
                category=None,
                uploaded_by=USER_ID,
            )

        assert exc_info.value.status_code == 422
        assert "Unsupported file type" in exc_info.value.message

    @pytest.mark.asyncio
    async def test_file_too_large_raises_validation_error(self):
        from src.apps.documents.services.document_service import (
            DocumentService,
            MAX_FILE_SIZE_BYTES,
        )
        from src.core.exceptions import ValidationError as AppValidationError

        db = _make_mock_db()
        service = DocumentService(db)

        with pytest.raises(AppValidationError) as exc_info:
            await service.get_upload_url(
                tenant_id=TENANT_ID,
                entity_type="record",
                entity_id=ENTITY_ID,
                filename="huge.pdf",
                mime_type="application/pdf",
                file_size_bytes=MAX_FILE_SIZE_BYTES + 1,
                category=None,
                uploaded_by=USER_ID,
            )

        assert exc_info.value.status_code == 422
        assert "20 MB" in exc_info.value.message

    @pytest.mark.asyncio
    async def test_none_file_size_is_accepted(self):
        """file_size_bytes is optional — None must not trigger size validation."""
        from src.apps.documents.services.document_service import DocumentService

        db = _make_mock_db()
        presign = _make_presign_response()

        with patch(
            "src.apps.documents.services.document_service._make_s3_client"
        ) as mock_client_factory:
            mock_s3 = MagicMock()
            mock_s3.generate_presigned_post.return_value = presign
            mock_client_factory.return_value = mock_s3

            service = DocumentService(db)
            result = await service.get_upload_url(
                tenant_id=TENANT_ID,
                entity_type="record",
                entity_id=ENTITY_ID,
                filename="scan.png",
                mime_type="image/png",
                file_size_bytes=None,
                category=None,
                uploaded_by=USER_ID,
            )

        assert "document_id" in result

    @pytest.mark.asyncio
    async def test_exactly_max_file_size_is_accepted(self):
        """Exactly 20 MB should NOT raise — only exceeding it should."""
        from src.apps.documents.services.document_service import (
            DocumentService,
            MAX_FILE_SIZE_BYTES,
        )

        db = _make_mock_db()
        presign = _make_presign_response()

        with patch(
            "src.apps.documents.services.document_service._make_s3_client"
        ) as mock_client_factory:
            mock_s3 = MagicMock()
            mock_s3.generate_presigned_post.return_value = presign
            mock_client_factory.return_value = mock_s3

            service = DocumentService(db)
            result = await service.get_upload_url(
                tenant_id=TENANT_ID,
                entity_type="record",
                entity_id=ENTITY_ID,
                filename="big.pdf",
                mime_type="application/pdf",
                file_size_bytes=MAX_FILE_SIZE_BYTES,
                category=None,
                uploaded_by=USER_ID,
            )

        assert "document_id" in result


class TestDocumentServiceConfirmUpload:
    """Tests for DocumentService.confirm_upload."""

    def _make_mock_user(self, user_id=USER_ID, tenant_id=TENANT_ID):
        user = MagicMock()
        user.id = user_id
        user.tenant_id = tenant_id
        return user

    def _make_mock_request(self):
        req = MagicMock()
        req.client = MagicMock()
        req.client.host = "127.0.0.1"
        req.headers = {}
        return req

    @pytest.mark.asyncio
    async def test_wrong_user_raises_not_found(self):
        """confirm_upload binds to the uploading user (F-03). Wrong user → NotFoundError."""
        from src.apps.documents.services.document_service import DocumentService
        from src.core.exceptions import NotFoundError

        db = _make_mock_db()
        # scalar_one_or_none returns None — simulates user mismatch
        db.execute.return_value.scalar_one_or_none = MagicMock(return_value=None)

        other_user_id = uuid.uuid4()
        current_user = self._make_mock_user(user_id=other_user_id)
        request = self._make_mock_request()

        service = DocumentService(db)

        with pytest.raises(NotFoundError) as exc_info:
            await service.confirm_upload(
                tenant_id=TENANT_ID,
                document_id=DOC_ID,
                uploaded_by=other_user_id,
                current_user=current_user,
                request=request,
            )

        assert exc_info.value.status_code == 404

    @pytest.mark.asyncio
    async def test_confirm_upload_success(self):
        """Happy path: pending doc exists, correct user → confirmed and flushed."""
        from src.apps.documents.services.document_service import DocumentService

        db = _make_mock_db()
        doc = _make_document(upload_confirmed=False)
        db.execute.return_value.scalar_one_or_none = MagicMock(return_value=doc)

        current_user = self._make_mock_user()
        request = self._make_mock_request()

        # AuditService.log adds an entry and flushes — let flush succeed both times
        with patch(
            "src.apps.documents.services.document_service.AuditService.log",
            new_callable=AsyncMock,
        ):
            service = DocumentService(db)
            result = await service.confirm_upload(
                tenant_id=TENANT_ID,
                document_id=DOC_ID,
                uploaded_by=USER_ID,
                current_user=current_user,
                request=request,
            )

        assert result.upload_confirmed is True
        db.flush.assert_awaited()

    @pytest.mark.asyncio
    async def test_confirm_upload_updates_file_size(self):
        """file_size_bytes provided at confirm time is stored on the document."""
        from src.apps.documents.services.document_service import DocumentService

        db = _make_mock_db()
        doc = _make_document(upload_confirmed=False, file_size_bytes=None)
        db.execute.return_value.scalar_one_or_none = MagicMock(return_value=doc)

        current_user = self._make_mock_user()
        request = self._make_mock_request()

        with patch(
            "src.apps.documents.services.document_service.AuditService.log",
            new_callable=AsyncMock,
        ):
            service = DocumentService(db)
            result = await service.confirm_upload(
                tenant_id=TENANT_ID,
                document_id=DOC_ID,
                uploaded_by=USER_ID,
                current_user=current_user,
                request=request,
                file_size_bytes=99_999,
            )

        assert result.file_size_bytes == 99_999

    @pytest.mark.asyncio
    async def test_confirm_upload_oversized_file_raises(self):
        """file_size_bytes > 20 MB at confirm time → ValidationError (F-03 cap)."""
        from src.apps.documents.services.document_service import (
            DocumentService,
            MAX_FILE_SIZE_BYTES,
        )
        from src.core.exceptions import ValidationError as AppValidationError

        db = _make_mock_db()
        doc = _make_document(upload_confirmed=False)
        db.execute.return_value.scalar_one_or_none = MagicMock(return_value=doc)

        current_user = self._make_mock_user()
        request = self._make_mock_request()

        service = DocumentService(db)

        with pytest.raises(AppValidationError):
            await service.confirm_upload(
                tenant_id=TENANT_ID,
                document_id=DOC_ID,
                uploaded_by=USER_ID,
                current_user=current_user,
                request=request,
                file_size_bytes=MAX_FILE_SIZE_BYTES + 1,
            )


class TestDocumentServiceSoftDelete:
    """Tests for DocumentService.soft_delete (F-13 guard)."""

    def _make_mock_user(self):
        user = MagicMock()
        user.id = USER_ID
        user.tenant_id = TENANT_ID
        return user

    def _make_mock_request(self):
        req = MagicMock()
        req.client = MagicMock()
        req.client.host = "127.0.0.1"
        req.headers = {}
        return req

    @pytest.mark.asyncio
    async def test_unconfirmed_document_raises_not_found(self):
        """soft_delete must only operate on confirmed documents (F-13)."""
        from src.apps.documents.services.document_service import DocumentService
        from src.core.exceptions import NotFoundError

        db = _make_mock_db()
        # DB returns None because upload_confirmed filter excludes unconfirmed docs
        db.execute.return_value.scalar_one_or_none = MagicMock(return_value=None)

        current_user = self._make_mock_user()
        request = self._make_mock_request()

        service = DocumentService(db)

        with pytest.raises(NotFoundError):
            await service.soft_delete(
                tenant_id=TENANT_ID,
                document_id=DOC_ID,
                current_user=current_user,
                request=request,
            )

    @pytest.mark.asyncio
    async def test_soft_delete_success(self):
        """Confirmed document with correct tenant → soft-deleted and audit logged."""
        from src.apps.documents.services.document_service import DocumentService

        db = _make_mock_db()
        doc = _make_document(upload_confirmed=True)
        db.execute.return_value.scalar_one_or_none = MagicMock(return_value=doc)

        current_user = self._make_mock_user()
        request = self._make_mock_request()

        with patch(
            "src.apps.documents.services.document_service.AuditService.log",
            new_callable=AsyncMock,
        ):
            service = DocumentService(db)
            result = await service.soft_delete(
                tenant_id=TENANT_ID,
                document_id=DOC_ID,
                current_user=current_user,
                request=request,
            )

        assert result.deleted_at is not None
        assert result.is_deleted is True
        db.flush.assert_awaited()


class TestDocumentServiceGetList:
    """Tests for DocumentService.get_list — query filter validation."""

    @pytest.mark.asyncio
    async def test_get_list_filters_deleted_and_unconfirmed(self):
        """
        get_list should only return upload_confirmed=True + deleted_at IS NULL docs.
        We verify by inspecting the SQL compiled from the query that reaches execute().
        """
        from sqlalchemy import and_, func, select
        from src.apps.documents.services.document_service import DocumentService
        from src.apps.documents.models.document import Document

        db = _make_mock_db()

        # First call: count query returns 1
        count_result = MagicMock()
        count_result.scalar_one = MagicMock(return_value=1)

        # Second call: data query returns one document
        confirmed_doc = _make_document(upload_confirmed=True)
        data_result = MagicMock()
        data_result.scalars.return_value.all.return_value = [confirmed_doc]

        db.execute.side_effect = [count_result, data_result]

        service = DocumentService(db)
        docs, total = await service.get_list(
            tenant_id=TENANT_ID,
            entity_type="record",
            entity_id=ENTITY_ID,
            page=1,
            page_size=20,
        )

        assert total == 1
        assert len(docs) == 1
        assert docs[0].upload_confirmed is True
        assert docs[0].deleted_at is None

        # Two queries must have been issued (count + data)
        assert db.execute.await_count == 2

    @pytest.mark.asyncio
    async def test_get_list_pagination_offset(self):
        """Page 2, page_size=5 → offset=5 applied."""
        from src.apps.documents.services.document_service import DocumentService

        db = _make_mock_db()

        count_result = MagicMock()
        count_result.scalar_one = MagicMock(return_value=10)

        data_result = MagicMock()
        data_result.scalars.return_value.all.return_value = []

        db.execute.side_effect = [count_result, data_result]

        service = DocumentService(db)
        docs, total = await service.get_list(
            tenant_id=TENANT_ID,
            entity_type="record",
            entity_id=ENTITY_ID,
            page=2,
            page_size=5,
        )

        assert total == 10
        assert docs == []


# ---------------------------------------------------------------------------
# 3. Helper function unit tests
# ---------------------------------------------------------------------------

class TestDocumentServiceHelpers:
    """Tests for module-level helper functions."""

    def test_ext_from_filename_pdf(self):
        from src.apps.documents.services.document_service import _ext_from_filename

        assert _ext_from_filename("document.pdf") == "pdf"

    def test_ext_from_filename_uppercase(self):
        from src.apps.documents.services.document_service import _ext_from_filename

        assert _ext_from_filename("SCAN.JPG") == "jpg"

    def test_ext_from_filename_no_extension(self):
        from src.apps.documents.services.document_service import _ext_from_filename

        assert _ext_from_filename("noextension") == "bin"

    def test_build_s3_key_structure(self):
        from src.apps.documents.services.document_service import _build_s3_key

        tenant_id = uuid.UUID("11111111-1111-1111-1111-111111111111")
        entity_id = uuid.UUID("22222222-2222-2222-2222-222222222222")

        key = _build_s3_key(tenant_id, "record", entity_id, "cert.pdf")

        assert key.startswith(f"tenant/{tenant_id}/documents/record/{entity_id}/")
        assert key.endswith(".pdf")

    def test_build_s3_key_is_unique(self):
        """Two calls with identical args should produce different keys (UUID embedded)."""
        from src.apps.documents.services.document_service import _build_s3_key

        tenant_id = uuid.uuid4()
        entity_id = uuid.uuid4()

        key1 = _build_s3_key(tenant_id, "record", entity_id, "doc.pdf")
        key2 = _build_s3_key(tenant_id, "record", entity_id, "doc.pdf")

        assert key1 != key2

    def test_max_file_size_bytes_constant(self):
        from src.apps.documents.services.document_service import MAX_FILE_SIZE_BYTES

        assert MAX_FILE_SIZE_BYTES == 20 * 1024 * 1024

    def test_allowed_mime_types_set(self):
        from src.apps.documents.services.document_service import ALLOWED_MIME_TYPES

        expected = {"application/pdf", "image/jpeg", "image/png", "image/tiff"}
        assert ALLOWED_MIME_TYPES == expected
