import re
import sys
from decimal import Decimal
from enum import Enum
from pathlib import Path
from typing import TYPE_CHECKING, Callable, List, Optional, Sequence

from axipy import (
    AxipyProgressHandler,
    Feature,
    Geometry,
    MapView,
    MultiPolygon,
    Notifications,
    Pnt,
    Point,
    Polygon,
    ProgressGuiFlags,
    ProgressSpecification,
    Style,
    Table,
    VectorLayer,
    data_manager,
)
from axipy import geometry_uid as guid
from axipy import (
    provider_manager,
    selection_manager,
    task_manager,
    tr,
    view_manager,
)
from PySide2.QtCore import QDate, QDateTime, QLocale, QTime
from PySide2.QtGui import QDoubleValidator
from PySide2.QtWidgets import (
    QCheckBox,
    QComboBox,
    QFileDialog,
    QFormLayout,
    QGroupBox,
    QLabel,
    QLineEdit,
    QPushButton,
    QRadioButton,
    QSpinBox,
    QVBoxLayout,
    QWidget,
)

from .. import helper, helper_sql
from ..helper import print_, print_exc_
from ..map_tool_extended import SelectorMapTool

if TYPE_CHECKING:
    import sqlite3

# Минимальное количество точек для построения полигонов Вороного
MIN_POINTS_COUNT = 2
# Преобразование Feature Аксиомы в формат Sqlite
type_dict = {
    "decimal": "TEXT",
    "double": "REAL",
    "int": "INTEGER",
    "bool": "INTEGER",
    "string": "TEXT",
    "date": "TEXT",
    "time": "TEXT",
    "datetime": "TEXT",
}

sqlite_reserved_keywords = (
    "ABORT",
    "ACTION",
    "ADD",
    "AFTER",
    "ALL",
    "ALTER",
    "ALWAYS",
    "ANALYZE",
    "AND",
    "AS",
    "ASC",
    "ATTACH",
    "AUTOINCREMENT",
    "BEFORE",
    "BEGIN",
    "BETWEEN",
    "BY",
    "CASCADE",
    "CASE",
    "CAST",
    "CHECK",
    "COLLATE",
    "COLUMN",
    "COMMIT",
    "CONFLICT",
    "CONSTRAINT",
    "CREATE",
    "CROSS",
    "CURRENT",
    "CURRENT_DATE",
    "CURRENT_TIME",
    "CURRENT_TIMESTAMP",
    "DATABASE",
    "DEFAULT",
    "DEFERRABLE",
    "DEFERRED",
    "DELETE",
    "DESC",
    "DETACH",
    "DISTINCT",
    "DO",
    "DROP",
    "EACH",
    "ELSE",
    "END",
    "ESCAPE",
    "EXCEPT",
    "EXCLUDE",
    "EXCLUSIVE",
    "EXISTS",
    "EXPLAIN",
    "FAIL",
    "FILTER",
    "FIRST",
    "FOLLOWING",
    "FOR",
    "FOREIGN",
    "FROM",
    "FULL",
    "GENERATED",
    "GLOB",
    "GROUP",
    "GROUPS",
    "HAVING",
    "IF",
    "IGNORE",
    "IMMEDIATE",
    "IN",
    "INDEX",
    "INDEXED",
    "INITIALLY",
    "INNER",
    "INSERT",
    "INSTEAD",
    "INTERSECT",
    "INTO",
    "IS",
    "ISNULL",
    "JOIN",
    "KEY",
    "LAST",
    "LEFT",
    "LIKE",
    "LIMIT",
    "MATCH",
    "MATERIALIZED",
    "NATURAL",
    "NO",
    "NOT",
    "NOTHING",
    "NOTNULL",
    "NULL",
    "NULLS",
    "OF",
    "OFFSET",
    "ON",
    "OR",
    "ORDER",
    "OTHERS",
    "OUTER",
    "OVER",
    "PARTITION",
    "PLAN",
    "PRAGMA",
    "PRECEDING",
    "PRIMARY",
    "QUERY",
    "RAISE",
    "RANGE",
    "RECURSIVE",
    "REFERENCES",
    "REGEXP",
    "REINDEX",
    "RELEASE",
    "RENAME",
    "REPLACE",
    "RESTRICT",
    "RETURNING",
    "RIGHT",
    "ROLLBACK",
    "ROW",
    "ROWS",
    "SAVEPOINT",
    "SELECT",
    "SET",
    "TABLE",
    "TEMP",
    "TEMPORARY",
    "THEN",
    "TIES",
    "TO",
    "TRANSACTION",
    "TRIGGER",
    "UNBOUNDED",
    "UNION",
)


