# Copyright 2024 Nick Hao
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import datetime
import sys
import time
from decimal import Decimal
from typing import Dict
from aiophoenixdb.avatica.proto import common_pb


__all__ = [
    'to_date', 'to_time', 'to_timestamp', 'to_date_from_ticks', 'to_time_from_ticks', 'to_timestamp_from_ticks',
    'to_binary', 'STRING', 'BINARY', 'NUMBER', 'DATETIME', 'ROWID', 'BOOLEAN',
    'TypeHelper',
]


def to_date(year, month, day):
    """Constructs an object holding a date value."""
    return datetime.date(year, month, day)


def to_time(hour, minute, second):
    """Constructs an object holding a time value."""
    return datetime.time(hour, minute, second)


def to_timestamp(year, month, day, hour, minute, second):
    """Constructs an object holding a datetime/timestamp value."""
    return datetime.datetime(year, month, day, hour, minute, second)


def to_date_from_ticks(ticks):
    """Constructs an object holding a date value from the given UNIX timestamp."""
    return to_date(*time.localtime(ticks)[:3])


def to_time_from_ticks(ticks):
    """Constructs an object holding a time value from the given UNIX timestamp."""
    return to_time(*time.localtime(ticks)[3:6])


def to_timestamp_from_ticks(ticks):
    """Constructs an object holding a datetime/timestamp value from the given UNIX timestamp."""
    return to_timestamp(*time.localtime(ticks)[:6])


def to_binary(value):
    """Constructs an object capable of holding a binary (long) string value."""
    return bytes(value)


def time_from_java_sql_time(n):
    dt = datetime.datetime(1970, 1, 1) + datetime.timedelta(milliseconds=n)
    return dt.time()


def time_to_java_sql_time(t):
    return ((t.hour * 60 + t.minute) * 60 + t.second) * 1000 + t.microsecond // 1000


def date_from_java_sql_date(n):
    return datetime.date(1970, 1, 1) + datetime.timedelta(days=n)


def date_to_java_sql_date(d):
    if isinstance(d, datetime.datetime):
        d = d.date()
    td = d - datetime.date(1970, 1, 1)
    return td.days


def datetime_from_java_sql_timestamp(n):
    return datetime.datetime(1970, 1, 1) + datetime.timedelta(milliseconds=n)


def datetime_to_java_sql_timestamp(d):
    td = d - datetime.datetime(1970, 1, 1)
    return td.microseconds // 1000 + (td.seconds + td.days * 24 * 3600) * 1000


# FIXME This doesn't seem to be used anywhere in the code
class ColumnType(object):

    def __init__(self, eq_types):
        self.eq_types = tuple(eq_types)
        self.eq_types_set = set(eq_types)

    def __eq__(self, other):
        return other in self.eq_types_set

    def __cmp__(self, other):
        if other in self.eq_types_set:
            return 0
        if other < self.eq_types:
            return 1
        else:
            return -1


STRING = ColumnType(['VARCHAR', 'CHAR'])
"""Type object that can be used to describe string-based columns."""

BINARY = ColumnType(['BINARY', 'VARBINARY'])
"""Type object that can be used to describe (long) binary columns."""

NUMBER = ColumnType([
    'INTEGER', 'UNSIGNED_INT', 'BIGINT', 'UNSIGNED_LONG', 'TINYINT', 'UNSIGNED_TINYINT',
    'SMALLINT', 'UNSIGNED_SMALLINT', 'FLOAT', 'UNSIGNED_FLOAT', 'DOUBLE', 'UNSIGNED_DOUBLE', 'DECIMAL'
])
"""Type object that can be used to describe numeric columns."""

DATETIME = ColumnType(['TIME', 'DATE', 'TIMESTAMP', 'UNSIGNED_TIME', 'UNSIGNED_DATE', 'UNSIGNED_TIMESTAMP'])
"""Type object that can be used to describe date/time columns."""

ROWID = ColumnType([])
"""Only implemented for DB API 2.0 compatibility, not used."""

BOOLEAN = ColumnType(['BOOLEAN'])
"""Type object that can be used to describe boolean columns. This is a phoenixdb-specific extension."""

