"""Comments + notifications — the Interaction Standard (04), extracted from
punchlist's reference implementation and generalised. This module is the
extraction source for the `bw_interaction` kit: nothing here is caddie-specific
except the deep-link shape, which is one function.

The split that matters (04 §2): **comments live with the subject in the app
that owns it; notifications aggregate into ONE inbox.** A comment on a stage
belongs to caddie forever; the bell is the piece that will one day show
punchlist's mentions too.

Rules carried over from review by way of punchlist, each earned:
  * **Mentions are parsed SERVER-side.** The client payload never decides who
    gets notified; a crafted body must not be able to page anyone.
  * **Never notify the author of their own action**, and dedupe every emit, so
    a retry or a double-click cannot produce two bells.
  * **Resolved propagates** (04 §3): resolving a thread flags the notifications
    it raised, so resolved work stops nagging; reopening un-flags them.
  * **Never hard-delete a conversation** (04 §4): a comment is soft-deleted and
    drops out of reads; the row stays.
  * **A conversation can be re-filed** under another subject (`move_thread`);
    the comments keep their authors and times.

The stored notification is the §5 event — absolute `url` included — even
though the inbox is local today: the joint gate then repoints instead of
migrating.
"""

import re

from sqlalchemy import select

from app import accounts
from app import bw_accounts as bwa
from app.config import get_settings
from app.models import Comment, Notification, Thread, utcnow

_MENTION = re.compile(r"@([a-zA-Z0-9_.-]{2,64})")
_PREVIEW_LEN = 160
APP_NAME = "caddie"

# The 04 §5 vocabulary. `mention` and `turn` are addressed to you; the rest is
# news. `decision` is something DECIDED (an approval given, a direction picked)
# — it closes a loop, so it must never render as "needs you".
KINDS = {"mention", "reply", "turn", "status", "resolved", "decision"}
NEEDS_YOU = {"mention", "turn"}

SUBJECT_TYPES = {"project", "stage"}


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


def _abs(path: str) -> str:
    """Absolute, because the inbox is cross-app (04 §5)."""
    base = (get_settings().bw_app_domain or "").rstrip("/")
    return f"{base}{path}"


def deep_link(project_id: str, subject_type: str, subject_id: str,
              comment_id: str | None = None) -> str:
    """The one caddie-specific line: where a subject lives in the UI. A stage
    opens via `?stage=`; the project page itself needs no query. `#c-{id}`
    lands on the comment; the client resolves it by id, so a re-filed
    conversation's old links still land."""
    path = f"/projects/{project_id}"
    if subject_type == "stage":
        path += f"?stage={subject_id}"
    if comment_id:
        path += f"#c-{comment_id}"
    return _abs(path)


# ─────────────────────────────────────────────────────────────── threads

def thread_of(session, *, subject_type: str, subject_id: str) -> Thread | None:
    return session.execute(select(Thread).where(
        Thread.subject_type == subject_type,
        Thread.subject_id == subject_id)).scalars().first()


def thread_for(session, *, subject_type: str, subject_id: str,
               project_id: str | None) -> Thread:
    t = thread_of(session, subject_type=subject_type, subject_id=subject_id)
    if t is None:
        t = Thread(subject_type=subject_type, subject_id=subject_id,
                   project_id=project_id)
        session.add(t)
        session.flush()
    return t


def live_comments(session, thread_id: str) -> list[Comment]:
    """The conversation as a reader sees it: soft-deleted rows excluded."""
    return list(session.execute(
        select(Comment).where(Comment.thread_id == thread_id,
                              Comment.deleted_at.is_(None))
        .order_by(Comment.created_at, Comment.id)).scalars())


def comments_of(session, *, subject_type: str, subject_id: str) -> list[Comment]:
    t = thread_of(session, subject_type=subject_type, subject_id=subject_id)
    return live_comments(session, t.id) if t else []


def comment_out(c: Comment) -> dict:
    return {"id": c.id, "by": c.author, "kind": c.author_kind, "body": c.body,
            "at": c.created_at.isoformat(),
            "edited_at": c.edited_at.isoformat() if c.edited_at else None}


def thread_out(session, t: Thread | None) -> dict | None:
    if t is None:
        return None
    return {"id": t.id, "resolved": t.resolved,
            "resolved_by": t.resolved_by,
            "resolved_at": t.resolved_at.isoformat() if t.resolved_at else None,
            "comments": [comment_out(c) for c in live_comments(session, t.id)]}


