"""Per-project access levels: the migration/seed drift check, the resolution
order (grant beats app-wide level beats nothing), the owner's kit-level
short-circuit, the 403-vs-404 HTTP asymmetry, the assignment guards,
deactivation, and the staff/client split in results.

See /srv/apps/scout/.logs/planning/03-per-project-levels.md (D17-D24) for the
model this exercises: a per-project grant wins; failing that the app-wide
level applies only when `all_instances` is set; otherwise there is no access
to that project at all.
"""

from sqlalchemy import select

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

# --- seed integrity: the test schema must agree with SEED_LEVELS -----------


def test_seed_levels_match_database(db):
    """app_levels must hold exactly the names/permissions/assignable that
    app.constants.SEED_LEVELS defines. The conftest schema fixture seeds from
    SEED_LEVELS directly, so this is the guard against a level's data drifting
    from the migration's seed without both being updated together."""
    db_levels = {level.name: level for level in db.scalars(select(Level))}
    assert set(db_levels) == set(SEED_LEVELS), (
        f"level names differ: database has {sorted(db_levels)}, "
        f"SEED_LEVELS has {sorted(SEED_LEVELS)}"
    )
    for name, spec in SEED_LEVELS.items():
        row = db_levels[name]
        assert set(row.permissions) == set(spec["permissions"]), (
            f"level {name!r}: database permissions {sorted(row.permissions)} != "
            f"SEED_LEVELS permissions {sorted(spec['permissions'])}"
        )
        assert set(row.assignable) == set(spec["assignable"]), (
            f"level {name!r}: database assignable {sorted(row.assignable)} != "
            f"SEED_LEVELS assignable {sorted(spec['assignable'])}"
        )


# --- resolution order: grant > app-wide level (if all_instances) > none ----


def test_member_grant_resolves_on_own_project_and_none_on_another(client, seed, db):
    assert levels.effective_level("t_member", seed.project.id) == LEVEL_REVIEWER

    as_user(client, "t_member")
    own_project = client.get(f"/api/projects/{seed.project.id}")
    assert own_project.status_code == 200
    assert own_project.json()["my_level"] == LEVEL_REVIEWER

    other = projects_service.create(
        db, name="Second Project", client_name="Other Client", brief="",
        status="active", created_by="t_admin",
    )
    assert levels.effective_level("t_member", other.id) is None

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


def test_admin_all_instances_resolves_on_any_project_including_new_one(client, seed, db):
    # Created AFTER t_admin and after seed.project — proves all_instances is
    # resolved live, not cached against the set of projects that existed when
    # the account was created.
    new_project = projects_service.create(
        db, name="Project Created After Admin", client_name="Other Client", brief="",
        status="active", created_by="t_admin",
    )
    assert levels.effective_level("t_admin", seed.project.id) == LEVEL_ADMIN
    assert levels.effective_level("t_admin", new_project.id) == LEVEL_ADMIN

    as_user(client, "t_admin")
    resp = client.get(f"/api/projects/{new_project.id}")
    assert resp.status_code == 200
    assert resp.json()["my_level"] == LEVEL_ADMIN


def test_adding_staff_to_a_project_does_not_demote_them(client, seed, db):
    """Adding an all_instances account as a member must not write a grant below
    their app-wide level.

    Regression: migration 0003 gave every existing membership the default
    `reviewer` grant, and because a grant beats the app-wide level, an admin who
    was merely a MEMBER of a project silently lost the ability to manage it.
    Scoping someone down on one project stays possible — it just has to be the
    explicit act tested below, not a side effect of adding them.
    """
    account = Account(
        username="t_staff_added", email="t_staff_added@example.com",
        level=LEVEL_ADMIN, all_instances=True, active=True,
    )
    db.add(account)
    db.commit()
    member = projects_service.add_member(
        db, seed.project.id, account.username, "t_admin", level=LEVEL_REVIEWER
    )

    assert member.level == LEVEL_ADMIN, "adding staff must not write a lower grant"
    assert levels.effective_level(account.username, seed.project.id) == LEVEL_ADMIN
    assert levels.project_can(account.username, seed.project.id, levels.PROJECT_MANAGE)


