"""Бизнес-логика аутентификации: регистрация, подтверждение email, JWT access/refresh.

Refresh-токены хранятся server-side в Redis (`refresh:{jti}` -> user_id) с
TTL, равным сроку жизни refresh-токена. Каждое успешное использование
refresh-токена ротирует его: старый `jti` немедленно удаляется, выдаётся
новый; повторное использование уже потраченного refresh-токена (reuse)
обнаруживается по отсутствию ключа в Redis.
"""

import hashlib
import secrets
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta

import jwt
from redis.asyncio import Redis
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from core.config import get_settings
from core.security import (
    create_access_token,
    create_refresh_token,
    decode_token,
    hash_password,
    needs_rehash,
    verify_password,
)
from models.email_verification import EmailVerificationToken
from models.user import User
from repositories.admin import TeamRepository
from repositories.users import UserRepository
from services.email import EmailBackend
from services.instance_settings import InstanceSettingsService

REFRESH_KEY_PREFIX = "refresh:"


class EmailAlreadyRegisteredError(Exception):
    """Пользователь с таким email уже зарегистрирован."""


class InvalidTeamSelectionError(Exception):
    """Выбор команды при регистрации недоступен или команда не существует.

    Публичный эндпоинт `/auth/register` не должен различать эти две причины
    в ответе (не раскрываем администраторскую настройку/список команд
    перебором id) — единая ошибка для обоих случаев.
    """


class InvalidEmailDomainError(Exception):
    """Домен email регистрирующегося не совпадает с эталонным доменом инстанса.

    Поднимается только при включённой настройке инстанса
    `registration_email_domain_enabled` (см. `InstanceSettingsService`).
    """


class ConsentRequiredError(Exception):
    """Согласие на обработку персональных данных не отмечено.

    Поднимается только при включённой настройке инстанса `consent_required`
    (см. `InstanceSettingsService`) — второй эшелон проверки, фронт уже не
    даёт отправить форму без галочки, но сервер не полагается на это.
    """


class InvalidVerificationTokenError(Exception):
    """Токен подтверждения email не найден, просрочен или уже использован."""


class InvalidCredentialsError(Exception):
    """Неверный email или пароль."""


class EmailNotVerifiedError(Exception):
    """Email пользователя ещё не подтверждён."""


class InvalidRefreshTokenError(Exception):
    """Refresh-токен невалиден, просрочен, отозван или уже был использован (reuse)."""


@dataclass(frozen=True, slots=True)
class TokenPair:
    """Пара выданных JWT-токенов (access — в теле ответа, refresh — в cookie)."""

    access_token: str
    refresh_token: str


