import sqlite3
import json

from jinja2 import Template

from ploomber.products import Product
from ploomber.products.serializers import Base64Serializer
from ploomber.placeholders.Placeholder import SQLRelationPlaceholder


class ProductWithClientMixin:
    """
    Adds the client property to a Product with the hierarchical resolution
    logic: Product -> Task -> DAG.clients. Product.client is only used
    for storing metadata
    """
    @property
    def client(self):
        # FIXME: this nested reference looks ugly, how can we improve this?
        if self._client is None:
            if self._task is None:
                raise ValueError('Cannot obtain client for this product, '
                                 'the constructor did not receive a client '
                                 'and this product has not been assigned '
                                 'to a DAG yet, hence we cannot look up '
                                 'dag.clients')

            default = self.task.dag.clients.get(type(self))

            if default is None:
                raise ValueError('{} must be initialized with a client'
                                 .format(type(self).__name__))
            else:
                self._client = default

        return self._client


class SQLiteBackedProductMixin(ProductWithClientMixin):
    def _create_metadata_relation(self):
        create_metadata = """
        CREATE TABLE IF NOT EXISTS _metadata (
            name TEXT NOT NULL,
            schema TEXT,
            metadata BLOB,
            UNIQUE(schema, name)
        )
        """
        self.client.execute(create_metadata)

    def _get_schema(self):
        # the actual implementation can be a simple identifier or a sql
        # identifier
        if hasattr(self, 'schema'):
            # there is a schema (can be None)
            return self.schema
        else:
            # this means there is no schema
            return False

    def fetch_metadata(self):
        self._create_metadata_relation()

        query = Template("""
        SELECT metadata FROM _metadata
        WHERE name = '{{name}}'
        {% if schema is not sameas false %}
            {% if schema %}
                AND schema = '{{schema}}'
            {% else %}
                AND schema IS NULL
            {% endif %}
        {% endif %}
        """).render(name=self.name, schema=self._get_schema())

        cur = self.client.connection.cursor()
        cur.execute(query)
        records = cur.fetchone()
        cur.close()

        if records:
            metadata_bin = records[0]
            return json.loads(metadata_bin.decode("utf-8"))
        else:
            return None

    def save_metadata(self, metadata):
        self._create_metadata_relation()

        metadata_bin = json.dumps(metadata).encode('utf-8')
        cur = self.client.connection.cursor()
        schema = self._get_schema()

        if schema is not False:
            if schema is None:
                cur.execute('DELETE FROM _metadata WHERE name = ?',
                            (self.name,))
            else:
                cur.execute('DELETE FROM _metadata '
                            'WHERE name = ? AND schema = ?',
                            (self.name, schema))

            # we cannot rely on INSERT INTO since NULL schema values are
            # allowed
            query = """
                INSERT INTO _metadata (metadata, name, schema)
                VALUES(?, ?, ?)
            """
            cur.execute(query, (sqlite3.Binary(metadata_bin),
                                self.name,
                                schema))
        else:
            query = """
                REPLACE INTO _metadata(metadata, name)
                VALUES(?, ?)
            """
            cur.execute(query, (sqlite3.Binary(metadata_bin),
                                self.name))

        self.client.connection.commit()
        cur.close()

    def _delete_metadata(self):
        self._create_metadata_relation()

        query = Template("""
        DELETE FROM _metadata
        WHERE name = '{{name}}'
        {% if schema is not sameas false %}
            {% if schema %}
                AND schema = '{{schema}}'
            {% else %}
                AND schema IS NULL
            {% endif %}
        {% endif %}
        """).render(name=self.name, schema=self._get_schema())

        cur = self.client.connection.cursor()
        cur.execute(query)
        cur.close()