def quote_keyword(name: str) -> str:
    for keyword in sqlite_reserved_keywords:
        match = re.match(name, keyword, re.IGNORECASE)
        if match:
            return f'"{name}"'

    return name


# Уникальные alias для sqlite запросов
col_tmp_1 = "b6ded719b11c49ff94bb99dd2dfeb617"
pnt = "aa293ebfa1d3416e8d0e89e52c5531c0"
poly = "c883ee850e344fdf96b2a66c84e12945"
t2r = "fe1befdde2234095a069429a5d9579c6"

table1 = "points"
table2 = "voronoj"


class MakePolygonsMode(Enum):
    """
    Режим построения полигонов
    """

    NewTable = 0  # Создать новую таблицу
    EditableLayer = 1  # Построить на редактируемом слое


class VoronoiSettings:
    """Параметры, собираемые виджетом"""

    def __init__(self) -> None:
        self.inp_layer_index = None  # type: Optional[int]

        self.polygons_mode = MakePolygonsMode.EditableLayer  # type: MakePolygonsMode

        self.file_name = None

        # Значения по умолчанию взяты из документации к SpatiaLite
        self.edges_only = False  # type: bool
        self.frame_extra_size = 5  # type: int
        self.tolerance = 0  # type: float

    def __str__(self) -> str:
        txt = (
            f"self.inp_layer_index = {self.inp_layer_index}",
            f"self.polygons_mode = {self.polygons_mode}",
            f"self.file_name = {self.file_name}",
        )
        return "\n".join(txt)


