"""The rest of M3: level administration (D28/D29), membership-vs-manage scoping
(D30), invites and resets, per-project deletion (D31), and the anonymous
forgot-password endpoint (D32).

See /srv/apps/scout/.logs/planning/04-full-auth-suite.md for D25-D32.
"""

import pytest
from sqlalchemy import delete

from app.config import get_settings
from app.constants import LEVEL_ADMIN, LEVEL_REVIEWER
from app.models.account import Account
from app.models.level import Level
from app.services import accounts as accounts_service
from app.services import levels
from app.services import projects as projects_service
from tests.conftest import admin_import_and_publish, as_user

# --- local fixtures ----------------------------------------------------------
# conftest's autouse cleanup only knows about app_accounts/projects (keyed on
# the t_ prefix); this file also creates app_levels rows and, for owner-behavior
# tests, may create the one Account row conftest never does (the real owner has
# no seed row). Both need their own cleanup, local to this file.


@pytest.fixture(autouse=True)
def _cleanup_custom_levels(db):
    yield
    db.execute(delete(Level).where(Level.name.like("t\\_%")))
    db.commit()


@pytest.fixture
def owner(db):
    """Ensure a Scout Account row exists for the REAL app owner.

    current_account needs a row to resolve the effective user even though the
    kit's own permission checks bypass it via is_owner(). Track whether THIS
    fixture created the row, and remove only what it added -- a pre-existing
    owner row belongs to someone else's test or to production data and is not
    ours to delete.
    """
    username = get_settings().scout_owner
    created = db.get(Account, username) is None
    if created:
        db.add(Account(
            username=username, email=f"{username}@example.com",
            level=LEVEL_ADMIN, all_instances=True, active=True,
        ))
        db.commit()
    yield username
    if created:
        row = db.get(Account, username)
        if row is not None:
            db.delete(row)
            db.commit()


@pytest.fixture
def coordinator(db, owner, seed):
    """rian's 'example B': t_coord's APP-WIDE level is a plain reviewer
    (assignable=[]), but on the seeded project only they hold a scoped level
    (t_lvl_coord, assignable=['reviewer']) granting scout.review +
    scout.project.members -- invite/manage members of exactly one project,
    nothing else (D30)."""
    levels.create_level(owner, "t_lvl_coord", ["scout.review", "scout.project.members"],
                        ["reviewer"])
    coord = Account(username="t_coord", email="t_coord@example.com",
                    level=LEVEL_REVIEWER, all_instances=False, active=True)
    db.add(coord)
    db.commit()
    projects_service.add_member(db, seed.project.id, "t_coord", "t_admin", level=LEVEL_REVIEWER)
    projects_service.set_member_level(db, seed.project.id, "t_coord", "t_lvl_coord")
    return coord


# =============================================================================
# Levels CRUD (D28/D29)
# =============================================================================


def test_owner_creates_level_and_it_appears_with_zero_usage(client, owner):
    as_user(client, owner)
    create = client.post("/api/levels", json={
        "name": "t_lvl_created",
        "permissions": ["scout.projects.create", "accounts.add", "instances.grant",
                        "scout.project.manage"],
        "assignable": ["reviewer"],
    })
    assert create.status_code == 201, create.text
    body = create.json()
    assert body["name"] == "t_lvl_created"
    assert body["in_use_by"] == 0

    listing = client.get("/api/levels")
    assert listing.status_code == 200
    defs = {d["name"]: d for d in listing.json()["levels"]}
    assert "t_lvl_created" in defs
    assert defs["t_lvl_created"]["in_use_by"] == 0


def test_admin_cannot_create_level_but_can_list_without_edit(client, seed):
    as_user(client, "t_admin")
    create = client.post(
        "/api/levels", json={"name": "t_lvl_admin_should_fail", "permissions": [], "assignable": []}
    )
    assert create.status_code == 403
    assert create.json()["detail"]["error_code"] == "FORBIDDEN"

    listing = client.get("/api/levels")
    assert listing.status_code == 200
    assert listing.json()["can_edit"] is False

    as_user(client, "t_member")
    member_listing = client.get("/api/levels")
    assert member_listing.status_code == 403


