"""The Interaction Standard embryo — threads, comments, mentions, and the local
notification inbox. Table shapes are byte-compatible with caddie's
04-interaction-standard.md §4/§5; this module is the thin service the future
`bw_interaction` kit replaces, so keep its public surface small and boring:
routers call these functions and never touch the tables directly.

Recipient authority (§5): a notification only ever lands for a user who can
actually see the project it is about — enforced here, not trusted from callers.
"""

import logging
import re
from datetime import datetime, timezone

from sqlalchemy import select
from sqlalchemy.orm import Session

from app import accounts
from app import bw_accounts as bwa
from app import bw_config
from app.models import Comment, Notification, Thread

log = logging.getLogger(__name__)

MENTION_RE = re.compile(r"(?<![\w.@-])@([a-z0-9][a-z0-9_.-]{1,31})", re.IGNORECASE)
BODY_PREVIEW_LEN = 160
_IMG_MD = re.compile(r"!\[[^\]]*\]\([^)]*\)")


def _preview(body_md: str) -> str:
    text = _IMG_MD.sub("", body_md or "")
    text = re.sub(r"\s+", " ", text).strip()
    return text[:BODY_PREVIEW_LEN]


def _project_audience(project_id: str) -> set[str]:
    """Everyone with access to this project (any effective level), lowercased.
    The owner sees everything but is a code-level super admin, not a member row —
    include them explicitly so 'notify the team' reaches rian."""
    users = {m["username"].lower() for m in bwa.members()
             if bwa.effective_level(m["username"], project_id) is not None}
    users.add(bw_config.OWNER)
    return users


_NAME_TTL = 600.0
_names: dict[str, tuple[str, float]] = {}


def display_name(username: str) -> str:
    """First and last name from the directory, cached ten minutes; the
    username itself when the directory has nothing or cannot be reached."""
    import time

    hit = _names.get(username)
    if hit and hit[1] > time.monotonic():
        return hit[0]
    name = username
    try:
        from app import bw_auth

        info = bw_auth.userinfo(username) or {}
        name = " ".join(x for x in (info.get("first", ""), info.get("last", "")) if x).strip() or username
    except Exception:  # noqa: BLE001 - a directory hiccup shows the username, never fails a search
        pass
    _names[username] = (name, time.monotonic() + _NAME_TTL)
    return name


def people(project_id: str, q: str = "", limit: int = 8) -> list[dict]:
    """Who can be @mentioned on this project: exactly the audience `notify`
    accepts, matched on the username or the name, username first."""
    q = (q or "").strip().lower()
    rows = []
    for user in sorted(_project_audience(project_id)):
        name = display_name(user)
        if q and not (user.startswith(q) or any(part.startswith(q) for part in name.lower().split())
                      or name.lower().startswith(q)):
            continue
        rows.append({"username": user, "name": name,
                     "_rank": 0 if user.startswith(q) else 1})
    rows.sort(key=lambda r: (r["_rank"], r["username"]))
    return [{"username": r["username"], "name": r["name"]} for r in rows[:limit]]


def get_or_create_thread(db: Session, *, project_id: str, subject_type: str,
                         subject_id: str, title: str, created_by: str) -> Thread:
    row = db.scalar(select(Thread).where(Thread.subject_type == subject_type,
                                         Thread.subject_id == subject_id))
    if row:
        return row
    row = Thread(project_id=project_id, subject_type=subject_type,
                 subject_id=subject_id, title=title, created_by=created_by)
    db.add(row)
    db.flush()
    return row


