"""M5 notification behavior: mention (by email AND by bare @username,
self-mentions allowed), thread_reply (ball author + prior commenters), the
mention-beats-thread_reply dedup rule, action_to notifications (array-diff,
actor excluded) fired from the explicit patch/pass endpoints and the spike/
revision/reopen automations, resolved-state propagation (+ reopen flipping
it back), the read endpoints (own-only, someone else's id -> 404), and the
GET listing's cap + true-total counts — review-learnings §4 / review-
patterns-ref §2.6, relocated to the service layer per plan §6.4."""
from __future__ import annotations

from app import constants, events
from app.models import Ball, Court, Match, Notification, Side, TeamMember, TeamNode
from tests.conftest import make_player, mint_client_for


def _setup(db):
    """One ball on an issues court (author=erin, action_to=[erin] already),
    three real players sharing a team node — enough surface for mentions,
    thread replies, action_to moves, and resolve propagation."""
    rian = make_player(db, "rian", display_name="Rian", email="rian@rian.ca")
    erin = make_player(db, "erin", display_name="Erin", email="erin@brentwood.example")
    # No email on purpose: proves the @username mention path resolves
    # independently of email matching.
    adi = make_player(db, "adi", display_name="Adi", email=None)

    match = Match(slug="notifymatch", name="Notify Match")
    db.add(match)
    db.flush()
    side = Side(match_id=match.id, name="Bowden Works", position=0)
    db.add(side)
    db.flush()
    node = TeamNode(side_id=side.id, name="Front line", boundary_player_id=rian.id)
    db.add(node)
    db.flush()
    for p in (rian, erin, adi):
        db.add(TeamMember(match_id=match.id, node_id=node.id, player_id=p.id))

    court = Court(
        match_id=match.id,
        config_key="issues",
        name="Issues",
        position=0,
        config=constants.COURT_CONFIGS["issues"],
    )
    db.add(court)
    db.flush()

    ball = Ball(
        court_id=court.id,
        title="Footer bug",
        body="the footer is broken on mobile",
        status="Open",
        category="Revision",
        action_to=[erin.id],
        author_id=erin.id,
    )
    db.add(ball)
    db.commit()
    return {"rian": rian, "erin": erin, "adi": adi, "match": match, "court": court, "ball": ball}


def _notifications_for(db, player) -> list[Notification]:
    return (
        db.query(Notification)
        .filter(Notification.recipient_id == player.id)
        .order_by(Notification.created_at)
        .all()
    )


# ------------------------------------------------------------------ mentions


def test_mention_by_email_notifies_player_and_matches_endpoint_shape(client, db_session):
    b = _setup(db_session)
    c = mint_client_for(client, "rian")
    resp = c.post(
        f"/api/balls/{b['ball'].id}/comments",
        json={"body": "cc @erin@brentwood.example thanks"},
    )
    assert resp.status_code == 201, resp.text
    comment_id = resp.json()["id"]

    ce = mint_client_for(client, "erin")
    listed = ce.get("/api/notifications").json()
    assert listed["unread_count"] == 1
    assert listed["unresolved_count"] == 1
    item = listed["items"][0]
    assert item["kind"] == "mention"
    assert item["actor_id"] == str(b["rian"].id)
    assert item["recipient_id"] == str(b["erin"].id)
    assert item["category"] == "Revision"  # ball.category, denormalized
    assert item["context_label"] == "Footer bug"  # ball.title, denormalized
    assert item["body"] == "cc @erin@brentwood.example thanks"
    assert item["link"] == f"/?focus=ball:{b['ball'].id}&comment={comment_id}"
    assert item["source_type"] == "comment"
    assert item["source_id"] == comment_id
    assert item["ball_id"] == str(b["ball"].id)
    assert item["read"] is False
    assert item["resolved"] is False


def test_mention_by_bare_username_notifies_player(client, db_session):
    b = _setup(db_session)
    c = mint_client_for(client, "rian")
    # adi has NO email (see _setup) -- this only resolves via username.
    resp = c.post(f"/api/balls/{b['ball'].id}/comments", json={"body": "cc @adi, please take a look"})
    assert resp.status_code == 201, resp.text

    adi_notifs = _notifications_for(db_session, b["adi"])
    assert len(adi_notifs) == 1
    assert adi_notifs[0].kind == "mention"
    assert adi_notifs[0].actor_id == b["rian"].id

    # mentioning adi doesn't stop erin's OWN author-subscription thread_reply
    # for this same comment (dedup is per-recipient, not a global switch).
    erin_notifs = _notifications_for(db_session, b["erin"])
    assert len(erin_notifs) == 1
    assert erin_notifs[0].kind == "thread_reply"


