"""Tests for DMS Phase 9 — COLLABORATION.

Covers:
  * create_comment — top-level, version-specific, reply threading
  * reply-to-reply guard (raises CommentError)
  * update_comment, delete_comment (soft delete)
  * resolve_comment, unresolve_comment lifecycle
  * create_annotation — populates document from version, validates type
  * update_annotation, delete_annotation (soft delete)
  * sync_mentions — adds new, removes stale
  * Queries: list_comments_for_document (top_level_only), list_replies,
    list_annotations_for_version (with / without page filter)
  * HTTP API: all CRUD endpoints for comments and annotations
  * Owner-only PUT/DELETE: returns 403 for non-owner (unless delete_any perm)
  * Permission gates: 401 unauthenticated, 403 no permission
"""

from __future__ import annotations

import uuid

import pytest

from simorgh.apps.dms.collaboration import queries, services
from simorgh.apps.dms.collaboration.iam_permissions import (
    PERM_ANNOTATION_CREATE,
    PERM_ANNOTATION_DELETE_ANY,
    PERM_ANNOTATION_DELETE_OWN,
    PERM_ANNOTATION_VIEW,
    PERM_COMMENT_CREATE,
    PERM_COMMENT_DELETE_ANY,
    PERM_COMMENT_DELETE_OWN,
    PERM_COMMENT_RESOLVE,
    PERM_COMMENT_VIEW,
)
from simorgh.apps.dms.collaboration.models import Annotation, AnnotationType, Comment, Mention
from simorgh.apps.dms.collaboration.services import CollaborationError, CommentError
from simorgh.apps.dms.common.exceptions import AssetNotFound
from simorgh.apps.dms.documents.services import add_version
from simorgh.apps.dms.repositories.models import Repository
from simorgh.core.context import RequestContext, use_request_context

ALL_COLLAB_PERMS = frozenset({
    PERM_COMMENT_VIEW,
    PERM_COMMENT_CREATE,
    PERM_COMMENT_RESOLVE,
    PERM_COMMENT_DELETE_OWN,
    PERM_COMMENT_DELETE_ANY,
    PERM_ANNOTATION_VIEW,
    PERM_ANNOTATION_CREATE,
    PERM_ANNOTATION_DELETE_OWN,
    PERM_ANNOTATION_DELETE_ANY,
})

READ_ONLY_PERMS = frozenset({
    PERM_COMMENT_VIEW,
    PERM_ANNOTATION_VIEW,
})


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

@pytest.fixture
def alice_membership(alice, tenant_acme, acme_tree, role_admin):
    from simorgh.apps.memberships.models import Membership

    m = Membership.objects.create(
        tenant=tenant_acme,
        organization_node=acme_tree["root"],
        role=role_admin,
    )
    m.users.add(alice)
    return m


@pytest.fixture
def bob(db):
    from django.contrib.auth import get_user_model

    User = get_user_model()
    return User.objects.create_user(
        "+989000000099", password="x", email="bob@example.com",
        is_superuser=True,
    )


@pytest.fixture
def charlie(db):
    """A plain non-superuser without membership — for 403 tests."""
    from django.contrib.auth import get_user_model

    User = get_user_model()
    return User.objects.create_user(
        "+989000000077", password="x", email="charlie@example.com",
    )


@pytest.fixture
def collab_ctx(alice, tenant_acme, acme_tree):
    return RequestContext(
        actor=alice,
        tenant=tenant_acme,
        org_node_ids=frozenset({acme_tree["root"].pk}),
        permissions=ALL_COLLAB_PERMS,
    )


@pytest.fixture
def repo(tenant_acme, acme_tree):
    return Repository.objects.create(
        name="Collaboration Repo",
        slug="collab-repo",
        tenant=tenant_acme,
        organization_node=acme_tree["root"],
    )


