"""Authentication service.

Handles password verification, session creation, session validation, and
session expiry.  Sessions are stored in the SQLite database so they
survive server restarts.
"""

from __future__ import annotations

import hashlib
import hmac
import secrets
from typing import TYPE_CHECKING

import bcrypt
from app.utils.logging_compat import get_logger

from app.utils.async_utils import run_blocking

if TYPE_CHECKING:
    import aiosqlite

    from app.models.auth import Session
    from app.repositories.protocols import SessionRepository, SettingsRepository

from app.repositories import session_repo as default_session_repo
from app.repositories import settings_repo as default_settings_repo
from app.utils.constants import SESSION_TOKEN_BYTES, SESSION_TOKEN_SIGNATURE_SEPARATOR
from app.utils.time_utils import add_minutes, utc_now

log = get_logger(__name__)

# Settings key for password hash
_KEY_PASSWORD_HASH = "master_password_hash"


def _session_token_signature(token: str, secret: str) -> str:
    """Return the HMAC-SHA256 signature for a session token."""
    return hmac.new(secret.encode(), token.encode(), hashlib.sha256).hexdigest()


def sign_session_token(token: str, secret: str) -> str:
    """Return a signed session token string for the client."""
    return f"{token}{SESSION_TOKEN_SIGNATURE_SEPARATOR}{_session_token_signature(token, secret)}"


def unwrap_session_token(token: str, secret: str) -> str:
    """Verify and return the raw token from a signed session token.

    All tokens must carry a valid HMAC-SHA256 signature. Tokens without
    a signature are rejected.

    Args:
        token: The signed session token in format "raw_token.signature".
        secret: The HMAC secret used to verify the signature.

    Returns:
        The raw token component.

    Raises:
        ValueError: If the token lacks a signature or the signature is invalid.
    """
    if SESSION_TOKEN_SIGNATURE_SEPARATOR not in token:
        raise ValueError("Invalid session token.")

    raw_token, signature = token.rsplit(SESSION_TOKEN_SIGNATURE_SEPARATOR, 1)
    expected_signature = _session_token_signature(raw_token, secret)
    if not hmac.compare_digest(expected_signature, signature):
        raise ValueError("Invalid session token.")
    return raw_token


def unwrap_session_token_with_rotation(
    token: str,
    current_secret: str,
    previous_secret: str | None = None,
) -> tuple[str, bool]:
    """Verify and return the raw token, attempting rotation secrets if needed.

    Tries to validate a signed session token with the current secret first.
    If that fails and a previous secret is configured, tries with the previous secret.
    This enables gradual secret rotation without forcing all sessions to be invalidated.

    Args:
        token: The signed session token in format "raw_token.signature".
        current_secret: The current HMAC secret.
        previous_secret: The previous HMAC secret (optional). Used only if current secret validation fails.

    Returns:
        A tuple of (raw_token, was_re_signed_with_current_secret).
        If the token was signed with the previous secret, was_re_signed_with_current_secret is True.
        If the token was signed with the current secret, was_re_signed_with_current_secret is False.

    Raises:
        ValueError: If the token is invalid or no valid secret can validate it.
    """
    try:
        raw_token = unwrap_session_token(token, current_secret)
        return raw_token, False
    except ValueError:
        if previous_secret is not None:
            try:
                raw_token = unwrap_session_token(token, previous_secret)
                log.info(
                    "session_token_re_signed_after_rotation",
                    token_hash=hashlib.sha256(raw_token.encode()).hexdigest()[:12],
                )
                return raw_token, True
            except ValueError as exc:
                raise ValueError("Invalid session token.") from exc
        raise ValueError("Invalid session token.")


async def _check_password(plain: str, hashed: str) -> bool:
    """Return ``True`` if *plain* matches the bcrypt *hashed* password.

    Runs in a thread executor so the blocking bcrypt operation does not stall
    the asyncio event loop.

    Args:
        plain: The plain-text password to verify.
        hashed: The stored bcrypt hash string.

    Returns:
        ``True`` on a successful match, ``False`` otherwise.
    """
    plain_bytes = plain.encode()
    hashed_bytes = hashed.encode()
    return await run_blocking(lambda: bool(bcrypt.checkpw(plain_bytes, hashed_bytes)))


async def get_password_hash(
    db: aiosqlite.Connection,
    settings_repo: SettingsRepository = default_settings_repo,
) -> str | None:
    """Return the stored bcrypt password hash, or ``None`` if not set.

    Args:
        db: Active aiosqlite connection.
        settings_repo: Repository interface for settings persistence.

    Returns:
        The stored bcrypt hash string, or ``None`` if not configured.
    """
    return await settings_repo.get_setting(db, _KEY_PASSWORD_HASH)


