"""Integration tests for the auth REST routes (U4).

These tests exercise the full HTTP stack (TestClient → AuthMiddleware →
route → SessionService → SQLite) end-to-end.  They complement the unit
tests in ``tests/unit/test_auth.py`` by covering the new endpoints
introduced by the centralized-auth feature: ``/auth/whoami`` (with
refresh-token cold-start), ``/auth/sessions``, ``/auth/logout-others``,
``/auth/change-password``.

Per the plan (U4 test scenarios), the suite covers:
- Happy paths (login, refresh, whoami, sessions, logout-others, change-password)
- Error paths (wrong password, unknown user, expired token, missing auth)
- Integration (multi-device sessions, session cap eviction, password
  change invalidating other devices)
"""

from __future__ import annotations

import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

import aiosqlite
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient

from agentkit.server.auth.denylist import InMemoryRecentlyRevoked
from agentkit.server.auth.jwt_utils import verify_token
from agentkit.server.auth.middleware import AuthMiddleware
from agentkit.server.auth.models import init_auth_db
from agentkit.server.auth.password import hash_password
from agentkit.server.auth.session_service import SessionService, set_session_service
from agentkit.server.routes import auth as auth_routes


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture
def jwt_secret() -> str:
    return "integration-test-jwt-secret-do-not-use-in-prod-32bytes"


@pytest.fixture
async def tmp_auth_db(tmp_path: Path) -> Path:
    db_path = tmp_path / "auth_integration.db"
    await init_auth_db(db_path)
    return db_path


async def _insert_user(
    db_path: Path,
    *,
    username: str = "alice",
    password: str = "correct-horse-battery-staple",
    role: str = "member",
) -> dict[str, Any]:
    """Insert a user row and return the user fields + plaintext password."""
    user_id = str(uuid.uuid4())
    email = f"{username}@example.com"
    password_hash = hash_password(password)
    now_iso = datetime.now(timezone.utc).isoformat()
    async with aiosqlite.connect(str(db_path)) as db:
        await db.execute(
            "INSERT INTO users "
            "(id, username, email, password_hash, role, is_active, "
            " is_terminal_authorized, is_server_terminal_authorized, "
            " created_at, updated_at) "
            "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
            (user_id, username, email, password_hash, role, 1, 0, 0, now_iso, now_iso),
        )
        await db.commit()
    return {
        "id": user_id,
        "username": username,
        "email": email,
        "password": password,
        "role": role,
        "db_path": db_path,
    }


@pytest.fixture
async def auth_db_with_user(tmp_auth_db: Path) -> dict[str, Any]:
    return await _insert_user(tmp_auth_db)


@pytest.fixture
async def auth_db_with_admin(tmp_auth_db: Path) -> dict[str, Any]:
    """A second DB+user with admin role (for admin-route tests)."""
    return await _insert_user(
        tmp_auth_db,
        username="admin",
        password="admin-horse-battery-staple",
        role="admin",
    )


@pytest.fixture
def auth_app(jwt_secret: str, auth_db_with_user: dict[str, Any]) -> FastAPI:
    app = FastAPI()
    app.state.jwt_secret = jwt_secret
    app.state.auth_db_path = str(auth_db_with_user["db_path"])
    app.add_middleware(AuthMiddleware, jwt_secret=jwt_secret)
    app.include_router(auth_routes.router, prefix="/api/v1")
    app.include_router(auth_routes.admin_router, prefix="/api/v1")
    return app


@pytest.fixture
def auth_client(auth_app: FastAPI) -> TestClient:
    """TestClient with SessionService bound to the test DB."""
    test_svc = SessionService(
        db_path=auth_app.state.auth_db_path,
        denylist=InMemoryRecentlyRevoked(),
    )
    set_session_service(test_svc)
    try:
        yield TestClient(auth_app)
    finally:
        set_session_service(None)


