"""§22.8 S6 — request-to-join a scope + the cross-collection inbox.

A user who knows a (gated) scope exists asks to join it, naming a desired role;
the request is recorded and fanned out to that scope's Owners *across the
subtree* (the cross-collection inbox, §22.11). An Owner accepts — which writes
the `memberships` row via memberships.grant — or declines, and the requester is
§15-notified either way.

Built by analogy to test_contributions_vertical.py (the per-RFC contribute flow)
and test_s4_invitations_vertical.py (the scope/membership world-builders).

World: project "ohm" owns collections "model" (document, gated) and "features"
(bdd, gated). eve is project Owner; dan is collection Owner of features only;
zoe is a global Owner; ada is a deployment admin. ben is a plain granted account
(no scope role) — the would-be joiner.
"""
from __future__ import annotations

from fastapi.testclient import TestClient

from app import db

from test_propose_vertical import (  # noqa: F401 — fixtures land via import
    app_with_fake_gitea,
    provision_user_row,
    sign_in_as,
    tmp_env,
)


# ---------------------------------------------------------------------------
# World-builders (mirror the S4 vertical)
# ---------------------------------------------------------------------------


def _project(pid: str, visibility: str = "gated", content_repo: str = "meta") -> None:
    db.conn().execute(
        "INSERT OR REPLACE INTO projects (id, name, content_repo, visibility, updated_at) "
        "VALUES (?, ?, ?, ?, datetime('now'))",
        (pid, pid.capitalize(), content_repo, visibility),
    )


def _collection(cid: str, project_id: str, *, ctype: str = "document",
                visibility: str = "gated") -> None:
    db.conn().execute(
        "INSERT OR REPLACE INTO collections "
        "(id, project_id, type, subfolder, initial_state, visibility, name, created_at, updated_at) "
        "VALUES (?, ?, ?, ?, 'super-draft', ?, ?, datetime('now'), datetime('now'))",
        (cid, project_id, ctype, cid, visibility, cid.capitalize()),
    )


def _grant(scope_type: str, scope_id: str, user_id: int, role: str) -> None:
    db.conn().execute(
        "INSERT OR REPLACE INTO memberships (scope_type, scope_id, user_id, role) "
        "VALUES (?, ?, ?, ?)",
        (scope_type, scope_id, user_id, role),
    )


def _membership(user_id: int):
    rows = db.conn().execute(
        "SELECT scope_type, scope_id, role FROM memberships WHERE user_id = ?",
        (user_id,),
    ).fetchall()
    return {(r["scope_type"], r["scope_id"], r["role"]) for r in rows}


def _join_requests(scope_type: str, scope_id: str):
    rows = db.conn().execute(
        "SELECT id, requester_user_id, requested_role, status, granted_role "
        "FROM join_requests WHERE scope_type = ? AND scope_id = ?",
        (scope_type, scope_id),
    ).fetchall()
    return [dict(r) for r in rows]


def _join_notif_recipients(event_kind: str = "join_request_on_scope") -> set[int]:
    return {
        r["recipient_user_id"]
        for r in db.conn().execute(
            "SELECT recipient_user_id FROM notifications WHERE event_kind = ?",
            (event_kind,),
        )
    }


def _seed_world() -> None:
    _project("ohm", "gated")
    _collection("model", "ohm", ctype="document")
    _collection("features", "ohm", ctype="bdd")
    provision_user_row(user_id=2, login="ben", role="contributor")     # the joiner
    provision_user_row(user_id=4, login="dan", role="contributor")     # collection Owner (features)
    provision_user_row(user_id=5, login="eve", role="contributor")     # project Owner
    provision_user_row(user_id=6, login="zoe", role="contributor")     # global Owner
    provision_user_row(user_id=7, login="ada", role="admin")           # deployment admin
    _grant("project", "ohm", 5, "owner")
    _grant("collection", "features", 4, "owner")
    _grant("global", "*", 6, "owner")


def _login(client, uid: int, login: str, role: str = "contributor") -> None:
    sign_in_as(client, user_id=uid, gitea_login=login, display_name=login.capitalize(), role=role)


# ---------------------------------------------------------------------------
# Request → cross-collection fan-out
# ---------------------------------------------------------------------------