@pytest.fixture
def doc(repo, tenant_acme, acme_tree):
    from simorgh.apps.dms.documents.models import Document

    return Document.objects.create(
        title="Collaboration Document",
        repository=repo,
        tenant=tenant_acme,
        organization_node=acme_tree["root"],
    )


@pytest.fixture
def file_asset(tenant_acme, acme_tree):
    from simorgh.apps.dms.assets.constants import APP_CONTEXT
    from simorgh.apps.storage.models import FileMetadata, FileUploadStatus

    return FileMetadata.objects.create(
        filename="doc.pdf",
        content_type="application/pdf",
        size_bytes=2048,
        path=f"test/collab/{uuid.uuid4()}.pdf",
        tenant=tenant_acme,
        organization_node=acme_tree["root"],
        upload_status=FileUploadStatus.READY,
        app_context=APP_CONTEXT,
    )


@pytest.fixture
def version(doc, file_asset, tenant_acme, acme_tree):
    return add_version(
        document=doc,
        file_asset=file_asset,
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
    )


# ---------------------------------------------------------------------------
# Service — create_comment
# ---------------------------------------------------------------------------

@pytest.mark.django_db
def test_create_top_level_comment(alice, doc, tenant_acme, acme_tree):
    comment = services.create_comment(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        author=alice,
        body="This is a top-level comment.",
    )
    assert comment.pk is not None
    assert comment.body == "This is a top-level comment."
    assert comment.parent is None
    assert comment.version is None
    assert comment.is_resolved is False
    assert comment.is_deleted is False


@pytest.mark.django_db
def test_create_version_specific_comment(alice, doc, version, tenant_acme, acme_tree):
    comment = services.create_comment(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        author=alice,
        body="Comment on version 1.",
        version=version,
    )
    assert comment.version_id == version.pk


@pytest.mark.django_db
def test_create_reply(alice, doc, tenant_acme, acme_tree):
    parent = services.create_comment(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        author=alice,
        body="Parent comment.",
    )
    reply = services.create_comment(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        author=alice,
        body="Reply.",
        parent=parent,
    )
    assert reply.parent_id == parent.pk


@pytest.mark.django_db
def test_reply_to_reply_raises(alice, doc, tenant_acme, acme_tree):
    """Nesting deeper than 1 level must raise CommentError."""
    parent = services.create_comment(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        author=alice,
        body="Root.",
    )
    reply = services.create_comment(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        author=alice,
        body="Level 1 reply.",
        parent=parent,
    )
    with pytest.raises(CommentError, match="one level"):
        services.create_comment(
            tenant_id=tenant_acme.pk,
            organization_node_id=acme_tree["root"].pk,
            document=doc,
            author=alice,
            body="Level 2 reply.",
            parent=reply,
        )


@pytest.mark.django_db
def test_create_comment_empty_body_raises(alice, doc, tenant_acme, acme_tree):
    with pytest.raises(CommentError, match="empty"):
        services.create_comment(
            tenant_id=tenant_acme.pk,
            organization_node_id=acme_tree["root"].pk,
            document=doc,
            author=alice,
            body="   ",
        )


# ---------------------------------------------------------------------------
# Service — update_comment
# ---------------------------------------------------------------------------

@pytest.mark.django_db
def test_update_comment_body(alice, doc, tenant_acme, acme_tree):
    comment = services.create_comment(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        author=alice,
        body="Original.",
    )
    updated = services.update_comment(comment, body="Updated body.")
    assert updated.body == "Updated body."
    comment.refresh_from_db()
    assert comment.body == "Updated body."


@pytest.mark.django_db
def test_update_comment_empty_raises(alice, doc, tenant_acme, acme_tree):
    comment = services.create_comment(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        author=alice,
        body="OK",
    )
    with pytest.raises(CommentError, match="empty"):
        services.update_comment(comment, body="")


# ---------------------------------------------------------------------------
# Service — delete_comment (soft delete)
# ---------------------------------------------------------------------------