def discussion_of(session, subjects: list[dict]) -> dict:
    """One read per work surface (04 §4): every subject the surface has, in
    the order given, each with its conversation or `null` (a starter). The
    caller decides which subjects the reader may see."""
    out, open_n, total = [], 0, 0
    for s in subjects:
        t = thread_of(session, subject_type=s["subject_type"], subject_id=s["subject_id"])
        th = thread_out(session, t)
        if th and not th["comments"]:
            th = None                       # every comment deleted: back to a starter
        if th:
            total += 1
            open_n += 0 if th["resolved"] else 1
        out.append({**s, "thread": th})
    return {"subjects": out, "open": open_n, "total": total}


# ───────────────────────────────────────────────────────────────── sides

def agency_users() -> list[str]:
    """Anyone who may compose — caddie's internal side."""
    return [m["username"] for m in bwa.members()
            if accounts.app_can(m["username"], accounts.PERM_COMPOSE)]


def client_users_of(project_id: str) -> list[str]:
    out = []
    for m in bwa.members():
        u = m["username"]
        if accounts.app_can(u, accounts.PERM_COMPOSE):
            continue
        if m.get("grants", {}).get(project_id) or m.get("all_instances"):
            out.append(u)
    return out


def _known_users() -> set[str]:
    return {m["username"] for m in bwa.members()} | {bwa._state["owner"]}


def _other_side(project_id: str, author_kind: str) -> set[str]:
    return set(client_users_of(project_id) if author_kind == "agency" else agency_users())


# ─────────────────────────────────────────────────────────────────  emit

def _emit(session, *, recipient: str, actor: str, kind: str, project_id: str,
          context_label: str, category: str, body: str, url: str,
          source_type: str, source_id: str, dedupe: str) -> None:
    assert kind in KINDS, kind
    if recipient == actor:
        return                          # never notify the author of their own action
    key = f"{dedupe}:{recipient}"
    if session.execute(select(Notification.id).where(
            Notification.dedupe_key == key,
            Notification.recipient == recipient)).first():
        return
    session.add(Notification(
        recipient=recipient, actor=actor, app=APP_NAME, kind=kind,
        category=category, context_label=context_label, body=_preview(body),
        url=url, source_type=source_type, source_id=source_id,
        project_id=project_id, dedupe_key=key))


def _notify_comment(session, c: Comment, t: Thread, *, project_id: str,
                    context_label: str, category: str, author_kind: str,
                    only_new_mentions: bool = False) -> None:
    """Mentions from the STORED body, then the reply fan-out: everyone who took
    part in the thread, plus the other side of the conversation."""
    url = deep_link(project_id, t.subject_type, t.subject_id, c.id)
    mentioned = {u for u in _MENTION.findall(c.body) if u in _known_users()}
    common = dict(actor=c.author, project_id=project_id, context_label=context_label,
                  category=category, body=c.body, url=url,
                  source_type="comment", source_id=c.id)
    for u in mentioned:
        _emit(session, recipient=u, kind="mention", dedupe=f"mention:{c.id}", **common)
    if only_new_mentions:
        return                          # an edit never re-rings the reply fan-out
    participants = {x.author for x in live_comments(session, t.id)} - {c.author}
    for u in (participants | _other_side(project_id, author_kind)) - mentioned:
        _emit(session, recipient=u, kind="reply", dedupe=f"reply:{c.id}", **common)


def add_comment(session, *, project_id: str, subject_type: str, subject_id: str,
                context_label: str, category: str, author: str,
                author_kind: str, body: str) -> Comment:
    """The one write path for conversation. Mentions are parsed here, from the
    stored body — never taken from the client."""
    assert subject_type in SUBJECT_TYPES, subject_type
    body = (body or "").strip()
    if not body:
        raise ValueError("empty comment")
    t = thread_for(session, subject_type=subject_type, subject_id=subject_id,
                   project_id=project_id)
    c = Comment(thread_id=t.id, author=author, author_kind=author_kind, body=body)
    session.add(c)
    session.flush()
    _notify_comment(session, c, t, project_id=project_id, context_label=context_label,
                    category=category, author_kind=author_kind)
    return c


def edit_comment(session, c: Comment, *, project_id: str, context_label: str,
                 category: str, body: str) -> Comment:
    """The author rewrites their own words. A newly named person is mentioned;
    nobody else hears an edit."""
    body = (body or "").strip()
    if not body:
        raise ValueError("empty comment")
    c.body = body
    c.edited_at = utcnow()
    session.flush()
    t = session.get(Thread, c.thread_id)
    _notify_comment(session, c, t, project_id=project_id, context_label=context_label,
                    category=category, author_kind=c.author_kind, only_new_mentions=True)
    return c


