"""Unit tests for SessionService (U3)."""

from __future__ import annotations

import asyncio
import os
import uuid
from datetime import datetime, timezone
from pathlib import Path

import aiosqlite
import pytest

from agentkit.server.auth.denylist import InMemoryRecentlyRevoked
from agentkit.server.auth.models import init_auth_db
from agentkit.server.auth.session_service import (
    REVOKE_REASON_PASSWORD_CHANGED,
    REVOKE_REASON_REUSE_DETECTED,
    REVOKE_REASON_SESSION_CAP_EVICTION,
    REVOKE_REASON_USER_TERMINATED,
    SessionCreate,
    SessionNotFound,
    SessionReuseDetected,
    SessionService,
)


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


@pytest.fixture
async def auth_db(tmp_path: Path):
    """Initialise a fresh auth DB in a tmpdir; set AGENTKIT_AUTH_DB for the duration."""
    db_path = tmp_path / "auth.db"
    await init_auth_db(db_path)
    prev = os.environ.get("AGENTKIT_AUTH_DB")
    os.environ["AGENTKIT_AUTH_DB"] = str(db_path)
    try:
        yield db_path
    finally:
        if prev is None:
            os.environ.pop("AGENTKIT_AUTH_DB", None)
        else:
            os.environ["AGENTKIT_AUTH_DB"] = prev


@pytest.fixture
async def user_id(auth_db: Path) -> str:
    """Insert a single user and return its id."""
    user_id = str(uuid.uuid4())
    now = datetime.now(timezone.utc).isoformat()
    async with aiosqlite.connect(str(auth_db)) 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, last_login_at, created_by) "
            "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
            (
                user_id,
                "alice",
                "alice@example.com",
                "x",
                "member",
                1,
                0,
                0,
                now,
                now,
                None,
                None,
            ),
        )
        await db.commit()
    return user_id


@pytest.fixture
async def svc(auth_db: Path) -> SessionService:
    """A SessionService backed by the in-memory denylist for determinism."""
    return SessionService(
        db_path=auth_db, denylist=InMemoryRecentlyRevoked(), session_cap=3
    )


def _make_create(user_id: str, refresh_token: str = "r1") -> SessionCreate:
    return SessionCreate(
        user_id=user_id,
        refresh_token=refresh_token,
        device_fingerprint="fp",
        device_label="Test device",
        ip="127.0.0.1",
        user_agent="pytest",
        auth_provider="local",
        ttl_seconds=3600,
    )


# ---------------------------------------------------------------------------
# create
# ---------------------------------------------------------------------------


async def test_create_inserts_row_and_returns_info(svc: SessionService, user_id: str):
    info = await svc.create(_make_create(user_id))
    assert info.user_id == user_id
    assert info.revoked is False
    # Round-trip via the read API
    fetched = await svc.get(info.id)
    assert fetched is not None
    assert fetched.id == info.id


async def test_create_evicts_oldest_when_at_cap(svc: SessionService, user_id: str):
    # Cap is 3; insert 3, then a 4th should evict the first.
    a = await svc.create(_make_create(user_id, "rt-a"))
    b = await svc.create(_make_create(user_id, "rt-b"))
    c = await svc.create(_make_create(user_id, "rt-c"))
    d = await svc.create(_make_create(user_id, "rt-d"))

    assert (await svc.get(a.id)).revoked is True
    assert (await svc.get(a.id)).revoked_reason == REVOKE_REASON_SESSION_CAP_EVICTION
    for kept in (b, c, d):
        assert (await svc.get(kept.id)).revoked is False

    # Sanity: exactly session_cap remain (this test uses cap=3)
    remaining = [s for s in await svc.list_for_user(user_id) if not s.revoked]
    assert len(remaining) == 3


# ---------------------------------------------------------------------------
# rotate
# ---------------------------------------------------------------------------


async def test_rotate_replaces_refresh_hash(svc: SessionService, user_id: str):
    info = await svc.create(_make_create(user_id, "rt-old"))
    new = await svc.rotate(
        old_refresh_token="rt-old", new_refresh_token="rt-new", new_ttl_seconds=3600
    )
    assert new.id == info.id
    # Old hash no longer resolves
    assert await svc.find_by_refresh_token("rt-old") is None
    # New hash does
    assert (await svc.find_by_refresh_token("rt-new")).id == info.id


async def test_rotate_adds_old_to_denylist(svc: SessionService, user_id: str):
    await svc.create(_make_create(user_id, "rt-1"))
    await svc.rotate("rt-1", "rt-2", new_ttl_seconds=3600)
    assert svc._denylist.contains(__import__("hashlib").sha256(b"rt-1").hexdigest())


async def test_rotate_unknown_token_raises(svc: SessionService, user_id: str):
    with pytest.raises(SessionNotFound):
        await svc.rotate("never-issued", "rt-new", new_ttl_seconds=3600)


async def test_rotate_reuse_raises_and_revokes_all(svc: SessionService, user_id: str):
    await svc.create(_make_create(user_id, "rt-1"))
    await svc.rotate("rt-1", "rt-2", new_ttl_seconds=3600)
    # A second attempt with the old token: must raise and revoke all
    with pytest.raises(SessionReuseDetected):
        await svc.rotate("rt-1", "rt-2b", new_ttl_seconds=3600)
    # All sessions for this user are now revoked
    active = await svc.list_for_user(user_id, include_revoked=True)
    assert all(s.revoked for s in active)
    assert all(s.revoked_reason == REVOKE_REASON_REUSE_DETECTED for s in active)