def test_explicit_project_grant_beats_app_wide_level(client, seed, db):
    """An admin-level, all_instances account with an explicit `reviewer` grant
    on one project resolves to `reviewer` THERE and `admin` everywhere else —
    the grant wins locally without touching the app-wide level (D18).

    The grant is set with `set_member_level`, the deliberate act; `add_member`
    deliberately refuses to write it (see the test above).
    """
    account = Account(
        username="t_admin_with_grant", email="t_admin_with_grant@example.com",
        level=LEVEL_ADMIN, all_instances=True, active=True,
    )
    db.add(account)
    db.commit()
    projects_service.add_member(db, seed.project.id, account.username, "t_admin")
    projects_service.set_member_level(
        db, seed.project.id, account.username, LEVEL_REVIEWER
    )

    other = projects_service.create(
        db, name="Project Without A Grant", client_name="Other Client", brief="",
        status="active", created_by="t_admin",
    )

    assert levels.effective_level(account.username, seed.project.id) == LEVEL_REVIEWER
    assert levels.effective_level(account.username, other.id) == LEVEL_ADMIN

    as_user(client, account.username)
    granted_resp = client.get(f"/api/projects/{seed.project.id}")
    assert granted_resp.status_code == 200
    assert granted_resp.json()["my_level"] == LEVEL_REVIEWER

    fallback_resp = client.get(f"/api/projects/{other.id}")
    assert fallback_resp.status_code == 200
    assert fallback_resp.json()["my_level"] == LEVEL_ADMIN


def test_outsider_resolves_to_none_on_every_project(client, seed, db):
    """An account on no project has no level ON ANY PROJECT.

    Their app-wide level is still whatever the account holds — every route
    resolves per project, so an app-wide level alone reaches nothing.
    """
    assert levels.effective_level("t_outsider") == LEVEL_REVIEWER
    assert levels.effective_level("t_outsider", seed.project.id) is None

    other = projects_service.create(
        db, name="Another Project", client_name="Other Client", brief="",
        status="active", created_by="t_admin",
    )
    assert levels.effective_level("t_outsider", other.id) is None

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


# --- the owner: special-cased with no level row at all (D19) ---------------


def test_owner_is_special_cased_despite_no_level_row(seed):
    """D19 regression test: `member(owner)` synthesizes level "super admin",
    for which level_def() returns None — so project_can must short-circuit on
    is_owner() BEFORE ever resolving a level, or the owner silently loses
    every per-project permission. Deliberately does not sign the owner in or
    give them an app_accounts row: the whole point is that they need neither."""
    owner = get_settings().scout_owner
    assert levels.is_owner(owner) is True
    assert levels.project_can(owner, seed.project.id, levels.PROJECT_MANAGE) is True


# --- HTTP boundary: 403 when you can see the project, 404 when you can't ---


def test_lead_reaches_results_reviewer_forbidden_outsider_not_found(client, seed):
    as_user(client, "t_lead")
    lead_resp = client.get(f"/api/projects/{seed.project.id}/results")
    assert lead_resp.status_code == 200

    as_user(client, "t_member")
    member_resp = client.get(f"/api/projects/{seed.project.id}/results")
    assert member_resp.status_code == 403
    assert member_resp.json()["detail"]["error_code"] == "PERMISSION_REQUIRED"

    as_user(client, "t_outsider")
    outsider_resp = client.get(f"/api/projects/{seed.project.id}/results")
    assert outsider_resp.status_code == 404
    assert outsider_resp.status_code != 403
    assert outsider_resp.json()["detail"]["error_code"] == "PROJECT_NOT_FOUND"


def test_member_sees_project_outsider_gets_404(client, seed):
    as_user(client, "t_member")
    member_resp = client.get(f"/api/projects/{seed.project.id}")
    assert member_resp.status_code == 200
    assert member_resp.json()["id"] == seed.project.id

    as_user(client, "t_outsider")
    outsider_resp = client.get(f"/api/projects/{seed.project.id}")
    assert outsider_resp.status_code == 404
    assert outsider_resp.json()["detail"]["error_code"] == "PROJECT_NOT_FOUND"


def test_member_cannot_create_project(client, seed):
    as_user(client, "t_member")
    resp = client.post("/api/projects", json={"name": "t_should_not_be_created"})
    assert resp.status_code == 403
    assert resp.json()["detail"]["error_code"] == "PERMISSION_REQUIRED"


def test_member_cannot_list_accounts(client, seed):
    as_user(client, "t_member")
    resp = client.get("/api/accounts")
    assert resp.status_code == 403
    assert resp.json()["detail"]["error_code"] == "PERMISSION_REQUIRED"


def test_lead_downloads_report_member_forbidden(client, seed):
    as_user(client, "t_lead")
    lead_resp = client.get(f"/api/projects/{seed.project.id}/report")
    assert lead_resp.status_code == 200
    assert "text/markdown" in lead_resp.headers["content-type"]

    as_user(client, "t_member")
    member_resp = client.get(f"/api/projects/{seed.project.id}/report")
    assert member_resp.status_code == 403
    assert member_resp.json()["detail"]["error_code"] == "PERMISSION_REQUIRED"


# --- guards: assignability, owner immutability, self-modification ----------