async def login(
    db: aiosqlite.Connection,
    password: str,
    session_duration_minutes: int,
    session_secret: str,
    session_repo: SessionRepository = default_session_repo,
) -> tuple[str, str, Session]:
    """Verify *password*, create a new session, and sign the token.

    Args:
        db: Active aiosqlite connection.
        password: Plain-text password supplied by the user.
        session_duration_minutes: How long the new session is valid for.
        session_secret: Secret used to sign the session token.

    Returns:
        A tuple of the signed session token, its expiry timestamp,
        and the newly created session object.

    Raises:
        ValueError: If the password is incorrect or no password hash is stored.
    """
    stored_hash = await get_password_hash(db)
    if stored_hash is None:
        log.warning("bangui_login_no_hash")
        raise ValueError("No password is configured — run setup first.")

    if not await _check_password(password, stored_hash):
        log.warning("bangui_login_wrong_password")
        raise ValueError("Incorrect password.")

    token = secrets.token_hex(SESSION_TOKEN_BYTES)
    now = utc_now()
    created_iso = now.isoformat()
    expires_iso = add_minutes(now, session_duration_minutes).isoformat()

    session = await session_repo.create_session(
        db, token=token, created_at=created_iso, expires_at=expires_iso
    )
    signed_token = sign_session_token(session.token, session_secret)
    log.info("bangui_login_success", session_id=session.id)
    return signed_token, session.expires_at, session


async def validate_session(
    db: aiosqlite.Connection,
    token: str,
    session_secret: str | None = None,
    session_secret_previous: str | None = None,
    session_repo: SessionRepository = default_session_repo,
) -> Session:
    """Return the session for *token* if it is valid and not expired.

    Supports gradual secret rotation: accepts tokens signed with either the
    current or previous secret. If a token was signed with the previous secret,
    it is automatically re-signed with the current secret and persisted.

    Args:
        db: Active aiosqlite connection.
        token: The opaque session token from the client.
        session_secret: Current secret used to verify signed tokens.
        session_secret_previous: Previous secret for rotation support.
        session_repo: Repository interface for session persistence.

    Returns:
        The :class:`~app.models.auth.Session` if it is valid.

    Raises:
        ValueError: If the token is not found, invalid, or has expired.
    """
    raw_token = token
    token_was_re_signed = False

    if session_secret is not None:
        try:
            raw_token, token_was_re_signed = unwrap_session_token_with_rotation(
                token, session_secret, session_secret_previous
            )
        except ValueError as exc:
            raise ValueError("Session token is invalid.") from exc

    session = await session_repo.get_session(db, raw_token)
    if session is None:
        raise ValueError("Session not found.")

    now_iso = utc_now().isoformat()
    if session.expires_at <= now_iso:
        await session_repo.delete_session(db, raw_token)
        raise ValueError("Session has expired.")

    if token_was_re_signed and session_secret is not None:
        log.info(
            "session_token_rotated_in_place",
            session_id=session.id,
            old_secret_fragment=hashlib.sha256(
                (session_secret_previous or "").encode()
            ).hexdigest()[:6],
            new_secret_fragment=hashlib.sha256(session_secret.encode()).hexdigest()[:6],
        )

    return session


async def logout(
    db: aiosqlite.Connection,
    token: str,
    session_secret: str | None = None,
    session_secret_previous: str | None = None,
    session_repo: SessionRepository = default_session_repo,
) -> str | None:
    """Invalidate the session identified by *token*.

    Supports gradual secret rotation: accepts tokens signed with either the
    current or previous secret.

    Args:
        db: Active aiosqlite connection.
        token: The session token to revoke.
        session_secret: Current secret used to verify signed tokens.
        session_secret_previous: Previous secret for rotation support.
        session_repo: Repository interface for session persistence.

    Returns:
        The raw session token that was revoked, or ``None`` if the token was invalid.
    """
    if session_secret is not None:
        try:
            token, _ = unwrap_session_token_with_rotation(
                token, session_secret, session_secret_previous
            )
        except ValueError:
            token_hash = hashlib.sha256(token.encode()).hexdigest()[:12]
            log.warning("bangui_logout_invalid_token", token_hash=token_hash)
            return None

    token_hash = hashlib.sha256(token.encode()).hexdigest()[:12]
    await session_repo.delete_session(db, token)
    log.info("bangui_logout", token_hash=token_hash)
    return token
