'''
This file contains the decorators that create the view classes for the tables
of the database.
'''

from __future__ import annotations
from typing import Literal, Callable
from pathlib import Path
from collections import namedtuple
from typing import NamedTuple
from abc import ABC
import sqlite3

_SUPPORTED_TYPES = {
    Path: "Path",
    str: "TEXT",
    int: "INTEGER",
}

class _TableConnection(NamedTuple):
    columns: tuple[str, str]
    '''The pair of collumn names (this table column, other table column).'''

    is_pk: bool
    '''Whether the connection is referencing a primary key of other table.'''

class _WeakTableConnection(NamedTuple):
    '''
    Used for type annotation in the 'dbtableview' decorator for
    weak_connects_to argument.
    '''
    this_table_column_name: str
    other_table_name: str
    other_table_column_name: str

RELATION_MAP: dict[str, dict[str, _TableConnection]] = {}
'''
The RELATION_MAP is a map of all the relations between the tables. It is
generated by the decorators while creating the database tables. It's used to
build the easy queries.
'''

WRAPPER_CLASSES: dict[str, type] = {}
'''
The WRAPPER_CLASSES maps all of the wrapper classes annotaded with the
'dbtableview' decorator to their names (the names are the keys of the dict).
It's used in the easy queries to determine which wrapper class to use as a
result of the query.
'''

def validate_weak_connections():
    '''
    Runs at the end of importing the sqlite_bedrock_packs package. It validates the
    connections between the tables. It's used to make sure that the weak
    connections are correct (which can't be checked in the decorator while they
    are being created).
    '''
    # TODO - maybe also check that the types of the columns are the same
    for this_table, v in RELATION_MAP.items():
        for other_table, v2 in v.items():
            if v2.is_pk:
                continue  # No need to check primary key connections
            if other_table not in RELATION_MAP:
                raise AssertionError(
                    f"Invalid connection: {this_table} -> {other_table}:"
                    f"The {other_table} table doesn't exist"
                )
    return True  # Returns true for the assert statement

def add_reverse_connections():
    '''
    Runs at the end of importing the sqlite_bedrock_packs package. It adds the
    reverse connections to the relation map.
    '''
    for this_table, v in RELATION_MAP.items():
        for other_table, v2 in v.items():
            if this_table not in RELATION_MAP[other_table]:
                RELATION_MAP[other_table][this_table] = _TableConnection(
                    columns=(v2.columns[1], v2.columns[0]),
                    is_pk=RELATION_MAP[this_table][other_table].is_pk
                )


class AbstractDBView(ABC):
    '''
    This abstract class is used in the PYI files as a parent of all of
    the wrapped classes.
    '''