class AuthService:
    """Инкапсулирует сценарии регистрации, входа, обновления и отзыва токенов."""

    def __init__(self, session: AsyncSession, redis: Redis, email_backend: EmailBackend) -> None:
        self._session = session
        self._redis = redis
        self._email_backend = email_backend
        self._users = UserRepository(session)
        self._settings = get_settings()

    async def register(
        self,
        *,
        email: str,
        name_user: str,
        password: str,
        team_id: uuid.UUID | None = None,
        consent_accepted: bool = False,
    ) -> User:
        """Зарегистрировать пользователя и отправить письмо для подтверждения email.

        `team_id` допустим, только если в настройках инстанса включён выбор
        команды при регистрации (`registration_team_choice`) и команда
        существует — иначе `InvalidTeamSelectionError` (публичный
        эндпоинт, деталей не раскрываем). Если включена верификация домена
        email (`registration_email_domain_enabled`), домен `email` (часть
        после `@`, без учёта регистра) должен совпадать с одним из
        эталонных доменов (`registration_email_domains`) — иначе
        `InvalidEmailDomainError`. Если включено согласие на обработку
        персональных данных (`consent_required`), `consent_accepted` обязан
        быть `True` — иначе `ConsentRequiredError`; при принятии согласия
        в `User` пишутся `consent_version`/`consent_given_at` (редакция
        регламента на момент согласия и время). Все проверки — до создания
        пользователя.
        """
        existing = await self._users.get_by_email(email)
        if existing is not None:
            raise EmailAlreadyRegisteredError(email)

        cfg = await InstanceSettingsService(self._session).get()

        if cfg.registration_email_domain_enabled:
            email_domain = email.rsplit("@", 1)[-1].lower()
            if email_domain not in cfg.registration_email_domains:
                raise InvalidEmailDomainError(email)

        if team_id is not None:
            if not cfg.registration_team_choice:
                raise InvalidTeamSelectionError(team_id)
            team = await TeamRepository(self._session).get(team_id)
            if team is None:
                raise InvalidTeamSelectionError(team_id)

        if cfg.consent_required and not consent_accepted:
            raise ConsentRequiredError

        consent_version: int | None = None
        consent_given_at: datetime | None = None
        if cfg.consent_required and consent_accepted:
            consent_version = cfg.consent_policy_version
            consent_given_at = datetime.now(UTC)

        user = await self._users.create(
            email=email,
            name_user=name_user,
            password_hash=await hash_password(password),
            team_id=team_id,
            consent_version=consent_version,
            consent_given_at=consent_given_at,
        )
        reply_to = cfg.contact_email if cfg.contact_email_enabled else None
        await self._issue_verification_email(user, reply_to=reply_to)
        await self._session.commit()
        return user

    async def verify_email(self, token: str) -> None:
        """Подтвердить email пользователя по токену из письма."""
        token_hash = _hash_token(token)
        result = await self._session.execute(
            select(EmailVerificationToken).where(EmailVerificationToken.token_hash == token_hash)
        )
        record = result.scalar_one_or_none()
        now = datetime.now(UTC)
        if record is None or record.used_at is not None or record.expires_at < now:
            raise InvalidVerificationTokenError

        user = await self._users.get_by_id(record.user_id)
        if user is None:
            raise InvalidVerificationTokenError

        record.used_at = now
        user.email_verified = True
        await self._session.commit()

    async def login(self, *, email: str, password: str) -> TokenPair:
        """Проверить учётные данные и выдать пару access/refresh токенов."""
        user = await self._users.get_by_email(email)
        if user is None or not await verify_password(password, user.password_hash):
            raise InvalidCredentialsError
        if not user.email_verified:
            raise EmailNotVerifiedError

        # Постепенная миграция на актуальные параметры argon2 (см. core/security.py):
        # параметры зашиты в саму строку хэша, поэтому старые записи так и
        # проверялись бы вдвое дольше. Открытый пароль есть только здесь и
        # только сейчас — другого места для перевыпуска не будет.
        if needs_rehash(user.password_hash):
            user.password_hash = await hash_password(password)
            # Явный commit: выдача токенов идёт через Redis и БД не трогает,
            # поэтому без него перевыпущенный хэш откатился бы вместе с сессией.
            await self._session.commit()

        return await self._issue_token_pair(user.id, user.role)

    async def refresh(self, refresh_token: str) -> TokenPair:
        """Провалидировать refresh-токен, ротировать его и выдать новую пару токенов."""
        user_id = await self._validate_and_consume(refresh_token)
        user = await self._users.get_by_id(user_id)
        if user is None:
            raise InvalidRefreshTokenError
        return await self._issue_token_pair(user.id, user.role)

    async def logout(self, refresh_token: str) -> None:
        """Отозвать refresh-токен (удалить его из Redis), если он вообще декодируется."""
        try:
            payload = decode_token(refresh_token)
        except jwt.PyJWTError:
            return
        jti = payload.get("jti")
        if jti:
            await self._redis.delete(f"{REFRESH_KEY_PREFIX}{jti}")

    async def _validate_and_consume(self, refresh_token: str) -> uuid.UUID:
        """Проверить refresh JWT и его наличие в Redis, затем сразу удалить (ротация)."""
        try:
            payload = decode_token(refresh_token)
        except jwt.PyJWTError as exc:
            raise InvalidRefreshTokenError from exc

        if payload.get("type") != "refresh":
            raise InvalidRefreshTokenError

        jti = payload.get("jti")
        sub = payload.get("sub")
        if not jti or not sub:
            raise InvalidRefreshTokenError

        redis_key = f"{REFRESH_KEY_PREFIX}{jti}"
        stored_user_id = await self._redis.get(redis_key)
        if stored_user_id is None or stored_user_id != sub:
            raise InvalidRefreshTokenError

        # Немедленное удаление использованного jti: повторное предъявление
        # того же refresh-токена (reuse) после этой точки всегда даст 401.
        await self._redis.delete(redis_key)
        return uuid.UUID(sub)

    async def _issue_token_pair(self, user_id: uuid.UUID, role: str) -> TokenPair:
        access_token = create_access_token(user_id, role)
        refresh_token, jti = create_refresh_token(user_id)
        ttl_seconds = self._settings.refresh_token_ttl_days * 24 * 3600
        await self._redis.set(f"{REFRESH_KEY_PREFIX}{jti}", str(user_id), ex=ttl_seconds)
        return TokenPair(access_token=access_token, refresh_token=refresh_token)

    async def _issue_verification_email(self, user: User, *, reply_to: str | None = None) -> None:
        token = secrets.token_urlsafe(32)  # 256 бит случайности
        expires_at = datetime.now(UTC) + timedelta(
            hours=self._settings.email_verification_ttl_hours
        )
        self._session.add(
            EmailVerificationToken(
                user_id=user.id, token_hash=_hash_token(token), expires_at=expires_at
            )
        )
        link = f"{self._settings.frontend_url}/verify-email?token={token}"
        await self._email_backend.send(
            to=user.email,
            subject="Подтверждение регистрации VidConf",
            body=f"Для подтверждения email перейдите по ссылке: {link}",
            reply_to=reply_to,
        )


def _hash_token(token: str) -> str:
    """Захэшировать токен подтверждения email алгоритмом sha256 (hex-строка)."""
    return hashlib.sha256(token.encode()).hexdigest()
