"""Invites, resets, and directory search — permission-gated, side-effect-ordered.

The composite-flow rule (T8): a doomed invite must be refused BEFORE any central
side effect — no invite email is sent to someone the actor could never assign. The
happy path adds the membership AND records the central invite. Resets are permission-
gated (self-reset is open to any signed-in user; resetting others needs the perm),
and the directory typeahead is gated on accounts.add.
"""

from tests.conftest import as_user


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 _paths(bw_calls):
    return [path for path, _ in bw_calls]


def test_doomed_invite_sends_no_email(client, kit, bw_calls):
    """An admin cannot assign 'admin' → the invite is refused with NOT_ASSIGNABLE
    and NO /app/invite-user is emitted (no email on a doomed invite)."""
    kit.member("alice", "admin")
    as_user(client, "alice")
    r = client.post("/api/bw/accounts/invite",
                    json={"username": "victim", "level": "admin",
                          "email": "victim@example.com"})
    assert r.status_code == 403
    assert _code(r) == "NOT_ASSIGNABLE"
    assert "/app/invite-user" not in _paths(bw_calls)


def test_successful_invite_adds_member_and_records_central_invite(client, kit, bw_calls):
    kit.member("alice", "admin")
    as_user(client, "alice")
    r = client.post("/api/bw/accounts/invite",
                    json={"username": "newhire", "level": "member",
                          "email": "newhire@example.com"})
    assert r.status_code == 200
    assert kit.bwa.member("newhire") is not None
    invites = [payload for path, payload in bw_calls if path == "/app/invite-user"]
    assert any(p["username"] == "newhire" for p in invites)


def test_inviting_an_existing_member_conflicts_without_a_new_email(client, kit, bw_calls):
    kit.member("dup", "member")
    as_user(client, "rian")
    before = _paths(bw_calls).count("/app/invite-user")
    r = client.post("/api/bw/accounts/invite",
                    json={"username": "dup", "level": "member",
                          "email": "dup@example.com"})
    assert r.status_code == 409
    assert _code(r) == "EXISTS"
    assert _paths(bw_calls).count("/app/invite-user") == before  # no new invite


def test_adding_an_existing_account_skips_the_credential_email(client, kit, bw_calls):
    """Adding someone who ALREADY has a BW account is membership-only: userinfo
    resolves them, NO /app/invite-user (no set-password email), and by default no
    notification either."""
    as_user(client, "rian")
    r = client.post("/api/bw/accounts/invite",
                    json={"username": "carol", "level": "member"})
    assert r.status_code == 200
    assert "existing account added" in r.json()["note"]
    assert kit.bwa.member("carol") is not None
    assert "/app/userinfo" in _paths(bw_calls)
    assert "/app/invite-user" not in _paths(bw_calls)
    assert "/app/notify-added" not in _paths(bw_calls)


def test_adding_an_existing_account_with_notify_sends_the_courtesy_email(client, kit, bw_calls):
    as_user(client, "rian")
    r = client.post("/api/bw/accounts/invite",
                    json={"username": "carol", "level": "member", "notify": True})
    assert r.status_code == 200
    assert "notified" in r.json()["note"]
    notified = [p for path, p in bw_calls if path == "/app/notify-added"]
    assert any(p["username"] == "carol" for p in notified)
    assert "/app/invite-user" not in _paths(bw_calls)


def test_inviting_a_new_user_requires_an_email(client, kit, bw_calls):
    """A brand-new username (unknown centrally) can't be invited without an email
    — the set-password invite has nowhere to go. Refused BEFORE any central
    side effect beyond the existence lookup."""
    as_user(client, "rian")
    r = client.post("/api/bw/accounts/invite",
                    json={"username": "brandnew", "level": "member"})
    assert r.status_code == 400
    assert _code(r) == "EMAIL_REQUIRED"
    assert kit.bwa.member("brandnew") is None
    assert "/app/invite-user" not in _paths(bw_calls)


def test_check_user_reports_the_three_cases(client, kit):
    """/users/check is the add-flow's branch point: member / existing account /
    brand new. Exact-match only."""
    kit.member("bob", "member")
    as_user(client, "rian")
    r = client.get("/api/bw/users/check", params={"username": "bob"})
    assert r.json()["member"] is True
    r = client.get("/api/bw/users/check", params={"username": "carol"})
    body = r.json()
    assert body["member"] is False and body["exists"] is True
    assert body["profile"]["email"] == "carol@example.com"
    r = client.get("/api/bw/users/check", params={"username": "brandnew"})
    body = r.json()
    assert body["member"] is False and body["exists"] is False


def test_check_user_is_gated_on_accounts_add(client, kit):
    """Same gate as inviting — a plain member can't probe for account existence."""
    kit.member("bob", "member")
    as_user(client, "bob")
    r = client.get("/api/bw/users/check", params={"username": "carol"})
    assert r.status_code == 403
    assert _code(r) == "FORBIDDEN"


