# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0.  If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
#
# Copyright 1997 - July 2008 CWI, August 2008 - 2019 MonetDB B.V.
from itertools import repeat

from monetdblite.exceptions import ProgrammingError
from monetdblite import embeddedmonetdb
from monetdblite import monetize
import numpy


class Cursor(object):
    """This object represents a database cursor, which is used to manage
    the context of a fetch operation. Cursors created from the same
    connection are not isolated, i.e., any changes done to the
    database by a cursor are immediately visible by the other
    cursors"""

    def __init__(self, connection):
        """This read-only attribute return a reference to the Connection
        object on which the cursor was created."""
        self.connection = connection

        # last executed operation (query)
        self.operation = ""

        # This read/write attribute specifies the number of rows to
        # fetch at a time with .fetchmany()
        self.arraysize = connection.replysize

        # This read-only attribute specifies the number of rows that
        # the last .execute*() produced (for DQL statements like
        # 'select') or affected (for DML statements like 'update' or
        # 'insert').
        #
        # The attribute is -1 in case no .execute*() has been
        # performed on the cursor or the rowcount of the last
        # operation is cannot be determined by the interface.
        self.rowcount = -1

        # This read-only attribute is a sequence of 7-item
        # sequences.
        #
        # Each of these sequences contains information describing
        # one result column:
        #
        #   (name,
        #    type_code,
        #    display_size,
        #    internal_size,
        #    precision,
        #    scale,
        #    null_ok)
        #
        # This attribute will be None for operations that
        # do not return rows or if the cursor has not had an
        # operation invoked via the .execute*() method yet.
        self.description = None

        # This read-only attribute indicates at which row
        # we currently are
        self.rownumber = -1

        self.__executed = None

        # the offset of the current resultset in the total resultset
        self.__offset = 0

        # the resultsets
        self.__results = []

        # This is a Python list object to which the interface appends
        # tuples (exception class, exception value) for all messages
        # which the interfaces receives from the underlying database for
        # this cursor.
        #
        # The list is cleared by all standard cursor methods calls (prior
        # to executing the call) except for the .fetch*() calls
        # automatically to avoid excessive memory usage and can also be
        # cleared by executing "del cursor.messages[:]".
        #
        # All error and warning messages generated by the database are
        # placed into this list, so checking the list allows the user to
        # verify correct operation of the method calls.
        self.messages = []

        # This read-only attribute provides the rowid of the last
        # modified row (most databases return a rowid only when a single
        # INSERT operation is performed). If the operation does not set
        # a rowid or if the database does not support rowids, this
        # attribute should be set to None.
        #
        # The semantics of .lastrowid are undefined in case the last
        # executed statement modified more than one row, e.g. when
        # using INSERT with .executemany().
        self.lastrowid = None

    def __check_executed(self):
        if not self.__executed:
            self.__exception_handler(ProgrammingError, "do a execute() first")

    def close(self):
        """ Close the cursor now (rather than whenever __del__ is
        called).  The cursor will be unusable from this point
        forward; an Error (or subclass) exception will be raised
        if any operation is attempted with the cursor."""
        if self.connection is not None:
            self.connection.remove_cursor(self)
            self.connection = None

    def execute(self, operation, parameters=None, discard_previous=True):
        """Prepare and execute a database operation (query or
        command).  Parameters may be provided as mapping and
        will be bound to variables in the operation.
        """
        if not self.connection:
            self.__exception_handler(ProgrammingError, "cursor is closed")

        # clear message history
        self.messages = []

        # convert to utf-8
        operation = operation

        self.operation = operation

        query = ""
        if parameters:
            if isinstance(parameters, dict):
                query = operation % dict([(k, monetize.convert(v))
                                         for (k, v) in parameters.items()])
            elif type(parameters) == list or type(parameters) == tuple:
                query = operation % tuple([monetize.convert(item) for item
                                           in parameters])
            elif isinstance(parameters, str):
                query = operation % monetize.convert(parameters)
            else:
                msg = "Parameters should be None, dict or list, now it is %s"
                self.__exception_handler(ValueError, msg % type(parameters))
        else:
            query = operation

        if discard_previous:
            self.__results = []

        query = embeddedmonetdb.utf8_encode(query)
        result = self.connection.execute(query)
        if result is None or isinstance(type(result), dict) or len(result) == 0:
            result_set_length = 0
        else:
            result_set_length = len(result[list(result.keys())[0]])

        if result:
            keys, dtypes = zip(*((k, v.dtype) for k, v in result.items()))
            # description fields: name, type_code, display_size, internal_size, precision, scale, null_ok
            self.description = list(zip(keys, dtypes, repeat(None), repeat(None), repeat(None), repeat(None), repeat(None)))
            self.__results.append([keys, result, result_set_length])
        else:
            self.description = None

        self.rowcount = result_set_length
        self.rownumber = 0
        self.__executed = operation
        return self.rowcount

    def executemany(self, operation, seq_of_parameters):
        """Prepare a database operation (query or command) and then
        execute it against all parameter sequences or mappings
        found in the sequence seq_of_parameters.
        It will return the number or rows affected
        """

        count = 0
        for parameters in seq_of_parameters:
            count += self.execute(operation, parameters, False)
        self.rowcount = count
        return count

    def fetchone(self):
        """Fetch the next row of a query result set, returning a
        single sequence, or None when no more data is available."""

        self.__check_executed()

        if self.rownumber >= self.rowcount:
            return None

        if self.rownumber >= (self.__offset + self.__results[0][2]):
            self.nextset()

        rownumber = self.rownumber - self.__offset
        result = [self.__results[0][1][key][rownumber] for key in self.__results[0][0]]
        result = [x if x is not numpy.ma.masked else None for x in result]
        self.rownumber += 1
        return result

    def fetchmany(self, size=None):
        """Fetch the next set of rows of a query result, returning a
        sequence of sequences (e.g. a list of tuples). An empty
        sequence is returned when no more rows are available.
        The number of rows to fetch per call is specified by the
        parameter.  If it is not given, the cursor's arraysize
        determines the number of rows to be fetched. The method
        should try to fetch as many rows as indicated by the size
        parameter. If this is not possible due to the specified
        number of rows not being available, fewer rows may be
        returned.
        An Error (or subclass) exception is raised if the previous
        call to .execute*() did not produce any result set or no
        call was issued yet.
        Note there are performance considerations involved with
        the size parameter.  For optimal performance, it is
        usually best to use the arraysize attribute.  If the size
        parameter is used, then it is best for it to retain the
        same value from one .fetchmany() call to the next."""

        self.__check_executed()

        if size is None:
            size = self.arraysize

        if self.rownumber >= self.rowcount:
            return []

        result = []
        for i in range(size):
            row = self.fetchone()
            if row is None:
                break
            result.append(row)
        return result

    def fetchall(self):
        """Fetch all (remaining) rows of a query result, returning
        them as a sequence of sequences (e.g. a list of tuples).
        Note that the cursor's arraysize attribute can affect the
        performance of this operation.
        An Error (or subclass) exception is raised if the previous
        call to .execute*() did not produce any result set or no
        call was issued yet."""

        self.__check_executed()

        result = []
        while True:
            row = self.fetchone()
            if row is None:
                break
            result.append(row)
        return result

    def fetchnumpy(self):
        """Fetches the current result set as a dictionary of NumPy
        arrays. This is the most efficient fetch function as this
        is the way the connector actually stores the result, so
        no conversion to Python objects is necessary.
        """
        self.__check_executed()

        if len(self.__results) == 0:
            return None

        result = self.__results[0][1]
        del self.__results[0]
        return result

    def fetchdf(self):
        """Fetches the current result set as a Pandas DataFrame.
        """
        import pandas
        result = self.fetchnumpy()
        if result is None:
            return None
        return pandas.DataFrame.from_dict(result)

    def insert(self, table, values, schema=None):
        """Inserts a set of values into the specified table. The values must
           be either a pandas dataframe or a dictionary of values. If no schema
           is specified, the "sys" schema is used. If no client context is
           provided, the default client context is used. """
        if not self.connection:
            self.__exception_handler(ProgrammingError, "cursor is closed")
        return embeddedmonetdb.insert(table, values, schema=schema, client=self.connection.get_connection())

    def create(self, table, values, schema=None):
        """Creates a table from a set of values or a pandas DataFrame."""
        if not self.connection:
            self.__exception_handler(ProgrammingError, "cursor is closed")
        return embeddedmonetdb.create(table, values, schema=schema, client=self.connection.get_connection())

    def commit(self):
        """Commits the current transaction."""
        if not self.connection:
            self.__exception_handler(ProgrammingError, "cursor is closed")
        self.connection.commit()

    def rollback(self):
        """Rollbacks the current transaction."""
        if not self.connection:
            self.__exception_handler(ProgrammingError, "cursor is closed")
        self.connection.rollback()

    def transaction(self):
        """Starts a transaction. Non-Standard."""
        if not self.connection:
            self.__exception_handler(ProgrammingError, "cursor is closed")
        self.connection.transaction()

    def nextset(self):
        """This method will make the cursor skip to the next
        available set, discarding any remaining rows from the
        current set.
        If there are no more sets, the method returns
        None. Otherwise, it returns a true value and subsequent
        calls to the fetch methods will return rows from the next
        result set.
        An Error (or subclass) exception is raised if the previous
        call to .execute*() did not produce any result set or no
        call was issued yet."""

        self.__check_executed()

        if self.rownumber >= self.rowcount:
            return False

        self.__offset += self.__results[0][2]
        del self.__results[0]
        return True

    def setinputsizes(self, sizes):
        """
        This method would be used before the .execute*() method
        is invoked to reserve memory. This implementation doesn't
        use this.
        """
        pass

    def setoutputsize(self, size, column=None):
        """
        Set a column buffer size for fetches of large columns
        This implementation doesn't use this
        """
        pass

    def __iter__(self):
        return self

    def __next__(self):
        row = self.fetchone()
        if not row:
            raise StopIteration
        return row

    def scroll(self, value, mode='relative'):
        """Scroll the cursor in the result set to a new position according
        to mode.
        If mode is 'relative' (default), value is taken as offset to
        the current position in the result set, if set to 'absolute',
        value states an absolute target position.
        An IndexError is raised in case a scroll operation would
        leave the result set.
        """
        self.__check_executed()

        if mode not in ['relative', 'absolute']:
            msg = "unknown mode '%s'" % mode
            self.__exception_handler(ProgrammingError, msg)

        if mode == 'relative':
            value += self.rownumber

        if value > self.rowcount:
            self.__exception_handler(IndexError,
                                     "value beyond length of resultset")
        self.__offset = value

    def __exception_handler(self, exception_class, message):
        """ raises the exception specified by exception, and add the error
        to the message list """
        self.messages.append((exception_class, message))
        raise exception_class(message)