def test_self_mention_allowed(client, db_session):
    b = _setup(db_session)
    c = mint_client_for(client, "rian")
    resp = c.post(
        f"/api/balls/{b['ball'].id}/comments", json={"body": "note to self @rian@rian.ca"}
    )
    assert resp.status_code == 201, resp.text

    rian_notifs = _notifications_for(db_session, b["rian"])
    assert len(rian_notifs) == 1
    assert rian_notifs[0].kind == "mention"
    assert rian_notifs[0].recipient_id == b["rian"].id
    assert rian_notifs[0].actor_id == b["rian"].id  # self-mention: actor == recipient


def test_mention_to_unknown_email_or_username_creates_no_mention(client, db_session):
    b = _setup(db_session)
    c = mint_client_for(client, "rian")
    resp = c.post(
        f"/api/balls/{b['ball'].id}/comments",
        json={"body": "cc @ghost@nowhere.example and @nobody"},
    )
    assert resp.status_code == 201, resp.text

    all_kinds = [
        n.kind
        for p in (b["rian"], b["erin"], b["adi"])
        for n in _notifications_for(db_session, p)
    ]
    assert all_kinds == ["thread_reply"]  # only erin's author-subscription fired


# -------------------------------------------------------------- thread_reply


def test_thread_reply_reaches_author_and_prior_commenters(client, db_session):
    b = _setup(db_session)
    ca = mint_client_for(client, "adi")
    ca.post(f"/api/balls/{b['ball'].id}/comments", json={"body": "first take, no mentions"})

    # erin (ball author) gets a thread_reply from adi's comment.
    assert [n.kind for n in _notifications_for(db_session, b["erin"])] == ["thread_reply"]
    assert _notifications_for(db_session, b["adi"]) == []  # excluded: own comment

    cr = mint_client_for(client, "rian")
    cr.post(f"/api/balls/{b['ball'].id}/comments", json={"body": "second take, no mentions"})

    # now BOTH erin (author) and adi (prior commenter) get a thread_reply;
    # rian (this comment's author) gets nothing.
    assert [n.kind for n in _notifications_for(db_session, b["erin"])] == [
        "thread_reply",
        "thread_reply",
    ]
    assert [n.kind for n in _notifications_for(db_session, b["adi"])] == ["thread_reply"]
    assert _notifications_for(db_session, b["rian"]) == []


def test_mention_dedups_against_thread_reply_for_same_recipient(client, db_session):
    """The ball's author (erin) is ALSO mentioned in the new comment -- the
    dedup rule (review-learnings §4) means she gets ONLY the mention, never
    a duplicate thread_reply for the same comment."""
    b = _setup(db_session)
    c = mint_client_for(client, "rian")
    resp = c.post(
        f"/api/balls/{b['ball'].id}/comments", json={"body": "cc @erin@brentwood.example"}
    )
    assert resp.status_code == 201, resp.text

    erin_notifs = _notifications_for(db_session, b["erin"])
    assert len(erin_notifs) == 1
    assert erin_notifs[0].kind == "mention"


# ---------------------------------------------------------------- action_to


def test_action_to_patch_notifies_only_newly_added_excludes_actor(client, db_session):
    b = _setup(db_session)
    c = mint_client_for(client, "rian")
    resp = c.patch(
        f"/api/balls/{b['ball'].id}",
        json={"action_to": [str(b["erin"].id), str(b["adi"].id), str(b["rian"].id)]},
    )
    assert resp.status_code == 200, resp.text

    adi_notifs = _notifications_for(db_session, b["adi"])
    assert len(adi_notifs) == 1
    assert adi_notifs[0].kind == "action_to"
    assert adi_notifs[0].actor_id == b["rian"].id
    assert adi_notifs[0].ball_id == b["ball"].id
    assert adi_notifs[0].link == f"/?focus=ball:{b['ball'].id}"  # no &comment=

    assert _notifications_for(db_session, b["rian"]) == []  # actor excluded, even though new
    assert _notifications_for(db_session, b["erin"]) == []  # already had it, not "newly added"


