"""Local email/password authentication service.

Sits alongside the existing OIDC/Atoms platform flow. The shared ``users``
table (owned by Atoms Cloud) stores only identity fields. Local-auth-only
fields (``password_hash``, ``auth_provider``, ``is_active``) live on a
side table, :class:`LocalAuthProfile`, keyed by ``user_id``.

Also owns the password-reset token lifecycle for local accounts.
"""

from __future__ import annotations

import hashlib
import logging
import secrets
import uuid
from datetime import datetime, timedelta, timezone
from typing import Optional, Tuple

import bcrypt
from models.auth import LocalAuthProfile, PasswordResetToken, User
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

logger = logging.getLogger(__name__)

# Reset tokens are valid for 60 minutes. Tokens are 32-byte URL-safe strings.
RESET_TOKEN_BYTES = 32
RESET_TOKEN_TTL_MINUTES = 60


def _hash_reset_token(token: str) -> str:
    """Return the SHA-256 hex digest of a plaintext reset token."""
    return hashlib.sha256(token.encode("utf-8")).hexdigest()


def _hash_password(password: str) -> str:
    """Return a bcrypt hash for ``password``."""
    if not password:
        raise ValueError("Password must not be empty")
    return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt(rounds=12)).decode("utf-8")


def _verify_password(password: str, password_hash: str) -> bool:
    """Check a password against a stored bcrypt hash."""
    if not password or not password_hash:
        return False
    try:
        return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
    except ValueError:
        # Malformed hash
        return False


def _new_local_user_id() -> str:
    return f"local:{uuid.uuid4().hex}"


async def get_profile(db: AsyncSession, user_id: str) -> Optional[LocalAuthProfile]:
    """Return the :class:`LocalAuthProfile` for ``user_id`` or ``None``."""
    if not user_id:
        return None
    result = await db.execute(
        select(LocalAuthProfile).where(LocalAuthProfile.user_id == user_id)
    )
    return result.scalar_one_or_none()


async def get_or_create_profile(db: AsyncSession, user_id: str) -> LocalAuthProfile:
    """Return an existing profile or create a fresh platform profile."""
    profile = await get_profile(db, user_id)
    if profile is not None:
        return profile
    profile = LocalAuthProfile(
        user_id=user_id,
        auth_provider="platform",
        is_active=True,
    )
    db.add(profile)
    await db.flush()
    return profile