@pytest.mark.django_db
def test_delete_comment_soft_deletes(alice, doc, tenant_acme, acme_tree):
    comment = services.create_comment(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        author=alice,
        body="Will be deleted.",
    )
    services.delete_comment(comment)
    comment.refresh_from_db()
    assert comment.is_deleted is True


@pytest.mark.django_db
def test_deleted_comment_not_in_list(alice, doc, tenant_acme, acme_tree):
    comment = services.create_comment(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        author=alice,
        body="Will be deleted.",
    )
    services.delete_comment(comment)
    qs = queries.list_comments_for_document(doc)
    assert not qs.filter(pk=comment.pk).exists()


# ---------------------------------------------------------------------------
# Service — resolve / unresolve
# ---------------------------------------------------------------------------

@pytest.mark.django_db
def test_resolve_comment(alice, doc, tenant_acme, acme_tree):
    comment = services.create_comment(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        author=alice,
        body="Needs resolution.",
    )
    resolved = services.resolve_comment(comment, resolving_user=alice)
    assert resolved.is_resolved is True
    assert resolved.resolved_by_id == alice.pk
    assert resolved.resolved_at is not None


@pytest.mark.django_db
def test_resolve_already_resolved_raises(alice, doc, tenant_acme, acme_tree):
    comment = services.create_comment(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        author=alice,
        body="Resolved already.",
    )
    services.resolve_comment(comment, resolving_user=alice)
    with pytest.raises(CommentError, match="already resolved"):
        services.resolve_comment(comment, resolving_user=alice)


@pytest.mark.django_db
def test_resolve_reply_raises(alice, doc, tenant_acme, acme_tree):
    parent = services.create_comment(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        author=alice,
        body="Parent.",
    )
    reply = services.create_comment(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        author=alice,
        body="Reply.",
        parent=parent,
    )
    with pytest.raises(CommentError, match="top-level"):
        services.resolve_comment(reply, resolving_user=alice)


@pytest.mark.django_db
def test_unresolve_comment(alice, doc, tenant_acme, acme_tree):
    comment = services.create_comment(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        author=alice,
        body="Resolved and reopened.",
    )
    services.resolve_comment(comment, resolving_user=alice)
    services.unresolve_comment(comment, resolving_user=alice)
    comment.refresh_from_db()
    assert comment.is_resolved is False


@pytest.mark.django_db
def test_unresolve_not_resolved_raises(alice, doc, tenant_acme, acme_tree):
    comment = services.create_comment(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        author=alice,
        body="Not resolved.",
    )
    with pytest.raises(CommentError, match="not resolved"):
        services.unresolve_comment(comment, resolving_user=alice)


# ---------------------------------------------------------------------------
# Service — create_annotation
# ---------------------------------------------------------------------------

@pytest.mark.django_db
def test_create_annotation(alice, doc, version, tenant_acme, acme_tree):
    ann = services.create_annotation(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        version=version,
        author=alice,
        annotation_type=AnnotationType.HIGHLIGHT,
        body="Important section",
        page_number=3,
        position_data={"x": 0.1, "y": 0.5},
        color="#FF0000",
    )
    assert ann.pk is not None
    assert ann.annotation_type == AnnotationType.HIGHLIGHT
    assert ann.document_id == doc.pk
    assert ann.version_id == version.pk
    assert ann.page_number == 3
    assert ann.color == "#FF0000"
    assert ann.is_deleted is False


@pytest.mark.django_db
def test_create_annotation_invalid_type_raises(alice, doc, version, tenant_acme, acme_tree):
    from simorgh.apps.dms.collaboration.services import AnnotationError

    with pytest.raises(AnnotationError, match="Invalid annotation type"):
        services.create_annotation(
            tenant_id=tenant_acme.pk,
            organization_node_id=acme_tree["root"].pk,
            document=doc,
            version=version,
            author=alice,
            annotation_type="unknown_type",
            body="",
            page_number=None,
            position_data={},
            color="#FFFF00",
        )