def test_admin_cannot_assign_admin_level(client, seed):
    """`admin` does not list itself in its own `assignable` set (SEED_LEVELS),
    which is what stops an admin minting another admin."""
    as_user(client, "t_admin")
    resp = client.patch("/api/accounts/t_member/level", json={"level": LEVEL_ADMIN})
    assert resp.status_code == 403
    assert resp.json()["detail"]["error_code"] == "NOT_ASSIGNABLE"


def test_admin_cannot_modify_owner_level(client, seed, db):
    owner = get_settings().scout_owner
    created_owner_row = db.get(Account, owner) is None
    if created_owner_row:
        db.add(Account(
            username=owner, email=f"{owner}@example.com",
            level=LEVEL_ADMIN, all_instances=True, active=True,
        ))
        db.commit()
    try:
        as_user(client, "t_admin")
        resp = client.patch(f"/api/accounts/{owner}/level", json={"level": LEVEL_REVIEWER})
        assert resp.status_code == 409
        assert resp.json()["detail"]["error_code"] == "OWNER_IMMUTABLE"
    finally:
        # Only remove what this test added — an owner row that existed before
        # this test ran is not ours to delete.
        if created_owner_row:
            row = db.get(Account, owner)
            if row is not None:
                db.delete(row)
                db.commit()


def test_admin_cannot_change_own_level(client, seed):
    as_user(client, "t_admin")
    resp = client.patch("/api/accounts/t_admin/level", json={"level": LEVEL_REVIEWER})
    assert resp.status_code == 409
    assert resp.json()["detail"]["error_code"] == "CANNOT_CHANGE_OWN_LEVEL"


def test_admin_changes_member_project_level_and_access_follows(client, seed):
    """The point of this test is the LAST assertion: the level change is not
    cosmetic, the permission it carries genuinely follows it."""
    as_user(client, "t_member")
    before = client.get(f"/api/projects/{seed.project.id}/results")
    assert before.status_code == 403

    as_user(client, "t_admin")
    patch_resp = client.patch(
        f"/api/projects/{seed.project.id}/members/t_member", json={"level": LEVEL_LEAD}
    )
    assert patch_resp.status_code == 200
    assert patch_resp.json()["level"] == LEVEL_LEAD

    members_resp = client.get(f"/api/projects/{seed.project.id}/members")
    assert members_resp.status_code == 200
    member_row = next(m for m in members_resp.json() if m["username"] == "t_member")
    assert member_row["level"] == LEVEL_LEAD

    as_user(client, "t_member")
    after = client.get(f"/api/projects/{seed.project.id}/results")
    assert after.status_code == 200


def test_set_member_level_for_non_member_404(client, seed):
    as_user(client, "t_admin")
    resp = client.patch(
        f"/api/projects/{seed.project.id}/members/t_outsider", json={"level": LEVEL_REVIEWER}
    )
    assert resp.status_code == 404
    assert resp.json()["detail"]["error_code"] == "MEMBER_NOT_FOUND"


# --- deactivation (D22): cuts access through the kit, not just the session -


def test_deactivation_revokes_access_through_the_kit(client, seed):
    as_user(client, "t_admin")
    resp = client.patch("/api/accounts/t_member/active", json={"active": False})
    assert resp.status_code == 200
    assert resp.json()["active"] is False

    # The kit's store treats a deactivated account as not-a-member at all.
    assert levels.effective_level("t_member", seed.project.id) is None

    # Their existing session cookie is still valid Starlette-side; the account
    # check on the next request is what actually cuts them off.
    as_user(client, "t_member")
    blocked = client.get("/api/projects")
    assert blocked.status_code == 401
    assert blocked.json()["detail"]["error_code"] == "ACCOUNT_INACTIVE"


# --- results: staff previews ride along but never enter the client stats ---


def test_results_separate_staff_rating_from_client_rating(client, seed):
    """D7: a staff preview rating is shown (is_staff=True) but excluded from
    the distribution/mean, which must reflect only the client's rating."""
    as_user(client, "t_admin")
    ids = admin_import_and_publish(client, seed.project, [{"slug": "alpha"}])
    option_id = ids["alpha"]

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

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

    as_user(client, "t_lead")
    results = client.get(f"/api/projects/{seed.project.id}/results")
    assert results.status_code == 200
    body = results.json()

    option_result = next(o for o in body["options"] if o["option"]["id"] == option_id)
    ratings_by_user = {row["username"]: row for row in option_result["ratings"]}
    assert ratings_by_user["t_admin"]["is_staff"] is True
    assert ratings_by_user["t_member"]["is_staff"] is False

    # Only the client (t_member) rating of 3 feeds the stats; the admin's
    # staff-preview rating of 1 rides along in `ratings` but nowhere else.
    assert option_result["mean"] == 3
    assert option_result["distribution"] == {"0": 0, "1": 0, "2": 0, "3": 1}
