# -*- coding: utf-8 -*-
"""
/***************************************************************************
 GeoJsonUa
                                 A QGIS plugin
 Process Ukrainian GeoJSON files
 Generated by Plugin Builder: http://g-sherman.github.io/Qgis-Plugin-Builder/
                              -------------------
        begin                : 2026-02-28
        git sha              : $Format:%H$
        copyright            : (C) 2026 by Michael Krechkivsky
        email                : krechkivsky.dev@gmail.com
 ***************************************************************************/

/***************************************************************************
 *                                                                         *
 *   This program is free software; you can redistribute it and/or modify  *
 *   it under the terms of the GNU General Public License as published by  *
 *   the Free Software Foundation; either version 2 of the License, or     *
 *   (at your option) any later version.                                   *
 *                                                                         *
 ***************************************************************************/
"""
import configparser
import json
import uuid
import re

from qgis.PyQt.QtCore import QSettings, QTranslator, QCoreApplication, Qt, QItemSelectionModel, QVariant, QUrl
from qgis.PyQt.QtGui import QIcon, QGuiApplication, QDesktopServices
from qgis.PyQt.QtWidgets import QAction, QMenu, QStyle, QToolButton, QInputDialog, QProgressDialog
from qgis.PyQt.QtWidgets import (
    QFileDialog, QMessageBox,
    QDialog, QVBoxLayout, QHBoxLayout,
    QLabel, QLineEdit, QPushButton
)

from qgis.core import Qgis
from qgis.core import (
    QgsApplication,
    QgsProject,
    QgsCoordinateReferenceSystem,
    QgsMapLayer,
    QgsVectorLayer,
    QgsFields,
    QgsField,
    QgsFeature,
    QgsVectorFileWriter,
    QgsSpatialIndex,
    QgsWkbTypes,
    QgsGeometry,
    QgsFillSymbol,
    QgsLineSymbol,
    QgsMarkerSymbol,
    QgsSingleSymbolRenderer,
    QgsEditorWidgetSetup,
)

# Initialize Qt resources from file resources.py
from .resources import *
from . import common
from .opened_projects import OpenedProjects


PROJECT_FOLDER_PATTERN = re.compile(r"^BGD_(GP|KP|DPT)_UA\d{17}(?:_[^\s]+)?$")

# Import the code for the DockWidget
from .json_ua_dockwidget import GeoJsonUaDockWidget
import os