if sys.version_info[0] < 3:
    _long = long  # noqa: F821
else:
    _long = int

FIELD_MAP = {
    'bool_value': [
        (common_pb.Rep.BOOLEAN, None, None),
        (common_pb.Rep.PRIMITIVE_BOOLEAN, None, None),
    ],
    'string_value': [
        (common_pb.Rep.CHARACTER, None, None),
        (common_pb.Rep.PRIMITIVE_CHAR, None, None),
        (common_pb.Rep.STRING, None, None),
        (common_pb.Rep.BIG_DECIMAL, str, Decimal),
    ],
    'number_value': [
        (common_pb.Rep.INTEGER, None, int),
        (common_pb.Rep.PRIMITIVE_INT, None, int),
        (common_pb.Rep.SHORT, None, int),
        (common_pb.Rep.PRIMITIVE_SHORT, None, int),
        (common_pb.Rep.LONG, None, _long),
        (common_pb.Rep.PRIMITIVE_LONG, None, _long),
        (common_pb.Rep.BYTE, None, int),
        (common_pb.Rep.JAVA_SQL_TIME, time_to_java_sql_time, time_from_java_sql_time),
        (common_pb.Rep.JAVA_SQL_DATE, date_to_java_sql_date, date_from_java_sql_date),
        (common_pb.Rep.JAVA_SQL_TIMESTAMP, datetime_to_java_sql_timestamp, datetime_from_java_sql_timestamp),
    ],
    'bytes_value': [
        (common_pb.Rep.BYTE_STRING, to_binary, None),
    ],
    'double_value': [
        (common_pb.Rep.DOUBLE, float, float),
        (common_pb.Rep.PRIMITIVE_DOUBLE, float, float)
    ]
}
"""The master map that describes how to handle types, keyed by TypedData field"""

REP_MAP = dict((v[0], (k, v[0], v[1], v[2])) for k in FIELD_MAP for v in FIELD_MAP[k])
"""Flips the available types to allow for faster lookup by protobuf Rep

This mapping should be structured as:
    {
        'common_pb2.BIG_DECIMAL': ('string_value', common_pb2.BIG_DECIMAL, str, Decimal),),
        ...
        '<Rep enum>': (<field_name>, <mutate_to function>, <cast_from function>),
    }
"""

JDBC_TO_REP: Dict[int, common_pb.Rep] = dict([
    # These are the standard types that are used in Phoenix
    (-6, common_pb.Rep.BYTE),  # TINYINT
    (5, common_pb.Rep.SHORT),  # SMALLINT
    (4, common_pb.Rep.INTEGER),  # INTEGER
    (-5, common_pb.Rep.LONG),  # BIGINT
    (6, common_pb.Rep.DOUBLE),  # FLOAT
    (8, common_pb.Rep.DOUBLE),  # DOUBLE
    (2, common_pb.Rep.BIG_DECIMAL),  # NUMERIC
    (1, common_pb.Rep.STRING),  # CHAR
    (91, common_pb.Rep.JAVA_SQL_DATE),  # DATE
    (92, common_pb.Rep.JAVA_SQL_TIME),  # TIME
    (93, common_pb.Rep.JAVA_SQL_TIMESTAMP),  # TIMESTAMP
    (-2, common_pb.Rep.BYTE_STRING),  # BINARY
    (-3, common_pb.Rep.BYTE_STRING),  # VARBINARY
    (16, common_pb.Rep.BOOLEAN),  # BOOLEAN
    # These are the Non-standard types defined by Phoenix
    (19, common_pb.Rep.JAVA_SQL_DATE),  # UNSIGNED_DATE
    (15, common_pb.Rep.DOUBLE),  # UNSIGNED_DOUBLE
    (14, common_pb.Rep.DOUBLE),  # UNSIGNED_FLOAT
    (9, common_pb.Rep.INTEGER),  # UNSIGNED_INT
    (10, common_pb.Rep.LONG),  # UNSIGNED_LONG
    (13, common_pb.Rep.SHORT),  # UNSIGNED_SMALLINT
    (20, common_pb.Rep.JAVA_SQL_TIMESTAMP),  # UNSIGNED_TIMESTAMP
    (11, common_pb.Rep.BYTE),  # UNSIGNED_TINYINT
    # The following are not used by Phoenix, but some of these are used by Avaticafor
    # parameter types
    (-7, common_pb.Rep.BOOLEAN),  # BIT
    (7, common_pb.Rep.DOUBLE),  # REAL
    (3, common_pb.Rep.BIG_DECIMAL),  # DECIMAL
    (12, common_pb.Rep.STRING),  # VARCHAR
    (-1, common_pb.Rep.STRING),  # LONGVARCHAR
    (-4, common_pb.Rep.BYTE_STRING),  # LONGVARBINARY
    (2004, common_pb.Rep.BYTE_STRING),  # BLOB
    (2005, common_pb.Rep.STRING),  # CLOB
    (-15, common_pb.Rep.STRING),  # NCHAR
    (-9, common_pb.Rep.STRING),  # NVARCHAR
    (-16, common_pb.Rep.STRING),  # LONGNVARCHAR
    (2011, common_pb.Rep.STRING),  # NCLOB
    (2009, common_pb.Rep.STRING),  # SQLXML
    # Returned by Avatica for Arrays in EMPTY resultsets
    (2000, common_pb.Rep.BYTE_STRING)  # JAVA_OBJECT
    # These are defined by JDBC, but cannot be mapped
    # NULL
    # OTHER
    # DISTINCT
    # STRUCT
    # ARRAY 2003 - We are handling this as a special case
    # REF
    # DATALINK
    # ROWID
    # REF_CURSOR
    # TIME WITH TIMEZONE
    # TIMESTAMP WITH TIMEZONE

    ])
