"""Slice S4 — invitation surfaces + role-aware empty states (@S4).

The acceptance gate for S4 is "every Part C.2 invitation scenario passes" (the
design doc docs/design/2026-06-05-three-tier-projects-collections.md, §C.2,
tagged @S4), plus the capability flags that drive the C.3 (@S4) role-aware
empty states.

An Owner grants {owner, contributor} at a scope their reach covers — the
project, or a single collection within it — to an existing account looked up by
email. The grant writes a `memberships` row immediately and §15-notifies the
grantee (no accept round-trip). Reach is bounded by the inviter's Owner reach;
re-granting at a broader scope supersedes the narrower row; a `pending`
deployment account's grant is recorded but confers no write.

Background (C.2): project "ohm" owns collections "model" (document) and
"features" (bdd). eve is project Owner of ohm; dan is collection Owner of
features only; ben is a project Contributor of ohm. ivy / jo / jet are
grantees; kim is a pending deployment account.
"""
from __future__ import annotations

from fastapi.testclient import TestClient

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


# ---------------------------------------------------------------------------
# Helpers (mirror the S3 vertical's world-builders)
# ---------------------------------------------------------------------------


def _su(user_id: int, login: str, role: str = "contributor", *, state: str = "granted"):
    from app import auth

    return auth.SessionUser(
        user_id=user_id, gitea_id=user_id, gitea_login=login,
        display_name=login.capitalize(), email=f"{login}@test", avatar_url="",
        role=role, permission_state=state,
    )


def _project(pid: str, visibility: str = "public", content_repo: str = "meta") -> None:
    from app import db

    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 = "public", subfolder: str | None = None) -> None:
    from app import db

    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, subfolder if subfolder is not None else cid,
         visibility, cid.capitalize()),
    )


def _grant(scope_type: str, scope_id: str, user_id: int, role: str) -> None:
    from app import db

    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):
    """The set of (scope_type, scope_id, role) rows a user holds."""
    from app import db

    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 _seed_world() -> None:
    _project("ohm", "public")
    _collection("model", "ohm", ctype="document")
    _collection("features", "ohm", ctype="bdd")
    # the cast
    provision_user_row(user_id=2, login="ben", role="contributor")
    provision_user_row(user_id=4, login="dan", role="contributor")
    provision_user_row(user_id=5, login="eve", role="contributor")
    provision_user_row(user_id=10, login="ivy", role="contributor")
    provision_user_row(user_id=11, login="jo", role="contributor")
    provision_user_row(user_id=12, login="jet", role="contributor")
    provision_user_row(user_id=13, login="kim", role="contributor")
    _grant("project", "ohm", 5, "owner")          # eve — project Owner
    _grant("collection", "features", 4, "owner")  # dan — collection Owner only
    _grant("project", "ohm", 2, "contributor")    # ben — project Contributor
    # kim is a pending deployment account.
    from app import db
    db.conn().execute(
        "UPDATE users SET permission_state = 'pending' WHERE id = 13"
    )


def _login_eve(client) -> None:
    sign_in_as(client, user_id=5, gitea_login="eve", display_name="Eve", role="contributor")


# ---------------------------------------------------------------------------
# C.2 — invitation: who may invite whom, at which scope
# ---------------------------------------------------------------------------


def test_c2_1_project_owner_invites_at_project_scope(app_with_fake_gitea):
    """A project Owner grants at project scope; the grant covers every
    collection, and the grantee is §15-notified naming the project and role."""
    from app import auth
    app, _ = app_with_fake_gitea
    with TestClient(app) as client:
        _seed_world()
        _login_eve(client)
        r = client.post("/api/projects/ohm/members",
                        json={"email": "ivy@test", "role": "contributor"})
        assert r.status_code == 200, r.text
        # a membership row is written at scope project "ohm"
        assert ("project", "ohm", "contributor") in _membership(10)
        # ivy may propose in every collection of "ohm"
        ivy = _su(10, "ivy")
        assert auth.can_contribute_in_collection(ivy, "model") is True
        assert auth.can_contribute_in_collection(ivy, "features") is True
        # ivy receives a §15 notification naming the project and role
        sign_in_as(client, user_id=10, gitea_login="ivy", display_name="Ivy", role="contributor")
        inbox = client.get("/api/notifications").json()["items"]
        granted = [n for n in inbox if n["event_kind"] == "scope_role_granted"]
        assert granted, inbox
        assert "Ohm" in granted[0]["summary"]
        assert "RFC Contributor" in granted[0]["summary"]