async def test_rotate_revoked_session_raises(svc: SessionService, user_id: str):
    await svc.create(_make_create(user_id, "rt-1"))
    await svc.revoke_by_refresh_token("rt-1")
    with pytest.raises(SessionNotFound):
        await svc.rotate("rt-1", "rt-2", new_ttl_seconds=3600)


# ---------------------------------------------------------------------------
# revoke
# ---------------------------------------------------------------------------


async def test_revoke_marks_session_revoked(svc: SessionService, user_id: str):
    info = await svc.create(_make_create(user_id))
    ok = await svc.revoke(info.id)
    assert ok is True
    assert (await svc.get(info.id)).revoked is True
    assert (await svc.get(info.id)).revoked_reason == REVOKE_REASON_USER_TERMINATED


async def test_revoke_returns_false_if_already_revoked(svc: SessionService, user_id: str):
    info = await svc.create(_make_create(user_id))
    assert await svc.revoke(info.id) is True
    assert await svc.revoke(info.id) is False


async def test_revoke_by_refresh_token(svc: SessionService, user_id: str):
    await svc.create(_make_create(user_id, "rt-1"))
    assert await svc.revoke_by_refresh_token("rt-1") is True
    assert await svc.revoke_by_refresh_token("rt-1") is False
    assert await svc.revoke_by_refresh_token("rt-never") is False


async def test_revoke_all_for_user(svc: SessionService, user_id: str):
    await svc.create(_make_create(user_id, "a"))
    await svc.create(_make_create(user_id, "b"))
    n = await svc.revoke_all_for_user(
        user_id, reason=REVOKE_REASON_PASSWORD_CHANGED
    )
    assert n == 2
    active = await svc.list_for_user(user_id)
    assert active == []


# ---------------------------------------------------------------------------
# list / get
# ---------------------------------------------------------------------------


async def test_list_for_user_excludes_revoked_by_default(svc: SessionService, user_id: str):
    a = await svc.create(_make_create(user_id, "a"))
    b = await svc.create(_make_create(user_id, "b"))
    await svc.revoke(a.id)
    visible = await svc.list_for_user(user_id)
    assert [s.id for s in visible] == [b.id]
    visible_all = await svc.list_for_user(user_id, include_revoked=True)
    assert {s.id for s in visible_all} == {a.id, b.id}


async def test_is_session_valid_rejects_revoked(svc: SessionService, user_id: str):
    info = await svc.create(_make_create(user_id))
    assert await svc.is_session_valid(info.id) is True
    await svc.revoke(info.id)
    assert await svc.is_session_valid(info.id) is False


async def test_is_session_valid_rejects_expired(svc: SessionService, user_id: str):
    # Create with a very short TTL
    create = SessionCreate(
        user_id=user_id,
        refresh_token="rt-x",
        device_fingerprint="fp",
        device_label="d",
        ip="",
        user_agent="",
        auth_provider="local",
        ttl_seconds=0,  # expires immediately
    )
    info = await svc.create(create)
    # Re-check with a future expires_at: should be expired
    assert await svc.is_session_valid(info.id) is False


async def test_is_session_valid_returns_false_for_unknown(svc: SessionService):
    assert await svc.is_session_valid("nonexistent-id") is False


# ---------------------------------------------------------------------------
# list_active_by_provider (U2 fix: expired sessions must be filtered)
# ---------------------------------------------------------------------------


async def test_list_active_by_provider_excludes_expired(svc: SessionService, user_id: str):
    """Expired sessions must NOT appear in list_active_by_provider (U2 fix).

    The docstring promises "non-revoked, non-expired" — before the U2 fix
    the SQL only checked ``revoked = 0`` and ignored ``expires_at``.
    """
    # An expired session (TTL=0 → expires immediately).
    expired_create = SessionCreate(
        user_id=user_id,
        refresh_token="rt-expired",
        device_fingerprint="fp",
        device_label="expired-device",
        ip="",
        user_agent="",
        auth_provider="local",
        ttl_seconds=0,
    )
    expired_info = await svc.create(expired_create)

    # A live session.
    live_info = await svc.create(_make_create(user_id, "rt-live"))

    # list_active_by_provider must return ONLY the live one.
    active = await svc.list_active_by_provider("local")
    active_ids = {s.id for s in active}
    assert live_info.id in active_ids
    assert expired_info.id not in active_ids


async def test_list_active_by_provider_excludes_revoked(svc: SessionService, user_id: str):
    """Revoked sessions must also be excluded (regression guard)."""
    a = await svc.create(_make_create(user_id, "rt-a"))
    b = await svc.create(_make_create(user_id, "rt-b"))
    await svc.revoke(a.id)
    active = await svc.list_active_by_provider("local")
    active_ids = {s.id for s in active}
    assert b.id in active_ids
    assert a.id not in active_ids


async def test_list_active_by_provider_filters_by_provider(
    svc: SessionService, user_id: str
):
    """Only sessions matching the requested auth_provider are returned."""
    # SessionCreate is a frozen dataclass — build each with its provider.
    local_create = SessionCreate(
        user_id=user_id,
        refresh_token="rt-local",
        device_fingerprint="fp",
        device_label="d",
        ip="",
        user_agent="",
        auth_provider="local",
        ttl_seconds=3600,
    )
    await svc.create(local_create)

    oidc_create = SessionCreate(
        user_id=user_id,
        refresh_token="rt-oidc",
        device_fingerprint="fp",
        device_label="d",
        ip="",
        user_agent="",
        auth_provider="oidc",
        ttl_seconds=3600,
    )
    await svc.create(oidc_create)

    local_only = await svc.list_active_by_provider("local")
    assert all(s.auth_provider == "local" for s in local_only)
    assert len(local_only) == 1