def test_owner_patches_level_permissions_and_custom_strings_round_trip(client, owner):
    as_user(client, owner)
    create = client.post(
        "/api/levels",
        json={"name": "t_lvl_patchable", "permissions": ["scout.review"], "assignable": []},
    )
    assert create.status_code == 201

    patch = client.patch(
        "/api/levels/t_lvl_patchable",
        json={"permissions": ["scout.review", "scout.results.view", "t.custom.perm"]},
    )
    assert patch.status_code == 200
    assert set(patch.json()["permissions"]) == {
        "scout.review", "scout.results.view", "t.custom.perm"
    }

    listing = client.get("/api/levels").json()
    fetched = next(d for d in listing["levels"] if d["name"] == "t_lvl_patchable")
    assert set(fetched["permissions"]) == {
        "scout.review", "scout.results.view", "t.custom.perm"
    }


def test_delete_level_refused_while_in_use_then_succeeds(client, db, owner):
    as_user(client, owner)
    create = client.post(
        "/api/levels", json={"name": "t_lvl_inuse", "permissions": [], "assignable": []}
    )
    assert create.status_code == 201

    holder = Account(username="t_lvl_holder", email="t_lvl_holder@example.com",
                     level=LEVEL_REVIEWER, active=True)
    db.add(holder)
    db.commit()
    accounts_service.set_level(db, holder, "t_lvl_inuse")

    refused = client.delete("/api/levels/t_lvl_inuse")
    assert refused.status_code // 100 == 4, refused.text
    assert holder.level == "t_lvl_inuse"

    accounts_service.set_level(db, holder, LEVEL_REVIEWER)
    deleted = client.delete("/api/levels/t_lvl_inuse")
    assert deleted.status_code == 204

    listing = client.get("/api/levels").json()
    assert all(d["name"] != "t_lvl_inuse" for d in listing["levels"])


# --- example A: the "manager" level (projects + people + invites) -----------


def test_manager_level_end_to_end(client, db, owner, seed):
    levels.create_level(owner, "t_lvl_mgr", [
        "scout.projects.create", "accounts.add", "instances.grant", "scout.project.manage",
    ], ["reviewer"])

    mgr = Account(username="t_mgr", email="t_mgr@example.com", level=LEVEL_REVIEWER, active=True)
    db.add(mgr)
    db.commit()
    accounts_service.set_level(db, mgr, "t_lvl_mgr")
    accounts_service.set_all_instances(db, mgr, True)

    as_user(client, "t_mgr")

    create = client.post("/api/projects", json={"name": "t_mgr Project"})
    assert create.status_code == 201, create.text
    project_id = create.json()["id"]

    add_member = client.post(
        f"/api/projects/{project_id}/members", json={"username": "t_outsider", "level": "reviewer"}
    )
    assert add_member.status_code == 201, add_member.text

    change_level = client.patch("/api/accounts/t_outsider/level", json={"level": "reviewer"})
    assert change_level.status_code == 403

    list_levels_resp = client.get("/api/levels")
    assert list_levels_resp.status_code == 403


# --- example B: the scoped coordinator (invite + manage members of ONE project)


def test_scoped_coordinator_can_add_and_remove_members(client, seed, coordinator):
    as_user(client, "t_coord")
    add_member = client.post(
        f"/api/projects/{seed.project.id}/members",
        json={"username": "t_outsider", "level": "reviewer"},
    )
    assert add_member.status_code == 201, add_member.text

    remove_member = client.delete(f"/api/projects/{seed.project.id}/members/t_outsider")
    assert remove_member.status_code == 204


def test_scoped_coordinator_cannot_edit_project(client, seed, coordinator):
    as_user(client, "t_coord")
    resp = client.patch(f"/api/projects/{seed.project.id}", json={"name": "t_renamed"})
    assert resp.status_code == 403