class SQLiteRelation(SQLiteBackedProductMixin, Product):
    """A SQLite relation

    Parameters
    ----------
    identifier: tuple of length 3 or 2
        A tuple with (schema, name, kind) where kind must be either 'table'
        or 'view'. If passed a tuple with length 2, schema is assumed None.
        Schemas in SQLite represent other databases when using the ATTACH
        command.
    client: ploomber.clients.DBAPIClient or SQLAlchemyClient, optional
        The client used to connect to the database. Only required
        if no dag-level client has been declared using dag.clients[class]


    Examples
    --------
    >>> from ploomber.products import SQLiteRelation
    >>> relation = SQLiteRelation(('schema', 'some_table', 'table'))
    >>> str(relation) # returns qualified name
    """

    def __init__(self, identifier, client=None):
        super().__init__(identifier)
        self._client = client

    def _init_identifier(self, identifier):
        return SQLRelationPlaceholder(identifier)

    def exists(self):
        query = """
        SELECT name
        FROM sqlite_master
        WHERE type = '{kind}'
        AND name = '{name}'
        """.format(kind=self.kind,
                   name=self.name)

        cur = self.client.connection.cursor()
        cur.execute(query)
        exists = cur.fetchone() is not None
        cur.close()
        return exists

    def delete(self):
        """Deletes the product
        """
        query = ("DROP {kind} IF EXISTS {relation}"
                 .format(kind=self.kind,
                         relation=str(self)))
        self.logger.debug('Running "{query}" on the databse...'
                          .format(query=query))
        self.client.execute(query)

    @property
    def name(self):
        return self._identifier.name

    @property
    def schema(self):
        return self._identifier.schema

    @property
    def kind(self):
        return self._identifier.kind


# FIXME: self._identifier should not be accessed direclty since it might
# be a placeholder
class PostgresRelation(ProductWithClientMixin, Product):
    """A PostgreSQL relation

    Parameters
    ----------
    identifier: tuple of length 3
        A tuple with (schema, name, kind) where kind must be either 'table'
        or 'view'
    client: ploomber.clients.DBAPIClient or SQLAlchemyClient, optional
        The client used to connect to the database. Only required
        if no dag-level client has been declared using dag.clients[class]

    Examples
    --------
    >>> from ploomber.products import PostgresRelation
    >>> relation = PostgresRelation(('schema', 'some_table', 'table'))
    >>> str(relation) # returns qualified name
    """
    # FIXME: identifier has schema as optional but that introduces ambiguity
    # when fetching metadata and checking if the table exists so maybe it
    # should be required

    def __init__(self, identifier, client=None):
        self._client = client
        super().__init__(identifier)

    def _init_identifier(self, identifier):
        return SQLRelationPlaceholder(identifier)

    def fetch_metadata(self):
        cur = self.client.connection.cursor()

        if self.schema:
            schema = self.schema
        else:
            # if schema is empty, we have to find out the default one
            query = """
            SELECT current_schema()
            """
            cur.execute(query)
            schema = cur.fetchone()[0]

        # https://stackoverflow.com/a/11494353/709975
        query = """
        SELECT description
        FROM pg_description
        JOIN pg_class ON pg_description.objoid = pg_class.oid
        JOIN pg_namespace ON pg_class.relnamespace = pg_namespace.oid
        WHERE nspname = %(schema)s
        AND relname = %(name)s
        """
        cur.execute(query, dict(schema=schema, name=self.name))
        metadata = cur.fetchone()

        cur.close()

        # no metadata saved
        if metadata is None:
            return None
        else:
            return Base64Serializer.deserialize(metadata[0])

        # TODO: also check if metadata  does not give any parsing errors,
        # if yes, also return a dict with None values, and maybe emit a warn

    def save_metadata(self, metadata):
        metadata = Base64Serializer.serialize(metadata)

        query = (("COMMENT ON {} {} IS '{}';"
                  .format(self.kind,
                          self._identifier,
                          metadata)))

        cur = self.client.connection.cursor()
        cur.execute(query)
        self.client.connection.commit()
        cur.close()

    def exists(self):
        cur = self.client.connection.cursor()

        if self.schema:
            schema = self.schema
        else:
            # if schema is empty, we have to find out the default one
            query = """
            SELECT current_schema()
            """
            cur.execute(query)
            schema = cur.fetchone()[0]

        # https://stackoverflow.com/a/24089729/709975
        query = """
        SELECT EXISTS (
            SELECT 1
            FROM   pg_catalog.pg_class c
            JOIN   pg_catalog.pg_namespace n ON n.oid = c.relnamespace
            WHERE  n.nspname = %(schema)s
            AND    c.relname = %(name)s
        );
        """

        cur.execute(query, dict(schema=schema,
                                name=self.name))
        exists = cur.fetchone()[0]
        cur.close()
        return exists

    def delete(self, force=False):
        """Deletes the product
        """
        cascade = 'CASCADE' if force else ''
        query = ("DROP {} IF EXISTS {} {}"
                 .format(self.kind, self, cascade))
        self.logger.debug('Running "%s" on the databse...', query)

        cur = self.client.connection.cursor()
        cur.execute(query)
        cur.close()
        self.client.connection.commit()

    @property
    def name(self):
        return self._identifier.name

    @property
    def schema(self):
        return self._identifier.schema

    @property
    def kind(self):
        return self._identifier.kind
