"""SessionService — central business logic for ``auth_sessions`` (U3).

This module is the single owner of the ``auth_sessions`` table.  Routes,
the auth middleware, and the admin endpoints all call into
:class:`SessionService` rather than touching the table directly, which
keeps the rotation / reuse-detection / cap-eviction rules in one place.

Lifecycle
---------
::

    create   -> rotate   -> rotate   -> ...
       |            |            |
       v            v            v
    active       active       active
                                 │
                                 ├── revoke (user logout)   → revoked
                                 ├── revoke (admin kill)    → revoked
                                 ├── reuse_detected         → revoked + revoke_all_for_user
                                 ├── password_change        → revoke_all_for_user
                                 └── session_cap_eviction   → revoked (oldest)
"""

from __future__ import annotations

import logging
import os
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path

import aiosqlite

from .denylist import (
    DEFAULT_TTL_SECONDS,
    InMemoryRecentlyRevoked,
    RecentlyRevoked,
    hash_token,
)
from .models import DEFAULT_AUTH_DB_PATH

logger = logging.getLogger(__name__)


def _now_iso() -> str:
    """Return current UTC time as ISO 8601 string."""
    return datetime.now(timezone.utc).isoformat()


def _resolve_db_path() -> Path:
    """Resolve the auth DB path with runtime env-var priority (test-friendly)."""
    env = os.environ.get("AGENTKIT_AUTH_DB")
    if env:
        return Path(env)
    return DEFAULT_AUTH_DB_PATH


# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------


# Default per-user session cap. Matches the plan's "10 sessions" cap;
# new logins evict the oldest when the user is at the limit.
DEFAULT_SESSION_CAP = 10

# Reasons written to ``auth_sessions.revoked_reason``.
REVOKE_REASON_USER_TERMINATED = "user_terminated"
REVOKE_REASON_ADMIN_REVOKED = "admin_revoked"
REVOKE_REASON_PASSWORD_CHANGED = "password_changed"
REVOKE_REASON_REUSE_DETECTED = "reuse_detected"
REVOKE_REASON_SESSION_CAP_EVICTION = "session_cap_eviction"


class SessionNotFound(Exception):
    """The session id does not exist or is not owned by the user."""


class SessionReuseDetected(Exception):
    """A refresh token was reused within the denylist window.

    Raised by :meth:`SessionService.rotate` when the provided refresh
    token is in the denylist.  The caller should treat this as a security
    event and call :meth:`revoke_all_for_user`.
    """


@dataclass(frozen=True)
class SessionCreate:
    """Inputs for :meth:`SessionService.create`."""

    user_id: str
    refresh_token: str
    device_fingerprint: str
    device_label: str
    ip: str
    user_agent: str
    auth_provider: str
    ttl_seconds: int
    previous_session_id: str | None = None


@dataclass(frozen=True)
class SessionInfo:
    """Public projection of an ``auth_sessions`` row."""

    id: str
    user_id: str
    device_fingerprint: str
    device_label: str
    ip: str
    user_agent: str
    auth_provider: str
    created_at: str
    last_active_at: str
    expires_at: str
    revoked: bool
    revoked_reason: str | None
    previous_session_id: str | None


# ---------------------------------------------------------------------------
# SessionService
# ---------------------------------------------------------------------------