def test_c2_2_owner_invites_at_specific_collection(app_with_fake_gitea):
    """A grant at a single collection scope reaches that collection only."""
    from app import auth
    app, _ = app_with_fake_gitea
    with TestClient(app) as client:
        _seed_world()
        _login_eve(client)
        r = client.post("/api/projects/ohm/members",
                        json={"email": "jo@test", "role": "contributor",
                              "collection_id": "features"})
        assert r.status_code == 200, r.text
        assert ("collection", "features", "contributor") in _membership(11)
        jo = _su(11, "jo")
        assert auth.can_contribute_in_collection(jo, "features") is True
        assert auth.can_contribute_in_collection(jo, "model") is False


def test_c2_3_invitation_reach_bounded_by_inviter_scope(app_with_fake_gitea):
    """A collection Owner may invite within that collection, but is not offered
    (is refused) the control to invite at the project or globally."""
    app, _ = app_with_fake_gitea
    with TestClient(app) as client:
        _seed_world()
        # dan is Owner at collection "features" only.
        sign_in_as(client, user_id=4, gitea_login="dan", display_name="Dan", role="contributor")
        # may grant at his collection
        ok = client.post("/api/projects/ohm/members",
                         json={"email": "ivy@test", "role": "contributor",
                               "collection_id": "features"})
        assert ok.status_code == 200, ok.text
        # but not at the project scope
        no = client.post("/api/projects/ohm/members",
                         json={"email": "ivy@test", "role": "contributor"})
        assert no.status_code == 403, no.text
        # the capability flags the UI reads agree: no project invite, yes collection
        proj = client.get("/api/projects/ohm/collections").json()["viewer"]
        assert proj["can_invite"] is False
        col = client.get("/api/projects/ohm/collections/features").json()["viewer"]
        assert col["can_invite"] is True


def test_c2_4_contributors_do_not_manage_membership(app_with_fake_gitea):
    """An RFC Contributor (project- or collection-scoped) holds no invite
    capability and the grant endpoints refuse them."""
    app, _ = app_with_fake_gitea
    with TestClient(app) as client:
        _seed_world()
        # ben is a project *Contributor* on ohm.
        sign_in_as(client, user_id=2, gitea_login="ben", display_name="Ben", role="contributor")
        no_proj = client.post("/api/projects/ohm/members",
                              json={"email": "ivy@test", "role": "contributor"})
        assert no_proj.status_code == 403, no_proj.text
        no_col = client.post("/api/projects/ohm/members",
                             json={"email": "ivy@test", "role": "contributor",
                                   "collection_id": "features"})
        assert no_col.status_code == 403, no_col.text
        # no invite control surfaced anywhere
        assert client.get("/api/projects/ohm/collections").json()["viewer"]["can_invite"] is False
        assert client.get("/api/projects/ohm/collections/features").json()["viewer"]["can_invite"] is False


def test_c2_5_no_grant_at_parent_revoke_at_child_option(app_with_fake_gitea):
    """The grant surface offers only role + scope (project or one collection);
    there is no way to grant at the project yet carve out a child collection —
    a project grant reaches every collection, full stop."""
    from app import auth
    app, _ = app_with_fake_gitea
    with TestClient(app) as client:
        _seed_world()
        _login_eve(client)
        # the only knobs are role and an optional single collection_id; an
        # "exclude" field has no effect (it is not part of the contract).
        r = client.post("/api/projects/ohm/members",
                        json={"email": "ivy@test", "role": "contributor",
                              "exclude_collection_id": "features"})
        assert r.status_code == 200, r.text
        # the project grant still reaches the supposedly-excluded collection
        ivy = _su(10, "ivy")
        assert auth.can_contribute_in_collection(ivy, "features") is True