def _login(client: TestClient, username: str, password: str) -> dict[str, Any]:
    """Helper: log in and return the TokenResponse body."""
    resp = client.post(
        "/api/v1/auth/login",
        json={"username": username, "password": password},
    )
    assert resp.status_code == 200, resp.text
    return resp.json()


# ---------------------------------------------------------------------------
# Happy paths
# ---------------------------------------------------------------------------


class TestWhoamiColdStart:
    """GET /auth/whoami with access OR refresh token (cold-start)."""

    def test_whoami_with_access_token_returns_user(
        self,
        auth_client: TestClient,
        auth_db_with_user: dict[str, Any],
    ):
        body = _login(auth_client, auth_db_with_user["username"], auth_db_with_user["password"])
        resp = auth_client.get(
            "/api/v1/auth/whoami",
            headers={"Authorization": f"Bearer {body['access_token']}"},
        )
        assert resp.status_code == 200, resp.text
        data = resp.json()
        assert data["user"]["username"] == auth_db_with_user["username"]
        # Access-token call does NOT issue a new access token.
        assert data["access_token"] is None
        assert data["session_id"] is not None
        assert data["session"] is not None

    def test_whoami_with_refresh_token_issues_new_access_token(
        self,
        auth_client: TestClient,
        auth_db_with_user: dict[str, Any],
    ):
        """Cold-start: refresh token → fresh access token returned."""
        body = _login(auth_client, auth_db_with_user["username"], auth_db_with_user["password"])
        resp = auth_client.get(
            "/api/v1/auth/whoami",
            headers={"Authorization": f"Bearer {body['refresh_token']}"},
        )
        assert resp.status_code == 200, resp.text
        data = resp.json()
        assert data["user"]["username"] == auth_db_with_user["username"]
        # Cold-start issues a fresh access token.
        assert data["access_token"] is not None
        assert data["access_token"] != body["access_token"]
        assert data["session_id"] is not None

    def test_whoami_missing_bearer_returns_401(self, auth_client: TestClient):
        resp = auth_client.get("/api/v1/auth/whoami")
        assert resp.status_code == 401

    def test_whoami_invalid_token_returns_401(self, auth_client: TestClient):
        resp = auth_client.get(
            "/api/v1/auth/whoami",
            headers={"Authorization": "Bearer not.a.valid.jwt"},
        )
        assert resp.status_code == 401

    async def test_whoami_disabled_user_returns_401(
        self,
        auth_client: TestClient,
        auth_db_with_user: dict[str, Any],
        tmp_auth_db: Path,
    ):
        """A disabled user (is_active=0) must not pass whoami (U1 fix)."""
        body = _login(
            auth_client,
            auth_db_with_user["username"],
            auth_db_with_user["password"],
        )
        # Disable the user directly in the DB.
        async with aiosqlite.connect(str(tmp_auth_db)) as db:
            await db.execute(
                "UPDATE users SET is_active = 0 WHERE id = ?",
                (auth_db_with_user["id"],),
            )
            await db.commit()
        # whoami with the still-valid access token must now 401.
        resp = auth_client.get(
            "/api/v1/auth/whoami",
            headers={"Authorization": f"Bearer {body['access_token']}"},
        )
        assert resp.status_code == 401
        assert "disabled" in resp.json()["detail"].lower()

    async def test_whoami_refresh_token_does_not_leak_new_refresh_token(
        self,
        auth_client: TestClient,
        auth_db_with_user: dict[str, Any],
    ):
        """Cold-start must NOT issue a new refresh token (U1 token-amplification fix).

        The response only carries ``access_token``; the client keeps using
        its existing refresh token.
        """
        body = _login(
            auth_client,
            auth_db_with_user["username"],
            auth_db_with_user["password"],
        )
        resp = auth_client.get(
            "/api/v1/auth/whoami",
            headers={"Authorization": f"Bearer {body['refresh_token']}"},
        )
        assert resp.status_code == 200, resp.text
        data = resp.json()
        assert data["access_token"] is not None
        # WhoamiResponse has no refresh_token field — verify it's absent.
        assert "refresh_token" not in data


