import asyncio
import hashlib
import importlib
import logging
import re
import shutil
import sqlite3

from asyncio import Task
from datetime import datetime
from pathlib import Path
from typing import List

from server.models.extra_models import TokenModel
from sqliteclient.data.Models import (
    TableApiTokens,
    TablePasswords,
    ViewAllJoinedColumns,
    ViewUsersApiTokens,
    ViewUsersGroups,
    ViewUsersPasswords,
)

logger = logging.getLogger("webui.SQLiteClient")


class SqliteClient:
    """
    SQLite client for the WebUI database.

    The client keeps selected database views cached in memory and provides helper methods for reading and updating data.
    """

    DEFAULT_DATABASE_FOLDER: Path = Path("/mnt/persistent-data/evtec-webui-data/database")
    """Default location of the WebUi database"""
    # Todo: with get env from systemd service, replace default db path

    DEFAULT_DATABASE_PATH = DEFAULT_DATABASE_FOLDER / "webui_v2.db"
    DATABASE_MIGRATIONS_DIR = DEFAULT_DATABASE_FOLDER / "migrations"

    TASK_NAME_FETCH_DATABASE = "fetchDatabase"

    SELECT_SCHEMA_MIGRATIONS = "SELECT version FROM schema_migrations ORDER BY version"
    """Selects all schema migration rows"""
    INSERT_SCHEMA_MIGRATION = "INSERT INTO schema_migrations (version, name, checksum) VALUES (?, ?, ?)"
    """Adds a schema migration row for an applied migration"""

    CREATE_SCHEMA_MIGRATIONS_TABLE = """
    CREATE TABLE IF NOT EXISTS schema_migrations (
        version     INTEGER PRIMARY KEY,
        name        TEXT NOT NULL,
        checksum    TEXT,
        applied_at  TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
    )
    """

    SELECT_VIEW_USERS_PASSWORDS = "SELECT * FROM view_users_passwords"
    SELECT_VIEW_USERS_API_TOKENS = "SELECT * FROM view_users_apiTokens"
    SELECT_VIEW_USERS_PERMISSIONS = "SELECT * FROM view_users_groups"
    SELECT_VIEW_ALL_JOINED_COLUMNS = "SELECT * FROM view_all_data"
    SELECT_TABLE_UGROUPS = "SELECT * FROM uGroups"

    INSERT_INTO_API_TOKENS = (
        "INSERT INTO apiTokens (users_userId, token, validityStart, validityEnd, historyState, isSessionToken, tokenName) "
        "VALUES (:userId, :token, :validityStart, :validityEnd, :historyState, :isSessionToken, :tokenName)"
    )
    INSERT_INTO_USER_GROUPS = "INSERT INTO users_groups (users_userId, groups_groupId) VALUES (:userId, :groupId)"
    INSERT_INTO_UGROUPS = "INSERT INTO uGroups (groupName) VALUES (:groupName)"

    DELETE_FROM_API_TOKENS = (
        "DELETE FROM apiTokens WHERE users_userId=:userId AND isSessionToken=:isSessionToken AND tokenName=:tokenName"
    )
    UPDATE_PASSWORD = (
        "UPDATE passwords "
        "SET password = :password, seed = :seed "
        "WHERE passwordId = :passwordId AND users_userId = :userId"
    )

    def __init__(self, databasePath: Path | None = None):
        self.databasePath = databasePath or SqliteClient.DEFAULT_DATABASE_PATH

        self.connection: sqlite3.Connection | None = None
        self.cursor: sqlite3.Cursor | None = None

        self._usersPasswords: List[ViewUsersPasswords] | None = None
        self._usersApiTokens: List[ViewUsersApiTokens] | None = None
        self._usersPermissions: List[ViewUsersGroups] | None = None
        self._allJoinedColumns: List[ViewAllJoinedColumns] | None = None

        self.loadTablesTask: Task | None = None
        self.wasLoaded: bool = False

        self._dbLock = asyncio.Lock()
        self._loadLock = asyncio.Lock()

    def startLoadTablesTask(self) -> None:
        """Start asynchronous loading of the cached database views."""
        self.loadTablesTask = asyncio.create_task(self._ensureLoaded(), name=SqliteClient.TASK_NAME_FETCH_DATABASE)

    async def withConnection(self, fn, *args, **kwargs):
        """
        Run a callable with an open database connection.

        The callable receives the sqlite3 connection as first argument.

        :param fn: Callable to execute.
        :param args: Positional arguments forwarded to ``fn``.
        :param kwargs: Keyword arguments forwarded to ``fn``.
        :return: Return value of ``fn``.
        :raises sqlite3.Error: If the database operation fails.
        """
        async with self._dbLock:
            await self._buildConnection()
            try:
                result = fn(self.connection, *args, **kwargs)
                self.connection.commit()
                return result

            except sqlite3.Error:
                if self.connection is not None:
                    self.connection.rollback()
                logger.exception("Database operation failed (database=%s)", self.databasePath)
                raise

            finally:
                await self._closeConnection()

    async def applyMigrations(self, migrationsPath: Path | None = None) -> None:
        """
        Apply all pending SQL migrations in order.
        """
        migrationDir = migrationsPath or SqliteClient.DATABASE_MIGRATIONS_DIR

        if not migrationDir.exists() or not migrationDir.is_dir():
            raise FileNotFoundError(f"Migration directory not found: {migrationDir}")

        async with self._dbLock:
            self._invalidateCache()

            try:
                await self._buildConnection()

                await self._ensureSchemaMigrationsTable()
                appliedVersions = await self._getAppliedMigrationVersions()

                migrationFiles = sorted(
                    [
                        path
                        for path in migrationDir.iterdir()
                        if path.is_file() and re.match(r"^\d+_.*\.sql$", path.name)
                    ],
                    key=lambda p: p.name,
                )

                for migrationFile in migrationFiles:
                    version = self._parseMigrationVersion(migrationFile)

                    if version in appliedVersions:
                        continue

                    # Look for python migration
                    pyMigration = migrationFile.with_suffix(".py")

                    module = None
                    if pyMigration.exists():
                        spec = importlib.util.spec_from_file_location(pyMigration.stem, pyMigration)
                        module = importlib.util.module_from_spec(spec)
                        spec.loader.exec_module(module)

                    checksum = self._computeFileChecksum(migrationFile)

                    await self._applySingleMigration(Path(self.databasePath), migrationFile, module, version, checksum)

                fkIssues = self.cursor.execute("PRAGMA foreign_key_check;").fetchall()
                if fkIssues:
                    raise RuntimeError(f"Foreign key violations after migration: {fkIssues}")

            finally:
                await self._closeConnection()

        await self._ensureLoaded()

    async def _applySingleMigration(
        self, dbPath: Path, migrationFile: Path, module, version: int, checksum: str
    ) -> None:
        script = migrationFile.read_text(encoding="utf-8")
        backupPath = dbPath.with_suffix(f"{dbPath.suffix}.bak.{version}")

        logger.info(f"Applying version {version}")

        try:
            await self._createDatabaseBackup(backupPath)

            if module:
                module.migrate(self.cursor)

            self.cursor.executescript(script)
            self.cursor.execute(SqliteClient.INSERT_SCHEMA_MIGRATION, (version, migrationFile.name, checksum))
            self.connection.commit()
            await self._removeDatabaseBackup(backupPath)

        except Exception:
            try:
                self.connection.rollback()
            except Exception:
                logger.exception("Rollback failed for migration %s", migrationFile.name)

            await self._restoreDatabaseBackup(dbPath, backupPath)
            await self._buildConnection()
            raise

    async def _createDatabaseBackup(self, backupPath: Path) -> None:
        backupPath.parent.mkdir(parents=True, exist_ok=True)

        dest = sqlite3.connect(str(backupPath))
        try:
            with dest:
                self.connection.backup(dest)
        finally:
            dest.close()

    async def _restoreDatabaseBackup(self, dbPath: Path, backupPath: Path) -> None:
        await self._closeConnection()

        if dbPath.exists():
            dbPath.unlink()

        wal = Path(f"{dbPath}-wal")
        shm = Path(f"{dbPath}-shm")

        if wal.exists():
            wal.unlink()
        if shm.exists():
            shm.unlink()

        shutil.copy2(backupPath, dbPath)

    async def _removeDatabaseBackup(self, backupPath: Path) -> None:
        if backupPath.exists():
            backupPath.unlink()

    def checkUserExists(self, whereUser: str) -> bool:
        """
        Check if a user exists in the loaded password cache.

        param whereUser: The user's name to search for.
        return: ``True`` if the user exists, otherwise ``False``.
        """
        matchingUsers = [fetchedUser for fetchedUser in self._usersPasswords if fetchedUser.userName == whereUser]
        return len(matchingUsers) > 0

    def checkTokenExist(self, token: str) -> bool:
        """
        Check if a token exists in the loaded token cache.

        param token: The token string to search for.
        return: ``True`` if the token exists, otherwise ``False``.
        """
        return len([row for row in self._usersApiTokens if row.token == token]) >= 1

    def checkTokenNameExist(self, tokenName: str) -> bool:
        """
        Check if a token name exists in the loaded token cache.

        param tokenName: The name of a token string to search for.
        return: ``True`` if the token name exists, otherwise ``False``.
        """
        return len([row for row in self._usersApiTokens if row.tokenName == tokenName]) >= 1

    def getTokenData(self, tokenName: str) -> ViewUsersApiTokens | None:
        """
        Return token row data for the given token name.

        param tokenName: The token's name to return the data for.
        return: ```ViewUsersApiTokens``` if the token name exists, otherwise ``None``.
        """
        tokenData = [row for row in self._usersApiTokens if row.tokenName == tokenName]
        return tokenData[0] if len(tokenData) > 0 else None

    def getTokensUser(self, token: TokenModel) -> str | None:
        """
        Return the assigned user for the given token.

        param token: The ```TokenModel``` to return the user for.
        return: username if the token exists, otherwise ``None``.
        """
        rowMatch = [row for row in self._usersApiTokens if row.token == token.sub]
        return rowMatch[0].userName if rowMatch else None

    def getTokensUserByTokenName(self, tokenName: str) -> str | None:
        """
        Return the assigned user for the given token name.

        param token: The token's name to return the user for.
        return: username if the token exists, otherwise ``None``.
        """
        rowMatch = [row for row in self._usersApiTokens if row.tokenName == tokenName]
        return rowMatch[0].userName if rowMatch else None

    def checkTokenHasPrivileges(self, token: str, group: str) -> bool:
        """
        Check whether a token has a given privilege.

        param token: The token string to search for.
        param group: The group name which holds the privileges to check for.
        return: ``True`` if the token exists and has the right to the group action, otherwise ``False``.
        """
        return len([row for row in self._allJoinedColumns if row.token == token and row.groupName == group]) >= 1

    def getUsersPasswords(self, whereUser: str | None = None) -> List[ViewUsersPasswords] | None:
        """
        Return cached password rows, optionally filtered by username.

        :param whereUser: Optional username to get the password row for.
        return: List of ```ViewUsersPasswords``` or ```None``` if no match was found.
        """
        if whereUser:
            return [fetchedUser for fetchedUser in self._usersPasswords if fetchedUser.userName == whereUser]

        return self._usersPasswords

    def getUsersApiTokens(
        self, whereUser: str | None = None, getSessionToken: bool = False
    ) -> List[ViewUsersApiTokens] | None:
        """
        Return cached API token rows, optionally filtered by username and session-token flag.

        :param whereUser: Optional username to get the api token row for.
        :param getSessionToken: Filter for token type.
        return: List of ```ViewUsersApiTokens``` filtered for session-token flag and username if one was provided.
        """
        if whereUser:
            return [
                fetchedUser
                for fetchedUser in self._usersApiTokens
                if fetchedUser.userName == whereUser and fetchedUser.isSessionToken == getSessionToken
            ]

        return [fetchedUser for fetchedUser in self._usersApiTokens if fetchedUser.isSessionToken == getSessionToken]

    def getUsersGroups(self, whereUser: str | None = None) -> List[ViewUsersGroups] | None:
        """
        Return cached group rows, optionally filtered by username.

        :param whereUser: Optional username to get the api token row for.
        return: List of ```ViewUsersGroups``` filtered by username if one was provided.
        """
        if whereUser:
            return [fetchedUser for fetchedUser in self._usersPermissions if fetchedUser.userName == whereUser]

        return self._usersPermissions

    def getAllJoinedColumns(self, whereUser: str | None = None) -> List[ViewAllJoinedColumns] | None:
        """
        Return cached joined rows. Filtering is currently not implemented.

        :param whereUser: Optional username to get the api token row for.
        return: List of ```ViewUsersGroups``` filtered by username if one was provided.
        """
        if not whereUser:
            return self._allJoinedColumns

        # Todo: add filter
        return None

    async def createSessionToken(self, username: str, newTokenRow: TableApiTokens) -> str:
        """
        Create a token row in the database and return the token value.

        param username: The username to create the token for.
        param newTokenRow: ```TableApiTokens``` data.
        return: Returns the token string on success.
        :raises sqlite3.Error: If the database write fails.
        """
        async with self._dbLock:
            try:
                await self._buildConnection()
                await self._writeIntoDatabase(
                    statement=SqliteClient.INSERT_INTO_API_TOKENS,
                    parameters={
                        "userId": newTokenRow.users_userId,
                        "token": newTokenRow.token,
                        "validityStart": newTokenRow.validityStart,
                        "validityEnd": newTokenRow.validityEnd,
                        "historyState": newTokenRow.historyState,
                        "isSessionToken": newTokenRow.isSessionToken,
                        "tokenName": newTokenRow.tokenName,
                    },
                )
                self._invalidateCache()

            finally:
                await self._closeConnection()

        await self._ensureLoaded()

        logger.debug(
            "Created token for user=%s tokenName=%s isSessionToken=%s",
            username,
            newTokenRow.tokenName,
            newTokenRow.isSessionToken,
        )

        # we can return the original method variable, since a failed statement would mean, we never reach this point.
        return newTokenRow.token

    async def deleteApiToken(
        self, userData: ViewUsersPasswords, isSessionToken: bool = True, tokenName: str = ""
    ) -> None:
        """
        Delete a token for the given user.

        :param userData: Password view row containing the user identity.
        :param isSessionToken: Indicates whether the token to delete is a session token.
        :param tokenName: Name of the token to delete.
        :raises sqlite3.Error: If the database delete operation fails.
        """
        async with self._dbLock:
            try:
                await self._buildConnection()
                await self._writeIntoDatabase(
                    statement=SqliteClient.DELETE_FROM_API_TOKENS,
                    parameters={"userId": userData.userId, "isSessionToken": isSessionToken, "tokenName": tokenName},
                )
                self._invalidateCache()

            finally:
                await self._closeConnection()

        await self._ensureLoaded()

        logger.debug(
            "Deleted token for user=%s tokenName=%s isSessionToken=%s", userData.userName, tokenName, isSessionToken
        )

    async def setPassword(self, userData: TablePasswords) -> None:
        """
        Set a new password for the given user.
        :param userData: Password row data containing the new password and seed.
        :type userData: TablePasswords
        :return: ``None``
        :raises sqlite3.Error: If the database update operation fails.
        """
        async with self._dbLock:
            try:
                await self._buildConnection()
                await self._writeIntoDatabase(
                    SqliteClient.UPDATE_PASSWORD,
                    {
                        "password": userData.password,
                        "seed": userData.seed,
                        "userId": userData.users_userId,
                        "passwordId": userData.passwordId,
                    },
                )
                self._invalidateCache()

            finally:
                await self._closeConnection()

        await self._ensureLoaded()

        logger.info("Updated password for users_userId=%s", userData.users_userId)

    def _invalidateCache(self) -> None:
        """Mark all cached database views as outdated."""
        self.wasLoaded = False

    async def _ensureLoaded(self) -> None:
        """
        Ensure the in-memory cache is loaded exactly once.

        Concurrent callers synchronize via ``_loadLock``.
        """
        if self.wasLoaded:
            return

        async with self._loadLock:
            if self.wasLoaded:
                return

            await self._fetchDatabase()

    async def _buildConnection(self) -> None:
        """Open the SQLite connection and create a cursor if not already open."""
        if self.connection is None and self.cursor is None:
            logger.debug("Opening SQLite connection to %s", self.databasePath)
            self.connection = sqlite3.connect(self.databasePath)
            self.connection.execute("PRAGMA foreign_keys = ON;")
            self.cursor = self.connection.cursor()

    async def _closeConnection(self) -> None:
        """Close the SQLite connection and cursor if open."""
        if self.connection is not None and self.cursor is not None:
            logger.debug("Closing SQLite connection to %s", self.databasePath)
            self.connection.close()
            self.cursor = None
            self.connection = None

    async def _fetchDatabase(self) -> None:
        """
        Fetch all database-backed views and populate the internal caches.

        :raises sqlite3.Error: If one of the underlying queries fails.
        """
        async with self._dbLock:
            try:
                await self._buildConnection()

                self._fetchUsersPasswords()
                self._fetchUsersApiTokens()
                self._fetchUsersPermissions()
                self._fetchAllJoinedColumns()

                self.wasLoaded = True
                logger.info("Database cache loaded successfully from %s", self.databasePath)

            except sqlite3.Error:
                self.wasLoaded = False
                logger.exception("Failed to populate database cache (database=%s)", self.databasePath)
                raise

            finally:
                await self._closeConnection()

    def _fetchRows(self, statement: str) -> list[tuple]:
        """
        Execute a SELECT statement and return all rows.

        :param statement: SQL query to execute.
        :return: Result rows.
        :raises sqlite3.Error: If query execution fails.
        """
        return self.cursor.execute(statement).fetchall()

    def _fetchUsersPasswords(self) -> None:
        """Fetch ``view_users_passwords`` and populate the internal password cache."""
        rows = self._fetchRows(SqliteClient.SELECT_VIEW_USERS_PASSWORDS)
        self._usersPasswords = [
            ViewUsersPasswords(userId, userName, name, surName, passwordId, password, seed)
            for userId, userName, name, surName, passwordId, password, seed in rows
        ]

    def _fetchUsersApiTokens(self) -> None:
        """Fetch ``view_users_apiTokens`` and populate the internal token cache."""
        rows = self._fetchRows(SqliteClient.SELECT_VIEW_USERS_API_TOKENS)
        self._usersApiTokens = [
            ViewUsersApiTokens(
                userId,
                userName,
                name,
                surName,
                tokenId,
                token,
                tokenName,
                validityStart,
                validityEnd,
                historyState,
                isUserToken,
            )
            for (
                userId,
                userName,
                name,
                surName,
                tokenId,
                token,
                tokenName,
                validityStart,
                validityEnd,
                historyState,
                isUserToken,
            ) in rows
        ]

    def _fetchUsersPermissions(self) -> None:
        """Fetch ``view_users_groups`` and populate the internal permissions cache."""
        rows = self._fetchRows(SqliteClient.SELECT_VIEW_USERS_PERMISSIONS)
        self._usersPermissions = [
            ViewUsersGroups(userId, userName, name, surName, permissionId, permissionName)
            for userId, userName, name, surName, permissionId, permissionName in rows
        ]

    def _fetchAllJoinedColumns(self) -> None:
        """Fetch ``view_all_data`` and populate the internal joined-columns cache."""
        rows = self._fetchRows(SqliteClient.SELECT_VIEW_ALL_JOINED_COLUMNS)
        self._allJoinedColumns = [
            ViewAllJoinedColumns(
                userId,
                userName,
                name,
                surName,
                passwordId,
                password,
                tokenId,
                token,
                tokenName,
                validityStart,
                validityEnd,
                historyState,
                isSessionToken,
                groups_groupId,
                groupName,
            )
            for (
                userId,
                userName,
                name,
                surName,
                passwordId,
                password,
                tokenId,
                token,
                tokenName,
                validityStart,
                validityEnd,
                historyState,
                isSessionToken,
                groups_groupId,
                groupName,
            ) in rows
        ]

    async def _writeIntoDatabase(self, statement: str, parameters: dict | None = None) -> None:
        """
        Execute a write statement with named parameters.

        Assumes the caller already holds ``_dbLock`` and has opened the connection.

        :param statement: SQL statement with named parameters.
        :param parameters: Parameter mapping.
        :raises sqlite3.Error: If execution fails.
        """
        try:
            self.cursor.execute(statement, parameters or {})
            self.connection.commit()
            logger.debug("Database write succeeded (statement=%s)", statement)
        except sqlite3.Error:
            if self.connection is not None:
                self.connection.rollback()
            logger.exception(
                "Database write failed (database=%s, statement=%s, parameters=%s)",
                self.databasePath,
                statement,
                parameters,
            )
            raise

    def _computeFileChecksum(self, filePath: Path) -> str:
        """Return SHA-256 checksum of a migration file."""
        return hashlib.sha256(filePath.read_bytes()).hexdigest()

    def _parseMigrationVersion(self, filePath: Path) -> int:
        """
        Extract numeric migration version from file name.
        Example: 0004_add_customer_edit_rights.sql -> 4
        """
        match = re.match(r"^(\d+)_.*\.sql$", filePath.name)
        if not match:
            raise ValueError(f"Invalid migration filename: {filePath.name}")
        return int(match.group(1))

    async def _ensureSchemaMigrationsTable(self) -> None:
        """Create ``schema_migrations`` table if it does not exist yet."""
        self.cursor.execute(SqliteClient.CREATE_SCHEMA_MIGRATIONS_TABLE)
        self.connection.commit()

    async def _getAppliedMigrationVersions(self) -> set[int]:
        """Return all already applied migration versions."""
        try:
            res = self.cursor.execute(SqliteClient.SELECT_SCHEMA_MIGRATIONS)
            return {int(row[0]) for row in res.fetchall()}
        except sqlite3.Error:
            logger.exception("Failed to query applied schema migrations")
            raise