def test_c2_6_broader_scope_supersedes_narrower(app_with_fake_gitea):
    """Re-granting at a broader scope removes the subsumed narrower row; the
    grantee holds the role across the whole project."""
    from app import auth
    app, _ = app_with_fake_gitea
    with TestClient(app) as client:
        _seed_world()
        _grant("collection", "features", 12, "contributor")  # jet starts narrow
        _login_eve(client)
        r = client.post("/api/projects/ohm/members",
                        json={"email": "jet@test", "role": "contributor"})
        assert r.status_code == 200, r.text
        rows = _membership(12)
        # the project grant is present…
        assert ("project", "ohm", "contributor") in rows
        # …and the redundant collection-scope row is gone (subsumed)
        assert ("collection", "features", "contributor") not in rows
        jet = _su(12, "jet")
        assert auth.can_contribute_in_collection(jet, "model") is True
        assert auth.can_contribute_in_collection(jet, "features") is True


def test_c2_6b_narrower_stronger_role_is_not_subtracted(app_with_fake_gitea):
    """No negative override: a child Owner grant survives a parent Contributor
    grant (the stronger collection role is kept)."""
    app, _ = app_with_fake_gitea
    with TestClient(app) as client:
        _seed_world()
        _grant("collection", "features", 12, "owner")  # jet is collection Owner
        _login_eve(client)
        r = client.post("/api/projects/ohm/members",
                        json={"email": "jet@test", "role": "contributor"})
        assert r.status_code == 200, r.text
        rows = _membership(12)
        assert ("project", "ohm", "contributor") in rows
        # the stronger collection-Owner row is NOT pruned by a weaker project grant
        assert ("collection", "features", "owner") in rows


def test_c2_7_pending_account_grant_confers_no_write(app_with_fake_gitea):
    """A grant to a pending deployment account is recorded but confers no write
    until the account is granted at the deployment (§6)."""
    from app import auth
    app, _ = app_with_fake_gitea
    with TestClient(app) as client:
        _seed_world()
        _login_eve(client)
        r = client.post("/api/projects/ohm/members",
                        json={"email": "kim@test", "role": "contributor"})
        assert r.status_code == 200, r.text
        assert r.json()["pending"] is True
        # the grant row is recorded…
        assert ("project", "ohm", "contributor") in _membership(13)
        # …but confers no write while pending (the §6 admission floor)
        kim = _su(13, "kim", state="pending")
        assert auth.effective_scope_role(kim, "model") is None
        assert auth.can_contribute_in_collection(kim, "model") is False


# ---------------------------------------------------------------------------
# C.3 (@S4) — the capability flags behind the role-aware empty states
# ---------------------------------------------------------------------------


def test_c3_3_project_owner_sees_create_first_collection_capability(app_with_fake_gitea):
    """C3.3: a project Owner landing on an empty project may create a
    collection — the flag the 'Create your first collection' CTA reads."""
    app, _ = app_with_fake_gitea
    with TestClient(app) as client:
        _seed_world()
        _login_eve(client)
        caps = client.get("/api/projects/ohm/collections").json()["viewer"]
        assert caps["can_create_collection"] is True
        assert caps["role"] == "owner"


def test_c3_4_contributor_without_create_rights_has_no_create_capability(app_with_fake_gitea):
    """C3.4: a contributor whose only grant is at a collection elsewhere has no
    create-collection capability — the empty directory shows no create action."""
    app, _ = app_with_fake_gitea
    with TestClient(app) as client:
        _seed_world()
        # dan holds only a collection-scope grant (features); no project create right.
        sign_in_as(client, user_id=4, gitea_login="dan", display_name="Dan", role="contributor")
        caps = client.get("/api/projects/ohm/collections").json()["viewer"]
        assert caps["can_create_collection"] is False


def test_c3_5_collection_contributor_sees_propose_first_capability(app_with_fake_gitea):
    """C3.5: a collection contributor landing on an empty collection may propose
    — the flag the 'Propose the first entry' CTA reads; an anonymous reader may
    not (the sign-in prompt path, already shipped in S2)."""
    app, _ = app_with_fake_gitea
    with TestClient(app) as client:
        _seed_world()
        _grant("collection", "model", 10, "contributor")  # ivy contributes in model
        sign_in_as(client, user_id=10, gitea_login="ivy", display_name="Ivy", role="contributor")
        caps = client.get("/api/projects/ohm/collections/model").json()["viewer"]
        assert caps["can_contribute"] is True
        # anonymous reader: no propose capability
        client.cookies.clear()
        caps_anon = client.get("/api/projects/ohm/collections/model").json()["viewer"]
        assert caps_anon["can_contribute"] is False