def test_pass_notifies_only_new_assignee_and_self_pass_excluded(client, db_session):
    b = _setup(db_session)
    c = mint_client_for(client, "rian")
    resp = c.post(f"/api/balls/{b['ball'].id}/pass", json={"to_players": [str(b['adi'].id)]})
    assert resp.status_code == 200, resp.text

    assert len(_notifications_for(db_session, b["adi"])) == 1
    assert _notifications_for(db_session, b["erin"]) == []  # no longer held it, wasn't "new"

    # adi now self-passes to himself -- must NOT create a second notification.
    ca = mint_client_for(client, "adi")
    resp2 = ca.post(f"/api/balls/{b['ball'].id}/pass", json={"to_players": [str(b['adi'].id)]})
    assert resp2.status_code == 200, resp2.text
    assert len(_notifications_for(db_session, b["adi"])) == 1  # unchanged


def test_spike_notifies_author_unless_author_is_the_actor(client, db_session):
    b = _setup(db_session)
    ball = b["ball"]
    ball.action_to = [b["adi"].id]
    db_session.commit()

    c = mint_client_for(client, "adi")
    resp = c.patch(f"/api/balls/{ball.id}", json={"status": "Ready for Review"})
    assert resp.status_code == 200, resp.text
    assert resp.json()["action_to"] == [str(b["erin"].id)]  # spiked to the author

    erin_notifs = _notifications_for(db_session, b["erin"])
    assert len(erin_notifs) == 1
    assert erin_notifs[0].kind == "action_to"
    assert erin_notifs[0].actor_id == b["adi"].id


def test_spike_triggered_by_the_author_themself_gets_no_self_notification(client, db_session):
    b = _setup(db_session)
    ball = b["ball"]
    ball.action_to = [b["adi"].id]  # NOT already with the author
    db_session.commit()

    # erin (the AUTHOR) resolves her own ball to Ready for Review.
    c = mint_client_for(client, "erin")
    resp = c.patch(f"/api/balls/{ball.id}", json={"status": "Ready for Review"})
    assert resp.status_code == 200, resp.text
    assert resp.json()["action_to"] == [str(b["erin"].id)]  # newly added...

    assert _notifications_for(db_session, b["erin"]) == []  # ...but actor == recipient


# --------------------------------------------------------- resolve/reopen


def test_resolve_propagates_and_reopen_flips_back(client, db_session):
    b = _setup(db_session)
    ball = b["ball"]
    c = mint_client_for(client, "rian")

    resp = c.post(f"/api/balls/{ball.id}/pass", json={"to_players": [str(b["adi"].id)]})
    assert resp.status_code == 200, resp.text
    adi_notifs = _notifications_for(db_session, b["adi"])
    assert len(adi_notifs) == 1
    assert adi_notifs[0].resolved is False

    resp = c.patch(f"/api/balls/{ball.id}", json={"status": "Resolved"})
    assert resp.status_code == 200, resp.text
    db_session.refresh(adi_notifs[0])
    assert adi_notifs[0].resolved is True

    resp = c.patch(f"/api/balls/{ball.id}", json={"status": "Open"})
    assert resp.status_code == 200, resp.text
    db_session.refresh(adi_notifs[0])
    assert adi_notifs[0].resolved is False


# -------------------------------------------------------------- read/list


def test_read_endpoint_marks_read_and_blocks_other_recipients(client, db_session):
    b = _setup(db_session)
    c = mint_client_for(client, "rian")
    c.post(f"/api/balls/{b['ball'].id}/comments", json={"body": "cc @erin@brentwood.example"})

    ce = mint_client_for(client, "erin")
    listed = ce.get("/api/notifications").json()
    assert listed["unread_count"] == 1
    notif_id = listed["items"][0]["id"]
    assert listed["items"][0]["read"] is False

    # a different player can't mark erin's notification read -- 404, not 403,
    # so the endpoint never confirms the id belongs to someone else.
    ca = mint_client_for(client, "adi")
    resp = ca.post(f"/api/notifications/{notif_id}/read")
    assert resp.status_code == 404
    assert resp.json()["error_code"] == "NOTIFICATION_NOT_FOUND"

    # mint_client_for RE-POINTS the same client's cookie (it isn't an
    # independent session) -- re-mint back to the real recipient before
    # acting as her again.
    ce2 = mint_client_for(client, "erin")
    resp = ce2.post(f"/api/notifications/{notif_id}/read")
    assert resp.status_code == 200, resp.text
    assert resp.json()["read"] is True

    listed2 = ce2.get("/api/notifications").json()
    assert listed2["unread_count"] == 0
    assert listed2["items"][0]["read"] is True


