"""Local authentication provider — SQLite + bcrypt.

The default :class:`AuthProvider` implementation. Authenticates users
against the local ``users`` table in the auth SQLite database using
the bcrypt cost=12 password hash (see :mod:`agentkit.server.auth.password`).

This is a behavioral equivalent of the password-verification code that
previously lived inline in ``routes/auth.py`` — moved here so the route
layer can call a single :meth:`authenticate` method regardless of which
backend is configured.

Future-IdP note
---------------
When the organization moves to OIDC / SAML / LDAP, this class does not
need to be deleted. It can continue to serve as a "local emergency
account" provider, configurable side-by-side with the IdP provider via
a future composite / multi-provider setup. For now it is the only
implementation.
"""

from __future__ import annotations

import logging
import os
import uuid
from pathlib import Path

import aiosqlite

from ..models import DEFAULT_AUTH_DB_PATH, user_row_to_dict
from ..password import hash_password, verify_password
from .user import User

logger = logging.getLogger(__name__)


def _resolve_db_path() -> Path:
    """Resolve the auth DB path with runtime env-var priority.

    The :data:`models.DEFAULT_AUTH_DB_PATH` constant is captured at
    module-import time and therefore cannot see test-time env mutations.
    Re-reading ``AGENTKIT_AUTH_DB`` here keeps the provider
    "test-friendly" (tests can ``monkeypatch.setenv`` before constructing
    the provider) without giving up the default path when no env is set.
    """
    env = os.environ.get("AGENTKIT_AUTH_DB")
    if env:
        return Path(env)
    return DEFAULT_AUTH_DB_PATH