class TestWhoamiTokenHash:
    """GET /auth/whoami refresh-token hash verification (U7 — R9).

    After refresh-token rotation via ``/auth/refresh``, the old refresh
    token must NOT be usable for cold-start on ``/auth/whoami``.  The
    route verifies the presented token's SHA-256 hash against the
    session's stored ``refresh_token_hash`` and rejects mismatches with
    401 (constant-time comparison via ``hmac.compare_digest``).

    Note: ``create_token_pair`` does not add ``jti`` to refresh tokens,
    so login + refresh within the same second produce identical refresh
    tokens.  To test the hash-mismatch path deterministically we update
    ``refresh_token_hash`` directly in the DB (simulating rotation to a
    different token).
    """

    def test_whoami_with_valid_refresh_token_returns_200(
        self,
        auth_client: TestClient,
        auth_db_with_user: dict[str, Any],
    ):
        """A valid (non-rotated) refresh token passes the hash check."""
        body = _login(
            auth_client,
            auth_db_with_user["username"],
            auth_db_with_user["password"],
        )
        resp = auth_client.get(
            "/api/v1/auth/whoami",
            headers={"Authorization": f"Bearer {body['refresh_token']}"},
        )
        assert resp.status_code == 200, resp.text
        data = resp.json()
        assert data["access_token"] is not None
        assert data["user"]["username"] == auth_db_with_user["username"]

    async def test_whoami_with_rotated_refresh_token_returns_401(
        self,
        auth_client: TestClient,
        auth_db_with_user: dict[str, Any],
        tmp_auth_db: Path,
    ):
        """After rotation, the old refresh token is rejected.

        We simulate rotation by overwriting ``refresh_token_hash`` in
        the DB with a different value (the hash of a hypothetical new
        token).  The old token's hash no longer matches and whoami
        returns 401.
        """
        body = _login(
            auth_client,
            auth_db_with_user["username"],
            auth_db_with_user["password"],
        )
        old_refresh = body["refresh_token"]

        # Decode the old token to get the session id.
        old_payload = verify_token(
            old_refresh, auth_client.app.state.jwt_secret, expected_type="refresh"
        )
        sid = old_payload.get("sid")
        assert sid is not None, "refresh token must carry sid"

        # Simulate rotation: replace the stored hash with a different one.
        from agentkit.server.auth.denylist import hash_token as _hash_token

        new_hash = _hash_token("rotated-new-token-different-from-old")
        async with aiosqlite.connect(str(tmp_auth_db)) as db:
            await db.execute(
                "UPDATE auth_sessions SET refresh_token_hash = ? WHERE id = ?",
                (new_hash, sid),
            )
            await db.commit()

        # Old refresh token must now be rejected by whoami.
        resp = auth_client.get(
            "/api/v1/auth/whoami",
            headers={"Authorization": f"Bearer {old_refresh}"},
        )
        assert resp.status_code == 401
        detail = resp.json()["detail"].lower()
        assert "rotated" in detail or "revoked" in detail

    async def test_whoami_with_new_refresh_token_after_rotation_returns_200(
        self,
        auth_client: TestClient,
        auth_db_with_user: dict[str, Any],
        tmp_auth_db: Path,
    ):
        """After rotation, the NEW refresh token works on whoami.

        We simulate rotation by overwriting ``refresh_token_hash`` with
        the hash of a known new token, then present that new token.
        """
        body = _login(
            auth_client,
            auth_db_with_user["username"],
            auth_db_with_user["password"],
        )
        old_refresh = body["refresh_token"]
        old_payload = verify_token(
            old_refresh, auth_client.app.state.jwt_secret, expected_type="refresh"
        )
        sid = old_payload.get("sid")
        assert sid is not None

        # Mint a new refresh token with a unique jti claim so it differs
        # from the old token (create_token_pair doesn't add jti to
        # refresh tokens, so we craft one directly).
        import jwt as _jwt

        new_payload = {**old_payload, "jti": str(uuid.uuid4())}
        new_refresh = _jwt.encode(new_payload, auth_client.app.state.jwt_secret, algorithm="HS256")
        if isinstance(new_refresh, bytes):
            new_refresh = new_refresh.decode("utf-8")

        # Update the stored hash to match the new token.
        from agentkit.server.auth.denylist import hash_token as _hash_token

        new_hash = _hash_token(new_refresh)
        async with aiosqlite.connect(str(tmp_auth_db)) as db:
            await db.execute(
                "UPDATE auth_sessions SET refresh_token_hash = ? WHERE id = ?",
                (new_hash, sid),
            )
            await db.commit()

        # The new refresh token must pass the hash check.
        resp = auth_client.get(
            "/api/v1/auth/whoami",
            headers={"Authorization": f"Bearer {new_refresh}"},
        )
        assert resp.status_code == 200, resp.text
        assert resp.json()["access_token"] is not None

    def test_whoami_with_revoked_session_refresh_token_returns_401(
        self,
        auth_client: TestClient,
        auth_db_with_user: dict[str, Any],
    ):
        """A refresh token whose session was revoked is rejected.

        Although the session row's ``refresh_token_hash`` is unchanged
        by revocation, the session-revocation check (``info.revoked``)
        fires first and returns 401.
        """
        body = _login(
            auth_client,
            auth_db_with_user["username"],
            auth_db_with_user["password"],
        )
        # Revoke the session via DELETE /auth/sessions/{id}.
        sessions = auth_client.get(
            "/api/v1/auth/sessions",
            headers={"Authorization": f"Bearer {body['access_token']}"},
        ).json()
        sid = sessions[0]["id"]
        del_resp = auth_client.delete(
            f"/api/v1/auth/sessions/{sid}",
            headers={"Authorization": f"Bearer {body['access_token']}"},
        )
        assert del_resp.status_code == 200

        # Refresh token on the revoked session → 401.
        resp = auth_client.get(
            "/api/v1/auth/whoami",
            headers={"Authorization": f"Bearer {body['refresh_token']}"},
        )
        assert resp.status_code == 401

    async def test_whoami_access_token_skips_hash_check(
        self,
        auth_client: TestClient,
        auth_db_with_user: dict[str, Any],
        tmp_auth_db: Path,
    ):
        """Access tokens are not subject to the refresh-token hash check.

        The hash check only runs when ``token_type == "refresh"``;
        access tokens bypass it (they have their own expiry + jti).
        """
        body = _login(
            auth_client,
            auth_db_with_user["username"],
            auth_db_with_user["password"],
        )
        # Rotate the stored hash to a different value.
        old_refresh = body["refresh_token"]
        old_payload = verify_token(
            old_refresh, auth_client.app.state.jwt_secret, expected_type="refresh"
        )
        sid = old_payload.get("sid")
        assert sid is not None

        from agentkit.server.auth.denylist import hash_token as _hash_token

        new_hash = _hash_token("some-other-token-not-the-access-token")
        async with aiosqlite.connect(str(tmp_auth_db)) as db:
            await db.execute(
                "UPDATE auth_sessions SET refresh_token_hash = ? WHERE id = ?",
                (new_hash, sid),
            )
            await db.commit()

        # The original access token should still work (not yet expired).
        resp = auth_client.get(
            "/api/v1/auth/whoami",
            headers={"Authorization": f"Bearer {body['access_token']}"},
        )
        assert resp.status_code == 200, resp.text
        # Access-token call does NOT issue a new access token.
        assert resp.json()["access_token"] is None