def add_comment(db: Session, thread: Thread, author: str, body_md: str,
                *, url: str, context_label: str, category: str = "feedback",
                app_name: str | None = None) -> Comment:
    """Append a comment and emit the standard's notifications: one per distinct
    @mention, plus thread-participation replies. Mention parsing is server-side,
    on write — the client's payload never decides who gets notified."""
    comment = Comment(thread_id=thread.id, body_md=body_md, author_username=author)
    db.add(comment)
    db.flush()

    audience = _project_audience(thread.project_id or "")
    mentioned: set[str] = set()
    for m in MENTION_RE.finditer(body_md or ""):
        candidate = m.group(1).lower()
        # A self-mention is allowed, as review allows it: a reminder to
        # yourself lights your own bell.
        if candidate in audience:
            mentioned.add(candidate)

    participants = {c.author_username.lower() for c in thread.comments
                    if c.deleted_at is None}
    participants.add(thread.created_by.lower())
    repliees = (participants & audience) - mentioned - {author}

    for user in mentioned:
        notify(db, recipient=user, actor=author, kind="mention",
               category=category, context_label=context_label,
               body=_preview(body_md), url=url,
               source_type="comment", source_id=str(comment.id),
               project_id=thread.project_id,
               dedupe_key=f"comment:{comment.id}:{user}", app_name=app_name,
               allow_self=True)
    # Moment two of two: a client who closes the tab learns nothing until they
    # come back, so the one thing worth chasing them for is an answer they are
    # waiting on. Only a MANAGER's reply mails, and only to the client side —
    # us replying to each other is not news to anyone.
    from app.services import outbound
    from app.services import projects as _svc

    author_side = _svc.side_of(author, thread.project_id or "")
    for user in repliees:
        if author_side == "manager" and \
                _svc.side_of(user, thread.project_id or "") == "client":
            outbound.queue(
                db, recipient=user, kind="replied",
                project_id=thread.project_id,
                subject=f"We replied to your note on {context_label}",
                url=url, body=body_md)

    for user in repliees:
        notify(db, recipient=user, actor=author, kind="reply",
               category=category, context_label=context_label,
               body=_preview(body_md), url=url,
               source_type="comment", source_id=str(comment.id),
               project_id=thread.project_id,
               dedupe_key=f"comment:{comment.id}:{user}", app_name=app_name)

    # Every comment from the client side reaches the team. Mentions and replies
    # in a thread you are in (review's rule) cover a conversation already
    # going; they say nothing when a client STARTS one, which is most client
    # feedback, so a client could leave forty notes and nobody on the team
    # would hear. Anyone already told above is not told twice (one
    # notification per comment per person, by the dedupe key).
    if author_side == "client":
        team = set(_svc.manager_side(thread.project_id or "")) | {bw_config.OWNER}
        for user in sorted(team - mentioned - repliees - {author.lower()}):
            notify(db, recipient=user, actor=author, kind="comment",
                   category=category, context_label=context_label,
                   body=_preview(body_md), url=url,
                   source_type="comment", source_id=str(comment.id),
                   project_id=thread.project_id,
                   dedupe_key=f"comment:{comment.id}:{user}", app_name=app_name)
    return comment


def set_resolved(db: Session, thread: Thread, actor: str, resolved: bool,
                 *, url: str, context_label: str) -> None:
    thread.resolved = resolved
    db.flush()
    if resolved:
        # `resolved` clears the nag: mark this thread's unread notifications read
        # (the standard's resolved-kind semantics, embryo-local form) and tell
        # the participants who aren't the actor.
        now = datetime.now(timezone.utc)
        for n in db.scalars(select(Notification).where(
                Notification.source_type == "comment",
                Notification.read_at.is_(None))).all():
            comment = db.get(Comment, int(n.source_id)) if n.source_id.isdigit() else None
            if comment is not None and comment.thread_id == thread.id:
                n.read_at = now
        audience = _project_audience(thread.project_id or "")
        participants = ({c.author_username.lower() for c in thread.comments}
                        | {thread.created_by.lower()}) & audience
        for user in participants - {actor}:
            notify(db, recipient=user, actor=actor, kind="resolved",
                   category="feedback", context_label=context_label,
                   body="Resolved.", url=url, source_type="thread",
                   source_id=str(thread.id), project_id=thread.project_id,
                   dedupe_key=f"thread-resolved:{thread.id}:{user}")


def notify(db: Session, *, recipient: str, actor: str, kind: str, category: str,
           context_label: str, body: str, url: str, source_type: str,
           source_id: str, project_id: str | None, dedupe_key: str,
           app_name: str | None = None, allow_self: bool = False) -> None:
    """Idempotent single-recipient emission. Never the author of the action
    (except a deliberate self-mention); never someone outside the project's
    audience (recipient authority)."""
    recipient = recipient.lower()
    if recipient == actor.lower() and not allow_self:
        return
    if project_id and recipient != bw_config.OWNER and \
            bwa.effective_level(recipient, project_id) is None:
        return
    if db.scalar(select(Notification).where(Notification.dedupe_key == dedupe_key)):
        return
    db.add(Notification(
        recipient=recipient, actor=actor, app=app_name or bw_config.APP_NAME,
        kind=kind, category=category, context_label=context_label,
        body=body, url=url, source_type=source_type, source_id=source_id,
        project_id=project_id, dedupe_key=dedupe_key))
    db.flush()


def notify_project_side(db: Session, *, project_id: str, actor: str,
                        side: str, kind: str, category: str, context_label: str,
                        body: str, url: str, source_type: str, source_id: str,
                        dedupe_prefix: str) -> None:
    """Fan one event out to a whole side of a project: 'client' (no manage
    permission) or 'manager' (has it). Used for send / selection / approval."""
    for user in sorted(_project_audience(project_id)):
        manages = accounts.is_owner(user) or accounts.instance_can(
            user, project_id, accounts.PERM_PROJECTS_MANAGE)
        if (side == "manager") != manages:
            continue
        notify(db, recipient=user, actor=actor, kind=kind, category=category,
               context_label=context_label, body=body, url=url,
               source_type=source_type, source_id=source_id,
               project_id=project_id, dedupe_key=f"{dedupe_prefix}:{user}")


def reactions_of(comment) -> list[dict]:
    """A comment's reactions grouped by emoji, in the order each emoji was
    first used, with who used it. A removed comment carries none."""
    if comment.deleted_at is not None:
        return []
    grouped: dict[str, list[str]] = {}
    for r in comment.reactions:
        grouped.setdefault(r.emoji, []).append(r.username)
    return [{"emoji": e, "users": u} for e, u in grouped.items()]