@pytest.mark.django_db
def test_create_annotation_wrong_version_raises(alice, doc, version, tenant_acme, acme_tree):
    """Version must belong to the given document."""
    from simorgh.apps.dms.collaboration.services import AnnotationError
    from simorgh.apps.dms.documents.models import Document

    other_doc = Document.objects.create(
        title="Other Doc",
        repository=doc.repository,
        tenant=tenant_acme,
        organization_node=acme_tree["root"],
    )
    with pytest.raises(AnnotationError, match="does not belong"):
        services.create_annotation(
            tenant_id=tenant_acme.pk,
            organization_node_id=acme_tree["root"].pk,
            document=other_doc,  # wrong doc
            version=version,
            author=alice,
            annotation_type=AnnotationType.NOTE,
            body="",
            page_number=None,
            position_data={},
            color="#FFFF00",
        )


# ---------------------------------------------------------------------------
# Service — update_annotation
# ---------------------------------------------------------------------------

@pytest.mark.django_db
def test_update_annotation(alice, doc, version, tenant_acme, acme_tree):
    ann = services.create_annotation(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        version=version,
        author=alice,
        annotation_type=AnnotationType.NOTE,
        body="Old body",
        page_number=1,
        position_data={},
        color="#FFFF00",
    )
    updated = services.update_annotation(
        ann,
        body="New body",
        color="#0000FF",
        position_data={"x": 0.5},
        page_number=2,
    )
    assert updated.body == "New body"
    assert updated.color == "#0000FF"
    assert updated.position_data == {"x": 0.5}
    assert updated.page_number == 2


@pytest.mark.django_db
def test_update_annotation_partial(alice, doc, version, tenant_acme, acme_tree):
    ann = services.create_annotation(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        version=version,
        author=alice,
        annotation_type=AnnotationType.NOTE,
        body="Keep this",
        page_number=1,
        position_data={},
        color="#FFFF00",
    )
    services.update_annotation(ann, color="#00FF00")
    ann.refresh_from_db()
    assert ann.body == "Keep this"
    assert ann.color == "#00FF00"


# ---------------------------------------------------------------------------
# Service — delete_annotation
# ---------------------------------------------------------------------------

@pytest.mark.django_db
def test_delete_annotation_soft_deletes(alice, doc, version, tenant_acme, acme_tree):
    ann = services.create_annotation(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        version=version,
        author=alice,
        annotation_type=AnnotationType.NOTE,
        body="",
        page_number=None,
        position_data={},
        color="#FFFF00",
    )
    services.delete_annotation(ann)
    ann.refresh_from_db()
    assert ann.is_deleted is True


# ---------------------------------------------------------------------------
# Service — sync_mentions
# ---------------------------------------------------------------------------

@pytest.mark.django_db
def test_sync_mentions_creates_new(alice, bob, doc, tenant_acme, acme_tree):
    comment = services.create_comment(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        author=alice,
        body="Hey @bob",
    )
    mentions = services.sync_mentions(comment, [bob.pk])
    assert len(mentions) == 1
    assert mentions[0].mentioned_user_id == bob.pk
    assert mentions[0].is_notified is False


@pytest.mark.django_db
def test_sync_mentions_removes_stale(alice, bob, doc, tenant_acme, acme_tree):
    comment = services.create_comment(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        author=alice,
        body="Hey @bob",
    )
    services.sync_mentions(comment, [bob.pk])
    # Now edit to remove @bob
    remaining = services.sync_mentions(comment, [])
    assert remaining == []
    assert Mention.objects.filter(comment=comment).count() == 0


@pytest.mark.django_db
def test_sync_mentions_idempotent(alice, bob, doc, tenant_acme, acme_tree):
    comment = services.create_comment(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        author=alice,
        body="Hey @bob",
    )
    services.sync_mentions(comment, [bob.pk])
    mentions = services.sync_mentions(comment, [bob.pk])
    assert len(mentions) == 1  # not duplicated


