"""Slice M2 — project-scoped authorization + the §22.7 resolver.

M1 laid the schema spine (the `projects` / `project_members` tables and the
`project_id` column on every slug-bearing table). M2 builds the resolver on top:
the most-permissive union of the deployment role (§6.1), the project role
(§22.6), and the per-RFC authority (§6.3/§12), with the §22.5 visibility gate
subtractive on top (§22.7).

Two operator decisions are pinned here as executable expectations:

  * implicit-on-public — a granted deployment `contributor` keeps its
    pre-multi-project write *baseline* on a `public` project (propose freely;
    an owned RFC's discuss/contribute still needs the per-RFC invite) with no
    project_members row. So the single public default project behaves exactly
    as it did before M2 (verified across the rest of the suite, and the
    `*_public_*` tests below).
  * preserve curation — the implicit-public baseline does NOT override per-RFC
    owner curation; only an *explicit* project_contributor/admin grant (or a
    deployment owner/admin) bypasses it.

Everything is exercised on the single `default` project by flipping its
visibility and granting/revoking `project_members` roles — the M2 slice is
verifiable without a second project (which arrives with M3's registry mirror).
"""
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
# ---------------------------------------------------------------------------


def _su(user_id: int, login: str, role: str, *, state: str = "granted"):
    """A SessionUser handle for direct resolver calls (the endpoint tests use
    sign_in_as instead)."""
    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 _set_visibility(project_id: str, visibility: str) -> None:
    from app import db

    db.conn().execute(
        "UPDATE projects SET visibility = ? WHERE id = ?", (visibility, project_id)
    )


# §22 three-tier (§B.3): M2's three project roles collapse to {owner,
# contributor} in the unified `memberships` table at the project's default
# collection. The read-only `viewer` tier is deferred (folded into contributor
# for this pass), so the legacy role names map: admin→owner, contributor and
# viewer→contributor.
_ROLE_MAP = {
    "project_admin": "owner",
    "project_contributor": "contributor",
    "project_viewer": "contributor",
}


def _add_member(project_id: str, user_id: int, role: str) -> None:
    from app import collections as collections_mod, db

    cid = collections_mod.default_collection_id(project_id)
    db.conn().execute(
        "INSERT OR REPLACE INTO memberships (scope_type, scope_id, user_id, role) "
        "VALUES ('collection', ?, ?, ?)",
        (cid, user_id, _ROLE_MAP[role]),
    )


def _remove_member(project_id: str, user_id: int) -> None:
    from app import collections as collections_mod, db

    cid = collections_mod.default_collection_id(project_id)
    db.conn().execute(
        "DELETE FROM memberships WHERE scope_type = 'collection' AND scope_id = ? AND user_id = ?",
        (cid, user_id),
    )


def _seed_rfc(slug: str, *, state: str = "active", owners=None, project_id: str = "default") -> None:
    """A minimal cached_rfcs row — enough for the authz gates (state, owners,
    collection grain). The entry lands in the project's default collection
    (id == project_id for the single 'default' project under test)."""
    import json

    from app import collections as collections_mod, db

    cid = collections_mod.default_collection_id(project_id)
    db.conn().execute(
        """
        INSERT OR REPLACE INTO cached_rfcs
            (slug, title, state, owners_json, arbiters_json, tags_json, collection_id)
        VALUES (?, ?, ?, ?, '[]', '[]', ?)
        """,
        (slug, slug.capitalize(), state, json.dumps(owners or []), cid),
    )


# ---------------------------------------------------------------------------
# 1. The §22.7 resolver — tier composition
# ---------------------------------------------------------------------------


def test_resolver_public_project_tiers(app_with_fake_gitea):
    from app import auth

    app, _ = app_with_fake_gitea
    with TestClient(app):
        provision_user_row(user_id=1, login="alice", role="contributor")
        provision_user_row(user_id=2, login="ben", role="owner")
        contributor = _su(1, "alice", "contributor")
        owner = _su(2, "ben", "owner")

        # default is public — read open to everyone incl. anonymous.
        assert auth.can_read_project(None, "default") is True
        assert auth.can_read_project(contributor, "default") is True

        # implicit-on-public: a granted deployment contributor carries the
        # write baseline without a project_members row.
        assert auth.can_contribute_in_project(contributor, "default") is True
        assert auth.can_discuss_in_project(contributor, "default") is True

        # ... but it is NOT project_admin (curation/override authority).
        assert auth.is_project_superuser(contributor, "default") is False
        # a deployment owner/admin is a superuser in every project.
        assert auth.is_project_superuser(owner, "default") is True

        # a pending contributor has no write standing (the §6 admission floor).
        pending = _su(1, "alice", "contributor", state="pending")
        assert auth.can_contribute_in_project(pending, "default") is False


