"""services/comments — the global comment board + unread bell (parity
bar: 01-current-system/features/comments.md B1–B27; 02-domain-model §3;
06-test-plan §7.3 T-COM-*).

R1 is the GLOBAL board only: one org-wide feed reachable from the 💬
bell, tenant-scoped, newest-first. The schema carries nullable anchor
columns (entity_type/entity_id) for the future per-row feature, unused
here (02 §3). @mentions are cosmetic (frontend only); no notifications.

Rebuild deltas from the current system (comments.md "Rebuild deltas"):

  - RLS is gone — authorization is service-layer, derived from the
    resolved Actor (the same identity fields can()/scope() read). The
    four legacy RLS policies map to: read/post/resolve/reopen = any
    tenant MEMBER; delete = the AUTHOR or a (not-viewing-as) super
    admin. A non-author's delete is an EXPLICIT 403 (the legacy silent
    RLS no-op is fixed — T-COM-103).
  - The view-as identity wart is fixed: ONE identity — the EFFECTIVE
    user (`actor.user_id`) — drives BOTH attribution (author_id /
    resolved_by) AND delete gating. So `can_delete` in the serialized
    row and the delete handler's check are the SAME predicate,
    evaluated on the same identity (T-COM-109; ADR #033 lineage).
  - The badge is a REAL `count(*) where resolved_at is null`, tenant-
    scoped — not the legacy window-derived undercount (T-COM-106).
  - A mutation on a nonexistent / cross-tenant id is a 404, not the
    legacy silent success-shaped no-op (T-COM-113).

Lifecycle (B7–B11): resolved iff `resolved_at IS NOT NULL` (two states,
no status column). There is NO state precondition (B9): resolving an
already-resolved comment RE-STAMPS resolved_at/resolved_by to the new
actor/time; reopening an already-open comment is a no-op write. Every
mutation writes an audit row (04 "Audit"); comments are not part of the
generic entry-undo engine.
"""

from __future__ import annotations

import uuid
from datetime import UTC, date, datetime
from decimal import Decimal

from sqlalchemy import func, select
from sqlalchemy.orm import Session

from app.models import Comment, Party, User
from app.services.audit import write_audit
from app.services.errors import ServiceError

#: The newest-N window the bell panel lists (B16 parity — the badge
#: count is independent of it, see `unresolved_count`).
BELL_WINDOW = 50


# ------------------------------------------------------------- identity


def _is_member(actor) -> bool:
    """Tenant membership = the legacy `is_org_member`: a resolved user in
    the tenant with an active engagement (owner/manager/worker), or a
    super admin who is NOT currently viewing-as (impersonating anyone
    strips the standing super-admin power — capabilities.py idiom)."""
    if actor.user_id is None or actor.tenant_id is None:
        return False
    if actor.is_super_admin and not actor.is_viewing_as:
        return True
    return actor.role in ("owner", "manager", "worker")


def _require_member(actor) -> None:
    if actor.user_id is None:
        raise ServiceError(
            401, "NOT_AUTHENTICATED", "You must be signed in to use comments."
        )
    if not _is_member(actor):
        raise ServiceError(
            403,
            "NOT_A_MEMBER",
            "You do not have access to this workspace's comments.",
        )


def _can_delete(actor, comment: Comment) -> bool:
    """THE one delete predicate — used BOTH to set each row's
    `can_delete` flag AND to gate the delete handler, on the SAME
    (effective) identity (T-COM-109). Author, or a super admin who is
    not viewing-as."""
    if not _is_member(actor):
        return False
    if comment.author_id is not None and comment.author_id == actor.user_id:
        return True
    return bool(actor.is_super_admin and not actor.is_viewing_as)


# ---------------------------------------------------------- name lookup


def _display_names(session: Session, ids) -> dict[uuid.UUID, str]:
    """user_id -> display name (party name, else the login email). A row
    that resolves to neither, or an id absent from `users` (e.g. a
    SET-NULL'd author), is simply absent from the map — the caller
    renders "Unknown" / "someone" (B17, T-COM-110)."""
    wanted = {i for i in ids if i is not None}
    if not wanted:
        return {}
    rows = session.execute(
        select(User.id, User.email, Party.name)
        .select_from(User)
        .join(Party, Party.id == User.person_party_id, isouter=True)
        .where(User.id.in_(wanted))
    ).all()
    out: dict[uuid.UUID, str] = {}
    for uid, email, party_name in rows:
        name = party_name or (email or None)
        if name:
            out[uid] = name
    return out


def _serialize(comment: Comment, names: dict[uuid.UUID, str], actor) -> dict:
    author_name = names.get(comment.author_id) if comment.author_id else None
    resolved_by_name = names.get(comment.resolved_by) if comment.resolved_by else None
    return {
        "id": str(comment.id),
        "body": comment.body,
        "author_id": str(comment.author_id) if comment.author_id else None,
        # "Unknown" is the resolved label the deleted-author case renders
        # (B17); resolved_by_name stays null so the frontend can render
        # the "Resolved by someone" variant.
        "author_name": author_name or "Unknown",
        "created_at": comment.created_at.isoformat() if comment.created_at else None,
        "resolved": comment.resolved_at is not None,
        "resolved_at": comment.resolved_at.isoformat() if comment.resolved_at else None,
        "resolved_by": str(comment.resolved_by) if comment.resolved_by else None,
        "resolved_by_name": resolved_by_name,
        "can_delete": _can_delete(actor, comment),
    }


