"""Instances — the 404-vs-403 asymmetry and the grant resolution order.

An app instance is a project/workspace/client within the app. Two invariants:

  * Visibility leaks nothing: an unknown instance AND an instance the caller simply
    can't see both return 404. A 403 on an invisible-but-real instance would confirm
    the id exists — so "forbidden" is spelled "not found" here.
  * Access resolves in one fixed order: a per-instance GRANT wins over an
    `all_instances` default, which wins over no access at all (None).

Skipped cleanly on a `has_instances=false` app so the pack stays portable.

TWO TIERS of tests here:
  * kit-router tests (`/api/bw/...`) — always present, always asserted.
  * sample-route tests (`/api/instances/...`) — the TEMPLATE's demo instance
    resource. An app is EXPECTED to replace those routes with its own domain
    (projects/clients/workspaces); when it has, these skip with a pointer: the
    404-vs-403 asymmetry is then the app's own responsibility, covered in its
    own test files against its own routes.
"""

import pytest

from tests.conftest import HAS_INSTANCES, as_user

pytestmark = pytest.mark.skipif(not HAS_INSTANCES,
                                reason="app has no instances (bw_config.HAS_INSTANCES=False)")

SAMPLE_GONE = ("the template's sample /api/instances routes were replaced by the "
               "app's own domain — cover the 404-vs-403 asymmetry in the app's own "
               "tests against its own routes")


def _code(resp):
    body = resp.json()
    d = body.get("detail")
    return d.get("error_code") if isinstance(d, dict) else body.get("error_code")


def _sample_routes_present(client) -> bool:
    """True while the template's demo /api/instances routes still exist. When an
    app replaces them, the path falls through to the SPA catch-all (index.html —
    or a 503 when no build exists) instead of an API route — that's the signal to
    skip the sample-route tests. The catch-all may answer 200 with HTML, so the
    JSON parse must be allowed to fail."""
    as_user(client, "rian")
    r = client.get("/api/instances")
    if r.status_code != 200:
        return False
    try:
        return "instances" in r.json()
    except Exception:
        return False


# -- kit-router invariants (always present) -----------------------------------

def test_admin_router_unknown_instance_is_404(client):
    """The kit's own grant route 404s an unknown instance id — independent of
    whatever the app renamed its product routes to."""
    as_user(client, "rian")
    r = client.post("/api/bw/instances/nope-does-not-exist/members",
                    json={"username": "bob", "level": "member"})
    assert r.status_code == 404
    assert _code(r) == "NO_SUCH_INSTANCE"


def test_grant_shows_in_the_access_matrix(client, kit):
    """Owner grants a user on an instance → the matrix reflects the cell."""
    kit.instance("proj-a", "Project A")
    kit.member("bob", "member")
    as_user(client, "rian")
    r = client.post("/api/bw/instances/proj-a/members",
                    json={"username": "bob", "level": "member"})
    assert r.status_code == 200
    matrix = client.get("/api/bw/access-matrix").json()
    assert "proj-a" in [i["id"] for i in matrix["instances"]]
    bob_row = next(row for row in matrix["rows"] if row["username"] == "bob")
    assert bob_row["cells"]["proj-a"] == "member"


def test_resolution_order_grant_beats_all_instances_beats_none(client, kit):
    """One fixed order, read straight off the access matrix cells:
      dana: all_instances member + a per-instance 'admin' grant on p1
            → p1 resolves to the GRANT ('admin'), p2 to the all_instances default.
      erin: plain member, no all_instances, no grant → every cell None.
    """
    kit.member("dana", "member")
    kit.set_all("dana", True)
    kit.member("erin", "member")
    kit.instance("p1", "P1")
    kit.instance("p2", "P2")
    kit.grant("dana", "p1", "admin")       # grant outranks the all_instances default

    as_user(client, "rian")
    matrix = client.get("/api/bw/access-matrix").json()
    dana = next(r for r in matrix["rows"] if r["username"] == "dana")
    erin = next(r for r in matrix["rows"] if r["username"] == "erin")

    assert dana["cells"]["p1"] == "admin"   # grant wins
    assert dana["cells"]["p2"] == "member"  # all_instances fallback
    assert erin["cells"]["p1"] is None      # no grant, no all_instances → none
    assert erin["cells"]["p2"] is None


# -- the scoped-manager journey (kit-router; always present) -------------------
# A "coordinator" whose authority comes ONLY from their level on one instance:
# they can see and staff THEIR instance from inside it — no app-wide permission,
# no access to the Admin area's rosters.

def _seed_coordinator(kit):
    kit.bwa.create_level(kit.owner, "coord",
                         ["instances.grant", "accounts.add"], ["member"])
    kit.member("mia", "member")
    kit.instance("p1", "P1")
    kit.instance("p2", "P2")
    kit.grant("mia", "p1", "coord")   # mia coordinates p1, has nothing on p2