class LocalAuthService:
    """Business logic for local email/password auth."""

    def __init__(self, db: AsyncSession):
        self.db = db

    async def get_user_by_email(self, email: str) -> Optional[User]:
        normalized = (email or "").strip().lower()
        if not normalized:
            return None
        result = await self.db.execute(select(User).where(User.email == normalized))
        return result.scalar_one_or_none()

    async def get_profile_for_user(self, user: User) -> Optional[LocalAuthProfile]:
        return await get_profile(self.db, user.id)

    async def register(
        self,
        email: str,
        password: str,
        name: Optional[str] = None,
        role: str = "user",
    ) -> User:
        """Create a new local user. Raises ValueError if email already in use."""
        normalized = (email or "").strip().lower()
        existing = await self.get_user_by_email(normalized)
        if existing is not None:
            raise ValueError("Email is already registered")

        user = User(
            id=_new_local_user_id(),
            email=normalized,
            name=name or normalized.split("@", 1)[0],
            role=role,
            last_login=datetime.now(timezone.utc),
        )
        self.db.add(user)
        await self.db.flush()

        profile = LocalAuthProfile(
            user_id=user.id,
            password_hash=_hash_password(password),
            auth_provider="local",
            is_active=True,
        )
        self.db.add(profile)

        await self.db.commit()
        await self.db.refresh(user)
        logger.info(
            "[local_auth] Registered local user id=%s email=%s role=%s",
            user.id,
            user.email,
            user.role,
        )
        return user

    async def authenticate(self, email: str, password: str) -> Optional[User]:
        """Return user on valid credentials, None otherwise."""
        user = await self.get_user_by_email(email)
        if user is None:
            return None
        profile = await self.get_profile_for_user(user)
        if profile is None:
            return None
        if profile.auth_provider != "local" or not profile.password_hash:
            return None
        if profile.is_active is False:
            logger.info("[local_auth] Login rejected for disabled account: %s", user.email)
            return None
        if not _verify_password(password, profile.password_hash):
            return None

        user.last_login = datetime.now(timezone.utc)
        await self.db.commit()
        await self.db.refresh(user)
        return user

    async def set_password(self, user: User, new_password: str) -> User:
        """Set a new bcrypt password hash on a local user's profile.

        Promotes the user to ``auth_provider='local'`` if they had no
        local profile yet, so the new credentials actually work.
        """
        if not new_password or len(new_password) < 8:
            raise ValueError("Password must be at least 8 characters")
        profile = await get_or_create_profile(self.db, user.id)
        profile.password_hash = _hash_password(new_password)
        profile.auth_provider = "local"
        await self.db.commit()
        await self.db.refresh(user)
        return user

    async def create_reset_token(self, user: User) -> Tuple[str, datetime]:
        """Create and persist a one-time password-reset token for ``user``."""
        now = datetime.now(timezone.utc)
        # Invalidate outstanding tokens for this user
        existing = await self.db.execute(
            select(PasswordResetToken).where(
                PasswordResetToken.user_id == user.id,
                PasswordResetToken.used_at.is_(None),
            )
        )
        for row in existing.scalars().all():
            row.used_at = now

        plaintext = secrets.token_urlsafe(RESET_TOKEN_BYTES)
        expires_at = now + timedelta(minutes=RESET_TOKEN_TTL_MINUTES)
        record = PasswordResetToken(
            user_id=user.id,
            token_hash=_hash_reset_token(plaintext),
            expires_at=expires_at,
        )
        self.db.add(record)
        await self.db.commit()
        logger.info(
            "[local_auth] Issued password-reset token for user=%s expires=%s",
            user.id,
            expires_at.isoformat(),
        )
        return plaintext, expires_at

    async def consume_reset_token(self, token: str, new_password: str) -> User:
        """Verify a reset token and set a new password on the target user."""
        if not token:
            raise ValueError("Missing reset token")
        token_hash = _hash_reset_token(token)
        result = await self.db.execute(
            select(PasswordResetToken).where(PasswordResetToken.token_hash == token_hash)
        )
        record = result.scalar_one_or_none()
        if record is None:
            raise ValueError("Invalid or expired reset token")
        if record.used_at is not None:
            raise ValueError("This reset link has already been used")

        now = datetime.now(timezone.utc)
        expires_at = record.expires_at
        if expires_at is not None and expires_at.tzinfo is None:
            expires_at = expires_at.replace(tzinfo=timezone.utc)
        if expires_at is None or expires_at < now:
            raise ValueError("This reset link has expired")

        user_result = await self.db.execute(select(User).where(User.id == record.user_id))
        user = user_result.scalar_one_or_none()
        if user is None:
            raise ValueError("Account no longer exists")

        profile = await get_or_create_profile(self.db, user.id)
        profile.password_hash = _hash_password(new_password)
        profile.auth_provider = "local"
        record.used_at = now
        await self.db.commit()
        await self.db.refresh(user)
        logger.info("[local_auth] Reset token consumed for user=%s", user.id)
        return user


async def ensure_local_admin(
    db: AsyncSession,
    email: str,
    password: str,
    name: Optional[str] = None,
) -> User:
    """Create or promote a local admin user. Idempotent."""
    normalized = (email or "").strip().lower()
    if not normalized or not password:
        raise ValueError("Email and password are required to seed admin")

    result = await db.execute(select(User).where(User.email == normalized))
    user = result.scalar_one_or_none()

    if user is None:
        user = User(
            id=_new_local_user_id(),
            email=normalized,
            name=name or normalized.split("@", 1)[0],
            role="admin",
            last_login=None,
        )
        db.add(user)
        await db.flush()
        profile = LocalAuthProfile(
            user_id=user.id,
            password_hash=_hash_password(password),
            auth_provider="local",
            is_active=True,
        )
        db.add(profile)
        await db.commit()
        await db.refresh(user)
        logger.info("[local_auth] Seeded local admin id=%s email=%s", user.id, user.email)
        return user

    changed = False
    if user.role != "admin":
        user.role = "admin"
        changed = True

    profile = await get_profile(db, user.id)
    if profile is None:
        profile = LocalAuthProfile(
            user_id=user.id,
            password_hash=_hash_password(password),
            auth_provider="local",
            is_active=True,
        )
        db.add(profile)
        changed = True
    elif profile.auth_provider == "local":
        # Refresh the seeded password so operator credentials are deterministic.
        profile.password_hash = _hash_password(password)
        profile.is_active = True
        changed = True

    if changed:
        await db.commit()
        await db.refresh(user)
        logger.info(
            "[local_auth] Updated existing user to admin id=%s email=%s",
            user.id,
            user.email,
        )
    else:
        logger.debug(
            "[local_auth] Admin user already up to date id=%s email=%s",
            user.id,
            user.email,
        )
    return user