"""The conversation (M4): comments + attachments on a ball.

Comments interleave with touches into the rally timeline (plan §3.5) — the
thread IS the play-by-play. This module reads and writes RAW truth;
perspective scoping (plan §4.3's "comments are scoped, not just
anonymized" — landed at M6) is applied at the API boundary by
services.projection.project_comments over the acting_node_id every
comment has carried since this migration. The write-side counterpart
lives in services.notifications: mention/thread_reply recipients are
scoped by the same node visibility, so a sub-court comment never leaks
through a bell preview either.

Errors raise ConversationError carrying the structured shape
({error_code, summary, detail}) the router serializes — same error
language as services.board.BoardError.
"""
from __future__ import annotations

import datetime
import re

from sqlalchemy import select
from sqlalchemy.orm import Session

from .. import events
from ..models import Attachment, Ball, Comment, Court, Player
from . import attachments as attachments_service
from . import notifications as notifications_service
from . import touches as touch_service

# The only parent_type in use today (plan §5: the comments table is
# polymorphic by design, but the allowed-values check lives here, not a DB
# CHECK/enum — see models.conversation's docstring). A future parent kind
# (e.g. comments on a touch) adds a value here, not a migration.
PARENT_TYPE_BALL = "ball"

# Server-side mention regex (review-patterns-ref §2.6.1): a full email after
# '@'. Deliberately stricter than the client's caret-token detection (ref
# §3.4), which matches partial/in-progress input on every keystroke — this
# one runs once, over the finished, stored body.
MENTION_RE = re.compile(r"@([A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,})")


class ConversationError(Exception):
    """Structured service failure; router turns it into HTTP JSON (mirrors
    services.board.BoardError)."""

    def __init__(self, status_code: int, error_code: str, summary: str, detail: str = ""):
        super().__init__(summary)
        self.status_code = status_code
        self.error_code = error_code
        self.summary = summary
        self.detail = detail


def parse_mentions(body: str) -> list[str]:
    """@mentions in a comment body: the emails MENTION_RE matches, order-
    preserving de-dup. No lookup against `players` here — that resolution
    (and the notification it would trigger) is M5; this just surfaces the
    raw matches for the caller to return to the client."""
    if not body:
        return []
    return list(dict.fromkeys(MENTION_RE.findall(body)))


def _require_author(comment: Comment, actor: Player | None) -> None:
    """Author-gated edit/delete (review-learnings §9). An actor with no
    Player row, or a comment with no recorded author, can never satisfy
    this check — equality is never accidentally granted on two Nones."""
    if actor is None or comment.author_id is None or actor.id != comment.author_id:
        raise ConversationError(
            403,
            "AUTHOR_ONLY",
            "Only the comment's author can do this.",
            "Sign in as the author to edit or delete this comment.",
        )


def _attachments_for(db: Session, comment_id) -> list[Attachment]:
    return (
        db.execute(
            select(Attachment)
            .where(Attachment.comment_id == comment_id)
            .order_by(Attachment.created_at)
        )
        .scalars()
        .all()
    )


def _serialize(
    comment: Comment, attachments: list[Attachment], mentions: list[str] | None = None
) -> dict:
    """The CommentOut shape as a plain dict. ball_id replaces the DB's
    parent_id at the wire boundary — only 'ball' parents exist today, so the
    API doesn't need to expose the polymorphism the DB keeps for later."""
    return {
        "id": comment.id,
        "ball_id": comment.parent_id,
        "author_id": comment.author_id,
        "acting_node_id": comment.acting_node_id,
        "body": comment.body,
        "created_at": comment.created_at,
        "edited_at": comment.edited_at,
        "attachments": attachments,
        "mentions": mentions or [],
    }


def _link_attachments(db: Session, comment_id, attachments: list[dict]) -> list[Attachment]:
    """Create Attachment rows for already-staged uploads (services
    .attachments.store_upload already wrote the file to disk; this just
    claims it for a comment). Re-validates the filename (belt-and-braces —
    a client can't manufacture a row pointing outside upload_dir) and
    requires the file to actually exist on disk."""
    rows = []
    for att in attachments:
        file_path = att["file_path"]
        try:
            abs_path = attachments_service.resolve_upload_path(file_path)
        except ValueError as e:
            raise ConversationError(
                400,
                "INVALID_ATTACHMENT_PATH",
                str(e),
                "file_path must be the bare filename POST /api/attachments returned.",
            )
        if not abs_path.is_file():
            raise ConversationError(
                400,
                "ATTACHMENT_NOT_FOUND",
                f"Uploaded file {file_path!r} was not found.",
                "Upload the file via POST /api/attachments first, then reference "
                "its file_path here.",
            )
        row = Attachment(
            comment_id=comment_id,
            file_path=file_path,
            mime=att.get("mime") or "",
            bytes=att.get("bytes") or 0,
        )
        db.add(row)
        rows.append(row)
    return rows


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