class _DbTableView:
    '''
    Used for dbtableview decorator. For more information see the decorator
    documentation.
    '''
    def __init__(
            self,
            cls: type,
            properties: dict[str, tuple[type,Literal["NOT NULL", ""]]],
            enum_properties: dict[str, list[str]],
            connects_to: list[str],
            weak_connects_to: list[_WeakTableConnection]):
        # VALIDATE THE PARAMETERS
        # Properties
        if not isinstance(properties, dict):
            raise TypeError("The 'properties' parameter must be a dict")
        for name, value in properties.items():
            if not isinstance(name, str):
                raise TypeError(
                    "The keys of the 'properties' parameter must be strings")
            if not name.isidentifier():
                raise ValueError(
                    f"The key '{name}' of the 'properties' parameter is not a "
                    "valid identifier")
            if not isinstance(value, tuple):
                raise TypeError(
                    "The values of the 'properties' parameter must be tuples")
            if len(value) != 2:
                raise ValueError(
                    "The values of the 'properties' parameter must be tuples "
                    "of length 2")
            if not isinstance(value[0], type):
                raise TypeError(
                    "The first value of the tuples of the 'properties' "
                    "parameter must be a type")
            if value[0] not in _SUPPORTED_TYPES:
                raise ValueError(
                    f"The type {value[0]} is not supported by the decorator")
        # Enum properties
        if not isinstance(enum_properties, dict):
            raise TypeError("The 'enum_properties' parameter must be a dict")
        for name, value in enum_properties.items():
            if not isinstance(name, str):
                raise TypeError(
                    "The keys of the 'enum_properties' parameter must be "
                    "strings")
            if not name.isidentifier():
                raise ValueError(
                    f"The key '{name}' of the 'enum_properties' parameter is "
                    "not a valid identifier")
            if not isinstance(value, list):
                raise TypeError(
                    "The values of the 'enum_properties' parameter must be "
                    "lists")
            for item in value:
                if not isinstance(item, str):
                    raise TypeError(
                        "The values of the lists of the 'enum_properties' "
                        "parameter must be strings")
        # Connects to
        if not isinstance(connects_to, list):
            raise TypeError(
                "The 'connects_to' parameter must be a list of strings")
        for name in connects_to:
            if not isinstance(name, str):
                raise TypeError(
                    "The 'connects_to' parameter must be a list of strings")
            if not name.isidentifier():
                raise ValueError(
                    f"The value '{name}' of the 'connects_to' parameter is not "
                    "a valid identifier")
        # Weak connects to
        if not isinstance(weak_connects_to, list):
            raise TypeError(
                "The 'weak_connects_to' parameter must be a list of tuples")
        for item in weak_connects_to:
            if not isinstance(item, tuple):
                raise TypeError(
                    "The 'weak_connects_to' parameter must be a list of "
                    "tuples")
            if len(item) != 3:
                raise ValueError(
                    "The tuples of the 'weak_connects_to' parameter must be "
                    "of length 3")
            if not isinstance(item[0], str):
                raise TypeError(
                    "The first value of the tuples of the 'weak_connects_to' "
                    "parameter must be a string")
            if not item[0].isidentifier():
                raise ValueError(
                    f"The first value '{item[0]}' of the tuples of the "
                    "'weak_connects_to' parameter is not a valid identifier")
            if not isinstance(item[1], str):
                raise TypeError(
                    "The second value of the tuples of the "
                    "'weak_connects_to' parameter must be a string")
            if not item[1].isidentifier():
                raise ValueError(
                    f"The second value '{item[1]}' of the tuples of the "
                    "'weak_connects_to' parameter is not a valid identifier")
            if not isinstance(item[2], str):
                raise TypeError(
                    "The third value of the tuples of the "
                    "'weak_connects_to' parameter must be a string")
            if not item[2].isidentifier():
                raise ValueError(
                    f"The third value '{item[2]}' of the tuples of the "
                    "'weak_connects_to' parameter is not a valid identifier")
        # SAVE THE PARAMETERS
        self.cls = cls
        self.properties = properties
        self.enum_properties = enum_properties
        self.connects_to = connects_to
        # REGISTER THE CLASS IN THE RELATION MAP
        if self.cls.__name__ in RELATION_MAP:
            raise ValueError(
                f"The class {self.cls.__name__} is already registered in the "
                "relation map")
        this_table = {}
        RELATION_MAP[self.cls.__name__] = this_table
        # Add the connections to the relation map
        for name in self.connects_to:
            if name not in RELATION_MAP:
                raise ValueError(
                    f"Trying to connect {self.cls.__name__} to {name} but "
                    f"{name} is not registered in the relation map."
                )
            this_table[name] = _TableConnection(
                columns=(f"{name}_fk", f"{name}_pk"),
                is_pk=True
            )
        # Add the weak connections to the relation map
        for this_table_column, other_table, other_table_column in (
                weak_connects_to):
            this_table[other_table] = _TableConnection(
                columns=(this_table_column, other_table_column),
                is_pk=False
            )

    def __call__(self) -> type:
        # BUILD THE SCRIPT
        build_script_lines: list[str] = []
        # If there are enum properties, build the enum tables
        for name, values in self.enum_properties.items():
            table_name = f"{self.cls.__name__}{name.capitalize()}Enum"
            build_script_lines.extend([
                f"CREATE TABLE {table_name} (",
                f"    value TEXT PRIMARY KEY",
                f");"
            ])
            for value in values:
                build_script_lines.append(
                    f"INSERT INTO {table_name} (value) VALUES ('{value}');")
        # Start defining the main table
        build_script_lines.append(f"CREATE TABLE {self.cls.__name__} (")
        # Internal lines of the class are later indented and connected with commans
        build_class_internal: list[str] = []
        # Primary key
        build_class_internal.append(
            f"{self.cls.__name__}_pk INTEGER PRIMARY KEY AUTOINCREMENT")
        # Foreign keys definitions
        for table_name in self.connects_to:
            build_class_internal.append(f"{table_name}_fk INTEGER NOT NULL")
        # Enum properties definitions
        for property_name in self.enum_properties:
            build_class_internal.append(f"{property_name} TEXT NOT NULL")
        # The rest of the properties
        for property_name, (type_, nullable) in self.properties.items():
            build_class_internal.append(
                f"{property_name} {_SUPPORTED_TYPES[type_]} "
                f"{nullable}".rstrip())
        # Mark the foreign keys properties as foreign keys
        for table_name in self.connects_to:
            build_class_internal.append(
                f"FOREIGN KEY ({table_name}_fk) "
                f"REFERENCES {table_name} ({table_name}_pk) "
                "ON DELETE CASCADE")
        # Mark the enum properties as foreign keys
        for property_name in self.enum_properties:
            table_name = f"{self.cls.__name__}{property_name.capitalize()}Enum"
            build_class_internal.append(
                f"FOREIGN KEY ({property_name}) "
                f"REFERENCES {table_name} (value)")
        # Build the script
        build_script_lines.append(
            ",\n".join("    " + line for line in build_class_internal))
        build_script_lines.append(");")
        # Add the index lines on the foreign keys
        for name in self.connects_to:
            build_script_lines.append(
                f"CREATE INDEX {self.cls.__name__}_{name}_fk "
                f"ON {self.cls.__name__} ({name}_fk);")
        build_script = "\n".join(build_script_lines) + "\n"

        # BUILD THE CLASS
        result = namedtuple(
            self.cls.__name__,
            ["connection", "id"],
            module="sqlite_bedrock_packs.views"
        )
        # Add the attributes of the original class
        for name, value in list(self.cls.__dict__.items()):
            if name.startswith("__") and name.endswith("__"):
                continue
            setattr(result, name, value)


        # Add the 'query_result' method
        _query_result_columns = ", ".join(
            [f"{name}_fk" for name in self.connects_to] +
            [name for name in self.properties]
        )
        _qr_cache = [None]
        def query_result(self_):
            if _qr_cache[0] is None:
                _qr_cache[0] = self_.connection.execute(
                    f"SELECT {_query_result_columns} "
                    f"FROM {self.cls.__name__} "
                    f"WHERE {self.cls.__name__}_pk = ?",
                    (self_.id,)
                ).fetchone()
            return _qr_cache[0]
        result.query_result = query_result
        # Add the @property decorated property methods
        for i, name in enumerate(self.connects_to):
            @property
            def property_method(self, i=i) -> int:
                return self.query_result()[i]
            setattr(result, f"{name}_fk", property_method)
        for i, (name, (type_, _)) in enumerate(
                self.properties.items(),
                start=len(self.connects_to)):
            @property
            def property_method(self, i=i)  -> type_:
                return self.query_result()[i]
            setattr(result, name, property_method)

        # Add the script to the class
        result.build_script = build_script
        # ADD ANNOTATIONS TO THE CLASS
        # Add the annotations of the original class
        result.__annotations__ = self.cls.__annotations__
        # Add the annotations of the properties
        for name, (type_, _) in self.properties.items():
            result.__annotations__[name] = type_
        # Add the annotations of the foreign keys
        for name in self.connects_to:
            result.__annotations__[f"{name}_fk"] = int
        # Add the annotations of the enum properties
        for name in self.enum_properties:
            result.__annotations__[name] = str
        # Add the annotations of the id and connection
        result.__annotations__["id"] = int
        result.__annotations__["connection"] = sqlite3.Connection
        # Add the annotations of the query_result method
        result.__annotations__["query_result"] = Callable[[], tuple]

        # REGISTER IN THE WRAPPER_CLASSES MAP
        WRAPPER_CLASSES[self.cls.__name__] = result

        return result