# ---------------------------------------------------------------------------
# Queries
# ---------------------------------------------------------------------------

@pytest.mark.django_db
def test_list_comments_top_level_only(alice, doc, tenant_acme, acme_tree):
    root = services.create_comment(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        author=alice,
        body="Root.",
    )
    services.create_comment(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        author=alice,
        body="Reply.",
        parent=root,
    )
    top_level = list(queries.list_comments_for_document(doc, top_level_only=True))
    assert len(top_level) == 1
    assert top_level[0].pk == root.pk


@pytest.mark.django_db
def test_list_replies(alice, doc, tenant_acme, acme_tree):
    parent = services.create_comment(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        author=alice,
        body="Parent.",
    )
    r1 = services.create_comment(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        author=alice,
        body="Reply 1.",
        parent=parent,
    )
    r2 = services.create_comment(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        author=alice,
        body="Reply 2.",
        parent=parent,
    )
    replies = list(queries.list_replies(parent))
    assert len(replies) == 2


@pytest.mark.django_db
def test_list_annotations_for_version(alice, doc, version, tenant_acme, acme_tree):
    services.create_annotation(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        version=version,
        author=alice,
        annotation_type=AnnotationType.HIGHLIGHT,
        body="Page 1 note",
        page_number=1,
        position_data={},
        color="#FFFF00",
    )
    services.create_annotation(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        version=version,
        author=alice,
        annotation_type=AnnotationType.NOTE,
        body="Page 2 note",
        page_number=2,
        position_data={},
        color="#FFFF00",
    )
    all_anns = list(queries.list_annotations_for_version(version))
    assert len(all_anns) == 2

    page1 = list(queries.list_annotations_for_version(version, page_number=1))
    assert len(page1) == 1
    assert page1[0].page_number == 1


@pytest.mark.django_db
def test_get_comment_not_found_raises(tenant_acme):
    with pytest.raises(AssetNotFound):
        queries.get_comment(tenant_acme.pk, str(uuid.uuid4()))


@pytest.mark.django_db
def test_get_annotation_not_found_raises(tenant_acme):
    with pytest.raises(AssetNotFound):
        queries.get_annotation(tenant_acme.pk, str(uuid.uuid4()))


# ---------------------------------------------------------------------------
# HTTP API — comments
# ---------------------------------------------------------------------------

@pytest.mark.django_db
def test_api_list_comments(client, alice, tenant_acme, alice_membership, doc, collab_ctx):
    services.create_comment(
        tenant_id=tenant_acme.pk,
        organization_node_id=alice_membership.organization_node_id,
        document=doc,
        author=alice,
        body="Hello from DB.",
    )
    client.force_login(alice)
    with use_request_context(collab_ctx):
        resp = client.get(
            f"/api/v1/dms/documents/{doc.public_id}/comments/",
            HTTP_X_TENANT=tenant_acme.slug,
        )
    assert resp.status_code == 200
    data = resp.json()
    assert len(data) == 1
    assert data[0]["body"] == "Hello from DB."


@pytest.mark.django_db
def test_api_create_comment(client, alice, tenant_acme, alice_membership, doc, collab_ctx):
    client.force_login(alice)
    with use_request_context(collab_ctx):
        resp = client.post(
            f"/api/v1/dms/documents/{doc.public_id}/comments/",
            data={"body": "New comment via API."},
            content_type="application/json",
            HTTP_X_TENANT=tenant_acme.slug,
        )
    assert resp.status_code == 201
    data = resp.json()
    assert data["body"] == "New comment via API."
    assert data["parent_id"] is None