def test_scoped_coordinator_project_scoped_invite(client, seed, coordinator, bw_calls):
    as_user(client, "t_coord")
    resp = client.post("/api/accounts/invite", json={
        "username": "t_coordinvite", "email": "t_coordinvite@example.com",
        "project_id": seed.project.id, "project_level": "reviewer",
    })
    assert resp.status_code == 201, resp.text
    assert any(c[0] == "/app/invite-user" for c in bw_calls)


def test_scoped_coordinator_invite_without_project_id_forbidden(client, coordinator):
    as_user(client, "t_coord")
    resp = client.post("/api/accounts/invite", json={
        "username": "t_coordinvite2", "email": "t_coordinvite2@example.com",
    })
    assert resp.status_code == 403


def test_scoped_coordinator_invite_on_foreign_project_404(client, db, coordinator):
    other = projects_service.create(
        db, name="Not Coord's Project", client_name="Other Client", brief="",
        status="active", created_by="t_admin",
    )
    as_user(client, "t_coord")
    resp = client.post("/api/accounts/invite", json={
        "username": "t_coordinvite3", "email": "t_coordinvite3@example.com",
        "project_id": other.id, "project_level": "reviewer",
    })
    assert resp.status_code == 404


# =============================================================================
# Invites (app-wide)
# =============================================================================


def test_app_wide_invite_creates_account_and_emails(client, seed, db, bw_calls):
    as_user(client, "t_admin")
    resp = client.post("/api/accounts/invite", json={
        "username": "t_newbie", "email": "t_newbie@example.com",
    })
    assert resp.status_code == 201, resp.text
    body = resp.json()
    assert body["created"] is True
    assert body["emailed"] is True

    account = accounts_service.get(db, "t_newbie")
    assert account is not None
    assert account.level == "reviewer"

    invite_calls = [c for c in bw_calls if c[0] == "/app/invite-user"]
    assert invite_calls
    assert invite_calls[-1][1]["email"] == "t_newbie@example.com"


def test_app_wide_invite_admin_level_not_assignable(client, seed):
    as_user(client, "t_admin")
    resp = client.post("/api/accounts/invite", json={
        "username": "t_newbie2", "email": "t_newbie2@example.com", "level": "admin",
    })
    assert resp.status_code == 403
    assert resp.json()["detail"]["error_code"] == "NOT_ASSIGNABLE"


# =============================================================================
# send-reset
# =============================================================================


def test_send_reset_self_others_and_unknown(client, seed, bw_calls):
    as_user(client, "t_member")
    self_reset = client.post("/api/accounts/t_member/send-reset")
    assert self_reset.status_code == 202
    reset_calls = [c for c in bw_calls if c[0] == "/app/send-reset"]
    assert reset_calls
    assert reset_calls[-1][1]["username"] == "t_member"

    other_reset = client.post("/api/accounts/t_lead/send-reset")
    assert other_reset.status_code == 403

    as_user(client, "t_admin")
    admin_reset = client.post("/api/accounts/t_member/send-reset")
    assert admin_reset.status_code == 202

    unknown_reset = client.post("/api/accounts/t_does_not_exist/send-reset")
    assert unknown_reset.status_code == 404


# =============================================================================
# Delete project (D31)
# =============================================================================


def test_delete_empty_project_succeeds(client, db, seed):
    as_user(client, "t_admin")
    project = projects_service.create(
        db, name="t_throwaway", client_name="", brief="", status="active", created_by="t_admin",
    )

    delete_resp = client.delete(f"/api/projects/{project.id}")
    assert delete_resp.status_code == 204

    get_after = client.get(f"/api/projects/{project.id}")
    assert get_after.status_code == 404