def dbtableview(
        properties: dict[str, tuple[type, Literal["NOT NULL", ""]]] = None,
        enum_properties: dict[str, list[str]] = None,
        connects_to: list[str] = None,
        weak_connects_to: list[_WeakTableConnection] = None,
):
    '''
    The "dbtableview" is a decorator that turns a class into a read-only view
    of a row in a table in the database. It also registers the class in the
    relation map and adds some attributes to the class.

    This decorator uses additional parameters, so in order to use it, you have
    to use the parentesis even if you don't pass any values, e.g.:

    .. code-block:: python
        @dbtable()
        class MyClassName: ...

    The decorator adds following properties properties:

    The constructor method takes 2 parameters - the Connection object to the
    database, and the ID of the row in the table.
    
    Other parameters are not stored directly in the class and are accessed by
    querying the database. Accessing any of the properties runs a query, which
    gets all of the properties of the row at once. The results are cached to
    provide the data to any subsequent calls to the properties. Note that the
    cache is not invalidated if the data in the database changes.

    Running the query without accessing the properties can be forced by calling
    the 'query_result' method.

    The class has a class attribute called 'build_script' that contains the SQL
    script to create the table.

    :param properties: A dictionary that defines the properties of the table.
        The keys are the names of the properties to be added to the class and
        SQL table. The values define the type of the property and whether it is
        nullable. The first value is the type, the second value is either
        "NOT NULL" or an empty string.
    :param enum_properties: A dictionary that defines the properties of the
        table that are enums. In the database, the enums are stored as separate
        tables that use their values as primary keys. The creation and
        population of the enum tables is added at the top of the
        'build_script'. The table that references the enum table has a property
        with a constraint that it can only contain values from the enum table.
    :param connects_to: A list of names of the other previously defined tables
        that this table has a foreign key to. The names of the foreign keys are
        generated by the name of the table and the suffix '_fk'. If the 
        referenced table is not defined yet, the decorator will raise an
        exception.
    :param weak_connects_to: A list of connections to the other tables in the
        database. The tables don't need to be defined at the time of the
        decorator call. The weak connections don't have any effect on the
        database schema, but they are used to generate the "easy queries".
        Unlike the "connects_to" connections, the weak connections can connect
        any row in the table to any other row in other table (it's not just
        a foreign key-primary key connection).
    '''
    if properties is None:
        properties = {}
    if enum_properties is None:
        enum_properties = {}
    if connects_to is None:
        connects_to = []
    if weak_connects_to is None:
        weak_connects_to = []
    def decorator(cls: type):
        return _DbTableView(
            cls, properties, enum_properties, connects_to, weak_connects_to)()
    return decorator