def test_resolver_gated_project_requires_membership(app_with_fake_gitea):
    from app import auth

    app, _ = app_with_fake_gitea
    with TestClient(app):
        provision_user_row(user_id=1, login="alice", role="contributor")
        provision_user_row(user_id=2, login="ben", role="owner")
        _set_visibility("default", "gated")
        contributor = _su(1, "alice", "contributor")
        owner = _su(2, "ben", "owner")

        # gated: no membership → invisible and no standing.
        assert auth.can_read_project(None, "default") is False
        assert auth.can_read_project(contributor, "default") is False
        assert auth.can_contribute_in_project(contributor, "default") is False

        # deployment owner is a superuser regardless of membership.
        assert auth.can_read_project(owner, "default") is True
        assert auth.is_project_superuser(owner, "default") is True

        # §22 three-tier (§B.3): the read-only viewer tier is deferred — the
        # smallest grant is `contributor`, which grants read + discuss +
        # contribute across the subtree.
        _add_member("default", 1, "project_contributor")
        assert auth.can_read_project(contributor, "default") is True
        assert auth.can_discuss_in_project(contributor, "default") is True
        assert auth.can_contribute_in_project(contributor, "default") is True
        assert auth.is_project_superuser(contributor, "default") is False

        # project_admin → owner → superuser within the project.
        _add_member("default", 1, "project_admin")
        assert auth.is_project_superuser(contributor, "default") is True


# ---------------------------------------------------------------------------
# 2. Public default unchanged — the regression floor (curation preserved)
# ---------------------------------------------------------------------------


def test_public_per_rfc_curation_preserved(app_with_fake_gitea):
    """On the public default project a granted contributor still cannot
    discuss an *owned* RFC without a per-RFC invite (the v0.16.0 contract);
    but an unclaimed (no-owners) entry stays open. This is the implicit-public
    baseline with curation preserved."""
    from app import auth

    app, _ = app_with_fake_gitea
    with TestClient(app):
        provision_user_row(user_id=1, login="alice", role="contributor")
        provision_user_row(user_id=2, login="bob", role="contributor")
        bob = _su(2, "bob", "contributor")

        _seed_rfc("owned", state="active", owners=["alice"])
        assert auth.can_discuss_rfc(bob, "owned") is False
        assert auth.can_contribute_to_rfc(bob, "owned") is False

        _seed_rfc("draft", state="super-draft", owners=[])
        assert auth.can_discuss_rfc(bob, "draft") is True
        assert auth.can_contribute_to_rfc(bob, "draft") is True


def test_public_read_open_write_gated_endpoints(app_with_fake_gitea):
    app, _ = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=2, login="bob", role="contributor")
        _seed_rfc("ohm", state="active", owners=["alice"])

        # anonymous can read the entry on a public project.
        r = client.get("/api/rfcs/ohm")
        assert r.status_code == 200, r.text

        # anonymous cannot open a discussion thread (401, the §6 write floor).
        r = client.post("/api/rfcs/ohm/discussion/threads", json={"message": "hi"})
        assert r.status_code == 401


# ---------------------------------------------------------------------------
# 3. The §22.5 visibility gate — 404 to non-members on read
# ---------------------------------------------------------------------------


def test_gated_project_404s_non_members(app_with_fake_gitea):
    app, _ = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=1, login="alice", role="contributor")
        provision_user_row(user_id=2, login="bob", role="contributor")
        provision_user_row(user_id=3, login="ben", role="owner")
        _seed_rfc("sekret", state="active", owners=["alice"])
        _set_visibility("default", "gated")

        # anonymous → 404 (indistinguishable from an unknown slug).
        assert client.get("/api/rfcs/sekret").status_code == 404

        # signed-in non-member → 404 on the entry and its discussion.
        sign_in_as(client, user_id=2, gitea_login="bob", display_name="Bob", role="contributor")
        assert client.get("/api/rfcs/sekret").status_code == 404
        assert client.get("/api/rfcs/sekret/discussion/threads").status_code == 404
        # the gated entry never surfaces in the non-member's catalog.
        assert client.get("/api/rfcs").json()["items"] == []

        # a project_viewer member can read again.
        _add_member("default", 2, "project_viewer")
        assert client.get("/api/rfcs/sekret").status_code == 200
        assert [i["slug"] for i in client.get("/api/rfcs").json()["items"]] == ["sekret"]

        # a deployment owner can always read.
        sign_in_as(client, user_id=3, gitea_login="ben", display_name="Ben", role="owner")
        assert client.get("/api/rfcs/sekret").status_code == 200