def test_delete_project_with_reviews_requires_force(client, db, seed):
    as_user(client, "t_admin")
    project = projects_service.create(
        db, name="t_has_reviews", client_name="", brief="", status="active", created_by="t_admin",
    )
    projects_service.add_member(db, project.id, "t_member", "t_admin", level=LEVEL_REVIEWER)

    ids = admin_import_and_publish(client, project, [{"slug": "alpha"}])
    option_id = ids["alpha"]

    as_user(client, "t_member")
    review = client.patch(
        f"/api/projects/{project.id}/review/options/{option_id}", json={"rating": 2}
    )
    assert review.status_code == 200

    as_user(client, "t_admin")
    refused = client.delete(f"/api/projects/{project.id}")
    assert refused.status_code == 409
    assert refused.json()["detail"]["error_code"] == "PROJECT_HAS_REVIEWS"

    forced = client.delete(f"/api/projects/{project.id}?force=true")
    assert forced.status_code == 204


def test_member_cannot_delete_project_outsider_gets_404(client, seed):
    as_user(client, "t_member")
    member_resp = client.delete(f"/api/projects/{seed.project.id}")
    assert member_resp.status_code == 403

    as_user(client, "t_outsider")
    outsider_resp = client.delete(f"/api/projects/{seed.project.id}")
    assert outsider_resp.status_code == 404


# =============================================================================
# Forgot password (D32)
# =============================================================================


def test_forgot_password_constant_response_and_anonymous(client, db, seed, bw_calls):
    # No as_user() call anywhere in this test -- /auth/forgot is a public path,
    # exercised here by a deliberately anonymous client.
    accounts_service.set_active(db, seed.lead, False)

    existing = client.post("/auth/forgot", json={"username": "t_member"})
    unknown = client.post("/auth/forgot", json={"username": "t_definitely_not_a_user"})
    deactivated = client.post("/auth/forgot", json={"username": "t_lead"})

    assert existing.status_code == 200
    assert unknown.status_code == 200
    assert deactivated.status_code == 200
    assert existing.json() == unknown.json() == deactivated.json()

    # The per-IP throttle (TestClient always reports the same fake client IP)
    # means at most the FIRST of these three calls actually reaches send_reset;
    # the rest return the identical constant body via the throttle path. That
    # is fine and is exactly the point -- the response never discloses which
    # path was taken.
    send_reset_calls = [c for c in bw_calls if c[0] == "/app/send-reset"]
    assert len(send_reset_calls) <= 1


# =============================================================================
# Matrix
# =============================================================================


def test_access_matrix_shows_seeded_project_and_member_cell(client, seed):
    as_user(client, "t_admin")
    resp = client.get("/api/auth/matrix")
    assert resp.status_code == 200
    body = resp.json()

    project_ids = {p["id"] for p in body["projects"]}
    assert str(seed.project.id) in project_ids

    member_row = next(u for u in body["users"] if u["username"] == "t_member")
    assert member_row["cells"][str(seed.project.id)] == "reviewer"

    as_user(client, "t_member")
    forbidden = client.get("/api/auth/matrix")
    assert forbidden.status_code == 403


# =============================================================================
# Status / sync
# =============================================================================


def test_bw_status_endpoint(client, seed, monkeypatch):
    canned = {
        "configured": True, "client_id": "t-client", "auth_host": "https://auth.bowden.works",
        "domain_mode": "public", "owner": get_settings().scout_owner, "reachable": True,
    }
    monkeypatch.setattr("app.services.levels.bw_status", lambda: canned)

    as_user(client, "t_admin")
    resp = client.get("/api/auth/status")
    assert resp.status_code == 200
    assert resp.json() == canned


def test_sync_pushes_catalog_and_access(client, seed, bw_calls, monkeypatch):
    monkeypatch.setattr(
        "app.services.levels.bw_status",
        lambda: {
            "configured": True, "client_id": "t-client", "auth_host": "https://auth.bowden.works",
            "domain_mode": "public", "owner": get_settings().scout_owner, "reachable": True,
        },
    )

    as_user(client, "t_admin")
    resp = client.post("/api/auth/sync")
    assert resp.status_code == 202
    assert resp.json() == {"synced": True}

    paths = [c[0] for c in bw_calls]
    assert "/app/report-instances" in paths
    assert "/app/report-access" in paths