"""Maps the JDBC Type IDs to Protobuf Reps """

JDBC_MAP = {}
for k, v in JDBC_TO_REP.items():
    JDBC_MAP[k & 0xffffffff] = REP_MAP[v]
"""Flips the available types to allow for faster lookup by JDBC type ID

It has the same format as REP_MAP, but is keyed by JDBC type ID
"""


class TypeHelper(object):

    @staticmethod
    def from_param(param):
        """Retrieves a field name and functions to cast to/from based on an AvaticaParameter object

        :param param:
            Protobuf AvaticaParameter object

        :returns: tuple ``(field_name, rep, mutate_to, cast_from, is_array)``
            WHERE
            ``field_name`` is the attribute in ``common_pb2.TypedValue``
            ``rep`` is the common_pb2.Rep enum
            ``mutate_to`` is the function to cast values into Phoenix values, if any
            ``cast_from`` is the function to cast from the Phoenix value to the Python value, if any
            ``is_array`` the param expects an array instead of scalar

        :raises:
            NotImplementedError
        """
        jdbc_code = param.parameter_type
        if 2900 < jdbc_code < 3100:
            return TypeHelper._from_jdbc(jdbc_code-3000) + (True,)
        else:
            return TypeHelper._from_jdbc(jdbc_code) + (False,)

    @staticmethod
    def from_column(column):
        """Retrieves a field name and functions to cast to/from based on a TypedValue object

        :param column:
            Protobuf TypedValue object

        :returns: tuple ``(field_name, rep, mutate_to, cast_from)``
            WHERE
            ``field_name`` is the attribute in ``common_pb2.TypedValue``
            ``rep`` is the common_pb2.Rep enum
            ``mutate_to`` is the function to cast values into Phoenix values, if any
            ``cast_from`` is the function to cast from the Phoenix value to the Python value, if any

        :raises:
            NotImplementedError
        """
        if column.type.id == 2003:
            return TypeHelper._from_jdbc(column.type.component.id)
        else:
            return TypeHelper._from_jdbc(column.type.id)

    @staticmethod
    def _from_jdbc(jdbc_code):
        if jdbc_code not in JDBC_MAP:
            # This should not happen. It's either a bug, or Avatica has added new types
            raise NotImplementedError('JDBC TYPE CODE {} is not supported'.format(jdbc_code))

        return JDBC_MAP[jdbc_code]
