"""View As (impersonation): D25 policy, D26 fail-closed re-authorization and the
read-only write-block, D27 effective-vs-real identity, and the report_impersonation
oversight signal.

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
from tests.conftest import as_user as _mint_session


def as_user(client, username):
    """conftest's as_user(), plus a hard reset of the cookie jar first.

    Needed only in this file. A view-as start/stop is what makes
    _https_scheme necessary in the first place (see its docstring): the
    server's Set-Cookie response for scout_session, parsed by httpx from an
    actual response, ends up recorded with an explicit domain, while
    conftest's as_user() sets a cookie directly via client.cookies.set(),
    which lands as a SEPARATE host-only (no domain) entry. The two can coexist
    in the jar and both be valid to attach to the same request; which one the
    server reads back as THE scout_session cookie is not something this suite
    should depend on. Clearing the jar before every identity switch removes
    the ambiguity -- this app sets no other cookie a test here needs to keep.
    """
    client.cookies.jar.clear()
    _mint_session(client, username)


# --- 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(autouse=True)
def _https_scheme(client):
    """Make this file's requests look like they came in over https.

    SessionMiddleware is configured with https_only=True (correct: the app is
    only ever reached over TLS through Caddy in production), so every response
    that mutates request.session -- exactly what view-as start/stop do -- sets
    scout_session with the Secure attribute. TestClient's default base_url is
    plain http://testserver, and httpx's cookie jar correctly (per RFC 6265)
    refuses to resend a Secure cookie on a non-https request. The result, with
    no fix: the start/stop call itself succeeds server-side (you can see it in
    the audit log), but the NEXT request silently goes back to using the
    stale, pre-mutation cookie, making it look like the session never changed.

    Confirmed with a standalone repro against this same app/session config:
    the Set-Cookie header on a view-as/start response does carry `secure`,
    httpx's jar ends up holding BOTH the original (non-secure) and the new
    (secure) scout_session entries, and only re-pointing this same client at
    an https base_url makes it select the mutated one. Nothing server-side
    depends on request.url.scheme anywhere in this app, so flipping the test
    transport's scheme is a test-harness fix, not a behavior change: every
    request in this file becomes able to see session state the app already
    correctly set.
    """
    client.base_url = "https://testserver"


@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()


# --- targets: who the policy offers ------------------------------------------


def test_targets_owner_sees_others_not_self(client, seed, owner):
    as_user(client, owner)
    resp = client.get("/api/view-as/targets")
    assert resp.status_code == 200
    usernames = {t["username"] for t in resp.json()}
    assert "t_member" in usernames
    assert owner not in usernames


def test_targets_and_start_forbidden_for_plain_member(client, seed):
    as_user(client, "t_member")
    targets = client.get("/api/view-as/targets")
    assert targets.status_code == 200
    assert targets.json() == []

    start = client.post("/api/view-as/start", json={"target": "t_lead"})
    assert start.status_code == 403
    assert start.json()["detail"]["error_code"] == "FORBIDDEN"


# --- start: effective identity, capability collapse, no reach leak ----------


def test_start_effective_identity_and_no_reach_leak(client, db, seed, owner):
    other_project = projects_service.create(
        db, name="Not T Member's Project", client_name="Other Client", brief="",
        status="active", created_by="t_admin",
    )

    as_user(client, owner)
    start = client.post("/api/view-as/start", json={"target": "t_member"})
    assert start.status_code == 204

    me = client.get("/api/me")
    assert me.status_code == 200
    body = me.json()
    assert body["impersonating"] is True
    assert body["viewing_as"] == "t_member"
    assert body["real_user"] == owner
    assert body["can_write"] is False
    assert body["view_as_mode"] == "readonly"
    assert body["account"]["username"] == "t_member"
    # Capabilities follow the EFFECTIVE (t_member) user, not the real owner.
    assert body["can_manage_accounts"] is False
    assert body["is_owner"] is False

    projects_resp = client.get("/api/projects")
    assert projects_resp.status_code == 200
    assert {p["id"] for p in projects_resp.json()} == {seed.project.id}

    # The owner's own reach must NOT leak through the impersonated view.
    other_resp = client.get(f"/api/projects/{other_project.id}")
    assert other_resp.status_code == 404
    assert other_resp.json()["detail"]["error_code"] == "PROJECT_NOT_FOUND"

    stop = client.post("/api/view-as/stop")
    assert stop.status_code == 204


# --- write block + the stop exemption ----------------------------------------


def test_write_blocked_while_impersonating_readonly(client, seed, owner):
    as_user(client, owner)
    start = client.post("/api/view-as/start", json={"target": "t_member"})
    assert start.status_code == 204

    patch_resp = client.patch(
        f"/api/projects/{seed.project.id}", json={"name": "t_should_not_apply"}
    )
    assert patch_resp.status_code == 403
    assert patch_resp.json()["error_code"] == "VIEW_AS_READ_ONLY"

    create_resp = client.post("/api/projects", json={"name": "t_should_not_be_created"})
    assert create_resp.status_code == 403
    assert create_resp.json()["error_code"] == "VIEW_AS_READ_ONLY"

    # The one exemption: ending the view-as is never blocked.
    stop = client.post("/api/view-as/stop")
    assert stop.status_code == 204


# --- stop restores the real identity and its capabilities -------------------


def test_stop_restores_owner_identity_and_capabilities(client, seed, owner):
    as_user(client, owner)
    client.post("/api/view-as/start", json={"target": "t_member"})
    stop = client.post("/api/view-as/stop")
    assert stop.status_code == 204

    me = client.get("/api/me").json()
    assert me["impersonating"] is False
    assert me["account"]["username"] == owner
    assert me["is_owner"] is True
    assert me["can_manage_accounts"] is True


# --- act mode: real writes, attributed to the target -------------------------


def test_act_mode_write_succeeds_and_attributed_to_target(client, seed, owner):
    as_user(client, "t_admin")
    ids = admin_import_and_publish(client, seed.project, [{"slug": "actmode"}])
    option_id = ids["actmode"]

    as_user(client, owner)
    start = client.post("/api/view-as/start", json={"target": "t_member", "mode": "act"})
    assert start.status_code == 204

    me = client.get("/api/me").json()
    assert me["can_write"] is True
    assert me["view_as_mode"] == "act"

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

    stop = client.post("/api/view-as/stop")
    assert stop.status_code == 204

    # Back to being the real owner: their OWN review must not have been
    # written as a side effect of a write made while acting as t_member.
    owner_bundle = client.get(f"/api/projects/{seed.project.id}/review").json()
    assert owner_bundle["my_reviews"] == []

    as_user(client, "t_member")
    bundle = client.get(f"/api/projects/{seed.project.id}/review").json()
    my_review = next(r for r in bundle["my_reviews"] if r["option_id"] == option_id)
    assert my_review["rating"] == 2


# --- act mode is owner-only, even for a level holding scout.view_as ---------


def test_act_mode_forbidden_for_non_owner_even_with_view_as_permission(client, db, owner):
    levels.create_level(owner, "t_viewer_lvl_a", ["scout.view_as"], [])
    account = Account(username="t_viewer_a", email="t_viewer_a@example.com",
                      level=LEVEL_REVIEWER, active=True)
    db.add(account)
    db.commit()
    accounts_service.set_level(db, account, "t_viewer_lvl_a")

    as_user(client, "t_viewer_a")
    resp = client.post("/api/view-as/start", json={"target": "t_member", "mode": "act"})
    assert resp.status_code == 403
    assert resp.json()["detail"]["error_code"] == "FORBIDDEN"


def test_readonly_view_as_lower_ranked_target_succeeds(client, db, seed, owner):
    """D25: a non-owner holding scout.view_as may view-as anyone ranked LOWER
    than themselves; a plain reviewer should be viewable by someone whose level
    was built specifically to hold scout.view_as.

    NOTE for triage if this goes red: app/services/view_as.py's rank_of() only
    recognizes the three SEED level names by literal string ("admin"/"lead"/
    "reviewer") and falls back to rank 0 for any OTHER level name -- including
    a bespoke level created specifically to hold scout.view_as. Rank 0 is BELOW
    a plain reviewer (rank 1), so the escalation guard
    (rank_of(target) >= rank_of(real)) would refuse this as ESCALATION instead
    of allowing it. If that is what happens, this is an app bug, not a test
    bug: D25's promise ("a permission string lets rian test delegating [view-as]
    from the level editor without a code change") is broken for any level whose
    name isn't literally one of the three seeded ones.
    """
    levels.create_level(owner, "t_viewer_lvl_b", ["scout.view_as"], [])
    account = Account(username="t_viewer_b", email="t_viewer_b@example.com",
                      level=LEVEL_REVIEWER, active=True)
    db.add(account)
    db.commit()
    accounts_service.set_level(db, account, "t_viewer_lvl_b")

    as_user(client, "t_viewer_b")
    resp = client.post("/api/view-as/start", json={"target": "t_member"})
    assert resp.status_code == 204, resp.text

    me = client.get("/api/me").json()
    assert me["viewing_as"] == "t_member"

    client.post("/api/view-as/stop")


def test_readonly_view_as_equal_ranked_target_escalation(client, db, owner):
    levels.create_level(owner, "t_viewer_lvl_c", ["scout.view_as"], [])
    a = Account(username="t_viewer_c1", email="t_viewer_c1@example.com",
               level=LEVEL_REVIEWER, active=True)
    b = Account(username="t_viewer_c2", email="t_viewer_c2@example.com",
               level=LEVEL_REVIEWER, active=True)
    db.add_all([a, b])
    db.commit()
    accounts_service.set_level(db, a, "t_viewer_lvl_c")
    accounts_service.set_level(db, b, "t_viewer_lvl_c")

    as_user(client, "t_viewer_c1")
    resp = client.post("/api/view-as/start", json={"target": "t_viewer_c2"})
    assert resp.status_code == 403
    assert resp.json()["detail"]["error_code"] in {"FORBIDDEN", "ESCALATION"}


# --- D26: fail-closed auto-stop when the target is invalidated mid-session ---


def test_deactivating_target_auto_stops_on_next_request(client, db, seed, owner):
    as_user(client, owner)
    start = client.post("/api/view-as/start", json={"target": "t_member"})
    assert start.status_code == 204

    me_during = client.get("/api/me").json()
    assert me_during["impersonating"] is True

    # Deactivate the target directly via the accounts service -- NOT over HTTP,
    # which would be blocked by the read-only write-block anyway, and the point
    # here is target_valid() catching the change on the NEXT request regardless
    # of how the target became invalid.
    accounts_service.set_active(db, seed.member, False)

    me_after = client.get("/api/me").json()
    assert me_after["impersonating"] is False
    assert me_after["account"]["username"] == owner
    assert me_after["viewing_as"] is None


# --- report_impersonation: the owner-oversight signal ------------------------


def test_report_impersonation_flows(client, seed, owner, bw_calls):
    as_user(client, owner)
    start = client.post("/api/view-as/start", json={"target": "t_member"})
    assert start.status_code == 204

    start_calls = [c for c in bw_calls if c[0] == "/app/report-impersonation"]
    assert start_calls, "expected a report-impersonation call on start"
    last_start = start_calls[-1][1]
    assert last_start["real"] == owner
    assert last_start["target"] == "t_member"
    assert last_start["active"] is True

    stop = client.post("/api/view-as/stop")
    assert stop.status_code == 204

    stop_calls = [c for c in bw_calls if c[0] == "/app/report-impersonation"]
    last_stop = stop_calls[-1][1]
    assert last_stop["real"] == owner
    assert last_stop["target"] == "t_member"
    assert last_stop["active"] is False