@pytest.mark.django_db
def test_api_create_reply(client, alice, tenant_acme, alice_membership, doc, collab_ctx, acme_tree):
    parent = services.create_comment(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        author=alice,
        body="Parent.",
    )
    client.force_login(alice)
    with use_request_context(collab_ctx):
        resp = client.post(
            f"/api/v1/dms/documents/{doc.public_id}/comments/",
            data={"body": "Reply via API.", "parent_id": str(parent.public_id)},
            content_type="application/json",
            HTTP_X_TENANT=tenant_acme.slug,
        )
    assert resp.status_code == 201
    assert resp.json()["parent_id"] == str(parent.public_id)


@pytest.mark.django_db
def test_api_get_comment(client, alice, tenant_acme, alice_membership, doc, collab_ctx, acme_tree):
    comment = services.create_comment(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        author=alice,
        body="Readable.",
    )
    client.force_login(alice)
    with use_request_context(collab_ctx):
        resp = client.get(
            f"/api/v1/dms/documents/{doc.public_id}/comments/{comment.public_id}/",
            HTTP_X_TENANT=tenant_acme.slug,
        )
    assert resp.status_code == 200
    assert resp.json()["body"] == "Readable."


@pytest.mark.django_db
def test_api_update_comment_by_author(client, alice, tenant_acme, alice_membership, doc, collab_ctx, acme_tree):
    comment = services.create_comment(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        author=alice,
        body="Original.",
    )
    client.force_login(alice)
    with use_request_context(collab_ctx):
        resp = client.put(
            f"/api/v1/dms/documents/{doc.public_id}/comments/{comment.public_id}/",
            data={"body": "Edited."},
            content_type="application/json",
            HTTP_X_TENANT=tenant_acme.slug,
        )
    assert resp.status_code == 200
    assert resp.json()["body"] == "Edited."


@pytest.mark.django_db
def test_api_update_comment_by_non_author_forbidden(
    client, charlie, tenant_acme, alice_membership, doc, acme_tree, alice
):
    """Non-author with only read perms cannot edit someone else's comment."""
    comment = services.create_comment(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        author=alice,
        body="Alice's comment.",
    )
    # Charlie has no membership — PermissionGateMiddleware gives empty perms
    charlie_ctx = RequestContext(
        actor=charlie,
        tenant=tenant_acme,
        org_node_ids=frozenset(),
        permissions=frozenset({PERM_COMMENT_VIEW}),
    )
    client.force_login(charlie)
    with use_request_context(charlie_ctx):
        resp = client.put(
            f"/api/v1/dms/documents/{doc.public_id}/comments/{comment.public_id}/",
            data={"body": "Hacked."},
            content_type="application/json",
            HTTP_X_TENANT=tenant_acme.slug,
        )
    assert resp.status_code == 403


@pytest.mark.django_db
def test_api_delete_comment(client, alice, tenant_acme, alice_membership, doc, collab_ctx, acme_tree):
    comment = services.create_comment(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        author=alice,
        body="Bye.",
    )
    client.force_login(alice)
    with use_request_context(collab_ctx):
        resp = client.delete(
            f"/api/v1/dms/documents/{doc.public_id}/comments/{comment.public_id}/",
            HTTP_X_TENANT=tenant_acme.slug,
        )
    assert resp.status_code == 204
    comment.refresh_from_db()
    assert comment.is_deleted is True


@pytest.mark.django_db
def test_api_resolve_comment(client, alice, tenant_acme, alice_membership, doc, collab_ctx, acme_tree):
    comment = services.create_comment(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        author=alice,
        body="To resolve.",
    )
    client.force_login(alice)
    with use_request_context(collab_ctx):
        resp = client.post(
            f"/api/v1/dms/documents/{doc.public_id}/comments/{comment.public_id}/resolve/",
            HTTP_X_TENANT=tenant_acme.slug,
        )
    assert resp.status_code == 200
    assert resp.json()["is_resolved"] is True