class SessionService:
    """CRUD + lifecycle for ``auth_sessions``.

    Args:
        db_path: Path to the auth SQLite DB. Defaults to the env-var
            ``AGENTKIT_AUTH_DB`` or :data:`models.DEFAULT_AUTH_DB_PATH`.
        denylist: Recently-revoked refresh-token store. Defaults to an
            in-memory LRU; tests can pass their own instance for
            deterministic window control.
        session_cap: Maximum number of active sessions per user. New
            logins evict the oldest when the user is at the limit.
    """

    def __init__(
        self,
        db_path: str | Path | None = None,
        *,
        denylist: RecentlyRevoked | None = None,
        session_cap: int = DEFAULT_SESSION_CAP,
    ) -> None:
        self._db_path = Path(db_path) if db_path is not None else _resolve_db_path()
        self._denylist = denylist or InMemoryRecentlyRevoked()
        self._session_cap = session_cap
        # Parallel to ``self._denylist``: maps an old (rotated) refresh
        # token hash to the user_id it belonged to, so reuse detection
        # can find the right user to revoke (the row has already been
        # updated to the new hash, so the table itself can't tell us).
        self._recent_users: dict[str, str] = {}

    # ------------------------------------------------------------------
    # Read
    # ------------------------------------------------------------------

    async def get(self, session_id: str) -> SessionInfo | None:
        """Look up a session by id. Returns ``None`` if not found."""
        async with aiosqlite.connect(str(self._db_path)) as db:
            db.row_factory = aiosqlite.Row
            cursor = await db.execute(
                "SELECT * FROM auth_sessions WHERE id = ?",
                (session_id,),
            )
            row = await cursor.fetchone()
        return _row_to_info(row) if row else None

    async def list_for_user(
        self, user_id: str, *, include_revoked: bool = False
    ) -> list[SessionInfo]:
        """List sessions for ``user_id``, newest first.

        By default only active (non-revoked) sessions are returned.  The
        admin "list all sessions" view passes ``include_revoked=True``.
        """
        sql = "SELECT * FROM auth_sessions WHERE user_id = ?"
        args: tuple[object, ...] = (user_id,)
        if not include_revoked:
            sql += " AND revoked = 0"
        sql += " ORDER BY created_at DESC"
        async with aiosqlite.connect(str(self._db_path)) as db:
            db.row_factory = aiosqlite.Row
            cursor = await db.execute(sql, args)
            rows = await cursor.fetchall()
        return [_row_to_info(r) for r in rows]

    async def list_all(
        self, *, include_revoked: bool = False, limit: int = 200
    ) -> list[SessionInfo]:
        """List recent sessions across all users (admin view)."""
        sql = "SELECT * FROM auth_sessions"
        if not include_revoked:
            sql += " WHERE revoked = 0"
        sql += " ORDER BY created_at DESC LIMIT ?"
        async with aiosqlite.connect(str(self._db_path)) as db:
            db.row_factory = aiosqlite.Row
            cursor = await db.execute(sql, (limit,))
            rows = await cursor.fetchall()
        return [_row_to_info(r) for r in rows]

    async def list_active_by_provider(self, auth_provider: str) -> list[SessionInfo]:
        """List active sessions for a given auth provider (KTD-10).

        Supports the future "show me all OIDC sessions" admin view.
        Only non-revoked, non-expired sessions are returned.
        """
        now_iso = datetime.now(timezone.utc).isoformat()
        sql = (
            "SELECT * FROM auth_sessions "
            "WHERE auth_provider = ? AND revoked = 0 AND expires_at > ? "
            "ORDER BY created_at DESC"
        )
        async with aiosqlite.connect(str(self._db_path)) as db:
            db.row_factory = aiosqlite.Row
            cursor = await db.execute(sql, (auth_provider, now_iso))
            rows = await cursor.fetchall()
        return [_row_to_info(r) for r in rows]

    async def find_by_refresh_token(self, refresh_token: str) -> SessionInfo | None:
        """Look up a session by the SHA-256 hash of its refresh token."""
        h = hash_token(refresh_token)
        async with aiosqlite.connect(str(self._db_path)) as db:
            db.row_factory = aiosqlite.Row
            cursor = await db.execute(
                "SELECT * FROM auth_sessions WHERE refresh_token_hash = ?",
                (h,),
            )
            row = await cursor.fetchone()
        return _row_to_info(row) if row else None

    async def get_stored_refresh_hash(self, session_id: str) -> str | None:
        """Return the stored ``refresh_token_hash`` for ``session_id``.

        Used by callers (e.g. ``/auth/whoami``) that need to verify a
        presented refresh token against the session's current hash without
        going through :meth:`rotate`.  Returns ``None`` if the session
        does not exist.
        """
        async with aiosqlite.connect(str(self._db_path)) as db:
            db.row_factory = aiosqlite.Row
            cursor = await db.execute(
                "SELECT refresh_token_hash FROM auth_sessions WHERE id = ?",
                (session_id,),
            )
            row = await cursor.fetchone()
        if row is None:
            return None
        return row["refresh_token_hash"]

    # ------------------------------------------------------------------
    # Write
    # ------------------------------------------------------------------

    async def create(self, payload: SessionCreate) -> SessionInfo:
        """Insert a new session row.

        If the user is already at the :attr:`session_cap`, the oldest
        active session is evicted first.  This is the only place where
        the cap is enforced — callers don't have to think about it.
        """
        session_id = str(uuid.uuid4())
        now = _now_iso()
        expires = datetime.now(timezone.utc).timestamp() + payload.ttl_seconds
        expires_iso = datetime.fromtimestamp(expires, tz=timezone.utc).isoformat()
        refresh_hash = hash_token(payload.refresh_token)

        await self._enforce_session_cap(payload.user_id, keep_id=session_id)

        async with aiosqlite.connect(str(self._db_path)) as db:
            await db.execute(
                "INSERT INTO auth_sessions "
                "(id, user_id, refresh_token_hash, device_fingerprint, device_label, "
                " ip, user_agent, auth_provider, created_at, last_active_at, expires_at, "
                " revoked, revoked_reason, previous_session_id) "
                "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
                (
                    session_id,
                    payload.user_id,
                    refresh_hash,
                    payload.device_fingerprint,
                    payload.device_label,
                    payload.ip,
                    payload.user_agent,
                    payload.auth_provider,
                    now,
                    now,
                    expires_iso,
                    0,
                    None,
                    payload.previous_session_id,
                ),
            )
            await db.commit()

        return SessionInfo(
            id=session_id,
            user_id=payload.user_id,
            device_fingerprint=payload.device_fingerprint,
            device_label=payload.device_label,
            ip=payload.ip,
            user_agent=payload.user_agent,
            auth_provider=payload.auth_provider,
            created_at=now,
            last_active_at=now,
            expires_at=expires_iso,
            revoked=False,
            revoked_reason=None,
            previous_session_id=payload.previous_session_id,
        )

    async def rotate(
        self,
        old_refresh_token: str,
        new_refresh_token: str,
        *,
        new_ttl_seconds: int,
    ) -> SessionInfo:
        """Rotate a refresh token: replace the old hash with a new one.

        Adds the old token's hash to the denylist so a concurrent retry
        using the old token is detected.

        Raises:
            SessionNotFound: the old token has no matching session.
            SessionReuseDetected: the old token is in the denylist.
                The caller should call :meth:`revoke_all_for_user`.
        """
        old_hash = hash_token(old_refresh_token)
        if self._denylist.contains(old_hash):
            # Concurrent retry — revoke everything for this user.
            user_id = self._recent_users.pop(old_hash, None)
            if user_id is not None:
                await self.revoke_all_for_user(user_id, reason=REVOKE_REASON_REUSE_DETECTED)
            raise SessionReuseDetected("refresh token reuse detected")

        info = await self.find_by_refresh_token(old_refresh_token)
        if info is None:
            raise SessionNotFound("refresh token has no matching session")
        if info.revoked:
            raise SessionNotFound("refresh token has been revoked")

        new_hash = hash_token(new_refresh_token)
        now = _now_iso()
        new_expires_iso = datetime.fromtimestamp(
            datetime.now(timezone.utc).timestamp() + new_ttl_seconds,
            tz=timezone.utc,
        ).isoformat()

        async with aiosqlite.connect(str(self._db_path)) as db:
            await db.execute(
                "UPDATE auth_sessions "
                "SET refresh_token_hash = ?, last_active_at = ?, expires_at = ? "
                "WHERE id = ?",
                (new_hash, now, new_expires_iso, info.id),
            )
            await db.commit()

        # Add the old hash to the denylist AFTER the row is updated so
        # a fast retry sees the new row rather than triggering reuse
        # detection on a value that has just been rotated.
        self._denylist.add(old_hash, ttl_seconds=DEFAULT_TTL_SECONDS)
        # Track which user the old token belonged to so reuse detection
        # can locate the right user to revoke (the row is already
        # pointing at the new hash).
        self._recent_users[old_hash] = info.user_id

        refreshed = await self.get(info.id)
        assert refreshed is not None  # we just wrote it
        return refreshed

    async def touch(self, session_id: str) -> None:
        """Bump ``last_active_at`` (called on /auth/whoami success)."""
        async with aiosqlite.connect(str(self._db_path)) as db:
            await db.execute(
                "UPDATE auth_sessions SET last_active_at = ? WHERE id = ?",
                (_now_iso(), session_id),
            )
            await db.commit()

    # ------------------------------------------------------------------
    # Revoke
    # ------------------------------------------------------------------

    async def revoke(self, session_id: str, *, reason: str = REVOKE_REASON_USER_TERMINATED) -> bool:
        """Revoke a single session.

        Returns ``True`` if a row was updated, ``False`` if the session
        was already revoked or does not exist.
        """
        async with aiosqlite.connect(str(self._db_path)) as db:
            cursor = await db.execute(
                "UPDATE auth_sessions "
                "SET revoked = 1, revoked_reason = ? "
                "WHERE id = ? AND revoked = 0",
                (reason, session_id),
            )
            await db.commit()
            return cursor.rowcount > 0

    async def revoke_by_refresh_token(
        self,
        refresh_token: str,
        *,
        reason: str = REVOKE_REASON_USER_TERMINATED,
    ) -> bool:
        """Revoke a session identified by its refresh token (logout)."""
        info = await self.find_by_refresh_token(refresh_token)
        if info is None or info.revoked:
            return False
        return await self.revoke(info.id, reason=reason)

    async def revoke_all_for_user(
        self,
        user_id: str,
        *,
        except_sid: str | None = None,
        reason: str = REVOKE_REASON_USER_TERMINATED,
    ) -> int:
        """Revoke all of a user's active sessions.

        Used when:
        - The user changes their password (invalidate all other devices).
        - Token reuse is detected (invalidate everything as a precaution).
        - An admin kills a user account.
        - The user calls ``/auth/logout-others`` (keep current session).

        If ``except_sid`` is provided, that session is spared (e.g. the
        caller's current session on "logout others").
        """
        sql = (
            "UPDATE auth_sessions "
            "SET revoked = 1, revoked_reason = ? "
            "WHERE user_id = ? AND revoked = 0"
        )
        args: list[object] = [reason, user_id]
        if except_sid is not None:
            sql += " AND id != ?"
            args.append(except_sid)
        async with aiosqlite.connect(str(self._db_path)) as db:
            cursor = await db.execute(sql, tuple(args))
            await db.commit()
            return cursor.rowcount

    # ------------------------------------------------------------------
    # Cap enforcement
    # ------------------------------------------------------------------

    async def _enforce_session_cap(self, user_id: str, *, keep_id: str) -> int:
        """Evict the oldest sessions when the user is at the cap.

        Returns the number of sessions evicted (0 if under the cap).
        The new session id (``keep_id``) is exempt from eviction.
        """
        async with aiosqlite.connect(str(self._db_path)) as db:
            db.row_factory = aiosqlite.Row
            cursor = await db.execute(
                "SELECT id FROM auth_sessions "
                "WHERE user_id = ? AND revoked = 0 AND id != ? "
                "ORDER BY created_at DESC",
                (user_id, keep_id),
            )
            rows = await cursor.fetchall()
        if len(rows) < self._session_cap:
            return 0
        # Evict the oldest until we are under the cap (with the new row).
        to_evict = rows[self._session_cap - 1 :]
        if not to_evict:
            return 0
        ids = [r["id"] for r in to_evict]
        placeholders = ",".join("?" for _ in ids)
        async with aiosqlite.connect(str(self._db_path)) as db:
            await db.execute(
                f"UPDATE auth_sessions "
                f"SET revoked = 1, revoked_reason = ? "
                f"WHERE id IN ({placeholders})",
                (REVOKE_REASON_SESSION_CAP_EVICTION, *ids),
            )
            await db.commit()
        return len(ids)

    # ------------------------------------------------------------------
    # Cache invalidation
    # ------------------------------------------------------------------

    async def is_session_valid(self, session_id: str) -> bool:
        """Return ``True`` if the session exists, is not revoked, and not expired.

        The middleware calls this on every request that carries a V2
        JWT (sid claim).  The result is cached in a 30-second
        in-process LRU to keep the hot path cheap.
        """
        info = await self.get(session_id)
        if info is None or info.revoked:
            return False
        expires = datetime.fromisoformat(info.expires_at)
        if expires.timestamp() <= datetime.now(timezone.utc).timestamp():
            return False
        return True


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _row_to_info(row: aiosqlite.Row) -> SessionInfo:
    return SessionInfo(
        id=row["id"],
        user_id=row["user_id"],
        device_fingerprint=row["device_fingerprint"],
        device_label=row["device_label"],
        ip=row["ip"],
        user_agent=row["user_agent"],
        auth_provider=row["auth_provider"],
        created_at=row["created_at"],
        last_active_at=row["last_active_at"],
        expires_at=row["expires_at"],
        revoked=bool(row["revoked"]),
        revoked_reason=row["revoked_reason"],
        previous_session_id=row["previous_session_id"],
    )


# ---------------------------------------------------------------------------
# Module-level singleton (overridable in tests via set_session_service)
# ---------------------------------------------------------------------------


_session_service: SessionService | None = None


def get_session_service() -> SessionService:
    """Return the process-wide :class:`SessionService` (lazy singleton)."""
    global _session_service
    if _session_service is None:
        _session_service = SessionService()
    return _session_service


def set_session_service(service: SessionService | None) -> None:
    """Inject a custom :class:`SessionService` (used by tests)."""
    global _session_service
    _session_service = service