def test_manual_handoff_creates_with_a_password_and_sends_no_email(client, kit, bw_calls):
    """delivery="password": the account is created centrally with a generated
    password returned ONCE, and NO invite email is sent."""
    as_user(client, "rian")
    r = client.post("/api/bw/accounts/invite",
                    json={"username": "handoff", "level": "member",
                          "email": "handoff@example.com", "delivery": "password"})
    assert r.status_code == 200, r.text
    body = r.json()
    assert body.get("password"), "the generated password must come back once"
    assert len(body["password"]) >= 10
    assert kit.bwa.member("handoff") is not None
    paths = _paths(bw_calls)
    assert "/app/invite-user" in paths          # same endpoint, delivery=password
    sent = [p for path, p in bw_calls if path == "/app/invite-user"]
    assert sent[-1]["delivery"] == "password"   # ...and it asked for no email


def test_manual_handoff_still_requires_an_email(client, kit, bw_calls):
    """The address is the account's identity and its only recovery path, so it is
    required even though nothing is mailed."""
    as_user(client, "rian")
    r = client.post("/api/bw/accounts/invite",
                    json={"username": "noaddr", "level": "member",
                          "delivery": "password"})
    assert r.status_code == 400
    assert _code(r) == "EMAIL_REQUIRED"
    assert kit.bwa.member("noaddr") is None


def test_manual_handoff_is_never_used_for_an_existing_account(client, kit, bw_calls):
    """The takeover boundary: a username that ALREADY has a BW account is added
    as an existing account — no password is minted, nothing central is created."""
    as_user(client, "rian")
    r = client.post("/api/bw/accounts/invite",
                    json={"username": "carol", "level": "member",
                          "delivery": "password"})
    assert r.status_code == 200, r.text
    body = r.json()
    assert "password" not in body or not body.get("password")
    assert "existing account added" in body["note"]


def test_owner_can_issue_a_new_password_once(client, kit, bw_calls):
    """The owner replaces a member's password; it comes back ONCE, nothing is
    emailed (the central call is /app/reset-password, not a reset link)."""
    kit.member("carol", "member")
    as_user(client, "rian")
    r = client.post("/api/bw/accounts/carol/new-password")
    assert r.status_code == 200, r.text
    assert r.json()["password"]
    paths = _paths(bw_calls)
    assert "/app/reset-password" in paths
    assert "/app/send-reset" not in paths


def test_new_password_is_owner_only_even_for_account_admins(client, kit, bw_calls):
    """An admin holding every account permission still cannot: handing someone
    another person's working password is acting-as, reserved for the owner."""
    kit.member("alice", "admin")
    kit.member("carol", "member")
    as_user(client, "alice")
    r = client.post("/api/bw/accounts/carol/new-password")
    assert r.status_code == 403
    assert _code(r) == "FORBIDDEN"
    assert "/app/reset-password" not in _paths(bw_calls)


def test_new_password_requires_a_member(client, kit, bw_calls):
    as_user(client, "rian")
    r = client.post("/api/bw/accounts/nobody/new-password")
    assert r.status_code == 404
    assert "/app/reset-password" not in _paths(bw_calls)


def test_send_reset_requires_the_permission(client, kit):
    """A plain member cannot send another user a reset link."""
    kit.member("bob", "member")
    kit.member("carol", "member")
    as_user(client, "bob")
    r = client.post("/api/bw/accounts/carol/send-reset")
    assert r.status_code == 403
    assert _code(r) == "FORBIDDEN"


def test_self_send_reset_works_for_any_signed_in_user(client, kit, bw_calls):
    """/api/bw/my/send-reset is open to any signed-in user and records /app/send-reset
    for themselves."""
    kit.member("bob", "member")
    as_user(client, "bob")
    r = client.post("/api/bw/my/send-reset")
    assert r.status_code == 200
    resets = [payload for path, payload in bw_calls if path == "/app/send-reset"]
    assert any(p["username"] == "bob" for p in resets)


def test_directory_search_is_gated_on_accounts_add(client, kit):
    """A member (no accounts.add) cannot use the directory typeahead."""
    kit.member("bob", "member")
    as_user(client, "bob")
    r = client.get("/api/bw/users/search", params={"q": "al"})
    assert r.status_code == 403
    assert _code(r) == "FORBIDDEN"


def test_directory_search_returns_stubbed_users_for_an_adder(client, kit, bw_calls):
    """An admin (has accounts.add) gets results, and the query funnels through
    /app/search-users."""
    kit.member("alice", "admin")
    as_user(client, "alice")
    r = client.get("/api/bw/users/search", params={"q": "al"})
    assert r.status_code == 200
    usernames = [u["username"] for u in r.json()["users"]]
    assert usernames == ["alice", "aline"]
    assert "/app/search-users" in _paths(bw_calls)