@pytest.mark.django_db
def test_api_unresolve_comment(client, alice, tenant_acme, alice_membership, doc, collab_ctx, acme_tree):
    comment = services.create_comment(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        author=alice,
        body="Toggle.",
    )
    services.resolve_comment(comment, resolving_user=alice)
    client.force_login(alice)
    with use_request_context(collab_ctx):
        resp = client.post(
            f"/api/v1/dms/documents/{doc.public_id}/comments/{comment.public_id}/resolve/",
            HTTP_X_TENANT=tenant_acme.slug,
        )
    assert resp.status_code == 200
    assert resp.json()["is_resolved"] is False


# ---------------------------------------------------------------------------
# HTTP API — annotations
# ---------------------------------------------------------------------------

@pytest.mark.django_db
def test_api_list_annotations(client, alice, tenant_acme, alice_membership, doc, version, collab_ctx, acme_tree):
    services.create_annotation(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        version=version,
        author=alice,
        annotation_type=AnnotationType.HIGHLIGHT,
        body="DB annotation",
        page_number=1,
        position_data={},
        color="#FFFF00",
    )
    client.force_login(alice)
    with use_request_context(collab_ctx):
        resp = client.get(
            f"/api/v1/dms/documents/{doc.public_id}/versions/{version.public_id}/annotations/",
            HTTP_X_TENANT=tenant_acme.slug,
        )
    assert resp.status_code == 200
    assert len(resp.json()) == 1


@pytest.mark.django_db
def test_api_create_annotation(client, alice, tenant_acme, alice_membership, doc, version, collab_ctx):
    client.force_login(alice)
    with use_request_context(collab_ctx):
        resp = client.post(
            f"/api/v1/dms/documents/{doc.public_id}/versions/{version.public_id}/annotations/",
            data={
                "annotation_type": "highlight",
                "body": "New annotation",
                "page_number": 2,
                "position_data": {"x": 0.1, "y": 0.3},
                "color": "#FF0000",
            },
            content_type="application/json",
            HTTP_X_TENANT=tenant_acme.slug,
        )
    assert resp.status_code == 201
    data = resp.json()
    assert data["annotation_type"] == "highlight"
    assert data["page_number"] == 2
    assert data["color"] == "#FF0000"


@pytest.mark.django_db
def test_api_get_annotation(client, alice, tenant_acme, alice_membership, doc, version, collab_ctx, acme_tree):
    ann = services.create_annotation(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        version=version,
        author=alice,
        annotation_type=AnnotationType.NOTE,
        body="Get me.",
        page_number=None,
        position_data={},
        color="#FFFF00",
    )
    client.force_login(alice)
    with use_request_context(collab_ctx):
        resp = client.get(
            f"/api/v1/dms/documents/{doc.public_id}/versions/{version.public_id}/annotations/{ann.public_id}/",
            HTTP_X_TENANT=tenant_acme.slug,
        )
    assert resp.status_code == 200
    assert resp.json()["body"] == "Get me."


@pytest.mark.django_db
def test_api_update_annotation(client, alice, tenant_acme, alice_membership, doc, version, collab_ctx, acme_tree):
    ann = services.create_annotation(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        version=version,
        author=alice,
        annotation_type=AnnotationType.NOTE,
        body="Old.",
        page_number=1,
        position_data={},
        color="#FFFF00",
    )
    client.force_login(alice)
    with use_request_context(collab_ctx):
        resp = client.put(
            f"/api/v1/dms/documents/{doc.public_id}/versions/{version.public_id}/annotations/{ann.public_id}/",
            data={"body": "Updated.", "color": "#0000FF"},
            content_type="application/json",
            HTTP_X_TENANT=tenant_acme.slug,
        )
    assert resp.status_code == 200
    assert resp.json()["body"] == "Updated."
    assert resp.json()["color"] == "#0000FF"


