import asyncio
from string.templatelib import Template
from typing import Any, List
from psycopg_pool import AsyncConnectionPool
from psycopg import AsyncConnection, sql
from nc_db.metadata import nc_config
from nc_db.metadata.class_metadata import ClassMetadata
from nc_db.handlers.common import table_exists, get_config_version

from nc_db.identifiable import Identifiable

# The number doesn't really matter -- we just need to ensure every instance of the
# class uses the same lock number.
DB_CONFIG_LOCK = 100
"""
A pg advisory lock used to guarantee only one process creates the config tables.
"""


class CommandHandler():
    """
    Handles executing commands against the database
    """

    def __init__(self, connection_pool: AsyncConnectionPool):
        self._pool = connection_pool

    @classmethod
    async def set_search_path(cls, schema: str, conn: AsyncConnection[Any]):
        # autopep8: off
        await conn.execute(t"SET search_path TO {schema:i}, public")
        # autopep8: on
        await conn.commit()

    async def ensure_config_tables(self):
        """
        Ensures the configuration tables exist in the database.
        If the tables exist it pulls the version number from the database.
        If the tables do not exist it creates them and adds the version into the table.

        Returns: True if the version in the config table matches the nc-db library version in nc_config.py

        :param self: Description
        """

        async with self._pool.connection() as conn:
            async with conn.cursor() as cursor:
                # autopep8: off
                await cursor.execute(t"SELECT pg_advisory_xact_lock({DB_CONFIG_LOCK})")
                # autopep8: on
                if not await table_exists(cursor, nc_config.VERSION_TABLE):

                    # autopep8: off
                    # 1. Create the version table
                    create_version_table = t"""
                        CREATE TABLE {nc_config.VERSION_TABLE:i} (
                            version INT PRIMARY KEY
                        );
                        """

                    # 2. Insert the version
                    insert_version = t"INSERT INTO {nc_config.VERSION_TABLE:i} VALUES ({nc_config.VERSION});"

                    # 3. Create the schema_config table to make sure class schemas don't change without the
                    #       schema version number being updated.
                    create_registration_table = t"""
                        CREATE TABLE {nc_config.CLASS_REGISTRATION_TABLE:i} (
                            name TEXT PRIMARY KEY,
                            signature TEXT
                        );
                        """
                    # autopep8: on
                    await cursor.execute(create_version_table)
                    await cursor.execute(insert_version)
                    await cursor.execute(create_registration_table)
                db_version = await get_config_version(cursor, nc_config.VERSION_TABLE)
                if (db_version != nc_config.VERSION):
                    raise RuntimeError("It looks like this database was used with an older version of nc-db (version mismatch)")

    async def ensure_class_table(self, metadata: ClassMetadata) -> None:
        """
        A thread-safe function that creates a table for storing objects of the class defined by metadata if
        the table doesn't already exist.

        :param metadata: Metdata representing a class whose instances will be stored in the database.
        """
        async with self._pool.connection() as conn:
            async with conn.cursor() as cursor:
                # Get the table name for this class
                table_name = metadata.table_name
                # autopep8: off
                # To ensure there are no issue with concurrency, we get a transaction-based advisory lock so 
                # only one process / task can be in this section at a time.
                advisory_lock = t"SELECT pg_advisory_xact_lock(hashtext({metadata.table_name}))"
                await cursor.execute(advisory_lock)
                if not await table_exists(cursor,metadata.table_name):
                    # 1. Create a table to store instances of this class
                    columns = [t"{col.name:i} {Template(col.column_definition):q}" 
                                for col in 
                                metadata.columns
                                ]
                    column_definitions = sql.SQL(",").join(columns)
                    create_table=t"""
                        CREATE TABLE {table_name:i} ({column_definitions:q});
                        """
                    await cursor.execute(create_table)
                    create_indexes = [
                        t"CREATE INDEX {f'{col.name}_index':i} on {metadata.table_name:i} ({col.name:i});"
                        for col in metadata.columns
                        if col.index and not col.primary_key
                    ]
                    index_tasks = [ cursor.execute(index) for index in create_indexes]
                    await asyncio.gather(*index_tasks)

                    # 2. Create an entry in the class registration table with this class signature.
                    insert_signature = t"INSERT INTO {nc_config.CLASS_REGISTRATION_TABLE:i} VALUES ({metadata.table_name}, {metadata.signature});"
                    await cursor.execute(insert_signature)
                    # autopep8: on

    async def insert(self, metadata: ClassMetadata, obj: Identifiable) -> int:
        """
        Saves the object to the database, updates the id attribute with the new id, and returns the id.
        """
        async with self._pool.connection() as conn:
            async with conn.cursor() as cursor:
                # autopep8: off
                # Create SQL for all columns
                obj_dict = obj.model_dump()
                # The following call updates the obj_dict (e.g. removes data stored in columns) and gets a list of db updates
                db_updates = [col.object_to_db_update(obj_dict) for col in metadata.columns]
                columns = [t"{db_update.column_name:i}" for db_update in db_updates if db_update]
                values = [t"{db_update.value}" for db_update in db_updates if db_update is not None]
                columns_sql = sql.SQL(",").join(columns)
                values_sql = sql.SQL(",").join(values)
                insert_cmd = t"""INSERT INTO {metadata.table_name:i} ({columns_sql:q}) VALUES (
                    {values_sql:q}
                )  RETURNING id;"""

                # autopep8: on
                await cursor.execute(insert_cmd)
                row = await cursor.fetchone()
                if row:
                    id: int = row[0]
                    obj.id = id
                    return id
                else:
                    raise RuntimeError(
                        f"Could not insert object of type {metadata.class_name}. The database did not return a new id.")

    async def update(self, metadata: ClassMetadata, obj: Identifiable):
        async with self._pool.connection() as conn:
            async with conn.cursor() as cursor:

                # autopep8: off
                # Create SQL to set values for all columns
                obj_dict = obj.model_dump()
                db_updates = [col.object_to_db_update(obj_dict) for col in metadata.columns]
                set_cmds = [t"{db_update.column_name:i}={db_update.value}" for db_update in db_updates if db_update ]
                set_sql = sql.SQL(",").join(set_cmds)
                update_cmd = t"UPDATE {metadata.table_name:i} SET {set_sql:q} WHERE id={obj.id};"

                # autopep8: on
                await cursor.execute(update_cmd)

    async def delete(self, metadata: ClassMetadata, ids: List[int]) -> List[int]:
        async with self._pool.connection() as conn:
            async with conn.cursor() as cursor:
                # autopep8: off
                delete_cmd=t"DELETE FROM {metadata.table_name:i} WHERE id=ANY({ids}) RETURNING id;"
                # autopep8: on
                await cursor.execute(delete_cmd)
                results = await cursor.fetchall()
                return [result[0] for result in results]