class VoronoiWidget(QWidget):

    def __init__(self, title) -> None:
        super().__init__()
        self._title = title

        self._layer_ids = None
        self._settings = VoronoiSettings()

        self._init_gui()

        self.active_view_connections = []

        self.refresh_layers()

    def _init_gui(self) -> None:
        vbox = QVBoxLayout()

        txt = tr("Выбрать слой с точками")
        label = QLabel(txt)
        vbox.addWidget(label)
        self.cbox_choose_inp_table = QComboBox()
        vbox.addWidget(self.cbox_choose_inp_table)

        txt = tr("Поместить результат")
        self.mode_g_box = QGroupBox(txt)
        g_box_vbox = QVBoxLayout()
        r1 = QRadioButton(tr("В новую таблицу"), objectName="r1")

        def create_file_dialog(qfile_dialog: Callable):
            lnd = QLineEdit(readOnly=True)

            btn = QPushButton(
                text="...",
                maximumWidth=20,
                clicked=lambda lnd_arg=lnd: lnd_arg.setText(qfile_dialog(filter="*.tab")[0]),
            )

            hbox_arg = helper.create_hbox(lnd, btn)
            return hbox_arg

        hbox = create_file_dialog(QFileDialog.getSaveFileName)
        self.lnd_file_name = hbox.itemAt(0).widget()
        self.lnd_file_name.setText(self._settings.file_name)
        r2 = QRadioButton(tr("На изменяемый слой"), objectName="r2")
        for w in (r1, r2):
            g_box_vbox.addWidget(w)
        g_box_vbox.insertLayout(1, hbox)
        self.mode_g_box.setLayout(g_box_vbox)
        self.mode_g_box.findChildren(QRadioButton)[self._settings.polygons_mode.value].setChecked(True)
        vbox.addWidget(self.mode_g_box)

        txt = tr("Настройки полигонов")
        g_box = QGroupBox(txt)
        g_box_layout = QFormLayout()
        label = QLabel(tr("Расширить границу на"))
        self.spin_box_frame_extra_size = QSpinBox(maximum=100, suffix="%")
        self.spin_box_frame_extra_size.setValue(self._settings.frame_extra_size)
        g_box_layout.addRow(label, self.spin_box_frame_extra_size)
        label = QLabel(tr("Чувствительность"))
        self.lnd_tolerance = QLineEdit()
        validator = QDoubleValidator(bottom=sys.float_info.min, top=sys.float_info.max, decimals=6)
        validator.setNotation(QDoubleValidator.StandardNotation)
        self.lnd_tolerance.setValidator(validator)
        sys_locale = QLocale.system()  # type: QLocale
        self.lnd_tolerance.setText(sys_locale.toString(float(self._settings.tolerance), prec=6))
        self.lnd_tolerance.setToolTip(
            tr(
                "Если координаты точек отличаются друг от друга меньше чем на введенное значение, "
                "то они не будут учитываться при выполнении операции."
            )
        )
        self.units_label = QLabel()
        hbox = helper.create_hbox(label, self.lnd_tolerance, self.units_label)
        hbox.setContentsMargins(0, 0, 0, 0)
        w = QWidget(layout=hbox)
        g_box_layout.addRow(label, w)
        label = QLabel(tr("Только контуры"))
        self.check_box_edges_only = QCheckBox(objectName="edges_only")
        self.check_box_edges_only.setChecked(self._settings.edges_only)
        self.check_box_edges_only.setToolTip(
            tr("При установленном флажке полигон представлен только " "контурами, без заливки.")
        )
        g_box_layout.addRow(label, self.check_box_edges_only)
        g_box.setLayout(g_box_layout)
        vbox.addWidget(g_box)

        vbox.addStretch()

        self.setLayout(vbox)

        self.refresh_cs_units()

    def refresh_layers(self) -> None:

        self.cbox_choose_inp_table.clear()

        layer_ids = []

        n = selection_manager.count
        if n > 0:
            self.cbox_choose_inp_table.addItem(data_manager.selection.name)
            layer_ids.append("selection")

        active_view = view_manager.active
        if not isinstance(active_view, MapView):
            return None
        cosmetic_layer = active_view.map.cosmetic
        table = cosmetic_layer.data_object
        if not isinstance(table, Table):
            return None
        cosmetic_items = table.items()
        if next(cosmetic_items, None) is not None:
            self.cbox_choose_inp_table.addItem(cosmetic_layer.title)
            layer_ids.append("cosmetic")

        layers = helper.active_map_vector_layers(cosmetic=False)
        if layers is None:
            return None
        layers_titles = list(map(lambda layer: layer.title, layers))
        arg = range(len(layers_titles))  # type: Sequence
        layer_ids.extend(arg)

        self._layer_ids = layer_ids
        self.cbox_choose_inp_table.addItems(layers_titles)

    def refresh_cs_units(self) -> None:
        active_view = view_manager.active
        self.units_label.setText(active_view.coordsystem.unit.localized_name)

    def refresh_all(self) -> None:
        self.refresh_layers()
        self.refresh_cs_units()

    def read_settings(self) -> Optional[VoronoiSettings]:
        result = self._settings

        result.file_name = self.lnd_file_name.text()

        current_index = self.cbox_choose_inp_table.currentIndex()
        result.inp_layer_index = self._layer_ids[current_index]

        tmp_list = self.mode_g_box.findChildren(QRadioButton)
        for i, elem in enumerate(tmp_list):
            if elem.isChecked():
                result.polygons_mode = MakePolygonsMode(i)

        if result.polygons_mode == MakePolygonsMode.EditableLayer:
            # TODO: Передавать таблицу в класс создания полигонов
            try:
                helper.ensure_editable_table()
            except (Exception,):
                Notifications.push(self._title, tr("Не удалось найти изменяемый слой."), Notifications.Critical)
                return None
        elif result.polygons_mode == MakePolygonsMode.NewTable:
            if not Path(result.file_name).is_absolute():
                Notifications.push(
                    self._title, tr("Пожалуйста, выберите имя для новой таблицы."), Notifications.Critical
                )
                return None

        result.edges_only = self.check_box_edges_only.isChecked()
        result.frame_extra_size = self.spin_box_frame_extra_size.value()
        result.tolerance = helper.text_to_float(self.lnd_tolerance.text(), self.lnd_tolerance.locale())

        return result

    def selection_changed(self) -> None:
        self.refresh_layers()

    def active_changed(self) -> None:
        self.refresh_all()

        self.unload_once()

        view = view_manager.active
        self.active_view_connections.append(helper.Connection(view.map.need_redraw, self.refresh_all))
        self.active_view_connections.append(helper.Connection(view.coordsystem_changed, self.refresh_cs_units))

    def unload_once(self) -> None:
        for connection in self.active_view_connections:
            connection.disconnect()
        self.active_view_connections.clear()