def delete_comment(session, c: Comment) -> None:
    """Soft: the row stays, the reader stops seeing it."""
    c.deleted_at = utcnow()
    session.flush()


def _thread_notifications(session, t: Thread):
    ids = list(session.execute(
        select(Comment.id).where(Comment.thread_id == t.id)).scalars())
    if not ids:
        return []
    return list(session.execute(select(Notification).where(
        Notification.source_type == "comment",
        Notification.source_id.in_(ids))).scalars())


def resolve_thread(session, t: Thread, *, actor: str, resolved: bool,
                   project_id: str, context_label: str, category: str,
                   actor_kind: str) -> Thread:
    """Resolve or reopen. Resolving flags every notification the thread raised
    (they leave the unread count) and tells the people in it — once."""
    if t.resolved == resolved:
        return t
    t.resolved = resolved
    t.resolved_by = actor if resolved else None
    t.resolved_at = utcnow() if resolved else None
    for n in _thread_notifications(session, t):
        n.resolved = resolved
    session.flush()
    if resolved:
        url = deep_link(project_id, t.subject_type, t.subject_id)
        participants = {c.author for c in live_comments(session, t.id)}
        for u in (participants | _other_side(project_id, actor_kind)) - {actor}:
            _emit(session, recipient=u, actor=actor, kind="resolved",
                  project_id=project_id, context_label=context_label,
                  category=category, body="Resolved.", url=url,
                  source_type="thread", source_id=t.id,
                  dedupe=f"resolved:{t.id}:{int(t.resolved_at.timestamp())}")
    return t


def move_thread(session, t: Thread, *, subject_type: str, subject_id: str) -> Thread:
    """Re-file a conversation under another subject of the same project. Onto
    an empty subject the thread simply changes address (id, resolved state and
    comments intact); onto a subject that already has a conversation the
    comments join it — re-parented through the relationship, never deleted —
    and the empty source row goes. Returns the thread the comments now live in."""
    assert subject_type in SUBJECT_TYPES, subject_type
    target = thread_of(session, subject_type=subject_type, subject_id=subject_id)
    if target is None or target.id == t.id:
        t.subject_type, t.subject_id = subject_type, subject_id
        session.flush()
        return t
    for c in session.execute(select(Comment).where(Comment.thread_id == t.id)).scalars():
        c.thread_id = target.id
    session.flush()
    session.delete(t)
    session.flush()
    return target


def notify_stage(session, *, project_id: str, stage_id: str, title: str,
                 actor: str, verb: str, recipients: list[str]) -> None:
    """A stage moved. One line, deep-linked, deduped on the verb so re-running
    the same transition cannot double-ring. An approval is a DECISION — it
    closes a loop — where a stage starting or closing is status news."""
    body, kind = {"stage.active": ("This is underway.", "status"),
                  "stage.done": ("This one is closed.", "status"),
                  "approval.given": ("Approved.", "decision")}.get(verb, (verb, "status"))
    for u in recipients:
        _emit(session, recipient=u, actor=actor, kind=kind,
              project_id=project_id, context_label=title, category="stage",
              body=body, url=deep_link(project_id, "stage", stage_id),
              source_type="stage", source_id=stage_id,
              dedupe=f"{verb}:{stage_id}")


# ───────────────────────────────────────────────────────────────── inbox

def inbox(session, username: str, limit: int = 40) -> dict:
    rows = list(session.execute(
        select(Notification).where(Notification.recipient == username)
        .order_by(Notification.read, Notification.created_at.desc())
        .limit(limit)).scalars())
    # A resolved row is out of the count and the default list, and stays
    # reachable behind the bell's "Show resolved".
    unread = [r for r in rows if not r.read and not r.resolved]
    return {
        "unread": len(unread),
        # "needs you" is stronger than "unread": a mention or a turn is
        # addressed to you personally; a status change is news.
        "needs_you": any(r.kind in NEEDS_YOU for r in unread),
        "notifications": [{
            "id": r.id, "actor": r.actor, "app": r.app, "kind": r.kind,
            "category": r.category, "context_label": r.context_label,
            "body": r.body, "url": r.url, "project_id": r.project_id,
            "read": r.read, "resolved": r.resolved, "at": r.created_at.isoformat(),
        } for r in rows],
    }


def mark_read(session, username: str, ids: list[str] | None = None) -> int:
    q = select(Notification).where(Notification.recipient == username,
                                   Notification.read.is_(False))
    if ids:
        q = q.where(Notification.id.in_(ids))
    n = 0
    for r in session.execute(q).scalars():
        r.read = True
        n += 1
    session.flush()
    return n
