# This file is part of discord-shared-db
#
# Copyright (C) 2026 CouchComfy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.

"""Database module for managing Discord Shared DB connections and operations.

This module provides a singleton Database class that handles all database
operations including user management, Discord user management, authentication,
and session management.
"""

import os
import logging
from warnings import deprecated
from typing import AsyncGenerator
from datetime import date, datetime, timedelta, timezone

from dotenv import load_dotenv
from sqlalchemy import delete, select
from sqlalchemy.orm import selectinload
from contextlib import asynccontextmanager
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker

from discord_shared_db.card_game.raw_card_data import RawCard
from discord_shared_db.user import User, Base
from discord_shared_db.user_auth import UserAuth
from discord_shared_db.user_session import UserSession
from discord_shared_db.auth_enums import AuthProviders
from discord_shared_db.card_game.raw_pack_data import RawPack, RawPackSlot
from discord_shared_db.discord.discord_user import DiscordUser
from discord_shared_db.user_wallet import CurrencyTransaction, UserWallet

load_dotenv()

class Database:
    """Singleton class for managing database connections and operations.
    
    Provides methods for user management, Discord user operations, authentication,
    and session management. Uses SQLAlchemy with async/await for non-blocking
    database operations.
    
    Attributes:
        engine: AsyncIO SQLAlchemy engine for async database operations.
        async_session: Async session factory for creating database sessions.
    """
    _instance = None

    def __new__(cls, *args, **kwargs):
        """Ensure singleton pattern - only one Database instance exists.
        
        Returns:
            Database: The singleton instance of the Database class.
        """
        if cls._instance is None:
            cls._instance = super(Database, cls).__new__(cls)
        return cls._instance

    def __init__(self):
        """Initialize database connection with PostgreSQL credentials from environment.
        
        Reads PostgreSQL connection parameters from environment variables:
        - POSTGRES_USER: Database user
        - POSTGRES_PASS: Database password
        - POSTGRES_ADDY: Database address/host
        - POSTGRES_NAME: Database name
        - POSTGRES_PORT: Database port (defaults to 5432)
        
        Raises:
            Exception: If any required environment variable is missing.
        """
        if hasattr(self, "_initialized") and self._initialized:
            return  # Prevent reinitialization on multiple calls
        
        POSTGRES_USER = os.getenv("POSTGRES_USER")
        POSTGRES_PASS = os.getenv("POSTGRES_PASS")
        POSTGRES_ADDY = os.getenv("POSTGRES_ADDY")
        POSTGRES_NAME = os.getenv("POSTGRES_NAME")
        POSTGRES_PORT = os.getenv("POSTGRES_PORT", '5432')

        if not POSTGRES_USER:
            raise Exception("Postgres User not Defined")
        if not POSTGRES_PASS:
            raise Exception("Postgres Password not Defined")
        if not POSTGRES_ADDY:
            raise Exception("Postgres Address not Defined")
        if not POSTGRES_NAME:
            raise Exception("Postgres Database Name not Defined")
        if not POSTGRES_PORT:
            raise Exception("Postgres Port not Defined. How did you manage this!!")

        database_url = (
            f'postgresql+asyncpg://{POSTGRES_USER}:{POSTGRES_PASS}'
            f'@{POSTGRES_ADDY}:{POSTGRES_PORT}/{POSTGRES_NAME}'
        )
        
        self.engine = create_async_engine(database_url, echo=False)
        self.async_session = async_sessionmaker(
            self.engine, 
            class_=AsyncSession,
            expire_on_commit=False
        )

        self._initialized = True 
    
    async def init_database(self):
        """Create all database tables based on defined models.
        
        Uses SQLAlchemy metadata to create all tables that don't already exist.
        Should be called once at application startup.
        """
        logging.info(f'Initializing the Database with the models')
        async with self.engine.begin() as conn:
            await conn.run_sync(Base.metadata.create_all)
    
    async def get_db(self):
        """Get a database session as an async generator.
        
        Yields:
            AsyncSession: A database session for executing queries.
        """
        async with self.get_session() as session:
            yield session
    
    @asynccontextmanager
    async def get_session(self) -> AsyncGenerator[AsyncSession, None]:
        """Context manager for database sessions.
        
        Provides automatic transaction handling with commit on success and
        rollback on exception. Ensures proper cleanup of database connections.
        
        Yields:
            AsyncSession: A database session with automatic transaction management.
            
        Raises:
            Exception: Any exception raised during database operations (after rollback).
        """
        logging.debug(f'Get database Session')
        async with self.async_session() as session:
            try:
                yield session
                await session.commit()
            except Exception as e:
                await session.rollback()
                raise e
            finally:
                await session.close()
           
    @deprecated("This method will be removed SOON. Use `get_user_by_id, get_user_by_username or get_user_by_email` instead.")
    async def get_or_create_user(self, username: str = None, email: str = None, hashed_password: str = None, dob: date = None,  session: AsyncSession = None):
        """
        Fetch existing Lyko user by username/email.
        If user does not exist, create a new one.
        Only fills fields (hashed_password, dob, email) if creating a new account.
        """

        owns_session = False
        if session is None:
            session = self.async_session()
            owns_session = True

        try:
            # Try to find user first
            result = await session.execute(
                select(User).where((User.username == username) | (User.email == email))
            )
            user = result.scalar_one_or_none()

            if user:
                # Existing user, just return it
                return user

            # Only create a new user if it does not exist
            if not (email and hashed_password and dob):
                raise ValueError("Missing required fields to create a new user")

            user = User(
                username=username,
                email=email,
                hashed_password=hashed_password,
                dob=dob,
                is_claimed=True,
                disabled=False
            )
            session.add(user)
            await session.flush()
            return user

        finally:
            if owns_session:
                await session.commit()
                await session.close()

    async def get_user_by_id(
        self,
        user_id: int,
        session: AsyncSession
    ) -> User | None:
        """Retrieve a user by their unique ID.
        
        Args:
            user_id: The unique identifier of the user to retrieve.
            session: Database session for executing the query.
            
        Returns:
            User: The user object if found, None otherwise.
            
        Raises:
            ValueError: If user_id is None or empty.
            RuntimeError: If session is not provided.
        """
        if not user_id:
            raise ValueError("username is required")
        if session is None:
            raise RuntimeError("Session must be provided")
        
        result = await session.execute(
            select(User).where(User.user_id == user_id)
        )
        return result.scalar_one_or_none()

    async def get_user_by_username(
        self,
        username: str,
        session: AsyncSession
    ) -> User | None:
        """Retrieve a user by their username.
        
        Args:
            username: The username to search for.
            session: Database session for executing the query.
            
        Returns:
            User: The user object if found, None otherwise.
            
        Raises:
            ValueError: If username is None or empty.
            RuntimeError: If session is not provided.
        """
        if not username:
            raise ValueError("username is required")
        if session is None:
            raise RuntimeError("Session must be provided")

        result = await session.execute(
            select(User).where(User.username == username)
        )
        return result.scalar_one_or_none()

    async def get_user_by_email(
        self,
        email: str,
        session: AsyncSession
    ) -> User | None:
        """Retrieve a user by their email address.
        
        Args:
            email: The email address to search for.
            session: Database session for executing the query.
            
        Returns:
            User: The user object if found, None otherwise.
            
        Raises:
            ValueError: If email is None or empty.
            RuntimeError: If session is not provided.
        """
        if not email:
            raise ValueError("username is required")
        if session is None:
            raise RuntimeError("Session must be provided")

        result = await session.execute(
            select(User).where(User.email == email)
        )
        return result.scalar_one_or_none()

    async def get_all_users(self, session=None):
        """Retrieve all users from the database.
        
        Args:
            session: Optional database session. If not provided, creates a new one.
            
        Returns:
            list[User]: List of all user objects in the database.
        """
        owns_session = False
        if session is None:
            session = self.async_session()
            owns_session = True

        try:
            async with session.begin():
                result = await session.execute(select(User))
                users = result.scalars().all()
        finally:
            if owns_session:
                await session.close()

        return users
    
    async def create_user(
        self,
        username: str,
        email: str | None,
        hashed_password: str | None,
        dob: date | None,
        created_at: datetime | None,
        session: AsyncSession
    ) -> User:
        """Create a new user account.
        
        Args:
            username: The username for the new account (required).
            email: The user's email address (optional).
            hashed_password: Pre-hashed password (optional).
            dob: Date of birth (optional).
            created_at: Account creation timestamp (optional).
            session: Database session for persisting the user.
            
        Returns:
            User: The newly created user object.
            
        Raises:
            ValueError: If username is not provided.
            RuntimeError: If session is not provided.
        """
        if not username:
            raise ValueError("Missing required fields")
        if session is None:
            raise RuntimeError("Session must be provided")

        user = User(
            username=username,
            email=email,
            hashed_password=hashed_password,
            dob=dob,
            created_at= created_at,
            is_claimed=True,
            disabled=False
        )

        session.add(user)
        await session.flush()
        return user
    

    async def get_discord_user(
        self,
        discord_user_id: int,
        session: AsyncSession
    ) -> DiscordUser | None:
        """Retrieve a Discord user profile by Discord user ID.
        
        Args:
            discord_user_id: The Discord user ID.
            session: Database session for executing the query.
            
        Returns:
            DiscordUser: The Discord user object if found, None otherwise.
        """
        result = await session.execute(
            select(DiscordUser).where(
                DiscordUser.discord_user_id == discord_user_id
            )
        )
        return result.scalar_one_or_none()

    async def get_or_create_discord_user(self, discord_user_id: int, discord_username: str, session=None):
        """Retrieve or create a Discord user and associated account.
        
        Creates a 'soft' unclaimed user account if the Discord user doesn't exist.
        Automatically sets up the Discord user profile and authentication link.
        
        Args:
            discord_user_id: The Discord user ID from the Discord API.
            discord_username: The Discord username at time of account creation.
            session: Optional database session. If not provided, creates a new one.
            
        Returns:
            DiscordUser: The Discord user object (new or existing).
        """
        owns_session = False
        if session is None:
            session = self.async_session()
            owns_session = True

        try:
            # Check if Discord user exists
            result = await session.execute(
                select(DiscordUser).where(DiscordUser.discord_user_id == discord_user_id)
            )
            discord_user = result.scalar_one_or_none()
            if discord_user:
                return discord_user

            # Create soft User
            user = User(
                username=f"discord_{discord_user_id}",  # placeholder username
                is_claimed=False
            )
            session.add(user)
            await session.flush()  # assigns user_id

            # Create DiscordUser profile
            discord_user = DiscordUser(
                user_id=user.user_id,
                discord_user_id=discord_user_id,
                discord_username=discord_username
            )
            session.add(discord_user)

            # Create auth link
            auth_link = UserAuth(
                user_id=user.user_id,
                provider=AuthProviders.DISCORD,
                provider_account_id=str(discord_user_id),
                linked_date=datetime.now(datetime.timezone.utc)
            )
            session.add(auth_link)

            await session.flush()
            return discord_user
        finally:
            if owns_session:
                await session.commit()
                await session.close()
        
    async def claim_discord_user(self, discord_user: DiscordUser, username: str, email: str, hashed_password: str, dob: date, session=None):
        """Convert a soft Discord user account into a claimed user account.
        
        Takes a previously unclaimed Discord user account and adds full user
        credentials to mark it as a claimed account.
        
        Args:
            discord_user: The Discord user object to claim.
            username: The username for the claimed account.
            email: The email address for the account.
            hashed_password: Pre-hashed password.
            dob: Date of birth.
            session: Optional database session. If not provided, creates a new one.
            
        Returns:
            User: The updated user object with claimed status.
        """
        owns_session = False
        if session is None:
            session = self.async_session()
            owns_session = True

        try:
            user: User = discord_user.user
            user.username = username
            user.email = email
            user.hashed_password = hashed_password
            user.dob = dob
            user.is_claimed = True

            session.add(user)
            await session.flush()
            return user
        finally:
            if owns_session:
                await session.commit()
                await session.close()

    async def ensure_user_stats(self, user: User, relation_name, session, stat_cls):
        """Ensure a user has initialized stats/profile relation.
        
        Checks if a user has a specific stats/profile relation initialized.
        If not, creates and initializes it with the provided stat class.
        
        Args:
            user: The user object to check/update.
            relation_name: The name of the relationship attribute on the user.
            session: Database session for the operation.
            stat_cls: The stat class to instantiate if relation doesn't exist.
        """
        await session.refresh(user, attribute_names=[relation_name])
        if getattr(user, relation_name) is None:
            setattr(user, relation_name, stat_cls())
            session.add(user)
            await session.commit()
            await session.refresh(user, attribute_names=[relation_name])

    async def get_valid_user_token_session(
        self,
        user_id: int,
        refresh_token_hash: str,
        session: AsyncSession
    ) -> UserSession | None:
        """Retrieve a valid, non-revoked user token session.
        
        Finds an active user session matching the user ID and refresh token,
        checking that the session is not revoked and has not expired.
        
        Args:
            user_id: The user's unique ID.
            refresh_token_hash: The hashed refresh token.
            session: Database session for executing the query.
            
        Returns:
            UserSession: The valid user session if found, None otherwise.
        """
        result = await session.execute(
            select(UserSession).where(
                UserSession.user_id == user_id,
                UserSession.refresh_token_hash == refresh_token_hash,
                UserSession.revoked == False,
                UserSession.expires_at > datetime.now(timezone.utc)
            )
        )
        return result.scalar_one_or_none()

    async def get_valid_user_token_session_by_token(
        self,
        refresh_token_hash: str,
        session: AsyncSession
    ) -> UserSession | None:
        """Retrieve a valid user session by refresh token hash only.
        
        Finds an active user session by the refresh token hash,
        checking that the session is not revoked and has not expired.
        
        Args:
            refresh_token_hash: The hashed refresh token.
            session: Database session for executing the query.
            
        Returns:
            UserSession: The valid user session if found, None otherwise.
        """
        result = await session.execute(
            select(UserSession)
            .where(
                UserSession.refresh_token_hash == refresh_token_hash,
                UserSession.revoked == False,
                UserSession.expires_at > datetime.now(timezone.utc)
            )
        )
        return result.scalar_one_or_none()

    async def create_user_session(
        self,
        user_id: int,
        refresh_token_hash: str,
        expires_at: datetime,
        session: AsyncSession | None = None
    ):
        """Create a new user token session.
        
        Creates a new session record for managing user authentication tokens
        and refresh token rotation.
        
        Args:
            user_id: The user's unique ID.
            refresh_token_hash: The hashed refresh token.
            expires_at: When this session token should expire.
            session: Optional database session. If not provided, creates a new one.
            
        Returns:
            UserSession: The newly created session object.
        """
        owns_session = False
        if session is None:
            logging.warning("🚨🚨🚨 NO SESSION was provided to create_user_session()! Make sure to pass in a session! 🚨🚨🚨")
            session = self.async_session()
            owns_session = True

        try:
            session_obj = UserSession(
                user_id=user_id,
                refresh_token_hash=refresh_token_hash,
                created_at=datetime.now(timezone.utc),
                expires_at=expires_at,
                revoked=False
            )
            session.add(session_obj)
            await session.flush()
            return session_obj
        finally:
            if owns_session:
                await session.commit()
                await session.close()

    async def revoke_user_token_session(
        self,
        session_obj: UserSession,
        session: AsyncSession
    ):
        """Revoke a specific user token session.
        
        Marks a session as revoked to invalidate its refresh token.
        
        Args:
            session_obj: The UserSession object to revoke.
            session: Database session for persisting the revocation.
        """
        session_obj.revoked = True
        session.add(session_obj)
    
    async def revoke_user_sessions(self, user_id: int, session: AsyncSession):
        """Revoke all active sessions for a user.
        
        Marks all non-revoked user sessions as revoked (e.g., on password change
        or logout-all action).
        
        Args:
            user_id: The user's unique ID.
            session: Database session for persisting the revocation.
        """
        result = await session.execute(
            select(UserSession).where(
                UserSession.user_id == user_id,
                UserSession.revoked == False
            )
        )
        sessions = result.scalars().all()

        for s in sessions:
            s.revoked = True

    async def delete_expired_sessions(self) -> int:
        """Delete all expired user sessions from the database.
        
        Removes session records where the expiration time has passed.
        Should be called periodically as a cleanup task.
        
        Returns:
            int: The number of expired sessions deleted.
        """
        async with self.session() as db:
            result = await db.execute(
                delete(UserSession).where(
                    UserSession.expires_at < datetime.now(timezone.utc)
                )
            )
            await db.commit()
            return result.rowcount or 0

    async def get_raw_pack_by_id(
        self,
        pack_id: str,
        session: AsyncSession,
    ) -> RawPack | None:
        """Retrieve a raw pack by ID with full slot structure and relationships loaded.

        Eagerly loads all pack slots, their probabilities, and allowed card types
        to avoid N+1 query problems when accessing nested relationships.

        Args:
            pack_id: Unique identifier of the pack to retrieve.
            session: Database session for executing the query.

        Returns:
            RawPack | None: The raw pack with fully loaded slot structure, or None if not found.

        Raises:
            ValueError: If pack_id is None or empty.
            RuntimeError: If session is not provided.
        """
        if not pack_id:
            raise ValueError("pack_id is required")
        if session is None:
            raise RuntimeError("Session must be provided")

        result = await session.execute(
            select(RawPack)
            .where(RawPack.id == pack_id)
            .options(
                selectinload(RawPack.slots)
                    .selectinload(RawPackSlot.probabilities),
                selectinload(RawPack.slots)
                    .selectinload(RawPackSlot.allowed_types),
            )
        )
        return result.scalar_one_or_none()

    async def get_all_raw_packs_pre_loaded(
        self,
        session: AsyncSession,
    ) -> list[RawPack]:
        """Retrieve all raw packs with all slot structures and relationships eagerly loaded.

        Fetches all pack definitions with nested relationships pre-loaded, including
        all pack slots, their probabilities, and allowed card types. This avoids
        N+1 query problems when working with the full pack hierarchy.

        Args:
            session: Database session for executing the query.

        Returns:
            list[RawPack]: List of all raw pack objects with fully populated slot data.

        Raises:
            RuntimeError: If session is not provided.
        """
        if session is None:
            raise RuntimeError("Session must be provided")

        result = await session.execute(
            select(RawPack).options(
                selectinload(RawPack.slots)
                    .selectinload(RawPackSlot.probabilities),
                selectinload(RawPack.slots)
                    .selectinload(RawPackSlot.allowed_types),
            )
        )
        return result.scalars().all()
    

    async def get_all_raw_packs(
        self,
        session: AsyncSession,
    ) -> list[RawPack]:
        """Retrieve all raw pack definitions.

        Args:
            session: Database session for executing the query.

        Returns:
            list[RawPack]: List of all raw pack objects.

        Raises:
            RuntimeError: If session is not provided.
        """
        if session is None:
            raise RuntimeError("Session must be provided")

        result = await session.execute(
            select(RawPack)
        )
        return result.scalars().all()
    
    async def get_all_raw_cards(
        self,
        session: AsyncSession,
    ) -> list[RawCard]:
        """Retrieve all raw card definitions (all card types).

        Args:
            session: Database session for executing the query.

        Returns:
            list[RawCard]: List of raw cards, including polymorphic subclasses.

        Raises:
            RuntimeError: If session is not provided.
        """
        if session is None:
            raise RuntimeError("Session must be provided")

        result = await session.execute(
            select(RawCard)
        )
        return result.scalars().all()
    
    async def get_raw_card_by_id(
        self,
        card_id: str,
        session: AsyncSession,
    ) -> RawCard | None:
        """Retrieve a raw card by its unique ID.

        This method returns the correct polymorphic card type
        (character, action, location, mana).

        Args:
            card_id: Unique identifier of the card.
            session: Database session for executing the query.

        Returns:
            RawCard | None: The raw card if found, otherwise None.

        Raises:
            ValueError: If card_id is None or empty.
            RuntimeError: If session is not provided.
        """
        if not card_id:
            raise ValueError("card_id is required")
        if session is None:
            raise RuntimeError("Session must be provided")

        result = await session.execute(
            select(RawCard).where(RawCard.id == card_id)
        )
        return result.scalar_one_or_none()

    async def apply_currency_transaction(
        self,
        db: AsyncSession,
        *,
        user_id: str,
        currency: str,
        amount: int,
        reason: str,
        reference_id: str | None = None,
    ):
        """
        Apply a currency transaction to a user's wallet (async-safe).

        Example:
            await apply_currency_transaction(
                db,
                user_id=user.user_id,
                currency="premium_coins",
                amount=-100,
                reason="pack_open",
                reference_id=pack_id,
            )

        NOTE:
        - This function does NOT commit.
        - Caller is responsible for committing or rolling back.
        """

        if currency not in ("coins", "premium_coins"):
            raise ValueError("Invalid currency type")

        # Lock wallet row to prevent race conditions
        result = await db.execute(
            select(UserWallet)
            .where(UserWallet.user_id == user_id)
            .with_for_update()
        )

        wallet = result.scalar_one_or_none()

        if wallet is None:
            wallet = UserWallet(user_id=user_id)
            db.add(wallet)
            # Flush so the wallet row exists before we update it
            await db.flush()

        current_balance = getattr(wallet, currency)
        new_balance = current_balance + amount

        if new_balance < 0:
            raise ValueError("Insufficient balance")

        setattr(wallet, currency, new_balance)

        txn = CurrencyTransaction(
            user_id=user_id,
            currency=currency,
            amount=amount,
            reason=reason,
            reference_id=reference_id,
        )

        db.add(txn)