class MakePolygons:

    def __init__(self, title: str, settings: VoronoiSettings, db_settings, view_cs) -> None:
        self._title = title  # type: str
        self._settings = settings  # type: VoronoiSettings
        self._db_settings = db_settings  # type: helper_sql.DbSettings
        self._view_cs = view_cs

        self._inp_table = None  # type: Optional[Table]
        self._out_table = None  # type: Optional[Table]

        self.spec = ProgressSpecification(window_title=title, flags=ProgressGuiFlags.CANCELABLE)

    # @time_f
    def run(self) -> None:
        try:

            self._check_input_table()

            self._check_input_items()

            def in_thread(ph: AxipyProgressHandler):
                try:
                    ph.set_description(tr("Инициализация"))

                    self._init_db()

                    self._prepare_points(ph)

                    self._build_mbr_cache_points()

                    self._make_polygons(ph)

                    self._ensure_out_table()

                    self._insert_from_cursor(ph)
                except (Exception,):
                    print_exc_()
                    return False
                else:
                    return True
                finally:
                    if hasattr(self, "conn") and self.conn:
                        self.conn.close()

            success = task_manager.run_and_get(self.spec, in_thread)
            if not success:
                raise Exception("not success")

            # Попытка добавить полученные данные на активную карту
            try:
                if self._settings.polygons_mode == MakePolygonsMode.NewTable:
                    active_view = view_manager.active
                    if isinstance(active_view, MapView):
                        layer = VectorLayer.create(self._out_table)
                        active_view.map.layers.append(layer)
            except (Exception,):
                print_exc_()
                Notifications.push(
                    self._title, tr("Не удалось добавить полигоны Вороного на карту."), Notifications.Warning
                )

        except (Exception,):
            print_exc_()
            Notifications.push(self._title, tr("Не удалось построить полигоны Вороного."), Notifications.Critical)

    def _check_input_table(self) -> None:

        inp_table = None

        inp_layer = self._settings.inp_layer_index

        if inp_layer == "selection":
            inp_table = data_manager.selection

        elif inp_layer == "cosmetic":
            active_view = view_manager.active
            layer = active_view.map.cosmetic
            inp_table = layer.data_object

        elif isinstance(inp_layer, int):

            def get_vector_layer(i: int) -> List:
                # Существующая таблица
                active_view_arg = view_manager.active
                vector_layers = filter(lambda l: isinstance(l, VectorLayer), active_view_arg.map.layers)
                return list(vector_layers)[i].data_object

            inp_table = get_vector_layer(inp_layer)

        if inp_table is None:
            raise Exception("table is None")

        self._inp_table = inp_table

    def _check_input_items(self) -> None:

        def notify_not_enough() -> None:
            Notifications.push(self._title, tr(f"Недостаточно точек. Минимальное колличество: {MIN_POINTS_COUNT}."))

        n = self._inp_table.count()
        if n < 2:
            notify_not_enough()
            raise Exception("not enough features")

        items = self._inp_table.items()

        min_points_counter = 0
        for f in items:
            if isinstance(f.geometry, Point):
                min_points_counter += 1
                if min_points_counter >= MIN_POINTS_COUNT:
                    break

        if min_points_counter < MIN_POINTS_COUNT:
            notify_not_enough()
            raise Exception("not enough points")

    # @time_f
    def _init_db(self) -> None:
        self.conn = helper_sql.LocalConnection(self._db_settings)
        self.conn.init_db()

    # @time_f
    def _prepare_points(self, ph: AxipyProgressHandler) -> None:
        """
        Перепроецирование геометрии и конвертация записей в формат sqlite
        """

        ph.set_description(tr("Подготовка точек"))

        attr_list = self._inp_table.schema

        col_names_list = []
        insert_values = []
        for attr in attr_list:
            attr_name = attr.name
            attr_name = quote_keyword(attr_name)
            col_names_list.append(f"{attr_name} {type_dict[attr.type_string]}")
            insert_values.append("?")

        # MBR cache можно построить,только если кроме столбца геометрии, есть хотя бы один другой столбец
        attr_list_is_empty = len(attr_list) == 0
        if attr_list_is_empty:
            col_names_list.append(f"{col_tmp_1} integer")
            insert_values.append("?")

        col_names_list.append(f"{guid} BLOB")
        insert_values.append("PointFromWKB(?, 0)")

        col_names = ", ".join(col_names_list)
        insert_values = ", ".join(insert_values)

        reproj = self._inp_table.coordsystem != self._view_cs

        def ensure_reproj(g: Geometry) -> Optional[Geometry]:
            if reproj:
                g = g.reproject(self._view_cs)
                # Проверка выхода геометрии за рамки ск, в которую она была перепроецирована
                rect = g.bounds
                if not self._view_cs.rect.contains(Pnt(rect.xmin, rect.ymin)):
                    return None

            return g

        table_name = "points"
        cursor = self.conn.get_cursor()
        print_(f"create table {table_name} ({col_names})")
        cursor.execute(f"create table {table_name} ({col_names})")

        def insert_and_commit(values_arg) -> None:
            cursor.executemany(f"insert into {table_name} values ({insert_values})", values_arg)
            self.conn.commit()

        def is_point(f_arg: Feature):
            return isinstance(f_arg.geometry, Point)

        points_iter = filter(is_point, self._inp_table.items())
        values = []
        points_count = 0
        for f in points_iter:

            f_list = []
            for k, v in f.items():
                if k not in ("+geometry", "+style"):
                    if isinstance(v, Decimal):
                        v = str(v)
                    elif isinstance(v, (QDate, QTime, QDateTime)):
                        v = v.toString()
                    f_list.append(v)

            if attr_list_is_empty:
                f_list.append(0)

            geometry = ensure_reproj(f.geometry)
            if geometry is None:
                continue

            values.append(
                (
                    *f_list,
                    geometry.to_wkb(),
                )
            )
            points_count += 1
            if len(values) == 10000:
                insert_and_commit(values)
                values.clear()

        if len(values) > 0:
            insert_and_commit(values)

        print_(f"points_count = {points_count}")
        self.points_count = points_count

    # @time_f
    def _build_mbr_cache_points(self) -> None:
        self.conn.c_execute(f"select RecoverGeometryColumn('{table1}', '{guid}', 0, 'POINT')")
        self.conn.c_execute(f"select CreateMbrCache('{table1}', '{guid}')")

    # @time_f
    def _make_polygons(self, ph: AxipyProgressHandler) -> None:
        ph.set_description(tr("Подготовка полигонов"))

        self.conn.c_execute(f"create table voronoj ({guid} blob)")

        edges_only = False  # Этот параметр обрабатывается отдельно
        frame_extra_size = self._settings.frame_extra_size
        tolerance = self._settings.tolerance

        cursor = self.conn.get_cursor()

        result = cursor.execute(
            f"select AsBinary("
            f"VoronojDiagram(Collect({guid}), {edges_only}, {frame_extra_size}, {tolerance})) "
            f"from points"
        )

        row = next(result, None)
        col = row[0]
        if col is None:
            raise Exception("multipolygon is None")
        multi_polygon = Geometry.from_wkb(col)
        if not isinstance(multi_polygon, MultiPolygon):
            raise RuntimeError("Result is not MultiPolygon")
        for polygon in multi_polygon:
            cursor.execute("insert into voronoj values(PolyFromWKB(?, 0))", (polygon.to_wkb(),))
        self.conn.commit()

        self.conn.c_execute(f"select RecoverGeometryColumn('voronoj', '{guid}', 0, 'POLYGON')")

    def _ensure_out_table(self) -> None:
        inp_schema = self._inp_table.schema

        out_table = None
        if self._settings.polygons_mode == MakePolygonsMode.NewTable:
            out_table = provider_manager.tab.create_open(self._settings.file_name, inp_schema)
        elif self._settings.polygons_mode == MakePolygonsMode.EditableLayer:
            out_table = helper.ensure_editable_table()
        if out_table is None:
            raise Exception("out_table is None")
        else:
            self._out_table = out_table

    # @time_f
    def _insert_from_cursor(self, ph: AxipyProgressHandler) -> None:

        select_2 = (
            f"select rowid "
            f"from cache_points_{guid} "
            f"where mbr = FilterMbrWithin("
            f"MbrMinX(t2.{guid}), "
            f"MbrMinY(t2.{guid}), "
            f"MbrMaxX(t2.{guid}), "
            f"MbrMaxY(t2.{guid})"
            f") "
        )

        attr_list = self._inp_table.schema.attribute_names
        attr_list = [quote_keyword(attr_name) for attr_name in attr_list]
        attr_list.extend([f"t1.{guid} as {pnt}", f"t2.{guid} as {poly}", f"t2.rowid as {t2r}"])
        names = ", ".join(attr_list)

        select_1 = f"select {names} " f"from points as t1, voronoj as t2 " f"where t1.rowid in ({select_2}) "

        attr_list = self._inp_table.schema.attribute_names
        attr_list = [quote_keyword(attr_name) for attr_name in attr_list]
        attr_list.append(f"AsBinary(s.{poly})")
        names = ", ".join(attr_list)

        select_0 = (
            f"select {names} " f"from ({select_1}) as s " f"where Intersects(s.{poly}, s.{pnt}) " f"group by s.{t2r}"
        )
        # group by нужен при построении полигонов с ненулевой чувствительностью, чтобы не было дублирующих пересечений

        cursor = self.conn.get_cursor()
        print_(select_0)
        cursor = cursor.execute(select_0)

        ph.set_description(tr("Создание записей"))
        names_list = self._inp_table.schema.attribute_names

        type_list = []
        for attr in self._inp_table.schema:
            attr_type_string = attr.type_string
            if attr_type_string == "date":
                attr_type_string = attr.typedef
            type_list.append(attr_type_string)

        def make_f(row: "sqlite3.Row") -> Feature:
            attr_dict = {}
            for name, col, attr_type in zip(names_list, row[:-1], type_list):
                if attr_type == "date":
                    col = QDate.fromString(col)
                elif attr_type == "time":
                    col = QTime.fromString(col)
                elif attr_type == "datetime":
                    col = QDateTime.fromString(col)
                attr_dict[name] = col

            g = Polygon.from_wkb(row[-1])
            g.coordsystem = self._view_cs

            if self._settings.edges_only:
                g = g.boundary()
                style = Style.from_mapinfo("Pen (1, 2, 0)")
            else:
                style = Style.from_mapinfo("Pen (1, 2, 0) Brush (1, 16777215, 16777215)")

            f = Feature(attr_dict, geometry=g, style=style)
            return f

        items = map(make_f, cursor)

        helper.insert_to_table(ph, items, self._out_table, self._title, True, self.points_count)


class VoronoiMapTool(SelectorMapTool):

    def __init__(self) -> None:
        super().__init__()

    def load_once(self) -> None:
        VoronoiMapTool.widget = VoronoiWidget(self.title)

        super().load_once()

    @staticmethod
    def tool_panel_accepted() -> None:
        settings = VoronoiMapTool.widget.read_settings()
        if settings is None:
            return None

        VoronoiMapTool.panel.disable()

        db_settings = VoronoiMapTool.db_settings
        make_polygons = MakePolygons(VoronoiMapTool.title, settings, db_settings, view_manager.active.coordsystem)
        make_polygons.run()

        VoronoiMapTool.panel.try_enable()