class GeoJsonUa:
    """QGIS Plugin Implementation."""

    def __init__(self, iface):
        """Constructor.

        :param iface: An interface instance that will be passed to this class
            which provides the hook by which you can manipulate the QGIS
            application at run time.
        :type iface: QgsInterface
        """
        if common.LOG:
            common.log_calls(common.logFile, f"GeoJsonUa.__init__(iface={type(iface).__name__})")
        # Save reference to the QGIS interface
        self.iface = iface

        # initialize plugin directory
        self.plugin_dir = os.path.dirname(__file__)
        self._schema_cache = None
        self._enums_cache = None
        self._class_group_cache = None
        self._attributes_enum_cache = None
        self._cyrillic_fix_map = {
            "а": "a",
            "с": "c",
            "о": "o",
            "е": "e",
            "р": "p",
            "х": "x",
            "у": "y",
            "к": "k",
            "м": "m",
            "т": "t",
            "н": "h",
            "і": "i",
        }
        self._cyrillic_fix_map.update({k.upper(): v.upper() for k, v in self._cyrillic_fix_map.items()})
        self._normative_style_cache = None
        self._missing_style_reported = set()

        settings = QSettings()
        debug_value = settings.value("json_ua/debug", False)
        if isinstance(debug_value, str):
            self.debug_enabled = debug_value.strip().lower() in ("1", "true", "yes", "on")
        else:
            self.debug_enabled = bool(debug_value)

        self.tools_menu = None
        self.tools_button = None
        self.tools_button_action = None
        common.set_log_enabled(self.debug_enabled)
        self.current_project_label = None
        self.opened_projects = OpenedProjects(self.iface, self._update_current_project_label)
        self._connect_project_signals()
        self._rename_existing_project_groups()
        try:
            self.opened_projects.sync_existing_project_groups()
        except Exception:
            pass
        self._refresh_current_project_label()

        # initialize locale
        locale = QSettings().value('locale/userLocale')[0:2]
        locale_path = os.path.join(
            self.plugin_dir,
            'i18n',
            'GeoJsonUa_{}.qm'.format(locale))

        if os.path.exists(locale_path):
            self.translator = QTranslator()
            self.translator.load(locale_path)
            QCoreApplication.installTranslator(self.translator)

        # Declare instance attributes
        self.actions = []
        self.menu = self.tr(u'&json_ua')
        # TODO: We are going to let the user set this up in a future iteration
        self.toolbar = self.iface.addToolBar(u'json_ua')
        self.toolbar.setObjectName(u'json_ua')

        #print "** INITIALIZING GeoJsonUa"

        self.pluginIsActive = False
        self.dockwidget = None
        self.layer_registry = {}
        self.class_layers = {}


    # noinspection PyMethodMayBeStatic
    def tr(self, message):
        """Get the translation for a string using Qt translation API.

        We implement this ourselves since we do not inherit QObject.

        :param message: String for translation.
        :type message: str, QString

        :returns: Translated version of message.
        :rtype: QString
        """
        if common.LOG:
            common.log_calls(common.logFile, f"GeoJsonUa.tr(message={message!r})")
        # noinspection PyTypeChecker,PyArgumentList,PyCallByClass
        return QCoreApplication.translate('GeoJsonUa', message)


    def add_action(
            self,
            icon_path,
            text,
            callback,
        enabled_flag=True,
        add_to_menu=True,
        add_to_toolbar=True,
        status_tip=None,
        whats_this=None,
        parent=None):
        """Add a toolbar icon to the toolbar.

        :param icon_path: Path to the icon for this action. Can be a resource
            path (e.g. ':/plugins/foo/bar.png') or a normal file system path.
        :type icon_path: str

        :param text: Text that should be shown in menu items for this action.
        :type text: str

        :param callback: Function to be called when the action is triggered.
        :type callback: function

        :param enabled_flag: A flag indicating if the action should be enabled
            by default. Defaults to True.
        :type enabled_flag: bool

        :param add_to_menu: Flag indicating whether the action should also
            be added to the menu. Defaults to True.
        :type add_to_menu: bool

        :param add_to_toolbar: Flag indicating whether the action should also
            be added to the toolbar. Defaults to True.
        :type add_to_toolbar: bool

        :param status_tip: Optional text to show in a popup when mouse pointer
            hovers over the action.
        :type status_tip: str

        :param parent: Parent widget for the new action. Defaults None.
        :type parent: QWidget

        :param whats_this: Optional text to show in the status bar when the
            mouse pointer hovers over the action.

        :returns: The action that was created. Note that the action is also
            added to self.actions list.
        :rtype: QAction
        """
        if common.LOG:
            common.log_calls(
                common.logFile,
                f"GeoJsonUa.add_action(icon_path={icon_path!r}, text={text!r}, add_to_menu={add_to_menu}, add_to_toolbar={add_to_toolbar})",
            )

        icon = QIcon(icon_path)
        action = QAction(icon, text, parent)
        action.triggered.connect(callback)
        action.setEnabled(enabled_flag)

        if status_tip is not None:
            action.setStatusTip(status_tip)

        if whats_this is not None:
            action.setWhatsThis(whats_this)

        if add_to_toolbar:
            self.toolbar.addAction(action)

        if add_to_menu:
            self.iface.addPluginToMenu(
                self.menu,
                action)

        self.actions.append(action)

        return action


    def initGui(self):
        """Create the menu entries and toolbar icons inside the QGIS GUI."""
        if common.LOG:
            common.log_calls(common.logFile, "GeoJsonUa.initGui()")
        self._ensure_current_project_label()

        # Avoid duplicates on plugin reload.
        if getattr(self, "tools_button_action", None) is not None:
            try:
                self.toolbar.removeAction(self.tools_button_action)
            except Exception:
                pass
            self.tools_button_action = None
        if getattr(self, "tools_button", None) is not None:
            try:
                self.tools_button.deleteLater()
            except Exception:
                pass
            self.tools_button = None
        self.tools_menu = None

        icon_path = os.path.join(self.plugin_dir, "icon_v3.svg")
        if not os.path.exists(icon_path):
            icon_path = os.path.join(self.plugin_dir, "icon.svg")

        # No plugin menu entry; use a toolbar tool-button with a drop-down menu (xml_ua style).
        self.add_action(
            icon_path,
            text=self.tr(u"geojson"),
            callback=self.run,
            parent=self.iface.mainWindow(),
            add_to_toolbar=False,
            add_to_menu=False,
        )

        main_window = self.iface.mainWindow()
        style = main_window.style()

        self.tools_menu = QMenu(main_window)

        self.action_help = QAction(self.tr(u"Довідка"), main_window)
        self.action_help.setIcon(QIcon(QgsApplication.iconPath("mActionHelpContents.svg")))
        try:
            self.iface.registerMainWindowAction(self.action_help, "F1")
        except Exception:
            self.action_help.setShortcut("F1")
            self.action_help.setShortcutContext(Qt.WindowShortcut)
        self.action_help.triggered.connect(self.on_open_help)

        self.action_new_geojson = QAction(self.tr(u"Новий проєкт"), main_window)
        self.action_new_geojson.setIcon(style.standardIcon(QStyle.SP_FileIcon))
        self.action_new_geojson.triggered.connect(self.on_new_geojson)

        self.action_open = QAction(self.tr(u"Відкрити проєкт"), main_window)
        self.action_open.setIcon(style.standardIcon(QStyle.SP_DirOpenIcon))
        self.action_open.triggered.connect(self.on_open)

        check_icon_path = QgsApplication.iconPath("mActionCheckQgis.svg")
        if check_icon_path and os.path.exists(check_icon_path):
            check_icon = QIcon(check_icon_path)
        else:
            check_icon = style.standardIcon(QStyle.SP_DialogApplyButton)
        self.action_validate = QAction(self.tr(u"Перевірити проєкт"), main_window)
        self.action_validate.setIcon(check_icon)
        self.action_validate.triggered.connect(self.on_validate_project)
        self._update_validate_action()

        self.action_save = QAction(self.tr(u"Зберегти проєкт"), main_window)
        self.action_save.setIcon(style.standardIcon(QStyle.SP_DialogSaveButton))
        self.action_save.setEnabled(False)
        self.action_save.triggered.connect(self.on_save)

        self.action_close = QAction(self.tr(u"Закрити проєкт"), main_window)
        self.action_close.setIcon(style.standardIcon(QStyle.SP_DialogCloseButton))
        self.action_close.triggered.connect(self.on_close)
        self._update_close_action()

        self.action_normative_style = QAction(self.tr(u"Нормативний стиль"), main_window)
        self.action_normative_style.setCheckable(True)
        self.action_normative_style.setChecked(False)
        self.action_normative_style.triggered.connect(self.on_toggle_normative_style)

        self.action_debug_mode = QAction(self.tr(u"Налагоджувальний режим"), main_window)
        self.action_debug_mode.setCheckable(True)
        self.action_debug_mode.setChecked(self.debug_enabled)
        self.action_debug_mode.triggered.connect(self.on_toggle_debug_mode)

        self.tools_menu.addAction(self.action_help)
        self.tools_menu.addSeparator()
        self.tools_menu.addAction(self.action_new_geojson)
        self.tools_menu.addAction(self.action_open)
        self.tools_menu.addAction(self.action_validate)
        self.tools_menu.addAction(self.action_close)
        self.tools_menu.addAction(self.action_save)
        self.tools_menu.addAction(self.action_normative_style)
        self.tools_menu.addSeparator()
        self.tools_menu.addAction(self.action_debug_mode)

        self.tools_button = QToolButton()
        self.tools_button.setObjectName("json_ua_tools_button")
        self.tools_button.setIcon(QIcon(icon_path))
        self.tools_button.setToolTip(self.tr(u"json_ua"))
        self.tools_button.setMenu(self.tools_menu)
        self.tools_button.setPopupMode(QToolButton.MenuButtonPopup)

        self.tools_button_action = self.toolbar.addWidget(self.tools_button)
        self.tools_button.clicked.connect(self.run)

    #--------------------------------------------------------------------------

    def onClosePlugin(self):
        """Cleanup necessary items here when plugin dockwidget is closed"""
        if common.LOG:
            common.log_calls(common.logFile, "GeoJsonUa.onClosePlugin()")

        #print "** CLOSING GeoJsonUa"

        # disconnects
        self.dockwidget.closingPlugin.disconnect(self.onClosePlugin)

        # remove this statement if dockwidget is to remain
        # for reuse if plugin is reopened
        # Commented next statement since it causes QGIS crashe
        # when closing the docked window:
        # self.dockwidget = None

        self.pluginIsActive = False


    def unload(self):
        """Removes the plugin menu item and icon from QGIS GUI."""
        if common.LOG:
            common.log_calls(common.logFile, "GeoJsonUa.unload()")
        if not self._prompt_save_dirty():
            return

        #print "** UNLOAD GeoJsonUa"

        if getattr(self, "tools_button_action", None) is not None:
            try:
                self.toolbar.removeAction(self.tools_button_action)
            except Exception:
                pass
            self.tools_button_action = None
        if getattr(self, "tools_button", None) is not None:
            try:
                self.tools_button.deleteLater()
            except Exception:
                pass
            self.tools_button = None
        self.tools_menu = None

        for action in self.actions:
            self.iface.removePluginMenu(
                self.tr(u'&json_ua'),
                action)
            self.iface.removeToolBarIcon(action)
        # remove the toolbar
        del self.toolbar
        if getattr(self, "current_project_label", None) is not None:
            try:
                status_bar = self.iface.mainWindow().statusBar()
                status_bar.removeWidget(self.current_project_label)
            except Exception:
                pass
            try:
                self.current_project_label.deleteLater()
            except Exception:
                pass
            self.current_project_label = None

    #--------------------------------------------------------------------------

    def _push_info(self, message, duration=5):
        if common.LOG:
            common.log_calls(common.logFile, f"GeoJsonUa._push_info(message={message!r}, duration={duration})")
        try:
            self.iface.messageBar().pushMessage(
                self.tr(u"json_ua"),
                message,
                level=Qgis.Info,
                duration=duration,
            )
        except Exception:
            pass

    def _ensure_current_project_label(self):
        if getattr(self, "current_project_label", None) is not None:
            return self.current_project_label
        try:
            status_bar = self.iface.mainWindow().statusBar()
        except Exception:
            return None
        try:
            label = QLabel()
            label.setObjectName("json_ua_current_project_label")
            status_bar.insertWidget(0, label)
            self.current_project_label = label
            self._update_current_project_label(None)
        except Exception:
            return None
        return self.current_project_label

    def _update_current_project_label(self, name) -> None:
        label = self._ensure_current_project_label()
        if label is None:
            return
        if name:
            text = self.tr(u"Поточний проєкт: {0}").format(name)
        elif not getattr(self.opened_projects, "projects", []):
            text = self.tr(u"Немає поточного проєкту")
        else:
            text = self.tr(u"Немає поточного проєкту")
        label.setText(text)
        self._update_close_action()
        self._update_validate_action()
        if getattr(self, "dockwidget", None) is not None:
            try:
                self.dockwidget.setWindowTitle(text)
            except Exception:
                pass
            self._ensure_dockwidget_panel_name()

    def _is_project_folder_name(self, name: str) -> bool:
        if not name:
            return False
        candidate = name.strip().upper()
        if " " in candidate:
            return False
        return bool(PROJECT_FOLDER_PATTERN.fullmatch(candidate))

    def _extract_katotth(self, name: str) -> str:
        if not name:
            return ""
        candidate = name.strip().upper()
        match = re.search(r"UA\d{17}", candidate)
        return match.group(0) if match else ""

    def _create_project_group(self, project_name: str):
        try:
            root = QgsProject.instance().layerTreeRoot()
        except Exception:
            return None
        if root is None:
            return None
        try:
            if hasattr(root, "insertGroup"):
                return root.insertGroup(0, project_name)
            group = root.addGroup(project_name)
            try:
                root.insertChildNode(0, group)
            except Exception:
                pass
            return group
        except Exception:
            return None

    def _select_group_in_tree(self, group) -> None:
        if group is None:
            return
        try:
            view = self.iface.layerTreeView()
        except Exception:
            return
        if view is None:
            return
        set_current_node = getattr(view, "setCurrentNode", None)
        if callable(set_current_node):
            try:
                set_current_node(group)
                return
            except Exception:
                pass
        try:
            model = view.layerTreeModel()
        except Exception:
            return
        index = None
        for method_name in ("node2index", "indexFromNode", "indexForNode"):
            method = getattr(model, method_name, None)
            if callable(method):
                try:
                    index = method(group)
                except Exception:
                    index = None
                if index is not None:
                    break
        if index is None:
            return
        try:
            selection_model = view.selectionModel()
        except Exception:
            selection_model = None
        if selection_model is not None:
            try:
                selection_model.clearSelection()
                selection_model.setCurrentIndex(
                    index,
                    QItemSelectionModel.ClearAndSelect | QItemSelectionModel.Rows,
                )
            except Exception:
                pass
        try:
            view.setCurrentIndex(index)
        except Exception:
            pass

    def _load_lcs_regions(self):
        lcss_path = os.path.join(self.plugin_dir, "ini", "lcss.ini")
        parser = configparser.ConfigParser(interpolation=None)
        parser.optionxform = str
        try:
            with open(lcss_path, "r", encoding="utf-8") as handle:
                parser.read_file(handle)
        except UnicodeDecodeError:
            try:
                with open(lcss_path, "r", encoding="cp1251") as handle:
                    parser.read_file(handle)
            except Exception:
                return {}
        except Exception:
            return {}
        if not parser.has_section("LCS"):
            return {}
        return {key.strip(): value.strip() for key, value in parser.items("LCS")}

    def _ensure_project_crs(self, parent) -> bool:
        lcs_regions = self._load_lcs_regions()
        if not lcs_regions:
            self._push_info(self.tr(u"Список регіонів LCS не знайдено."))
            return False

        project = QgsProject.instance()
        try:
            authid = project.crs().authid()
        except Exception:
            authid = ""

        epsg_code = None
        if authid:
            authid_upper = authid.upper()
            if authid_upper.startswith("EPSG:"):
                epsg_code = authid_upper.split(":", 1)[1]

        if epsg_code in lcs_regions:
            self._push_info(
                self.tr(u"Ваш проєкт буде створено у регіоні {0}").format(lcs_regions[epsg_code])
            )
            return True

        regions = list(lcs_regions.values())
        title = self.tr(u"Виберіть регіон проєкту")
        label = self.tr(u"Регіон проєкту:")
        region, ok = QInputDialog.getItem(parent, title, label, regions, 0, False)
        if not ok or not region:
            return False

        selected_code = None
        for code, value in lcs_regions.items():
            if value == region:
                selected_code = code
                break
        if not selected_code:
            return False

        try:
            epsg_id = int(selected_code)
        except ValueError:
            return False

        new_crs = QgsCoordinateReferenceSystem.fromEpsgId(epsg_id)
        if new_crs.isValid():
            project.setCrs(new_crs)
            return True
        return False

    def _connect_project_signals(self) -> None:
        project = QgsProject.instance()
        signal = getattr(project, "readProject", None)
        if signal is None:
            return
        try:
            signal.connect(self._on_project_read)
        except Exception:
            return

    def _on_project_read(self, *args) -> None:
        self._rename_existing_project_groups()
        try:
            self.opened_projects.sync_existing_project_groups()
        except Exception:
            pass
        self._refresh_current_project_label()

    def _refresh_current_project_label(self) -> None:
        current_name = None
        try:
            current_group = self.opened_projects.current_project
        except Exception:
            current_group = None
        if current_group is not None:
            try:
                current_name = current_group.name()
            except Exception:
                current_name = None
        self._update_current_project_label(current_name)
        self._update_close_action()

    def _update_close_action(self) -> None:
        action = getattr(self, "action_close", None)
        if action is None:
            return
        try:
            current_group = self.opened_projects.current_project
        except Exception:
            current_group = None
        action.setEnabled(current_group is not None)

    def _update_validate_action(self) -> None:
        action = getattr(self, "action_validate", None)
        if action is None:
            return
        try:
            current_group = self.opened_projects.current_project
        except Exception:
            current_group = None
        action.setEnabled(current_group is not None)

    def _ensure_dockwidget_panel_name(self) -> None:
        if getattr(self, "dockwidget", None) is None:
            return
        try:
            self.dockwidget.setObjectName("json_ua")
        except Exception:
            pass
        try:
            action = self.dockwidget.toggleViewAction()
        except Exception:
            action = None
        if action is not None:
            try:
                action.setText("json_ua")
            except Exception:
                pass

    def _push_message(self, message: str, level=Qgis.Info, duration: int = 5) -> None:
        try:
            self.iface.messageBar().pushMessage(
                self.tr(u"json_ua"),
                message,
                level=level,
                duration=duration,
            )
        except Exception:
            pass

    def _show_status(self, message: str):
        item = None
        try:
            item = self.iface.messageBar().createMessage(self.tr(u"json_ua"), message)
            self.iface.messageBar().pushWidget(item, Qgis.Info)
        except Exception:
            item = None
        return item

    def _update_status(self, item, message: str) -> None:
        if item is None:
            return
        try:
            item.setText(message)
        except Exception:
            pass

    def _close_status(self, item) -> None:
        if item is None:
            return
        try:
            self.iface.messageBar().popWidget(item)
        except Exception:
            pass

    def _constrain_main_window(self) -> None:
        try:
            window = self.iface.mainWindow()
        except Exception:
            return
        if window is None:
            return
        try:
            screen = window.screen()
        except Exception:
            screen = None
        if screen is None:
            try:
                screen = QGuiApplication.primaryScreen()
            except Exception:
                screen = None
        if screen is None:
            return
        try:
            geom = screen.availableGeometry()
        except Exception:
            return
        try:
            window.setMaximumSize(geom.width(), geom.height())
        except Exception:
            pass

    def _load_schema_cache(self):
        if self._schema_cache is not None:
            return self._schema_cache
        schema_path = os.path.join(self.plugin_dir, "templates", "schema.json")
        common_path = os.path.join(self.plugin_dir, "templates", "common.json")
        schema = self._read_json_file(schema_path)
        common_schema = self._read_json_file(common_path)
        self._schema_cache = (schema or {}, common_schema or {})
        return self._schema_cache

    def _read_json_file(self, path: str):
        for encoding in ("utf-8", "cp1251"):
            try:
                with open(path, "r", encoding=encoding) as handle:
                    return json.load(handle)
            except UnicodeDecodeError:
                continue
            except Exception:
                break
        return None

    def _extract_enum_mapping(self, schema_node):
        if not isinstance(schema_node, dict):
            return None
        if "$ref" in schema_node:
            schema, common_schema = self._load_schema_cache()
            return self._extract_enum_mapping(self._resolve_ref(schema_node["$ref"], schema, common_schema))
        values = schema_node.get("enum")
        labels = schema_node.get("x-enum-descriptions")
        if isinstance(values, list) and isinstance(labels, list) and len(values) == len(labels):
            mapping = {}
            for value, label in zip(values, labels):
                mapping[str(label)] = value
            return mapping or None
        if isinstance(values, list) and values:
            mapping = {}
            for value in values:
                mapping[str(value)] = value
            return mapping or None
        one_of = schema_node.get("oneOf")
        if isinstance(one_of, list) and one_of:
            mapping = {}
            for item in one_of:
                if not isinstance(item, dict):
                    continue
                if "$ref" in item:
                    schema, common_schema = self._load_schema_cache()
                    item = self._resolve_ref(item["$ref"], schema, common_schema) or item
                if "const" in item:
                    value = item["const"]
                    label = item.get("title")
                    if label is None:
                        label = str(value)
                    mapping[str(label)] = value
            return mapping or None
        for key in ("allOf", "anyOf"):
            if key in schema_node and isinstance(schema_node[key], list):
                for part in schema_node[key]:
                    mapping = self._extract_enum_mapping(part)
                    if mapping:
                        return mapping
        return None

    def _load_enums_cache(self, class_key: str = None):
        if self._enums_cache is None:
            self._enums_cache = {}
        if not class_key:
            return self._enums_cache
        if class_key in self._enums_cache:
            return self._enums_cache[class_key]
        schema, _ = self._load_schema_cache()
        props_schema = self._collect_properties_schema(class_key, schema)
        enums = {}
        if props_schema:
            for field_name, prop_schema in props_schema.items():
                mapping = self._extract_enum_mapping(prop_schema)
                if mapping:
                    enums[field_name] = mapping
        if self._uses_state_change(class_key):
            for field_name in ("state", "change"):
                mapping = self._attributes_enum_mapping(field_name)
                if mapping:
                    enums.setdefault(field_name, mapping)
        if common.LOG:
            common.log_calls(common.logFile, f"GeoJsonUa._load_enums_cache(class={class_key}): fields={len(enums)}")
        self._enums_cache[class_key] = enums
        return enums

    def _load_class_group_cache(self):
        if self._class_group_cache is not None:
            return self._class_group_cache
        mapping = {}
        ini_path = os.path.join(self.plugin_dir, "ini", "layer_names.ini")
        parser = configparser.ConfigParser()
        parser.optionxform = str
        try:
            parser.read(ini_path, encoding="utf-8")
        except Exception:
            self._class_group_cache = mapping
            return mapping
        for section in parser.sections():
            if section == "LayerGroups":
                continue
            for key in parser[section].keys():
                mapping[key] = section
        self._class_group_cache = mapping
        return mapping

    def _class_group_name(self, class_key: str):
        if not class_key:
            return None
        mapping = self._load_class_group_cache()
        return mapping.get(class_key)

    def _uses_state_change(self, class_key: str) -> bool:
        group = self._class_group_name(class_key)
        return group in {
            "Structures",
            "Transport_networks",
            "Engineering_networks",
            "Inf_social_objects",
            "Inf_tourism_objects",
            "Inf_community_facilities",
            "Inf_enterprise_objects",
            "Inf_transport_objects",
            "Inf_engineering_objects",
        }

    def _load_attributes_enum_cache(self):
        if self._attributes_enum_cache is not None:
            return self._attributes_enum_cache
        mapping = {}
        ini_path = os.path.join(self.plugin_dir, "ini", "attributes_enums.ini")
        parser = configparser.ConfigParser()
        parser.optionxform = str
        try:
            parser.read(ini_path, encoding="utf-8")
        except Exception:
            self._attributes_enum_cache = mapping
            return mapping
        for section in parser.sections():
            section_map = {}
            section_keys = set()
            for key, label in parser[section].items():
                key = key.strip()
                label = label.strip()
                try:
                    value = int(key)
                except Exception:
                    value = key
                section_keys.add(str(value))
                section_map[str(label)] = value
            mapping[section] = {"labels": section_map, "keys": section_keys}
        self._attributes_enum_cache = mapping
        return mapping

    def _attributes_enum_mapping(self, name: str):
        cache = self._load_attributes_enum_cache()
        if not name:
            return None
        entry = cache.get(name)
        if isinstance(entry, dict):
            return entry.get("labels")
        return None

    def _attributes_enum_keys(self, name: str):
        if not name:
            return None
        cache = self._load_attributes_enum_cache()
        entry = cache.get(name)
        if isinstance(entry, dict):
            return entry.get("keys")
        return None

    def _normalize_cyrillic_key(self, key: str) -> str:
        if not key:
            return key
        return "".join(self._cyrillic_fix_map.get(ch, ch) for ch in key)

    def _allowed_props_for_class(self, class_key: str):
        if not class_key:
            return set()
        schema, common_schema = self._load_schema_cache()
        props_schema = self._collect_properties_schema(class_key, schema)
        if not props_schema:
            return set()
        allowed = set()
        for name in props_schema.keys():
            allowed.add(self._normalize_cyrillic_key(name))
        return allowed

    def _find_cyrillic_property_keys(self, data: dict, allowed_props: set) -> bool:
        if not isinstance(data, dict):
            return False
        features = None
        geo_type = data.get("type")
        if geo_type == "FeatureCollection":
            features = data.get("features")
        elif geo_type == "Feature":
            features = [data]
        if not isinstance(features, list):
            return False
        for feature in features:
            if not isinstance(feature, dict):
                continue
            props = feature.get("properties")
            if not isinstance(props, dict):
                continue
            for key in props.keys():
                normalized = self._normalize_cyrillic_key(key)
                if allowed_props and normalized not in allowed_props:
                    continue
                if normalized != key and any(ch in self._cyrillic_fix_map for ch in key):
                    return True
        return False

    def _apply_cyrillic_fix_to_geojson(self, data: dict, allowed_props: set) -> bool:
        if not isinstance(data, dict):
            return False
        features = None
        geo_type = data.get("type")
        if geo_type == "FeatureCollection":
            features = data.get("features")
        elif geo_type == "Feature":
            features = [data]
        if not isinstance(features, list):
            return False
        changed = False
        for feature in features:
            if not isinstance(feature, dict):
                continue
            props = feature.get("properties")
            if not isinstance(props, dict):
                continue
            new_props = {}
            local_changed = False
            for key, value in props.items():
                normalized = self._normalize_cyrillic_key(key)
                if allowed_props and normalized not in allowed_props:
                    new_props[key] = value
                    continue
                new_key = normalized
                if new_key != key:
                    local_changed = True
                if new_key in new_props:
                    # Prefer already-present corrected key.
                    continue
                new_props[new_key] = value
            if local_changed:
                feature["properties"] = new_props
                changed = True
        return changed

    def _prompt_fix_cyrillic_geojson(self, paths_by_class):
        if not paths_by_class:
            return
        paths = list(paths_by_class.keys())
        if not paths:
            return
        prompt = QMessageBox.question(
            self.iface.mainWindow(),
            self.tr(u"Автовиправлення атрибутів"),
            self.tr(u"Виявлено кириличні символи в іменах атрибутів. Виконати автоматичне виправлення у файлах GeoJSON?"),
            QMessageBox.Yes | QMessageBox.No,
            QMessageBox.No,
        )
        if prompt != QMessageBox.Yes:
            return
        for path, class_key in paths_by_class.items():
            data = self._read_json_file(path)
            if not isinstance(data, dict):
                continue
            allowed_props = self._allowed_props_for_class(class_key)
            if not self._apply_cyrillic_fix_to_geojson(data, allowed_props):
                continue
            backup_path = f"{path}_"
            if os.path.exists(backup_path):
                try:
                    os.replace(backup_path, f"{backup_path}1")
                except Exception:
                    pass
            try:
                os.replace(path, backup_path)
            except Exception:
                continue
            try:
                with open(path, "w", encoding="utf-8") as handle:
                    json.dump(data, handle, ensure_ascii=False, indent=2)
            except Exception:
                pass

    def _read_text_file(self, path: str):
        for encoding in ("utf-8", "cp1251"):
            try:
                with open(path, "r", encoding=encoding) as handle:
                    return handle.read()
            except UnicodeDecodeError:
                continue
            except Exception:
                break
        return ""

    def _load_topo_rules(self):
        rules = []
        rules_path = os.path.join(self.plugin_dir, "templates", "topo_rules.txt")
        content = self._read_text_file(rules_path)
        if not content:
            return rules
        phrases = [
            ("не повинні перекриватися самі себе", "no_overlap_self"),
            ("не повинні перекриватися з", "no_overlap"),
            ("не повинні перекриватися", "no_overlap"),
            ("не повинні мати проміжків", "no_gaps"),
            ("не повинні мати висячих вузлів", "no_dangling"),
            ("мають складатися з однієї частини", "singlepart"),
            ("мають суміщатися з об'єктами", "must_intersect"),
        ]
        for raw in content.splitlines():
            line = raw.strip()
            if not line or line.startswith("#"):
                continue
            src = line.split()[0]
            rule_type = None
            target = None
            for phrase, rtype in phrases:
                if phrase in line:
                    rule_type = rtype
                    if rtype in {"no_overlap", "must_intersect"}:
                        target = line.split()[-1]
                    elif rtype == "no_overlap_self":
                        target = src
                    else:
                        target = src
                    break
            if rule_type:
                rules.append({"src": src, "type": rule_type, "target": target, "raw": line})
        return rules

    def _geometry_to_geojson(self, geom: QgsGeometry):
        if geom is None or geom.isEmpty():
            return None
        try:
            return json.loads(geom.asJson())
        except Exception:
            return None

    def _topo_error_entry(self, class_key: str, geom: QgsGeometry, message: str):
        return {
            "layer": class_key,
            "feature_index": None,
            "feature_id": None,
            "geometry": self._geometry_to_geojson(geom),
            "messages": [message],
        }

    def _topo_check_must_intersect(self, layer_a, layer_b, name_b: str):
        entries = []
        if layer_a is None or layer_b is None:
            return entries
        index_b = QgsSpatialIndex(layer_b.getFeatures())
        for feature in layer_a.getFeatures():
            geom = feature.geometry()
            if geom is None or geom.isEmpty():
                continue
            hits = index_b.intersects(geom.boundingBox())
            ok = False
            for fid in hits:
                other = layer_b.getFeature(fid)
                if other is None:
                    continue
                other_geom = other.geometry()
                if other_geom is not None and geom.intersects(other_geom):
                    ok = True
                    break
            if not ok:
                entries.append(self._topo_error_entry(layer_a.name(), geom, f"Топологія: не суміщується з {name_b}."))
        return entries

    def _topo_check_no_overlap(self, layer_a, layer_b, name_b: str, same_layer: bool = False):
        entries = []
        if layer_a is None or layer_b is None:
            return entries
        index_b = QgsSpatialIndex(layer_b.getFeatures())
        for feature in layer_a.getFeatures():
            geom = feature.geometry()
            if geom is None or geom.isEmpty():
                continue
            hits = index_b.intersects(geom.boundingBox())
            for fid in hits:
                if same_layer and fid == feature.id():
                    continue
                other = layer_b.getFeature(fid)
                if other is None:
                    continue
                other_geom = other.geometry()
                if other_geom is None or other_geom.isEmpty():
                    continue
                if geom.overlaps(other_geom) or geom.equals(other_geom):
                    entries.append(self._topo_error_entry(layer_a.name(), geom, f"Топологія: перекривання з {name_b}."))
                    break
        return entries

    def _topo_check_no_dangling(self, layer_a):
        entries = []
        if layer_a is None:
            return entries
        index = QgsSpatialIndex(layer_a.getFeatures())
        for feature in layer_a.getFeatures():
            geom = feature.geometry()
            if geom is None or geom.isEmpty():
                continue
            try:
                line = geom.constGet()
                start = geom.vertexAt(0)
                end = geom.vertexAt(geom.vertexCount() - 1)
            except Exception:
                continue
            for point in (start, end):
                try:
                    pgeom = QgsGeometry.fromPointXY(point)
                except Exception:
                    continue
                hits = index.intersects(pgeom.boundingBox())
                has_other = False
                for fid in hits:
                    if fid == feature.id():
                        continue
                    other = layer_a.getFeature(fid)
                    if other is None:
                        continue
                    other_geom = other.geometry()
                    if other_geom is not None and pgeom.intersects(other_geom):
                        has_other = True
                        break
                if not has_other:
                    entries.append(self._topo_error_entry(layer_a.name(), pgeom, "Топологія: висячий вузол."))
        return entries

    def _topo_check_singlepart(self, layer_a):
        entries = []
        if layer_a is None:
            return entries
        for feature in layer_a.getFeatures():
            geom = feature.geometry()
            if geom is None or geom.isEmpty():
                continue
            if geom.isMultipart():
                entries.append(self._topo_error_entry(layer_a.name(), geom, "Топологія: має бути одна частина."))
        return entries

    def _topo_check_no_gaps(self, layer_a):
        entries = []
        if layer_a is None:
            return entries
        geoms = [f.geometry() for f in layer_a.getFeatures() if f.geometry() and not f.geometry().isEmpty()]
        if not geoms:
            return entries
        try:
            union_geom = QgsGeometry.unaryUnion(geoms)
        except Exception:
            return entries
        if union_geom is None or union_geom.isEmpty():
            return entries
        try:
            if union_geom.isMultipart():
                polygons = union_geom.asMultiPolygon()
            else:
                polygons = [union_geom.asPolygon()]
        except Exception:
            return entries
        for poly in polygons:
            if not poly or len(poly) < 2:
                continue
            for ring in poly[1:]:
                try:
                    hole_geom = QgsGeometry.fromPolygonXY([ring])
                except Exception:
                    continue
                if hole_geom is None or hole_geom.isEmpty():
                    continue
                entries.append(self._topo_error_entry(layer_a.name(), hole_geom, "Топологія: проміжок."))
        return entries

    def _run_topology_checks(self, rules=None, progress=None):
        errors_by_class = {}
        if rules is None:
            rules = self._load_topo_rules()
        if not rules:
            return errors_by_class
        total = len(rules)
        for idx, rule in enumerate(rules, start=1):
            if progress is not None:
                if progress.wasCanceled():
                    break
                progress.setLabelText(self.tr(u"Топологія: {0}/{1}").format(idx, total))
                progress.setValue(idx - 1)
                QCoreApplication.processEvents()
            src = rule["src"]
            target = rule["target"]
            rtype = rule["type"]
            layer_a = self._get_layer_for_class(src)
            if layer_a is None:
                continue
            if rtype == "must_intersect":
                layer_b = self._get_layer_for_class(target)
                entries = self._topo_check_must_intersect(layer_a, layer_b, target)
            elif rtype == "no_overlap":
                layer_b = self._get_layer_for_class(target)
                entries = self._topo_check_no_overlap(layer_a, layer_b, target, same_layer=(src == target))
            elif rtype == "no_overlap_self":
                entries = self._topo_check_no_overlap(layer_a, layer_a, src, same_layer=True)
            elif rtype == "no_dangling":
                entries = self._topo_check_no_dangling(layer_a)
            elif rtype == "singlepart":
                entries = self._topo_check_singlepart(layer_a)
            elif rtype == "no_gaps":
                entries = self._topo_check_no_gaps(layer_a)
            else:
                entries = []
            if entries:
                errors_by_class.setdefault(src, []).extend(entries)
        if progress is not None:
            progress.setValue(total)
        return errors_by_class

    def _apply_enum_widgets(self, layer, class_key: str = None) -> None:
        if layer is None:
            return
        if not class_key:
            try:
                class_key = layer.name()
            except Exception:
                class_key = None
        enums = self._load_enums_cache(class_key) if class_key else {}
        if not enums:
            return
        try:
            fields = layer.fields()
        except Exception:
            return
        for index, field in enumerate(fields):
            name = field.name()
            if name not in enums:
                continue
            try:
                setup = QgsEditorWidgetSetup("ValueMap", {"map": enums[name]})
                layer.setEditorWidgetSetup(index, setup)
            except Exception:
                continue

    def _resolve_json_pointer(self, doc: dict, pointer: str):
        if not isinstance(doc, dict):
            return None
        if pointer.startswith("#"):
            pointer = pointer[1:]
        if pointer.startswith("/"):
            pointer = pointer[1:]
        if not pointer:
            return doc
        current = doc
        for part in pointer.split("/"):
            part = part.replace("~1", "/").replace("~0", "~")
            if not isinstance(current, dict) or part not in current:
                return None
            current = current[part]
        return current

    def _resolve_ref(self, ref: str, schema: dict, common_schema: dict):
        if not ref:
            return None
        if ref.startswith("./common.json#"):
            return self._resolve_json_pointer(common_schema, ref.split("#", 1)[1])
        if ref.startswith("#"):
            return self._resolve_json_pointer(schema, ref)
        return None

    def _default_value(self, schema_node, schema: dict, common_schema: dict):
        if not isinstance(schema_node, dict):
            return None
        if "const" in schema_node:
            return schema_node["const"]
        if "default" in schema_node:
            return schema_node["default"]
        if "$ref" in schema_node:
            return self._default_value(self._resolve_ref(schema_node["$ref"], schema, common_schema), schema, common_schema)
        if "enum" in schema_node and schema_node["enum"]:
            return schema_node["enum"][0]
        for key in ("allOf", "oneOf", "anyOf"):
            if key in schema_node:
                for part in schema_node[key]:
                    value = self._default_value(part, schema, common_schema)
                    if value is not None:
                        return value
        schema_type = schema_node.get("type")
        if isinstance(schema_type, list):
            order = ("string", "integer", "number", "boolean", "object", "array", "null")
            for candidate in order:
                if candidate in schema_type:
                    return self._default_for_type(candidate)
        elif isinstance(schema_type, str):
            return self._default_for_type(schema_type)
        return None

    def _default_for_type(self, schema_type: str):
        if schema_type == "string":
            return ""
        if schema_type == "integer":
            return 0
        if schema_type == "number":
            return 0
        if schema_type == "boolean":
            return False
        if schema_type == "array":
            return []
        if schema_type == "object":
            return {}
        if schema_type == "null":
            return None
        return None

    def _collect_properties_schema(self, class_key: str, schema: dict):
        defs = schema.get("$defs", {})
        base_props = defs.get("BaseProperties", {}).get("properties", {})
        feature = defs.get(f"{class_key}Feature")
        if not isinstance(feature, dict):
            return None
        combined = {}

        def _merge(props: dict):
            for name, prop_schema in props.items():
                if name not in combined:
                    combined[name] = prop_schema

        for subschema in feature.get("allOf", []):
            if not isinstance(subschema, dict):
                continue
            props_schema = subschema.get("properties", {}).get("properties")
            if not isinstance(props_schema, dict):
                continue
            if "allOf" in props_schema:
                for part in props_schema["allOf"]:
                    if not isinstance(part, dict):
                        continue
                    ref = part.get("$ref")
                    if ref == "#/$defs/BaseProperties":
                        _merge(base_props)
                    elif "properties" in part:
                        _merge(part.get("properties", {}))
            elif "properties" in props_schema:
                _merge(props_schema.get("properties", {}))

        if base_props:
            _merge(base_props)
        return combined

    def _collect_required_properties_schema(self, class_key: str, schema: dict, common_schema: dict):
        defs = schema.get("$defs", {})
        feature = defs.get(f"{class_key}Feature")
        if not isinstance(feature, dict):
            return set()
        required = set()

        def _collect_required(node):
            if not isinstance(node, dict):
                return
            items = node.get("required")
            if isinstance(items, list):
                for name in items:
                    if isinstance(name, str):
                        required.add(name)

        def _resolve_node(node):
            if isinstance(node, dict) and "$ref" in node:
                return self._resolve_ref(node["$ref"], schema, common_schema)
            return node

        for subschema in feature.get("allOf", []):
            if not isinstance(subschema, dict):
                continue
            props_schema = subschema.get("properties", {}).get("properties")
            if not isinstance(props_schema, dict):
                continue
            if "allOf" in props_schema:
                for part in props_schema["allOf"]:
                    _collect_required(_resolve_node(part))
            else:
                _collect_required(_resolve_node(props_schema))
        return required

    def _schema_enum_values(self, schema_node, schema: dict, common_schema: dict):
        if not isinstance(schema_node, dict):
            return None
        if "$ref" in schema_node:
            return self._schema_enum_values(self._resolve_ref(schema_node["$ref"], schema, common_schema), schema, common_schema)
        values = schema_node.get("enum")
        if isinstance(values, list) and values:
            return set(values)
        one_of = schema_node.get("oneOf")
        if isinstance(one_of, list) and one_of:
            collected = set()
            for item in one_of:
                if isinstance(item, dict) and "const" in item:
                    collected.add(item["const"])
            return collected or None
        for key in ("allOf", "anyOf"):
            if key in schema_node:
                collected = set()
                for part in schema_node[key]:
                    part_values = self._schema_enum_values(part, schema, common_schema)
                    if part_values:
                        collected.update(part_values)
                if collected:
                    return collected
        return None

    def _validate_geojson_file(
        self,
        source_path: str,
        class_key: str,
        project_dir: str,
        schema: dict,
        common_schema: dict,
        data: dict = None,
    ):
        errors = []
        error_entries = []
        if data is None:
            data = self._read_json_file(source_path)
        if not isinstance(data, dict):
            return ["Файл не є валідним JSON або не є об'єктом."], []
        geo_type = data.get("type")
        if geo_type == "FeatureCollection":
            features = data.get("features")
        elif geo_type == "Feature":
            features = [data]
        else:
            return [f"Невідомий тип GeoJSON: {geo_type!r}."], []
        if not isinstance(features, list):
            return ["Поле 'features' має бути масивом."], []
        props_schema = self._collect_properties_schema(class_key, schema)
        required_props = self._collect_required_properties_schema(class_key, schema, common_schema)
        if not props_schema:
            return [f"Схема для класу {class_key!r} не знайдена."], []
        normalized_props = {}
        for name, node in props_schema.items():
            normalized = self._normalize_cyrillic_key(name)
            if normalized not in normalized_props:
                normalized_props[normalized] = node
        allowed_props = set(normalized_props.keys())
        required_norm = set()
        required_map = {}
        for name in required_props:
            normalized = self._normalize_cyrillic_key(name)
            required_norm.add(normalized)
            if normalized not in required_map:
                required_map[normalized] = name
        if self._uses_state_change(class_key):
            allowed_props.update({"state", "change"})
        for index, feature in enumerate(features, start=1):
            feature_errors = []
            if not isinstance(feature, dict):
                message = f"[{index}] Feature не є об'єктом."
                errors.append(message)
                feature_errors.append(message)
                error_entries.append(
                    {
                        "layer": class_key,
                        "feature_index": index,
                        "feature_id": None,
                        "geometry": None,
                        "messages": feature_errors,
                    }
                )
                continue
            props = feature.get("properties")
            if not isinstance(props, dict):
                message = f"[{index}] Відсутній або некоректний 'properties'."
                errors.append(message)
                feature_errors.append(message)
                error_entries.append(
                    {
                        "layer": class_key,
                        "feature_index": index,
                        "feature_id": feature.get("id"),
                        "geometry": feature.get("geometry"),
                        "messages": feature_errors,
                    }
                )
                continue
            normalized_prop_keys = {self._normalize_cyrillic_key(k) for k in props.keys()}
            for key in props.keys():
                normalized_key = self._normalize_cyrillic_key(key)
                if normalized_key not in allowed_props:
                    message = f"[{index}] Невідоме поле '{key}'."
                    errors.append(message)
                    feature_errors.append(message)
            for key in required_norm:
                if key not in normalized_prop_keys:
                    name = required_map.get(key, key)
                    message = f"[{index}] Відсутнє обов'язкове поле '{name}'."
                    errors.append(message)
                    feature_errors.append(message)
            for key, value in props.items():
                normalized_key = self._normalize_cyrillic_key(key)
                if normalized_key not in normalized_props:
                    if normalized_key in ("state", "change") and self._uses_state_change(class_key):
                        allowed_values = self._attributes_enum_keys(normalized_key)
                        if allowed_values is not None and value is not None:
                            value_key = str(int(value)) if isinstance(value, float) and value.is_integer() else str(value)
                            if value_key not in allowed_values:
                                message = f"[{index}] Поле '{key}' має значення {value!r}, якого немає у списку enum."
                                errors.append(message)
                                feature_errors.append(message)
                    continue
                schema_node = normalized_props.get(normalized_key)
                schema_type = self._schema_simple_type(schema_node, schema, common_schema)
                if value is not None:
                    if schema_type == "integer" and not isinstance(value, int):
                        message = f"[{index}] Поле '{key}' має бути integer, але отримано {type(value).__name__}."
                        errors.append(message)
                        feature_errors.append(message)
                    elif schema_type == "number" and not isinstance(value, (int, float)):
                        message = f"[{index}] Поле '{key}' має бути number, але отримано {type(value).__name__}."
                        errors.append(message)
                        feature_errors.append(message)
                    elif schema_type == "boolean" and not isinstance(value, bool):
                        message = f"[{index}] Поле '{key}' має бути boolean, але отримано {type(value).__name__}."
                        errors.append(message)
                        feature_errors.append(message)
                    elif schema_type == "string" and not isinstance(value, str):
                        message = f"[{index}] Поле '{key}' має бути string, але отримано {type(value).__name__}."
                        errors.append(message)
                        feature_errors.append(message)
                    elif schema_type == "array" and not isinstance(value, list):
                        message = f"[{index}] Поле '{key}' має бути array, але отримано {type(value).__name__}."
                        errors.append(message)
                        feature_errors.append(message)
                    elif schema_type == "object" and not isinstance(value, dict):
                        message = f"[{index}] Поле '{key}' має бути object, але отримано {type(value).__name__}."
                        errors.append(message)
                        feature_errors.append(message)
                enum_values = self._schema_enum_values(schema_node, schema, common_schema)
                if enum_values is not None and value is not None and value not in enum_values:
                    message = f"[{index}] Поле '{key}' має значення {value!r}, якого немає у списку enum."
                    errors.append(message)
                    feature_errors.append(message)
                if value is not None:
                    allowed_values = self._attributes_enum_keys(normalized_key)
                    if allowed_values is not None:
                        value_key = str(int(value)) if isinstance(value, float) and value.is_integer() else str(value)
                        if value_key not in allowed_values:
                            message = f"[{index}] Поле '{key}' має значення {value!r}, якого немає у списку enum."
                            errors.append(message)
                            feature_errors.append(message)
            if feature_errors:
                feature_id = feature.get("id")
                if feature_id is None and isinstance(props, dict):
                    feature_id = props.get("id")
                error_entries.append(
                    {
                        "layer": class_key,
                        "feature_index": index,
                        "feature_id": feature_id,
                        "geometry": feature.get("geometry"),
                        "messages": feature_errors,
                    }
                )
        return errors, error_entries

    def _ensure_errors_group(self, parent_group=None):
        try:
            project = QgsProject.instance()
            root = project.layerTreeRoot()
        except Exception:
            return None
        parent = parent_group or getattr(self.opened_projects, "current_project", None)
        if parent is None:
            parent = root
        if parent is None:
            return None
        existing = None
        try:
            for child in parent.children():
                if getattr(child, "name", None) and callable(child.name) and child.name() == "Errors":
                    existing = child
                    break
        except Exception:
            existing = None
        if existing is not None:
            try:
                parent.removeChildNode(existing)
                parent.insertChildNode(0, existing)
            except Exception:
                pass
            return existing
        try:
            return parent.insertGroup(0, "Errors")
        except Exception:
            try:
                return parent.addGroup("Errors")
            except Exception:
                return None

    def _write_error_geojson_files(self, error_entries, errors_dir: str, class_key: str):
        if not error_entries or not errors_dir or not class_key:
            return []
        layer_name = f"{class_key}_err"
        file_path = os.path.join(errors_dir, f"{layer_name}.geojson")
        features = []
        for entry in error_entries:
            features.append(
                {
                    "type": "Feature",
                    "geometry": entry.get("geometry"),
                    "properties": {
                        "layer": entry.get("layer"),
                        "feature_id": entry.get("feature_id"),
                        "feature_index": entry.get("feature_index"),
                        "error": "\n".join(entry.get("messages", [])),
                    },
                }
            )
        geojson = {"type": "FeatureCollection", "features": features}
        try:
            with open(file_path, "w", encoding="utf-8") as handle:
                json.dump(geojson, handle, ensure_ascii=False, indent=2)
            return [(layer_name, file_path)]
        except Exception:
            return []

    def _apply_error_style(self, layer: QgsVectorLayer) -> None:
        if layer is None:
            return
        try:
            geom_type = QgsWkbTypes.geometryType(layer.wkbType())
        except Exception:
            return
        symbol = None
        if geom_type == QgsWkbTypes.PolygonGeometry:
            symbol = QgsFillSymbol.createSimple({
                "color": "255,0,0,40",
                "outline_color": "255,0,0,255",
                "outline_width": "0.2",
            })
        elif geom_type == QgsWkbTypes.LineGeometry:
            symbol = QgsLineSymbol.createSimple({
                "color": "255,0,0,255",
                "width": "0.2",
            })
        else:
            symbol = QgsMarkerSymbol.createSimple({
                "name": "circle",
                "color": "255,0,0,255",
                "outline_color": "255,0,0,255",
                "size": "2.0",
            })
        if symbol is None:
            return
        try:
            layer.setRenderer(QgsSingleSymbolRenderer(symbol))
            layer.triggerRepaint()
        except Exception:
            return

    def _add_error_layers(self, layers, errors_group) -> None:
        if not layers or errors_group is None:
            return
        try:
            project = QgsProject.instance()
        except Exception:
            return
        for layer_name, file_path in layers:
            try:
                for existing in list(project.mapLayers().values()):
                    if existing is not None and existing.name() == layer_name:
                        project.removeMapLayer(existing.id())
            except Exception:
                pass
            try:
                layer = QgsVectorLayer(file_path, layer_name, "ogr")
            except Exception:
                layer = None
            if layer is None or not layer.isValid():
                continue
            try:
                base_name = layer_name[:-4] if layer_name.endswith("_err") else ""
                base_layer = self._get_layer_for_class(base_name) if base_name else None
                if base_layer is not None:
                    layer.setCrs(base_layer.crs())
                else:
                    layer.setCrs(project.crs())
            except Exception:
                pass
            project.addMapLayer(layer, False)
            try:
                errors_group.addLayer(layer)
            except Exception:
                project.addMapLayer(layer)
            self._apply_error_style(layer)
            try:
                layer.updateExtents()
                layer.triggerRepaint()
            except Exception:
                pass

    def _apply_property_aliases(self, class_key: str, props: dict, source_field_names: set, feature) -> None:
        if class_key == "hromada":
            if "hromada_type" in props and "type" in source_field_names and "hromada_type" not in source_field_names:
                try:
                    props["hromada_type"] = feature["type"]
                except Exception:
                    pass

    def _build_feature_properties(self, class_key: str, katotth: str, schema: dict, common_schema: dict):
        props_schema = self._collect_properties_schema(class_key, schema)
        if not props_schema:
            return {}
        props = {}
        for name, prop_schema in props_schema.items():
            props[name] = self._default_value(prop_schema, schema, common_schema)
        if self._uses_state_change(class_key):
            props.setdefault("state", 0)
            props.setdefault("change", 0)
        props["class"] = class_key
        if "guid" in props:
            props["guid"] = str(uuid.uuid4())
        if katotth and "katotth" in props:
            props["katotth"] = katotth
        if "koatuu" in props and props["koatuu"] is None:
            props["koatuu"] = ""
        return props

    def _current_project_info(self):
        group = getattr(self.opened_projects, "current_project", None)
        if group is None:
            return None
        for info in getattr(self.opened_projects, "projects", []):
            if info.group is group:
                return info
        group_name = None
        try:
            group_name = group.name()
        except Exception:
            group_name = None
        if group_name:
            for info in getattr(self.opened_projects, "projects", []):
                if info.folder_name == group_name:
                    return info
        return None

    def _resolve_project_dir(self, info) -> str:
        if info is not None:
            try:
                folder_path = info.folder_path
            except Exception:
                folder_path = ""
            if folder_path and os.path.isdir(folder_path):
                return folder_path
            group_name = getattr(info, "folder_name", "")
        else:
            group_name = ""
        if not group_name:
            return ""
        try:
            open_dir = QSettings().value("json_ua/open_project_dir", "")
        except Exception:
            open_dir = ""
        if open_dir and os.path.isdir(open_dir) and os.path.basename(open_dir) == group_name:
            return open_dir
        try:
            base_dir = QSettings().value("json_ua/new_project_dir", "")
        except Exception:
            base_dir = ""
        if base_dir:
            candidate = os.path.join(base_dir, group_name)
            if os.path.isdir(candidate):
                return candidate
        return ""

    def _schema_geometry_wkb(self, class_key: str, schema: dict, common_schema: dict):
        feature = schema.get("$defs", {}).get(f"{class_key}Feature")
        if not isinstance(feature, dict):
            return None
        geom_ref = None
        for subschema in feature.get("allOf", []):
            if not isinstance(subschema, dict):
                continue
            geom_schema = subschema.get("properties", {}).get("geometry")
            if isinstance(geom_schema, dict):
                geom_ref = geom_schema.get("$ref")
                if geom_ref:
                    break
        if not geom_ref:
            return None
        ref_schema = self._resolve_ref(geom_ref, schema, common_schema)
        geom_name = None
        if isinstance(ref_schema, dict):
            geom_name = ref_schema.get("properties", {}).get("type", {}).get("const")
        if not geom_name:
            geom_name = geom_ref.split("#")[-1].split("/")[-1]
        mapping = {
            "Point": QgsWkbTypes.Point,
            "LineString": QgsWkbTypes.LineString,
            "Polygon": QgsWkbTypes.Polygon,
            "MultiPoint": QgsWkbTypes.MultiPoint,
            "MultiLineString": QgsWkbTypes.MultiLineString,
            "MultiPolygon": QgsWkbTypes.MultiPolygon,
        }
        return mapping.get(geom_name)

    def _schema_simple_type(self, schema_node, schema: dict, common_schema: dict):
        if not isinstance(schema_node, dict):
            return None
        if "$ref" in schema_node:
            return self._schema_simple_type(self._resolve_ref(schema_node["$ref"], schema, common_schema), schema, common_schema)
        if "type" in schema_node:
            schema_type = schema_node["type"]
            if isinstance(schema_type, list):
                for candidate in schema_type:
                    if isinstance(candidate, str):
                        return candidate
            elif isinstance(schema_type, str):
                return schema_type
        for key in ("allOf", "oneOf", "anyOf"):
            if key in schema_node:
                for part in schema_node[key]:
                    result = self._schema_simple_type(part, schema, common_schema)
                    if result:
                        return result
        return None

    def _qvariant_for_schema(self, schema_node, schema: dict, common_schema: dict):
        schema_type = self._schema_simple_type(schema_node, schema, common_schema)
        if schema_type == "integer":
            return QVariant.Int
        if schema_type == "number":
            return QVariant.Double
        if schema_type == "boolean":
            return QVariant.Bool
        if schema_type == "string":
            return QVariant.String
        if schema_type in ("array", "object"):
            return QVariant.String
        return QVariant.String

    def _fields_from_schema(self, class_key: str, schema: dict, common_schema: dict):
        props_schema = self._collect_properties_schema(class_key, schema)
        if not props_schema:
            return None
        fields = QgsFields()
        for name, prop_schema in props_schema.items():
            fields.append(QgsField(name, self._qvariant_for_schema(prop_schema, schema, common_schema)))
        return fields

    def _get_layer_for_class(self, class_key: str):
        layer_id = self.class_layers.get(class_key)
        if not layer_id:
            return None
        layer = QgsProject.instance().mapLayer(layer_id)
        if layer is None:
            self.class_layers.pop(class_key, None)
            self.layer_registry.pop(layer_id, None)
        return layer

    def _add_layer_to_project_group(self, layer) -> None:
        project = QgsProject.instance()
        group = getattr(self.opened_projects, "current_project", None)
        if group is None:
            project.addMapLayer(layer)
            return
        project.addMapLayer(layer, False)
        try:
            group.addLayer(layer)
        except Exception:
            project.addMapLayer(layer)

    def _register_layer(self, layer, class_name: str, source_path: str, geometry_type, loaded_from_disk: bool) -> None:
        if layer is None:
            return
        meta = {
            "class_name": class_name,
            "source_path": source_path,
            "dirty": False,
            "geometry_type": geometry_type,
            "loaded_from_disk": loaded_from_disk,
            "spatial_index": None,
        }
        self.layer_registry[layer.id()] = meta
        self.class_layers[class_name] = layer.id()
        self._connect_layer_signals(layer)
        self._update_save_action()

    def _connect_layer_signals(self, layer) -> None:
        layer_id = layer.id()
        try:
            layer.featureAdded.connect(lambda _fid, lid=layer_id: self.mark_dirty(lid))
        except Exception:
            pass
        try:
            layer.featureDeleted.connect(lambda _fid, lid=layer_id: self.mark_dirty(lid))
        except Exception:
            pass
        try:
            layer.geometryChanged.connect(lambda _fid, _geom, lid=layer_id: self.mark_dirty(lid))
        except Exception:
            pass
        try:
            layer.attributeValueChanged.connect(lambda _fid, _idx, _val, lid=layer_id: self.mark_dirty(lid))
        except Exception:
            pass

    def mark_dirty(self, layer_id: str, dirty: bool = True) -> None:
        meta = self.layer_registry.get(layer_id)
        if meta is None:
            return
        meta["dirty"] = bool(dirty)
        self._update_save_action()

    def _update_save_action(self) -> None:
        action = getattr(self, "action_save", None)
        if action is None:
            return
        has_layers = bool(self.layer_registry)
        has_dirty = any(meta.get("dirty") for meta in self.layer_registry.values())
        action.setEnabled(has_layers or has_dirty)

    def build_index(self, layer) -> None:
        if layer is None:
            return
        try:
            index = QgsSpatialIndex(layer.getFeatures())
        except Exception:
            return
        meta = self.layer_registry.get(layer.id())
        if meta is not None:
            meta["spatial_index"] = index

    def _ensure_memory_layer_for_class(self, class_key: str, project_dir: str, source_layer=None, source_path: str = "", loaded_from_disk: bool = False):
        existing = self._get_layer_for_class(class_key)
        if existing is not None:
            return existing
        schema, common_schema = self._load_schema_cache()
        wkb_type = self._schema_geometry_wkb(class_key, schema, common_schema)
        if wkb_type is None and source_layer is not None:
            try:
                wkb_type = source_layer.wkbType()
            except Exception:
                wkb_type = None
        if wkb_type is None:
            wkb_type = QgsWkbTypes.Point
        fields = self._fields_from_schema(class_key, schema, common_schema)
        if fields is None or fields.count() == 0:
            if source_layer is not None:
                try:
                    fields = source_layer.fields()
                except Exception:
                    fields = QgsFields()
            else:
                fields = QgsFields()
        layer = self._create_memory_layer(class_key, wkb_type, fields)
        if layer is None:
            return None
        if not source_path and project_dir:
            source_path = os.path.join(project_dir, f"{class_key}.geojson")
        self._register_layer(layer, class_key, source_path, wkb_type, loaded_from_disk)
        self._add_layer_to_project_group(layer)
        return layer

    def _create_memory_layer(self, class_key: str, wkb_type, fields: QgsFields):
        geom_name = QgsWkbTypes.displayString(wkb_type) if wkb_type is not None else "Geometry"
        crs = QgsProject.instance().crs()
        uri = f"{geom_name}?crs={crs.authid()}" if crs.isValid() else geom_name
        layer = QgsVectorLayer(uri, class_key, "memory")
        if not layer.isValid():
            return None
        try:
            geom_type = QgsWkbTypes.geometryType(layer.wkbType())
            if geom_type == QgsWkbTypes.PolygonGeometry:
                symbol = QgsFillSymbol.createSimple({
                    "color": "0,0,0,0",
                    "outline_color": "0,0,0,255",
                    "outline_width": "0.3",
                })
            elif geom_type == QgsWkbTypes.LineGeometry:
                symbol = QgsLineSymbol.createSimple({
                    "color": "0,0,0,255",
                    "width": "0.3",
                })
            else:
                symbol = QgsMarkerSymbol.createSimple({
                    "color": "0,0,0,255",
                    "outline_color": "0,0,0,255",
                    "size": "2.0",
                })
            if symbol is not None:
                layer.renderer().setSymbol(symbol)
        except Exception:
            pass
        try:
            layer.dataProvider().addAttributes(fields)
            layer.updateFields()
        except Exception:
            pass
        self._apply_enum_widgets(layer, class_key)
        try:
            layer.startEditing()
        except Exception:
            pass
        return layer

    def _geometry_compatible(self, target_wkb, source_wkb) -> bool:
        if QgsWkbTypes.geometryType(target_wkb) != QgsWkbTypes.geometryType(source_wkb):
            return False
        if QgsWkbTypes.isMultiType(source_wkb) and not QgsWkbTypes.isMultiType(target_wkb):
            return False
        return True

    def _coerce_geometry(self, geometry, target_wkb):
        if geometry is None:
            return None
        try:
            new_geom = QgsGeometry(geometry)
        except Exception:
            return None
        if QgsWkbTypes.isMultiType(target_wkb) and not QgsWkbTypes.isMultiType(new_geom.wkbType()):
            try:
                new_geom.convertToMultiType()
            except Exception:
                pass
        return new_geom

    def _find_source_layer_for_selection(self):
        layer = None
        if self.iface is not None:
            try:
                layer = self.iface.activeLayer()
            except Exception:
                layer = None
        if isinstance(layer, QgsVectorLayer):
            return layer
        selected_layers = []
        try:
            for candidate in QgsProject.instance().mapLayers().values():
                if not isinstance(candidate, QgsVectorLayer):
                    continue
                try:
                    if candidate.selectedFeatureCount() > 0:
                        selected_layers.append(candidate)
                except Exception:
                    continue
        except Exception:
            selected_layers = []
        if selected_layers:
            return selected_layers[0]
        return None

    def append_selected_to_class(self, class_key: str) -> None:
        if not class_key:
            return
        info = self._current_project_info()
        project_dir = self._resolve_project_dir(info)
        if not project_dir:
            self._push_message(self.tr(u"Оберіть групу проєкту, щоб додати об'єкти."), level=Qgis.Warning)
            return

        source_layer = self._find_source_layer_for_selection()
        if source_layer is None:
            self._push_message(self.tr(u"Немає активного векторного шару з виділенням."), level=Qgis.Warning)
            return
        try:
            selected = source_layer.selectedFeatures()
        except Exception:
            selected = []

        target_layer = self._ensure_memory_layer_for_class(class_key, project_dir, source_layer=source_layer)
        if target_layer is None:
            self._push_message(self.tr(u"Не вдалося створити memory-layer для {0}.").format(class_key), level=Qgis.Critical)
            return

        if not selected:
            self._push_message(self.tr(u"Немає виділених об'єктів для копіювання."), level=Qgis.Warning)
            return

        if not self._geometry_compatible(target_layer.wkbType(), source_layer.wkbType()):
            self._push_message(self.tr(u"Тип геометрії не відповідає шару {0}.").format(class_key), level=Qgis.Warning)
            return

        schema, common_schema = self._load_schema_cache()
        katotth = getattr(info, "katotth", "") if info is not None else ""
        props_template = {}
        if schema and f"{class_key}Feature" in schema.get("$defs", {}):
            props_template = self._build_feature_properties(class_key, katotth, schema, common_schema)
        if not props_template:
            self._push_message(self.tr(u"Немає схеми для шару {0}. Атрибути не можуть бути згенеровані.").format(class_key), level=Qgis.Warning)
            return

        target_fields = target_layer.fields()
        new_features = []
        for feature in selected:
            props = dict(props_template)

            new_feature = QgsFeature(target_fields)
            geometry = self._coerce_geometry(feature.geometry(), target_layer.wkbType())
            if geometry is not None:
                new_feature.setGeometry(geometry)
            attributes = [props.get(field.name()) for field in target_fields]
            new_feature.setAttributes(attributes)
            new_features.append(new_feature)

        try:
            if not target_layer.isEditable():
                target_layer.startEditing()
        except Exception:
            pass
        try:
            ok, added = target_layer.dataProvider().addFeatures(new_features)
        except Exception:
            self._push_message(self.tr(u"Не вдалося додати об'єкти у шар {0}.").format(class_key), level=Qgis.Critical)
            return
        if not ok:
            self._push_message(self.tr(u"Не вдалося додати об'єкти у шар {0}.").format(class_key), level=Qgis.Critical)
            return
        target_layer.updateExtents()
        self.build_index(target_layer)
        self.mark_dirty(target_layer.id(), True)
        try:
            source_layer.removeSelection()
        except Exception:
            pass
        try:
            added_ids = [feature.id() for feature in added if feature.id() is not None]
            if added_ids:
                target_layer.selectByIds(added_ids)
        except Exception:
            pass
        self._push_info(self.tr(u"Додано {0} об'єктів у {1}. Нові об'єкти виділено.").format(len(new_features), class_key))

    def load_folder(self, folder_path: str) -> None:
        if not folder_path or not os.path.isdir(folder_path):
            return
        files = sorted(
            name for name in os.listdir(folder_path)
            if name.lower().endswith(".geojson")
        )
        if not files:
            return
        progress = QProgressDialog(
            self.tr(u"Відкриття проєкту..."),
            self.tr(u"Скасувати"),
            0,
            len(files),
            self.iface.mainWindow(),
        )
        progress.setWindowModality(Qt.WindowModal)
        progress.setMinimumDuration(0)
        progress.setValue(0)
        schema, common_schema = self._load_schema_cache()
        loaded = 0
        canvas = self.iface.mapCanvas() if getattr(self, "iface", None) is not None else None
        prev_render = None
        if canvas is not None:
            try:
                prev_render = canvas.renderFlag()
                canvas.setRenderFlag(False)
            except Exception:
                prev_render = None
        try:
            for index, name in enumerate(files, start=1):
                if progress.wasCanceled():
                    break
                progress.setLabelText(self.tr(u"Завантаження {0}").format(name))
                progress.setValue(index - 1)
                QCoreApplication.processEvents()
                class_key = os.path.splitext(name)[0]
                if self._get_layer_for_class(class_key) is not None:
                    continue
                source_path = os.path.join(folder_path, name)
                item = self._show_status(self.tr(u"{0}: парсинг geojson").format(name))
                source_layer = QgsVectorLayer(source_path, class_key, "ogr")
                if not source_layer.isValid():
                    self._close_status(item)
                    continue
                target_layer = self._ensure_memory_layer_for_class(
                    class_key,
                    folder_path,
                    source_layer=source_layer,
                    source_path=source_path,
                    loaded_from_disk=True,
                )
                if target_layer is None:
                    self._close_status(item)
                    continue
                props_template = {}
                if schema and f"{class_key}Feature" in schema.get("$defs", {}):
                    props_template = self._build_feature_properties(class_key, "", schema, common_schema)
                target_fields = target_layer.fields()
                source_field_names = set()
                try:
                    source_field_names = set(source_layer.fields().names())
                except Exception:
                    source_field_names = set()
                new_features = []
                for feature in source_layer.getFeatures():
                    new_feature = QgsFeature(target_fields)
                    geometry = self._coerce_geometry(feature.geometry(), target_layer.wkbType())
                    if geometry is not None:
                        new_feature.setGeometry(geometry)
                    if props_template:
                        props = dict(props_template)
                        for name in source_field_names:
                            if name in props:
                                try:
                                    props[name] = feature[name]
                                except Exception:
                                    pass
                        self._apply_property_aliases(class_key, props, source_field_names, feature)
                        attributes = [props.get(field.name()) for field in target_fields]
                    else:
                        attributes = feature.attributes()
                    new_feature.setAttributes(attributes)
                    new_features.append(new_feature)
                try:
                    if not target_layer.isEditable():
                        target_layer.startEditing()
                except Exception:
                    pass
                try:
                    target_layer.dataProvider().addFeatures(new_features)
                except Exception:
                    self._close_status(item)
                    continue
                target_layer.updateExtents()
                self._update_status(item, self.tr(u"{0}: побудова просторового індекса").format(name))
                self.build_index(target_layer)
                self._close_status(item)
                self.mark_dirty(target_layer.id(), False)
                loaded += 1
        finally:
            if canvas is not None and prev_render is not None:
                try:
                    canvas.setRenderFlag(prev_render)
                except Exception:
                    pass
        progress.setValue(len(files))
        if loaded:
            pass

    def save_layer(self, layer_id: str) -> bool:
        meta = self.layer_registry.get(layer_id)
        if meta is None:
            return False
        layer = QgsProject.instance().mapLayer(layer_id)
        if layer is None:
            return False
        source_path = meta.get("source_path", "")
        if not source_path:
            info = self._current_project_info()
            project_dir = self._resolve_project_dir(info)
            if project_dir:
                source_path = os.path.join(project_dir, f"{meta.get('class_name', layer.name())}.geojson")
                meta["source_path"] = source_path
        if not source_path:
            self._push_message(self.tr(u"Немає шляху для збереження шару {0}.").format(layer.name()), level=Qgis.Warning)
            return False
        self._close_geojson_layers_for_path(source_path)
        write_path = source_path
        locked = False
        if os.path.exists(source_path):
            try:
                os.remove(source_path)
            except Exception:
                locked = True
        if locked or os.path.exists(source_path):
            base, ext = os.path.splitext(source_path)
            write_path = f"{base}_tmp{ext or '.geojson'}"
        item = self._show_status(self.tr(u"{0}: парсинг geojson").format(os.path.basename(write_path)))
        options = QgsVectorFileWriter.SaveVectorOptions()
        options.driverName = "GeoJSON"
        options.fileEncoding = "UTF-8"
        options.actionOnExistingFile = QgsVectorFileWriter.CreateOrOverwriteFile
        result, error = QgsVectorFileWriter.writeAsVectorFormatV2(
            layer,
            write_path,
            QgsProject.instance().transformContext(),
            options,
        )
        self._close_status(item)
        if result != QgsVectorFileWriter.NoError:
            self._push_message(self.tr(u"Помилка збереження {0}: {1}").format(layer.name(), error), level=Qgis.Critical)
            return False
        final_path = source_path
        if write_path != source_path:
            try:
                if os.path.exists(source_path):
                    os.remove(source_path)
                os.replace(write_path, source_path)
            except Exception:
                try:
                    if os.path.exists(write_path):
                        os.remove(write_path)
                except Exception:
                    pass
                self._push_message(
                    self.tr(u"Не вдалося перезаписати існуючий файл {0}.").format(os.path.basename(source_path)),
                    level=Qgis.Critical,
                )
                return False
        meta["dirty"] = False
        meta["loaded_from_disk"] = True
        self._push_info(self.tr(u"Збережено GeoJSON: {0}").format(os.path.basename(final_path)))
        self._prompt_add_saved_file(final_path, meta.get("class_name", layer.name()))
        self._update_save_action()
        return True

    def _prompt_add_saved_file(self, source_path: str, class_name: str) -> None:
        parent = self.iface.mainWindow() if self.iface is not None else None
        dialog = QMessageBox(parent)
        dialog.setIcon(QMessageBox.Question)
        dialog.setWindowTitle(self.tr(u"Додати GeoJSON у проєкт"))
        dialog.setText(self.tr(u"Додати збережений GeoJSON файл у проєкт?"))
        add_button = dialog.addButton(self.tr(u"Додати"), QMessageBox.AcceptRole)
        dialog.addButton(self.tr(u"Не додавати"), QMessageBox.RejectRole)
        dialog.setDefaultButton(add_button)
        dialog.exec_()
        if dialog.clickedButton() != add_button:
            return
        if self._geojson_layer_exists(source_path):
            self._push_message(self.tr(u"GeoJSON файл вже додано у проєкт."), level=Qgis.Info)
            return
        layer = QgsVectorLayer(source_path, f"{class_name}_file", "ogr")
        if not layer.isValid():
            self._push_message(self.tr(u"Не вдалося додати GeoJSON файл у проєкт."), level=Qgis.Warning)
            return
        group = getattr(self.opened_projects, "current_project", None)
        project = QgsProject.instance()
        if group is None:
            project.addMapLayer(layer)
        else:
            project.addMapLayer(layer, False)
            try:
                group.addLayer(layer)
            except Exception:
                project.addMapLayer(layer)
        self._push_message(self.tr(u"GeoJSON файл додано."), level=Qgis.Info)

    def _geojson_layer_exists(self, source_path: str) -> bool:
        try:
            layers = QgsProject.instance().mapLayers().values()
        except Exception:
            return False
        for layer in layers:
            try:
                src = layer.source()
            except Exception:
                src = ""
            if source_path in src:
                return True
        return False

    def _close_geojson_layers_for_path(self, source_path: str) -> None:
        if not source_path:
            return
        try:
            project = QgsProject.instance()
            layers = list(project.mapLayers().values())
        except Exception:
            return
        for layer in layers:
            try:
                src = layer.source()
            except Exception:
                src = ""
            if source_path and source_path in src:
                try:
                    project.removeMapLayer(layer.id())
                except Exception:
                    continue

    def save_all_dirty(self) -> bool:
        dirty_layers = [lid for lid, meta in self.layer_registry.items() if meta.get("dirty")]
        if not dirty_layers:
            self._push_info(self.tr(u"Немає незбережених змін."))
            return True
        failed = []
        for layer_id in dirty_layers:
            if not self.save_layer(layer_id):
                failed.append(layer_id)
        if failed:
            self._push_message(self.tr(u"Не вдалося зберегти частину шарів."), level=Qgis.Warning)
            return False
        self._push_info(self.tr(u"Усі зміни збережено."))
        return True

    def _prompt_save_dirty(self) -> bool:
        dirty_layers = [lid for lid, meta in self.layer_registry.items() if meta.get("dirty")]
        if not dirty_layers:
            return True
        parent = self.iface.mainWindow() if self.iface is not None else None
        dialog = QMessageBox(parent)
        dialog.setIcon(QMessageBox.Warning)
        dialog.setWindowTitle(self.tr(u"Незбережені зміни"))
        dialog.setText(self.tr(u"Є незбережені зміни у memory-layers. Зберегти?"))
        save_button = dialog.addButton(self.tr(u"Зберегти"), QMessageBox.AcceptRole)
        discard_button = dialog.addButton(self.tr(u"Не зберігати"), QMessageBox.DestructiveRole)
        cancel_button = dialog.addButton(self.tr(u"Скасувати"), QMessageBox.RejectRole)
        dialog.setDefaultButton(save_button)
        dialog.exec_()
        clicked = dialog.clickedButton()
        if clicked == save_button:
            return self.save_all_dirty()
        if clicked == discard_button:
            return True
        return False

    def _clear_registry_for_group(self, group) -> None:
        if group is None:
            return
        layer_ids = []
        try:
            children = group.children()
        except Exception:
            children = []
        for child in children:
            layer = getattr(child, "layer", None)
            if callable(layer):
                try:
                    layer_obj = layer()
                except Exception:
                    layer_obj = None
                if layer_obj is not None:
                    layer_ids.append(layer_obj.id())
        for layer_id in layer_ids:
            meta = self.layer_registry.pop(layer_id, None)
            if meta:
                class_name = meta.get("class_name")
                if class_name and self.class_layers.get(class_name) == layer_id:
                    self.class_layers.pop(class_name, None)
        self._update_save_action()

    def _on_request_geojson(self, class_key: str) -> None:
        self.append_selected_to_class(class_key)

    def _is_katotth_group_name(self, name: str) -> bool:
        if not name:
            return False
        if name.startswith("old_"):
            return False
        return self._is_project_folder_name(name)

    def _rename_existing_project_groups(self) -> None:
        try:
            root = QgsProject.instance().layerTreeRoot()
        except Exception:
            return
        if root is None:
            return
        for child in root.children():
            is_group = getattr(child, "isGroup", None)
            if not callable(is_group) or not is_group():
                continue
            name = child.name()
            if name.startswith("old_"):
                continue
            if self._is_katotth_group_name(name):
                try:
                    child.setName(f"old_{name}")
                except Exception:
                    pass

    def on_open_help(self):
        if common.LOG:
            common.log_calls(common.logFile, "GeoJsonUa.on_open_help()")
        url = QUrl("https://krechkivsky.github.io/json_ua_docs/")
        if not QDesktopServices.openUrl(url):
            self._push_info(self.tr(u"Не вдалося відкрити довідку у браузері."))

    def on_new_geojson(self):
        if common.LOG:
            common.log_calls(common.logFile, "GeoJsonUa.on_new_geojson()")

        parent = self.iface.mainWindow()
        if not self._ensure_project_crs(parent):
            return

        # --- ЕТАП 1: вибір батьківської папки ---
        caption = self.tr(u"Виберіть папку у якій буде створено папку проєкту.")
        last_dir = QSettings().value("json_ua/new_project_dir", "")
        base_dir = QFileDialog.getExistingDirectory(parent, caption, last_dir)
        if not base_dir:
            return

        QSettings().setValue("json_ua/new_project_dir", base_dir)

        # --- ЕТАП 2: власний діалог для вводу назви ---
        while True:
            dialog = QDialog(parent)
            dialog.setWindowTitle(self.tr(u"Введіть назву папки проєкту"))

            # збільшуємо ширину вікна
            dialog.resize(550, 150)

            layout = QVBoxLayout(dialog)

            label = QLabel(self.tr(u"Назва папки проєкту:"))
            layout.addWidget(label)

            line_edit = QLineEdit()
            line_edit.setPlaceholderText("BGD_GP_UA00000000000000000")
            layout.addWidget(line_edit)

            # кнопки
            buttons_layout = QHBoxLayout()
            ok_button = QPushButton(self.tr(u"OK"))
            cancel_button = QPushButton(self.tr(u"Скасувати"))

            buttons_layout.addStretch()
            buttons_layout.addWidget(ok_button)
            buttons_layout.addWidget(cancel_button)

            layout.addLayout(buttons_layout)

            ok_button.clicked.connect(dialog.accept)
            cancel_button.clicked.connect(dialog.reject)

            if dialog.exec_() != QDialog.Accepted:
                return

            project_name = line_edit.text().strip().upper()

            if self._is_project_folder_name(project_name):
                katotth = self._extract_katotth(project_name)
                break

            retry = QMessageBox.warning(
                parent,
                self.tr(u"Новий проєкт"),
                self.tr(u"Назва має відповідати шаблону: BGD_{GP/KP/DPT}_UA<17 цифр>[_версія/дата] і не містити пробілів."),
                QMessageBox.Retry | QMessageBox.Cancel,
                QMessageBox.Retry,
            )
            if retry != QMessageBox.Retry:
                return

        suffix = 0
        while True:
            if suffix == 0:
                candidate_name = project_name
            else:
                candidate_name = f"{project_name}_{suffix}"
            candidate_dir = os.path.join(base_dir, candidate_name)
            if not os.path.exists(candidate_dir):
                project_name = candidate_name
                project_dir = candidate_dir
                break
            suffix += 1

        try:
            os.makedirs(project_dir, exist_ok=False)
        except Exception as e:
            QMessageBox.critical(parent, self.tr(u"Новий проєкт"),
                                 self.tr(u"Не вдалося створити папку:\n{0}\n\nПомилка: {1}")
                                 .format(project_dir, str(e)))
            return

        self._push_info(self.tr(u"Створено папку проєкту: {0}").format(project_dir))
        self._create_project_group(project_name)
        self.opened_projects.new_project(project_name, project_dir, katotth=katotth)

    def on_open(self):
        if common.LOG:
            common.log_calls(common.logFile, "GeoJsonUa.on_open()")
        parent = self.iface.mainWindow()
        if not self._ensure_project_crs(parent):
            return
        caption = self.tr(u"Виберіть папку проєкту")
        last_dir = QSettings().value("json_ua/open_project_dir", "")
        project_dir = QFileDialog.getExistingDirectory(parent, caption, last_dir)
        if not project_dir:
            return
        QSettings().setValue("json_ua/open_project_dir", project_dir)

        folder_name = os.path.basename(project_dir)
        if not self._is_project_folder_name(folder_name):
            QMessageBox.warning(
                parent,
                self.tr(u"Відкрити проєкт"),
                self.tr(u"Назва папки має відповідати шаблону: BGD_{GP/KP/DPT}_UA<17 цифр>[_версія/дата] і не містити пробілів."),
            )
            return

        try:
            root = QgsProject.instance().layerTreeRoot()
        except Exception:
            root = None
        if root is not None:
            try:
                if root.findGroup(folder_name) is not None:
                    QMessageBox.information(
                        parent,
                        self.tr(u"Відкрити проєкт"),
                        self.tr(u"Група з іменем вже існує: {0}").format(folder_name),
                    )
                    return
            except Exception:
                pass

        group = self._create_project_group(folder_name)
        if group is None and root is not None:
            try:
                group = root.findGroup(folder_name)
            except Exception:
                group = None

        katotth = self._extract_katotth(folder_name)
        self.opened_projects.new_project(folder_name, project_dir, katotth=katotth)
        self._select_group_in_tree(group)
        self.opened_projects.set_current_project_group(group)
        self.load_folder(project_dir)

    def on_validate_project(self):
        if common.LOG:
            common.log_calls(common.logFile, "GeoJsonUa.on_validate_project()")
        current_group = getattr(self.opened_projects, "current_project", None)
        if current_group is None:
            return
        info = self._current_project_info()
        project_dir = self._resolve_project_dir(info)
        if not project_dir:
            return
        files = sorted(
            name for name in os.listdir(project_dir)
            if name.lower().endswith(".geojson")
        )
        if not files:
            return
        try:
            root = QgsProject.instance().layerTreeRoot()
        except Exception:
            root = None
        parent_group = current_group
        if parent_group is None and root is not None:
            try:
                parent_group = root.findGroup(os.path.basename(project_dir))
            except Exception:
                parent_group = None
        schema, common_schema = self._load_schema_cache()
        progress = QProgressDialog(
            self.tr(u"Перевірка проєкту..."),
            self.tr(u"Скасувати"),
            0,
            len(files),
            self.iface.mainWindow(),
        )
        progress.setWindowModality(Qt.WindowModal)
        progress.setMinimumDuration(0)
        progress.setValue(0)
        errors_dir = os.path.join(project_dir, "Errors")
        error_entries_by_class = {}
        c_errors = {}
        canvas = self.iface.mapCanvas() if getattr(self, "iface", None) is not None else None
        prev_render = None
        if canvas is not None:
            try:
                prev_render = canvas.renderFlag()
                canvas.setRenderFlag(False)
            except Exception:
                prev_render = None
        try:
            for index, name in enumerate(files, start=1):
                if progress.wasCanceled():
                    break
                progress.setLabelText(self.tr(u"Перевірка {0}").format(name))
                progress.setValue(index - 1)
                QCoreApplication.processEvents()
                class_key = os.path.splitext(name)[0]
                source_path = os.path.join(project_dir, name)
                data = self._read_json_file(source_path)
                _, error_entries = self._validate_geojson_file(
                    source_path,
                    class_key,
                    project_dir,
                    schema,
                    common_schema,
                    data=data,
                )
                if error_entries:
                    error_entries_by_class.setdefault(class_key, []).extend(error_entries)
                allowed_props = self._allowed_props_for_class(class_key)
                if allowed_props and isinstance(data, dict) and self._find_cyrillic_property_keys(data, allowed_props):
                    c_errors[f"{class_key}_err"] = source_path
                    error_entries_by_class.setdefault(class_key, []).append(
                        {
                            "layer": class_key,
                            "feature_index": None,
                            "feature_id": None,
                            "geometry": None,
                            "messages": [
                                "C: Виявлено кириличні символи в іменах атрибутів.",
                            ],
                        }
                    )
        finally:
            if canvas is not None and prev_render is not None:
                try:
                    canvas.setRenderFlag(prev_render)
                except Exception:
                    pass
        progress.setValue(len(files))
        topo_rules = self._load_topo_rules()
        topo_errors_by_class = {}
        if topo_rules:
            topo_progress = QProgressDialog(
                self.tr(u"Перевірка топології..."),
                self.tr(u"Скасувати"),
                0,
                len(topo_rules),
                self.iface.mainWindow(),
            )
            topo_progress.setWindowModality(Qt.WindowModal)
            topo_progress.setMinimumDuration(0)
            topo_progress.setValue(0)
            topo_errors_by_class = self._run_topology_checks(topo_rules, topo_progress)
        for class_key, entries in topo_errors_by_class.items():
            error_entries_by_class.setdefault(class_key, []).extend(entries)
        created_layers = []
        if error_entries_by_class:
            try:
                os.makedirs(errors_dir, exist_ok=True)
            except Exception:
                errors_dir = ""
            for class_key, entries in error_entries_by_class.items():
                if not errors_dir:
                    break
                created_layers.extend(self._write_error_geojson_files(entries, errors_dir, class_key))
        errors_group = None
        if created_layers:
            errors_group = self._ensure_errors_group(parent_group)
            if errors_group is not None:
                self._add_error_layers(created_layers, errors_group)
        paths_to_fix = {}
        for layer_name, source_path in c_errors.items():
            base = layer_name[:-4] if layer_name.endswith("_err") else layer_name
            if not os.path.exists(source_path):
                continue
            paths_to_fix[source_path] = base
        if paths_to_fix:
            self._prompt_fix_cyrillic_geojson(paths_to_fix)

    def on_save(self):
        if common.LOG:
            common.log_calls(common.logFile, "GeoJsonUa.on_save()")
        self.save_all_dirty()

    def on_close(self):
        if common.LOG:
            common.log_calls(common.logFile, "GeoJsonUa.on_close()")
        if not self._prompt_save_dirty():
            return
        group = getattr(self.opened_projects, "current_project", None)
        group_name = None
        if group is not None:
            try:
                group_name = group.name()
            except Exception:
                group_name = None
        if group is not None:
            try:
                root = QgsProject.instance().layerTreeRoot()
            except Exception:
                root = None
            if root is not None:
                try:
                    root.removeChildNode(group)
                except Exception:
                    pass
            try:
                self.opened_projects.remove_by_group(group)
            except Exception:
                pass
            self._clear_registry_for_group(group)
            self._refresh_current_project_label()
        if group_name:
            self._push_info(self.tr(u"Проєкт {0} закрито").format(group_name))
        else:
            self._push_info(self.tr(u"Проєкт закрито."))

    def on_restore_backup(self):
        if common.LOG:
            common.log_calls(common.logFile, "GeoJsonUa.on_restore_backup()")
        self._push_info(self.tr(u"Відновити з резервної копії: поки не реалізовано."))

    def _normative_style_path(self, layer_name: str):
        if not layer_name:
            return None
        if self._normative_style_cache is None:
            cache = {}
            templates_dir = os.path.join(self.plugin_dir, "templates")
            for root, _dirs, files in os.walk(templates_dir):
                for file_name in files:
                    if not file_name.lower().endswith(".qml"):
                        continue
                    stem = os.path.splitext(file_name)[0]
                    if stem not in cache:
                        cache[stem] = os.path.join(root, file_name)
            self._normative_style_cache = cache
        return self._normative_style_cache.get(layer_name)

    def _group_layers(self, group):
        if group is None:
            return []
        try:
            layer_nodes = group.findLayers()
        except Exception:
            layer_nodes = []
        if layer_nodes:
            layers = []
            for node in layer_nodes:
                try:
                    layer = node.layer()
                except Exception:
                    layer = None
                if layer is not None:
                    layers.append(layer)
            return layers
        layers = []
        try:
            children = group.children()
        except Exception:
            children = []
        for child in children:
            try:
                layer = child.layer()
            except Exception:
                layer = None
            if layer is not None:
                layers.append(layer)
            else:
                layers.extend(self._group_layers(child))
        return layers

    def _apply_normative_style(self, group, enabled: bool) -> None:
        if group is None:
            return
        layers = self._group_layers(group)
        for layer in layers:
            if not isinstance(layer, QgsVectorLayer):
                continue
            if not enabled:
                self._apply_sketch_style(layer)
                continue
            style_path = self._normative_style_path(layer.name())
            if not style_path:
                if layer.name() not in self._missing_style_reported:
                    self._missing_style_reported.add(layer.name())
                    self._push_message(
                        self.tr(u"Не знайдено файл стилю для шару {0}.").format(layer.name()),
                        level=Qgis.Warning,
                        duration=0,
                    )
                continue
            if not os.path.exists(style_path):
                if layer.name() not in self._missing_style_reported:
                    self._missing_style_reported.add(layer.name())
                    self._push_message(
                        self.tr(u"Не знайдено файл стилю для шару {0}.").format(layer.name()),
                        level=Qgis.Warning,
                        duration=0,
                    )
                continue
            try:
                self._load_symbology_only(layer, style_path)
                layer.triggerRepaint()
            except Exception:
                continue

    def _load_symbology_only(self, layer: QgsVectorLayer, style_path: str) -> None:
        if layer is None or not style_path:
            return
        data = None
        for encoding in ("utf-8", "cp1251", "utf-8-sig"):
            try:
                with open(style_path, "r", encoding=encoding) as handle:
                    data = handle.read()
                break
            except Exception:
                continue
        if data is None:
            return
        import re
        strip_blocks = [
            "fieldConfiguration",
            "aliases",
            "defaults",
            "constraints",
            "constraintExpressions",
            "attributetableconfig",
            "editable",
            "editform",
            "editforminit",
            "editforminitcode",
            "editforminitcodesource",
            "editforminitfilepath",
            "attributeEditorForm",
            "attributeEditorField",
            "attributeEditorContainer",
            "attributeEditorRelation",
            "attributeEditorQml",
        ]
        for tag in strip_blocks:
            data = re.sub(rf"<{tag}[^>]*>.*?</{tag}>", "", data, flags=re.IGNORECASE | re.DOTALL)
        temp_path = os.path.join(self.plugin_dir, ".normative_style_tmp.qml")
        try:
            with open(temp_path, "w", encoding="utf-8") as handle:
                handle.write(data)
            try:
                categories = QgsMapLayer.StyleCategory.Symbology
                layer.loadNamedStyle(temp_path, categories)
            except Exception:
                layer.loadNamedStyle(temp_path)
        finally:
            try:
                if os.path.exists(temp_path):
                    os.remove(temp_path)
            except Exception:
                pass

    def _apply_sketch_style(self, layer: QgsVectorLayer) -> None:
        if layer is None:
            return
        try:
            geom_type = QgsWkbTypes.geometryType(layer.wkbType())
        except Exception:
            return
        symbol = None
        if geom_type == QgsWkbTypes.PolygonGeometry:
            symbol = QgsFillSymbol.createSimple({
                "color": "0,0,0,0",
                "outline_color": "0,0,0,255",
                "outline_width": "0.3",
            })
        elif geom_type == QgsWkbTypes.LineGeometry:
            symbol = QgsLineSymbol.createSimple({
                "color": "0,0,0,255",
                "width": "0.3",
            })
        else:
            symbol = QgsMarkerSymbol.createSimple({
                "color": "0,0,0,255",
                "outline_color": "0,0,0,255",
                "size": "2.0",
            })
        if symbol is None:
            return
        try:
            layer.setRenderer(QgsSingleSymbolRenderer(symbol))
            layer.triggerRepaint()
        except Exception:
            return

    def on_toggle_normative_style(self):
        if common.LOG:
            common.log_calls(common.logFile, f"GeoJsonUa.on_toggle_normative_style(checked={bool(self.action_normative_style.isChecked())})")
        checked = bool(self.action_normative_style.isChecked())
        group = getattr(self.opened_projects, "current_project", None)
        self._apply_normative_style(group, checked)

    def on_toggle_debug_mode(self):
        if common.LOG:
            common.log_calls(common.logFile, f"GeoJsonUa.on_toggle_debug_mode(checked={bool(self.action_debug_mode.isChecked())})")
        self.debug_enabled = bool(self.action_debug_mode.isChecked())
        QSettings().setValue("json_ua/debug", self.debug_enabled)
        common.set_log_enabled(self.debug_enabled)
        if common.LOG:
            common.log_calls(common.logFile, f"GeoJsonUa.on_toggle_debug_mode -> LOG={common.LOG}")
        if self.debug_enabled:
            self._push_info(self.tr(u"Налагоджувальний режим увімкнено."))
        else:
            self._push_info(self.tr(u"Налагоджувальний режим вимкнено."))

    def run(self):
        """Run method that loads and starts the plugin"""
        if common.LOG:
            common.log_calls(common.logFile, "GeoJsonUa.run()")

        if self.dockwidget is None:
            self.dockwidget = GeoJsonUaDockWidget()
            self.dockwidget.closingPlugin.connect(self.onClosePlugin)
            self.dockwidget.request_geojson.connect(self._on_request_geojson)
            self.iface.addDockWidget(Qt.LeftDockWidgetArea, self.dockwidget)
            self._ensure_dockwidget_panel_name()
            self._refresh_current_project_label()
            self._constrain_main_window()
            self.dockwidget.show()
            return

        if self.dockwidget.isVisible():
            self.dockwidget.hide()
        else:
            self._constrain_main_window()
            self.dockwidget.show()