def list_comments(db: Session, ball_id) -> list[dict]:
    """A ball's comments, oldest first (the rally timeline reads top-down
    like the touches it interleaves with), each with its attachments.
    Flat — see module docstring."""
    comments = (
        db.execute(
            select(Comment)
            .where(Comment.parent_type == PARENT_TYPE_BALL, Comment.parent_id == ball_id)
            .order_by(Comment.created_at)
        )
        .scalars()
        .all()
    )
    comment_ids = [c.id for c in comments]
    by_comment: dict = {cid: [] for cid in comment_ids}
    if comment_ids:
        atts = (
            db.execute(
                select(Attachment)
                .where(Attachment.comment_id.in_(comment_ids))
                .order_by(Attachment.created_at)
            )
            .scalars()
            .all()
        )
        for a in atts:
            by_comment[a.comment_id].append(a)
    return [_serialize(c, by_comment.get(c.id, [])) for c in comments]


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


def create_comment(
    db: Session,
    ball: Ball,
    author: Player | None,
    body: str,
    created_at_override: datetime.datetime | None = None,
    attachments: list[dict] | None = None,
) -> dict:
    """Post a comment on a ball. author_id/acting_node_id are stamped
    server-side (ref §2.7 port note: the client can't claim someone else's
    authorship); acting_node_id uses the exact same lookup touches uses for
    the same purpose (services.touches.acting_node_id_for), so a comment and
    a touch written in the same moment land in the same visibility scope.
    Returns the CommentOut-shaped dict (mentions populated; list_comments
    leaves them at [] — parsing is cheap but resolving/notifying is M5, and
    only the just-created comment's mentions are useful to hand back)."""
    court = db.get(Court, ball.court_id)
    comment = Comment(
        parent_type=PARENT_TYPE_BALL,
        parent_id=ball.id,
        author_id=author.id if author else None,
        acting_node_id=touch_service.acting_node_id_for(db, author, court),
        body=body,
    )
    if created_at_override is not None:
        comment.created_at = created_at_override  # author backdating, learnings §9
    db.add(comment)
    db.flush()  # populate comment.id for the attachment FK below

    attachment_rows = _link_attachments(db, comment.id, attachments or [])
    mentions = parse_mentions(body)

    # M5 hook: mention + thread_reply notifications, same transaction as
    # this comment insert (plan §6.4 / services.notifications).
    notifications_service.notify_comment_created(db, ball, comment, author, mentions)

    db.commit()
    db.refresh(comment)
    for row in attachment_rows:
        db.refresh(row)

    events.publish(
        {"type": "comment.created", "ball_id": str(ball.id), "comment_id": str(comment.id)}
    )
    return _serialize(comment, attachment_rows, mentions)


def update_comment(
    db: Session,
    comment: Comment,
    actor: Player | None,
    body: str | None = None,
    created_at: datetime.datetime | None = None,
) -> dict:
    """Author-gated. A body edit stamps edited_at; a created_at override
    (backdating, review-learnings §9) does NOT by itself — backdating isn't
    "this text changed," so it shouldn't render an "(edited)" mark alone."""
    _require_author(comment, actor)

    if body is not None:
        comment.body = body
        comment.edited_at = datetime.datetime.now(datetime.timezone.utc)
    if created_at is not None:
        comment.created_at = created_at

    db.commit()
    db.refresh(comment)

    attachments = _attachments_for(db, comment.id)
    events.publish(
        {
            "type": "comment.updated",
            "ball_id": str(comment.parent_id),
            "comment_id": str(comment.id),
        }
    )
    return _serialize(comment, attachments)


def delete_comment(db: Session, comment: Comment, actor: Player | None) -> None:
    """Author-gated. Attachment rows cascade at the DB level; their files
    are unlinked best-effort first — a missing or already-gone file must
    never block the delete."""
    _require_author(comment, actor)

    ball_id = comment.parent_id
    comment_id = comment.id
    for att in _attachments_for(db, comment.id):
        try:
            path = attachments_service.resolve_upload_path(att.file_path)
            path.unlink(missing_ok=True)
        except (ValueError, OSError):
            pass  # best-effort — disk cleanup never blocks the delete

    db.delete(comment)
    db.commit()
    events.publish(
        {"type": "comment.deleted", "ball_id": str(ball_id), "comment_id": str(comment_id)}
    )