class LocalAuthProvider:
    """AuthProvider backed by the local SQLite ``users`` table + bcrypt.

    Args:
        db_path: Path to the auth DB. Defaults to the value of the
            ``AGENTKIT_AUTH_DB`` env var, falling back to
            :data:`agentkit.server.auth.models.DEFAULT_AUTH_DB_PATH`.
            Each operation opens a short-lived aiosqlite connection;
            the existing route layer follows the same pattern, so no
            connection pooling is introduced here. If a future
            deployment needs pooling, swap in a ``db_factory: Callable``
            here without changing the protocol.
    """

    name = "local"

    def __init__(self, db_path: str | Path | None = None) -> None:
        self._db_path = Path(db_path) if db_path is not None else _resolve_db_path()

    @property
    def db_path(self) -> Path:
        return self._db_path

    async def authenticate(self, *, username: str, password: str) -> User:
        """Verify the username + password against the local users table.

        Raises :class:`InvalidCredentials` on every failure mode
        (unknown user, wrong password, inactive user) with the same
        error message — preventing username enumeration via error
        inspection. Constant-time-equivalent behavior is also
        ensured by always running a real bcrypt computation
        (against a dummy hash) when the user does not exist,
        matching the timing of the "user exists, wrong password" path.
        """
        from .exceptions import InvalidCredentials  # local import to avoid cycle at module load

        async with aiosqlite.connect(str(self._db_path)) as db:
            db.row_factory = aiosqlite.Row
            cursor = await db.execute(
                "SELECT id, username, email, password_hash, role, is_active, "
                "is_terminal_authorized, is_server_terminal_authorized, "
                "created_at, updated_at, last_login_at, created_by "
                "FROM users WHERE username = ?",
                (username,),
            )
            row = await cursor.fetchone()

        if row is None or not bool(row["is_active"]):
            # Run a real bcrypt verification against a valid-format dummy
            # hash so the response time matches the "user exists, wrong
            # password" path (~250ms). Prevents username enumeration via
            # timing. The dummy hash is invalid (won't match any password)
            # but has the right shape so bcrypt.checkpw doesn't short-circuit.
            _DUMMY_BCRYPT_HASH = "$2b$12$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ0123"
            verify_password(password, _DUMMY_BCRYPT_HASH)
            raise InvalidCredentials("invalid username or password")

        if not verify_password(password, row["password_hash"]):
            raise InvalidCredentials("invalid username or password")

        return _row_to_user(row)

    async def get_user_by_id(self, user_id: str) -> User | None:
        """Look up a user by id. Returns ``None`` if not found or inactive."""
        async with aiosqlite.connect(str(self._db_path)) as db:
            db.row_factory = aiosqlite.Row
            cursor = await db.execute(
                "SELECT id, username, email, password_hash, role, is_active, "
                "is_terminal_authorized, is_server_terminal_authorized, "
                "created_at, updated_at, last_login_at, created_by "
                "FROM users WHERE id = ? AND is_active = 1",
                (user_id,),
            )
            row = await cursor.fetchone()
        return _row_to_user(row) if row else None

    async def sync_user_attributes(self, user_id: str) -> None:
        """No-op: local provider has no upstream source of truth to sync from."""
        return None

    async def revoke_user(self, user_id: str) -> None:
        """Disable a user account (``is_active = 0``)."""
        async with aiosqlite.connect(str(self._db_path)) as db:
            await db.execute(
                "UPDATE users SET is_active = 0, updated_at = ? WHERE id = ?",
                (_now_iso(), user_id),
            )
            await db.commit()
        logger.info(f"Revoked user {user_id} via LocalAuthProvider")

    async def create_user(
        self,
        username: str,
        email: str,
        password: str,
        role: str = "member",
        is_terminal_authorized: bool = False,
        is_server_terminal_authorized: bool = False,
        created_by: str | None = None,
    ) -> dict[str, object]:
        """Create a new user in the local ``users`` table.

        Args:
            username: Unique username.
            email: Unique email address.
            password: Plain-text password (will be bcrypt-hashed with
                cost factor 12 before storage).
            role: Role name (``member`` / ``operator`` / ``admin``).
                Defaults to ``member``.
            is_terminal_authorized: Whether the user may use the local
                terminal. Defaults to ``False``.
            is_server_terminal_authorized: Whether the user may use the
                server terminal. Defaults to ``False``.
            created_by: Optional user id of the admin who created this
                user (audit trail).

        Returns:
            The newly-created user as a dict (via
            :func:`agentkit.server.auth.models.user_row_to_dict`).

        Raises:
            ValueError: If a user with the same username or email
                already exists (catches SQLite ``IntegrityError`` on the
                ``username`` / ``email`` UNIQUE constraints).
        """
        user_id = str(uuid.uuid4())
        password_hash = hash_password(password)
        now = _now_iso()

        try:
            async with aiosqlite.connect(str(self._db_path)) as db:
                db.row_factory = aiosqlite.Row
                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, created_by) "
                    "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
                    (
                        user_id,
                        username,
                        email,
                        password_hash,
                        role,
                        1,
                        1 if is_terminal_authorized else 0,
                        1 if is_server_terminal_authorized else 0,
                        now,
                        now,
                        created_by,
                    ),
                )
                await db.commit()
                cursor = await db.execute(
                    "SELECT id, username, email, password_hash, role, is_active, "
                    "is_terminal_authorized, is_server_terminal_authorized, "
                    "created_at, updated_at, last_login_at, created_by "
                    "FROM users WHERE id = ?",
                    (user_id,),
                )
                row = await cursor.fetchone()
        except aiosqlite.IntegrityError as exc:
            # SQLite IntegrityError message includes the column name; we
            # inspect it to give the caller a useful error. If for some
            # reason the message is unparseable, fall back to a generic
            # "duplicate" message.
            msg = str(exc).lower()
            if "username" in msg:
                raise ValueError(f"User with username {username!r} already exists") from exc
            if "email" in msg:
                raise ValueError(f"User with email {email!r} already exists") from exc
            raise ValueError(f"User already exists: {exc}") from exc

        assert row is not None  # we just inserted it
        logger.info(f"Created user {username!r} (id={user_id}) via LocalAuthProvider")
        return user_row_to_dict(row)


def _row_to_user(row: aiosqlite.Row) -> User:
    """Convert a ``users`` row to a :class:`User` value object."""
    return User(
        id=row["id"],
        username=row["username"],
        email=row["email"],
        role=row["role"],
        is_active=bool(row["is_active"]),
        is_terminal_authorized=bool(row["is_terminal_authorized"]),
        is_server_terminal_authorized=bool(row["is_server_terminal_authorized"]),
        created_at=row["created_at"],
        updated_at=row["updated_at"],
        last_login_at=row["last_login_at"],
        created_by=row["created_by"],
    )


def _now_iso() -> str:
    """Return current UTC time as ISO 8601 string."""
    from datetime import datetime, timezone

    return datetime.now(timezone.utc).isoformat()