# ---------------------------------------------------------------------------
# 4. The contribution gate on a gated project (401/403)
# ---------------------------------------------------------------------------


def test_gated_propose_requires_project_contributor(app_with_fake_gitea):
    """Propose checks project-level contribute standing before any Gitea
    work, so the gate is observable as a 403 (gated, non-member) without
    seeding the success path."""
    app, _ = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=2, login="bob", role="contributor")
        _set_visibility("default", "gated")
        sign_in_as(client, user_id=2, gitea_login="bob", display_name="Bob", role="contributor")

        body = {"slug": "newidea", "title": "New Idea", "pitch": "A pitch.", "tags": []}
        assert client.post("/api/rfcs/propose", json=body).status_code == 403

        # grant project_contributor — the project gate now passes (the request
        # proceeds past the gate; we assert only that it is no longer 403).
        _add_member("default", 2, "project_contributor")
        assert client.post("/api/rfcs/propose", json=body).status_code != 403


def test_gated_member_can_discuss_and_contribute(app_with_fake_gitea):
    from app import auth

    app, _ = app_with_fake_gitea
    with TestClient(app) as client:
        provision_user_row(user_id=2, login="bob", role="contributor")
        _seed_rfc("spec", state="super-draft", owners=[])
        _set_visibility("default", "gated")
        bob = _su(2, "bob", "contributor")

        # non-member: discussion thread create → 404 (visibility, before the
        # write gate even applies).
        sign_in_as(client, user_id=2, gitea_login="bob", display_name="Bob", role="contributor")
        assert client.post("/api/rfcs/spec/discussion/threads", json={"message": "q"}).status_code == 404

        # §22 three-tier (§B.3): a `contributor` member can both discuss and
        # contribute (the viewer-only read tier is deferred this pass).
        _add_member("default", 2, "project_contributor")
        r = client.post("/api/rfcs/spec/discussion/threads", json={"message": "q"})
        assert r.status_code == 200, r.text
        assert auth.can_contribute_to_rfc(bob, "spec") is True


# ---------------------------------------------------------------------------
# 5. Union of tiers + subtractive visibility gate
# ---------------------------------------------------------------------------


def test_per_rfc_authority_unions_then_yields_to_visibility(app_with_fake_gitea):
    from app import auth
    from test_propose_vertical import grant_rfc_collaborator

    app, _ = app_with_fake_gitea
    with TestClient(app):
        provision_user_row(user_id=2, login="bob", role="contributor")
        _seed_rfc("owned", state="active", owners=["alice"])
        bob = _su(2, "bob", "contributor")

        # public + per-RFC collaborator(contributor) → contribute (union term).
        grant_rfc_collaborator(user_id=2, rfc_slug="owned", role_in_rfc="contributor")
        assert auth.can_contribute_to_rfc(bob, "owned") is True

        # flip to gated: the §22.5 gate is subtractive — the per-RFC grant no
        # longer suffices without project membership.
        _set_visibility("default", "gated")
        assert auth.can_contribute_to_rfc(bob, "owned") is False

        # restore read via project membership → the per-RFC union applies again.
        _add_member("default", 2, "project_viewer")
        assert auth.can_contribute_to_rfc(bob, "owned") is True


# ---------------------------------------------------------------------------
# 6. Revocation takes effect on the next call
# ---------------------------------------------------------------------------


def test_revoking_membership_revokes_access(app_with_fake_gitea):
    from app import auth

    app, _ = app_with_fake_gitea
    with TestClient(app):
        provision_user_row(user_id=2, login="bob", role="contributor")
        _set_visibility("default", "gated")
        bob = _su(2, "bob", "contributor")

        _add_member("default", 2, "project_contributor")
        assert auth.can_contribute_in_project(bob, "default") is True

        _remove_member("default", 2)
        assert auth.can_read_project(bob, "default") is False
        assert auth.can_contribute_in_project(bob, "default") is False