class TestSessionsManagement:
    """GET /auth/sessions, DELETE /auth/sessions/{id}."""

    def test_list_sessions_returns_current_user_sessions(
        self,
        auth_client: TestClient,
        auth_db_with_user: dict[str, Any],
    ):
        body = _login(auth_client, auth_db_with_user["username"], auth_db_with_user["password"])
        resp = auth_client.get(
            "/api/v1/auth/sessions",
            headers={"Authorization": f"Bearer {body['access_token']}"},
        )
        assert resp.status_code == 200, resp.text
        sessions = resp.json()
        assert len(sessions) >= 1
        # The current session should be marked is_current=True.
        assert any(s["is_current"] for s in sessions)

    def test_revoke_own_session(
        self,
        auth_client: TestClient,
        auth_db_with_user: dict[str, Any],
    ):
        body = _login(auth_client, auth_db_with_user["username"], auth_db_with_user["password"])
        # List sessions to get the session id.
        sessions = auth_client.get(
            "/api/v1/auth/sessions",
            headers={"Authorization": f"Bearer {body['access_token']}"},
        ).json()
        sid = sessions[0]["id"]

        resp = auth_client.delete(
            f"/api/v1/auth/sessions/{sid}",
            headers={"Authorization": f"Bearer {body['access_token']}"},
        )
        assert resp.status_code == 200, resp.text
        assert resp.json()["revoked"] is True

    async def test_revoke_other_user_session_returns_404(
        self,
        auth_client: TestClient,
        auth_db_with_user: dict[str, Any],
        tmp_auth_db: Path,
    ):
        """A user cannot revoke another user's session (404, not 403, to avoid leaking)."""
        # Create a second user and log in as them.
        other = await _login_async_create_user(auth_client, tmp_auth_db, username="bob")
        # Alice tries to revoke Bob's session.
        body = _login(auth_client, auth_db_with_user["username"], auth_db_with_user["password"])
        resp = auth_client.delete(
            f"/api/v1/auth/sessions/{other['session_id']}",
            headers={"Authorization": f"Bearer {body['access_token']}"},
        )
        assert resp.status_code == 404