def test_unknown_notification_id_404(client, db_session):
    _setup(db_session)
    c = mint_client_for(client, "rian")
    resp = c.post("/api/notifications/00000000-0000-0000-0000-000000000000/read")
    assert resp.status_code == 404
    assert resp.json()["error_code"] == "NOTIFICATION_NOT_FOUND"


def test_read_all_marks_everything_read(client, db_session):
    b = _setup(db_session)
    c = mint_client_for(client, "rian")
    c.post(f"/api/balls/{b['ball'].id}/comments", json={"body": "cc @erin@brentwood.example one"})
    c.post(f"/api/balls/{b['ball'].id}/comments", json={"body": "cc @erin@brentwood.example two"})

    ce = mint_client_for(client, "erin")
    before = ce.get("/api/notifications").json()
    assert before["unread_count"] == 2

    resp = ce.post("/api/notifications/read-all")
    assert resp.status_code == 200
    assert resp.json()["updated"] == 2

    after = ce.get("/api/notifications").json()
    assert after["unread_count"] == 0
    assert all(item["read"] for item in after["items"])


def test_get_notifications_requires_session(client):
    resp = client.get("/api/notifications")
    assert resp.status_code == 401


def test_get_notifications_empty_for_session_without_player_row(authed_client):
    # authed_client is minted for username "rian" but no Player row exists
    # (no _setup call in this test) -- current_player() resolves to None.
    resp = authed_client.get("/api/notifications")
    assert resp.status_code == 200
    assert resp.json() == {"items": [], "unread_count": 0, "unresolved_count": 0}


def test_get_notifications_caps_but_counts_are_true_totals(client, db_session, monkeypatch):
    from app.services import notifications as notifications_service

    monkeypatch.setattr(notifications_service, "LIST_LIMIT", 2)

    b = _setup(db_session)
    c = mint_client_for(client, "rian")
    for i in range(4):
        c.post(
            f"/api/balls/{b['ball'].id}/comments",
            json={"body": f"cc @erin@brentwood.example {i}"},
        )

    ce = mint_client_for(client, "erin")
    resp = ce.get("/api/notifications")
    assert resp.status_code == 200
    body = resp.json()
    assert len(body["items"]) == 2  # capped
    assert body["unread_count"] == 4  # true total, not capped
    assert body["unresolved_count"] == 4


def test_mention_publishes_sse(client, db_session, monkeypatch):
    published = []
    monkeypatch.setattr(events, "publish", lambda evt: published.append(evt))
    b = _setup(db_session)
    c = mint_client_for(client, "rian")
    resp = c.post(f"/api/balls/{b['ball'].id}/comments", json={"body": "cc @erin@brentwood.example"})
    assert resp.status_code == 201, resp.text

    notif_events = [e for e in published if e["type"] == "notification.created"]
    assert len(notif_events) == 1
    evt = notif_events[0]
    assert evt["recipient_id"] == str(b["erin"].id)
    assert evt["notification"]["kind"] == "mention"
    assert evt["notification"]["ball_id"] == str(b["ball"].id)


def test_serve_with_assignee_notifies_first_assignee(client, db_session):
    """The serve IS the first 'ball's coming to you' — an initial assignee
    on a fresh ball gets an action_to notification (author excluded as
    always). Added with the create_ball hook (M5 follow-up)."""
    from app.models import Notification

    rows = _setup(db_session)
    c = mint_client_for(client, "erin")
    resp = c.post(
        "/api/matches/notifymatch/balls",
        json={
            "court_id": str(rows["court"].id),
            "title": "Serve ping test",
            "category": "Revision",
            "action_to": [str(rows["rian"].id), str(rows["erin"].id)],
        },
    )
    assert resp.status_code == 201, resp.text
    notifs = db_session.query(Notification).all()
    # rian pinged; erin (the author/actor) excluded
    assert [str(n.recipient_id) for n in notifs] == [str(rows["rian"].id)]
    assert notifs[0].kind == "action_to"
    assert notifs[0].context_label == "Serve ping test"