def test_request_to_join_collection_fans_out_to_subtree_owners(app_with_fake_gitea):
    """A request to join a collection lands a row and notifies every Owner whose
    reach covers it — the collection's Owner, the project's Owner, a global
    Owner, and the deployment admin (the cross-collection inbox) — never the
    requester."""
    app, _ = app_with_fake_gitea
    with TestClient(app) as client:
        _seed_world()
        _login(client, 2, "ben")
        r = client.post(
            "/api/scopes/collection/features/join-requests",
            json={"role": "contributor", "message": "I work on BDD corpora."},
        )
        assert r.status_code == 200, r.text
        assert r.json()["status"] == "pending"

        reqs = _join_requests("collection", "features")
        assert len(reqs) == 1
        assert reqs[0]["requester_user_id"] == 2
        assert reqs[0]["requested_role"] == "contributor"
        assert reqs[0]["status"] == "pending"

        # Owners across the subtree are notified; ben (requester) is not.
        recips = _join_notif_recipients()
        assert {4, 5, 6, 7}.issubset(recips)   # dan, eve, zoe, ada
        assert 2 not in recips


def test_request_to_join_project_reaches_project_and_global_owners(app_with_fake_gitea):
    """A project-scope request reaches the project's Owners + global Owners +
    admin, but NOT a collection-only Owner (their reach doesn't cover the
    project)."""
    app, _ = app_with_fake_gitea
    with TestClient(app) as client:
        _seed_world()
        _login(client, 2, "ben")
        r = client.post(
            "/api/scopes/project/ohm/join-requests",
            json={"role": "owner"},
        )
        assert r.status_code == 200, r.text
        recips = _join_notif_recipients()
        assert {5, 6, 7}.issubset(recips)   # eve (project), zoe (global), ada (admin)
        assert 4 not in recips               # dan is only a collection Owner


# ---------------------------------------------------------------------------
# Accept → writes membership + notifies
# ---------------------------------------------------------------------------


def test_owner_accept_writes_membership_and_notifies(app_with_fake_gitea):
    """The collection Owner accepts; a `memberships` row is written at the
    requested scope/role and the requester gets a join_request_accepted inbox
    row."""
    from app import auth
    app, _ = app_with_fake_gitea
    with TestClient(app) as client:
        _seed_world()
        _login(client, 2, "ben")
        client.post(
            "/api/scopes/collection/features/join-requests",
            json={"role": "contributor"},
        )
        req_id = _join_requests("collection", "features")[0]["id"]

        # dan (collection Owner of features) accepts.
        _login(client, 4, "dan")
        r = client.post(
            f"/api/scopes/collection/features/join-requests/{req_id}/accept",
            json={},
        )
        assert r.status_code == 200, r.text
        assert r.json()["granted_role"] == "contributor"

        # ben now holds the collection role and can contribute there.
        assert ("collection", "features", "contributor") in _membership(2)
        ben = auth.SessionUser(
            user_id=2, gitea_id=2, gitea_login="ben", display_name="Ben",
            email="ben@test", avatar_url="", role="contributor", permission_state="granted",
        )
        assert auth.can_contribute_in_collection(ben, "features") is True
        assert auth.can_contribute_in_collection(ben, "model") is False

        # the row is closed; the requester is notified.
        assert _join_requests("collection", "features")[0]["status"] == "accepted"
        _login(client, 2, "ben")
        inbox = client.get("/api/notifications").json()["items"]
        accepted = [n for n in inbox if n["event_kind"] == "join_request_accepted"]
        assert accepted, inbox
        assert "Features" in accepted[0]["summary"]


def test_owner_may_narrow_role_on_accept(app_with_fake_gitea):
    """A request for Owner may be accepted as RFC Contributor — the Owner narrows
    the grant; the membership row carries the granted (not requested) role."""
    app, _ = app_with_fake_gitea
    with TestClient(app) as client:
        _seed_world()
        _login(client, 2, "ben")
        client.post("/api/scopes/collection/features/join-requests", json={"role": "owner"})
        req_id = _join_requests("collection", "features")[0]["id"]

        _login(client, 5, "eve")   # project Owner — reach covers the collection
        r = client.post(
            f"/api/scopes/collection/features/join-requests/{req_id}/accept",
            json={"role": "contributor"},
        )
        assert r.status_code == 200, r.text
        assert ("collection", "features", "contributor") in _membership(2)
        assert _join_requests("collection", "features")[0]["granted_role"] == "contributor"