async def _login_async_create_user(
    client: TestClient,
    db_path: Path,
    *,
    username: str,
) -> dict[str, Any]:
    """Insert a user directly into the DB and log in via the API.

    Returns the login response + the session_id extracted from the JWT.
    Async so it can be ``await``-ed inside pytest-asyncio tests without
    triggering the ``asyncio.run() cannot be called from a running event
    loop`` error.
    """
    from agentkit.server.auth.jwt_utils import verify_token

    user = await _insert_user(db_path, username=username)
    body = _login(client, username, user["password"])
    payload = verify_token(body["access_token"], client.app.state.jwt_secret)
    return {**body, "session_id": payload.get("sid"), "user_id": user["id"]}


class TestLogoutOthers:
    """POST /auth/logout-others."""

    def test_logout_others_revokes_all_other_sessions(
        self,
        auth_client: TestClient,
        auth_db_with_user: dict[str, Any],
        tmp_auth_db: Path,
    ):
        """Login from 2 devices, logout-others from device A → B's session revoked."""
        # Device A
        body_a = _login(auth_client, auth_db_with_user["username"], auth_db_with_user["password"])
        # Device B (same user, different fingerprint header)
        body_b = _login(auth_client, auth_db_with_user["username"], auth_db_with_user["password"])

        # A calls logout-others.
        resp = auth_client.post(
            "/api/v1/auth/logout-others",
            headers={"Authorization": f"Bearer {body_a['access_token']}"},
        )
        assert resp.status_code == 200, resp.text
        assert resp.json()["revoked_count"] >= 1

        # B's session should now be revoked → whoami with B's refresh token → 401.
        resp_b = auth_client.get(
            "/api/v1/auth/whoami",
            headers={"Authorization": f"Bearer {body_b['refresh_token']}"},
        )
        assert resp_b.status_code == 401

        # A's session should still be valid.
        resp_a = auth_client.get(
            "/api/v1/auth/whoami",
            headers={"Authorization": f"Bearer {body_a['access_token']}"},
        )
        assert resp_a.status_code == 200