def _scalar_image(comment: Comment) -> dict:
    """A JSON-serializable image of one comments row for the audit
    trail (mirrors the invoice/entry snapshot helpers)."""
    out: dict = {}
    for column in Comment.__table__.columns:
        value = getattr(comment, column.name)
        if isinstance(value, uuid.UUID):
            value = str(value)
        elif isinstance(value, datetime | date):
            value = value.isoformat()
        elif isinstance(value, Decimal):
            value = str(value)
        out[column.name] = value
    return out


def _get_comment(session: Session, actor, comment_id: uuid.UUID) -> Comment:
    """Tenant-scoped fetch. A missing / cross-tenant id is a 404 — the
    legacy silent no-op is fixed (T-COM-113)."""
    comment = session.scalars(
        select(Comment).where(
            Comment.id == comment_id, Comment.tenant_id == actor.tenant_id
        )
    ).first()
    if comment is None:
        raise ServiceError(
            404, "COMMENT_NOT_FOUND", "That comment no longer exists."
        )
    return comment


# --------------------------------------------------------------- reads


def list_comments(session: Session, actor) -> dict:
    """The bell payload: the newest `BELL_WINDOW` comments (all lifecycle
    states, newest-first — the frontend partitions Open/Resolved) plus a
    REAL tenant-wide unresolved count for the badge (T-COM-106)."""
    _require_member(actor)
    comments = list(
        session.scalars(
            select(Comment)
            .where(Comment.tenant_id == actor.tenant_id)
            .order_by(Comment.created_at.desc(), Comment.id.desc())
            .limit(BELL_WINDOW)
        ).all()
    )
    ids = [c.author_id for c in comments] + [c.resolved_by for c in comments]
    names = _display_names(session, ids)
    unresolved_count = (
        session.scalar(
            select(func.count())
            .select_from(Comment)
            .where(
                Comment.tenant_id == actor.tenant_id,
                Comment.resolved_at.is_(None),
            )
        )
        or 0
    )
    return {
        "comments": [_serialize(c, names, actor) for c in comments],
        "unresolved_count": int(unresolved_count),
        "window": BELL_WINDOW,
    }


# -------------------------------------------------------------- writes


def post_comment(session: Session, actor, raw_body: str | None) -> dict:
    """B1/B3/B4: trim, reject empty, insert attributed to the EFFECTIVE
    user (view-as posts author AS the impersonated user — T-COM-108)."""
    _require_member(actor)
    body = (raw_body or "").strip()
    if not body:
        raise ServiceError(422, "BODY_REQUIRED", "Comment body is required.")

    comment = Comment(
        id=uuid.uuid4(),
        tenant_id=actor.tenant_id,
        author_id=actor.user_id,  # the EFFECTIVE user (ADR #033)
        body=body,
    )
    session.add(comment)
    session.flush()
    write_audit(
        session,
        actor,
        action="comments.post",
        entity_type="comment",
        entity_id=comment.id,
        after={"comment": _scalar_image(comment)},
    )
    session.commit()
    names = _display_names(session, [comment.author_id])
    return _serialize(comment, names, actor)


def resolve_comment(session: Session, actor, comment_id: uuid.UUID) -> dict:
    """B8/B9: stamp resolved_at=now + resolved_by=effective user. NO
    state precondition — re-resolving an already-resolved comment
    RE-STAMPS to the new actor/time (T-COM-115)."""
    _require_member(actor)
    comment = _get_comment(session, actor, comment_id)
    before = _scalar_image(comment)
    comment.resolved_at = datetime.now(UTC)
    comment.resolved_by = actor.user_id
    write_audit(
        session,
        actor,
        action="comments.resolve",
        entity_type="comment",
        entity_id=comment.id,
        before={"comment": before},
        after={"comment": _scalar_image(comment)},
    )
    session.commit()
    names = _display_names(session, [comment.author_id, comment.resolved_by])
    return _serialize(comment, names, actor)


def reopen_comment(session: Session, actor, comment_id: uuid.UUID) -> dict:
    """B10/B9: clear the resolve stamps. Reopening an already-open
    comment is a no-op write (no precondition, no error — T-COM-115)."""
    _require_member(actor)
    comment = _get_comment(session, actor, comment_id)
    before = _scalar_image(comment)
    comment.resolved_at = None
    comment.resolved_by = None
    write_audit(
        session,
        actor,
        action="comments.reopen",
        entity_type="comment",
        entity_id=comment.id,
        before={"comment": before},
        after={"comment": _scalar_image(comment)},
    )
    session.commit()
    names = _display_names(session, [comment.author_id])
    return _serialize(comment, names, actor)


def delete_comment(session: Session, actor, comment_id: uuid.UUID) -> dict:
    """B12: author-only (or not-viewing-as super admin), ENFORCED
    server-side. A non-author member gets an explicit 403 — the legacy
    RLS silent no-op is fixed (T-COM-103). Missing id -> 404 (T-COM-113,
    checked before authorization so authorization never leaks less than
    read already does — every member can read every comment)."""
    _require_member(actor)
    comment = _get_comment(session, actor, comment_id)
    if not _can_delete(actor, comment):
        raise ServiceError(
            403,
            "NOT_COMMENT_AUTHOR",
            "Only the person who wrote a comment (or an admin) can delete it.",
        )
    image = _scalar_image(comment)
    session.delete(comment)
    write_audit(
        session,
        actor,
        action="comments.delete",
        entity_type="comment",
        entity_id=comment_id,
        before={"comment": image},
    )
    session.commit()
    return {"ok": True, "deleted": str(comment_id)}