@pytest.mark.django_db
def test_api_delete_annotation(client, alice, tenant_acme, alice_membership, doc, version, collab_ctx, acme_tree):
    ann = services.create_annotation(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        version=version,
        author=alice,
        annotation_type=AnnotationType.STAMP,
        body="",
        page_number=None,
        position_data={},
        color="#FFFF00",
    )
    client.force_login(alice)
    with use_request_context(collab_ctx):
        resp = client.delete(
            f"/api/v1/dms/documents/{doc.public_id}/versions/{version.public_id}/annotations/{ann.public_id}/",
            HTTP_X_TENANT=tenant_acme.slug,
        )
    assert resp.status_code == 204
    ann.refresh_from_db()
    assert ann.is_deleted is True


@pytest.mark.django_db
def test_api_update_annotation_non_author_forbidden(
    client, charlie, tenant_acme, alice_membership, doc, version, acme_tree, alice
):
    ann = services.create_annotation(
        tenant_id=tenant_acme.pk,
        organization_node_id=acme_tree["root"].pk,
        document=doc,
        version=version,
        author=alice,
        annotation_type=AnnotationType.NOTE,
        body="Alice's annotation.",
        page_number=None,
        position_data={},
        color="#FFFF00",
    )
    charlie_ctx = RequestContext(
        actor=charlie,
        tenant=tenant_acme,
        org_node_ids=frozenset(),
        permissions=frozenset({PERM_ANNOTATION_VIEW}),
    )
    client.force_login(charlie)
    with use_request_context(charlie_ctx):
        resp = client.put(
            f"/api/v1/dms/documents/{doc.public_id}/versions/{version.public_id}/annotations/{ann.public_id}/",
            data={"body": "Hacked."},
            content_type="application/json",
            HTTP_X_TENANT=tenant_acme.slug,
        )
    assert resp.status_code == 403


# ---------------------------------------------------------------------------
# Permission gates
# ---------------------------------------------------------------------------

@pytest.mark.django_db
def test_api_unauthenticated_401(client, tenant_acme, doc):
    resp = client.get(
        f"/api/v1/dms/documents/{doc.public_id}/comments/",
        HTTP_X_TENANT=tenant_acme.slug,
    )
    assert resp.status_code == 401


@pytest.mark.django_db
def test_api_no_view_perm_403(client, charlie, tenant_acme, doc):
    """User with no permissions gets 403 on comment list."""
    no_perm_ctx = RequestContext(
        actor=charlie,
        tenant=tenant_acme,
        org_node_ids=frozenset(),
        permissions=frozenset(),
    )
    client.force_login(charlie)
    with use_request_context(no_perm_ctx):
        resp = client.get(
            f"/api/v1/dms/documents/{doc.public_id}/comments/",
            HTTP_X_TENANT=tenant_acme.slug,
        )
    assert resp.status_code == 403


@pytest.mark.django_db
def test_api_no_create_perm_403(client, charlie, tenant_acme, doc):
    """User with view but no create perm gets 403 on POST."""
    view_only_ctx = RequestContext(
        actor=charlie,
        tenant=tenant_acme,
        org_node_ids=frozenset(),
        permissions=frozenset({PERM_COMMENT_VIEW}),
    )
    client.force_login(charlie)
    with use_request_context(view_only_ctx):
        resp = client.post(
            f"/api/v1/dms/documents/{doc.public_id}/comments/",
            data={"body": "Should fail."},
            content_type="application/json",
            HTTP_X_TENANT=tenant_acme.slug,
        )
    assert resp.status_code == 403


@pytest.mark.django_db
def test_api_not_found_returns_404(client, alice, tenant_acme, alice_membership, doc, collab_ctx):
    client.force_login(alice)
    with use_request_context(collab_ctx):
        resp = client.get(
            f"/api/v1/dms/documents/{doc.public_id}/comments/{uuid.uuid4()}/",
            HTTP_X_TENANT=tenant_acme.slug,
        )
    assert resp.status_code == 404