class TestChangePassword:
    """POST /auth/change-password."""

    def test_change_password_revokes_other_sessions(
        self,
        auth_client: TestClient,
        auth_db_with_user: dict[str, Any],
    ):
        """Change password → other sessions revoked, current stays alive."""
        # Device A
        body_a = _login(auth_client, auth_db_with_user["username"], auth_db_with_user["password"])
        # Device B
        body_b = _login(auth_client, auth_db_with_user["username"], auth_db_with_user["password"])

        # A changes the password.
        resp = auth_client.post(
            "/api/v1/auth/change-password",
            headers={"Authorization": f"Bearer {body_a['access_token']}"},
            json={
                "old_password": auth_db_with_user["password"],
                "new_password": "new-strong-password-123",
            },
        )
        assert resp.status_code == 200, resp.text

        # B's session should be revoked.
        resp_b = auth_client.get(
            "/api/v1/auth/whoami",
            headers={"Authorization": f"Bearer {body_b['refresh_token']}"},
        )
        assert resp_b.status_code == 401

    def test_change_password_wrong_old_returns_400(
        self,
        auth_client: TestClient,
        auth_db_with_user: dict[str, Any],
    ):
        body = _login(auth_client, auth_db_with_user["username"], auth_db_with_user["password"])
        resp = auth_client.post(
            "/api/v1/auth/change-password",
            headers={"Authorization": f"Bearer {body['access_token']}"},
            json={
                "old_password": "totally-wrong",
                "new_password": "new-strong-password-123",
            },
        )
        assert resp.status_code == 400

    def test_change_password_weak_new_returns_400(
        self,
        auth_client: TestClient,
        auth_db_with_user: dict[str, Any],
    ):
        body = _login(auth_client, auth_db_with_user["username"], auth_db_with_user["password"])
        resp = auth_client.post(
            "/api/v1/auth/change-password",
            headers={"Authorization": f"Bearer {body['access_token']}"},
            json={
                "old_password": auth_db_with_user["password"],
                "new_password": "short",
            },
        )
        assert resp.status_code == 400


# ---------------------------------------------------------------------------
# Integration: multi-device + session cap
# ---------------------------------------------------------------------------


class TestMultiDeviceIntegration:
    """Login from multiple devices → independent sessions."""

    def test_multiple_logins_create_independent_sessions(
        self,
        auth_client: TestClient,
        auth_db_with_user: dict[str, Any],
    ):
        body_a = _login(auth_client, auth_db_with_user["username"], auth_db_with_user["password"])
        body_b = _login(auth_client, auth_db_with_user["username"], auth_db_with_user["password"])

        # Both tokens should work independently.
        resp_a = auth_client.get(
            "/api/v1/auth/whoami",
            headers={"Authorization": f"Bearer {body_a['access_token']}"},
        )
        resp_b = auth_client.get(
            "/api/v1/auth/whoami",
            headers={"Authorization": f"Bearer {body_b['access_token']}"},
        )
        assert resp_a.status_code == 200
        assert resp_b.status_code == 200
        # Different session ids.
        assert resp_a.json()["session_id"] != resp_b.json()["session_id"]

    def test_session_cap_evicts_oldest(
        self,
        auth_client: TestClient,
        auth_db_with_user: dict[str, Any],
    ):
        """The default session cap is 10; the 11th login evicts the oldest."""
        tokens = []
        for _ in range(11):
            body = _login(
                auth_client,
                auth_db_with_user["username"],
                auth_db_with_user["password"],
            )
            tokens.append(body)

        # The first session should have been evicted (whoami → 401).
        resp_first = auth_client.get(
            "/api/v1/auth/whoami",
            headers={"Authorization": f"Bearer {tokens[0]['refresh_token']}"},
        )
        assert resp_first.status_code == 401

        # The last session should still be valid.
        resp_last = auth_client.get(
            "/api/v1/auth/whoami",
            headers={"Authorization": f"Bearer {tokens[-1]['access_token']}"},
        )
        assert resp_last.status_code == 200