def test_owner_decline_notifies_and_grants_nothing(app_with_fake_gitea):
    app, _ = app_with_fake_gitea
    with TestClient(app) as client:
        _seed_world()
        _login(client, 2, "ben")
        client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
        req_id = _join_requests("collection", "features")[0]["id"]

        _login(client, 4, "dan")
        r = client.post(f"/api/scopes/collection/features/join-requests/{req_id}/decline")
        assert r.status_code == 200, r.text
        assert _membership(2) == set()
        assert _join_requests("collection", "features")[0]["status"] == "declined"

        _login(client, 2, "ben")
        inbox = client.get("/api/notifications").json()["items"]
        assert any(n["event_kind"] == "join_request_declined" for n in inbox)


# ---------------------------------------------------------------------------
# Gates & guards
# ---------------------------------------------------------------------------


def test_duplicate_pending_request_is_conflict(app_with_fake_gitea):
    app, _ = app_with_fake_gitea
    with TestClient(app) as client:
        _seed_world()
        _login(client, 2, "ben")
        r1 = client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
        assert r1.status_code == 200, r1.text
        r2 = client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
        assert r2.status_code == 409, r2.text


def test_existing_member_cannot_request(app_with_fake_gitea):
    """dan already owns the collection — there is nothing to request (409)."""
    app, _ = app_with_fake_gitea
    with TestClient(app) as client:
        _seed_world()
        _login(client, 4, "dan")
        r = client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
        assert r.status_code == 409, r.text


def test_non_owner_cannot_accept(app_with_fake_gitea):
    """A plain requester (or any non-Owner) is refused the accept action."""
    app, _ = app_with_fake_gitea
    with TestClient(app) as client:
        _seed_world()
        _login(client, 2, "ben")
        client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
        req_id = _join_requests("collection", "features")[0]["id"]
        # provision a second plain account that tries to accept
        provision_user_row(user_id=12, login="mal", role="contributor")
        _login(client, 12, "mal")
        r = client.post(
            f"/api/scopes/collection/features/join-requests/{req_id}/accept", json={}
        )
        assert r.status_code == 403, r.text
        assert _membership(2) == set()


def test_collection_owner_cannot_act_on_sibling_collection(app_with_fake_gitea):
    """dan owns 'features' only; a request to join 'model' is not his to act on."""
    app, _ = app_with_fake_gitea
    with TestClient(app) as client:
        _seed_world()
        _login(client, 2, "ben")
        client.post("/api/scopes/collection/model/join-requests", json={"role": "contributor"})
        req_id = _join_requests("collection", "model")[0]["id"]
        _login(client, 4, "dan")
        r = client.post(
            f"/api/scopes/collection/model/join-requests/{req_id}/accept", json={}
        )
        assert r.status_code == 403, r.text


def test_unknown_scope_404(app_with_fake_gitea):
    app, _ = app_with_fake_gitea
    with TestClient(app) as client:
        _seed_world()
        _login(client, 2, "ben")
        assert client.post(
            "/api/scopes/collection/nope/join-requests", json={"role": "contributor"}
        ).status_code == 404
        assert client.post(
            "/api/scopes/project/nope/join-requests", json={"role": "contributor"}
        ).status_code == 404
        # 'global' is not a join-able scope_type.
        assert client.post(
            "/api/scopes/global/*/join-requests", json={"role": "contributor"}
        ).status_code == 404


def test_join_target_reports_eligibility(app_with_fake_gitea):
    app, _ = app_with_fake_gitea
    with TestClient(app) as client:
        _seed_world()
        # ben: eligible (granted, no role).
        _login(client, 2, "ben")
        t = client.get("/api/scopes/collection/features/join-target").json()
        assert t["eligible"] is True
        assert t["name"] == "Features"
        assert t["current_role"] is None
        # after requesting, already_requested flips and eligible drops.
        client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
        t2 = client.get("/api/scopes/collection/features/join-target").json()
        assert t2["already_requested"] is True
        assert t2["eligible"] is False
        # dan: already a member → ineligible with current_role.
        _login(client, 4, "dan")
        t3 = client.get("/api/scopes/collection/features/join-target").json()
        assert t3["eligible"] is False
        assert t3["current_role"] == "owner"