def test_scoped_manager_sees_their_instance_roster(client, kit):
    _seed_coordinator(kit)
    kit.member("bobby", "member")
    kit.grant("bobby", "p1", "member")
    as_user(client, "mia")
    r = client.get("/api/bw/instances/p1/members")
    assert r.status_code == 200
    body = r.json()
    assert body["can_manage"] is True and body["can_add"] is True
    assert body["assignable"] == ["member"]
    assert {m["username"] for m in body["members"]} >= {"mia", "bobby"}
    # A foreign instance is a 404 — its existence is never confirmed.
    r = client.get("/api/bw/instances/p2/members")
    assert r.status_code == 404


def test_scoped_manager_invites_a_new_user_into_their_instance(client, kit, bw_calls):
    """The whole point: no app-wide permission, yet mia creates+invites a brand-new
    user from inside p1. The new member's footprint is exactly p1."""
    _seed_coordinator(kit)
    as_user(client, "mia")
    r = client.post("/api/bw/instances/p1/invite",
                    json={"username": "freshface", "level": "member",
                          "email": "fresh@example.com"})
    assert r.status_code == 200, r.text
    assert any(path == "/app/invite-user" for path, _ in bw_calls)
    m = kit.bwa.member("freshface")
    assert m is not None and m["all_instances"] is False
    assert kit.bwa.effective_level("freshface", "p1") == "member"
    assert kit.bwa.effective_level("freshface", "p2") is None
    # And refused elsewhere: p2 is invisible to mia → 404.
    r = client.post("/api/bw/instances/p2/invite",
                    json={"username": "other", "level": "member",
                          "email": "o@example.com"})
    assert r.status_code == 404


def test_scoped_manager_adds_an_existing_member_without_email(client, kit, bw_calls):
    """An existing app member added to the instance is a pure grant — no central
    invite email; notify=true sends only the courtesy notification."""
    _seed_coordinator(kit)
    kit.member("carol", "member")
    as_user(client, "mia")
    r = client.post("/api/bw/instances/p1/invite",
                    json={"username": "carol", "level": "member", "notify": True})
    assert r.status_code == 200, r.text
    assert kit.bwa.effective_level("carol", "p1") == "member"
    assert "/app/invite-user" not in [p for p, _ in bw_calls]
    assert any(path == "/app/notify-added" for path, _ in bw_calls)


def test_scoped_manager_directory_gates_resolve_per_instance(client, kit):
    """mia holds NO app-wide accounts.add: the plain directory search refuses,
    the instance-scoped one works."""
    _seed_coordinator(kit)
    as_user(client, "mia")
    assert client.get("/api/bw/users/search", params={"q": "al"}).status_code == 403
    r = client.get("/api/bw/users/search", params={"q": "al", "instance": "p1"})
    assert r.status_code == 200
    r = client.get("/api/bw/users/check",
                   params={"username": "alice", "instance": "p1"})
    assert r.status_code == 200 and r.json()["exists"] is True


def test_all_instances_members_show_as_all_access(client, kit):
    _seed_coordinator(kit)
    kit.member("staffer", "member", all_instances=True)
    as_user(client, "mia")
    rows = client.get("/api/bw/instances/p1/members").json()["members"]
    staff_row = next(m for m in rows if m["username"] == "staffer")
    assert staff_row["via"] == "all"


# -- sample-route invariants (skip once the app owns its domain routes) --------

def test_unknown_instance_is_404(client):
    if not _sample_routes_present(client):
        pytest.skip(SAMPLE_GONE)
    as_user(client, "rian")
    r = client.get("/api/instances/nope-does-not-exist")
    assert r.status_code == 404
    assert _code(r) == "NO_SUCH_INSTANCE"


def test_create_slugs_the_name_and_dodges_collisions(client):
    """Instance ids are URL slugs of the name; a collision appends -2, -3, …"""
    if not _sample_routes_present(client):
        pytest.skip(SAMPLE_GONE)
    as_user(client, "rian")
    r = client.post("/api/instances", json={"name": "Culture Foundry!"})
    assert r.status_code == 200 and r.json()["id"] == "culture-foundry"
    r = client.post("/api/instances", json={"name": "Culture  Foundry"})
    assert r.status_code == 200 and r.json()["id"] == "culture-foundry-2"


def test_invisible_instance_is_404_not_403(client, kit):
    """The instance EXISTS but the caller holds no level on it — 404, not 403, so
    the response never confirms the id is real."""
    if not _sample_routes_present(client):
        pytest.skip(SAMPLE_GONE)
    kit.instance("proj-a", "Project A")
    kit.member("bob", "member")            # member, but no grant on proj-a
    as_user(client, "bob")
    r = client.get("/api/instances/proj-a")
    assert r.status_code == 404
    assert _code(r) == "NO_SUCH_INSTANCE"


def test_grant_makes_an_instance_visible(client, kit):
    """Owner grants a user on an instance → the user can now see it (200)."""
    if not _sample_routes_present(client):
        pytest.skip(SAMPLE_GONE)
    kit.instance("proj-a", "Project A")
    kit.member("bob", "member")

    as_user(client, "bob")
    assert client.get("/api/instances/proj-a").status_code == 404

    as_user(client, "rian")
    r = client.post("/api/bw/instances/proj-a/members",
                    json={"username": "bob", "level": "member"})
    assert r.status_code == 200

    as_user(client, "bob")
    r = client.get("/api/instances/proj-a")
    assert r.status_code == 200
    assert r.json()["id"] == "proj-a"
