# -*- 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
import textwrap

from qgis.PyQt.QtCore import QSettings, QTranslator, QCoreApplication, Qt, QItemSelectionModel, QVariant, QUrl, QPointF, QObject, QEvent, QTimer, QEventLoop, QIdentityProxyModel
from qgis.PyQt.QtGui import QIcon, QGuiApplication, QDesktopServices, QPainter, QPen, QPixmap
from qgis.PyQt.QtWidgets import QAction, QMenu, QStyle, QToolButton, QInputDialog, QProgressDialog, QApplication
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,
    QgsFeatureRequest,
    QgsVectorFileWriter,
    QgsSpatialIndex,
    QgsWkbTypes,
    QgsGeometry,
    QgsRectangle,
    QgsFillSymbol,
    QgsLineSymbol,
    QgsMarkerSymbol,
    QgsSingleSymbolRenderer,
    QgsEditorWidgetSetup,
    QgsTask,
)

# Initialize Qt resources from file resources.py
from .resources import *
from . import common
from .opened_projects import OpenedProjects
from .syntax import SyntaxValidator
from .topology import TopologyValidator


PROJECT_FOLDER_PATTERN = re.compile(r"^BGD_(GP|KP|DPT)_UA\d{17}(?:_[^\s]+)?$")
METADATA_FILE_NAME_PATTERN = re.compile(r"^\d{10}_metadata\.geojson$", re.IGNORECASE)
METADATA_FILE_SUFFIX = "_metadata.geojson"
METADATA_CLASS_KEY = "metadata"
METADATA_FILE_CLASS_PATTERN = re.compile(r"^\d{10}_metadata$", re.IGNORECASE)
DEFAULT_METADATA_FILE_NAME = "0000000000_metadata.geojson"
HROM_CHARACTERISTICS_FILE_SUFFIX = "_hrom_characteristics.geojson"
HROM_CHARACTERISTICS_CLASS_KEY = "hrom_characteristics"
HROM_CHARACTERISTICS_FILE_NAME_PATTERN = re.compile(r"^UA\d{17}_hrom_characteristics\.geojson$", re.IGNORECASE)
HROM_CHARACTERISTICS_FILE_CLASS_PATTERN = re.compile(r"^UA\d{17}_hrom_characteristics$", re.IGNORECASE)
SETTL_CHARACTERISTICS_FILE_SUFFIX = "_settl_characteristics.geojson"
SETTL_CHARACTERISTICS_CLASS_KEY = "settl_characteristics"
SETTL_CHARACTERISTICS_FILE_NAME_PATTERN = re.compile(r"^UA\d{17}_settl_characteristics\.geojson$", re.IGNORECASE)
SETTL_CHARACTERISTICS_FILE_CLASS_PATTERN = re.compile(r"^UA\d{17}_settl_characteristics$", re.IGNORECASE)
PLAN_FILE_SUFFIX = "_plan.geojson"
PLAN_FILE_NAME_PATTERN = re.compile(r"^UA\d{17}_plan\.geojson$", re.IGNORECASE)
PLAN_FILE_CLASS_PATTERN = re.compile(r"^UA\d{17}_plan$", re.IGNORECASE)
PLAN_CLASS_KEY = "plan"
INFO_FILE_SUFFIX = "_info.geojson"
INFO_CLASS_KEY = "info"
INFO_FILE_NAME_PATTERN = re.compile(r"^UA\d{17}_info\.geojson$", re.IGNORECASE)
INFO_FILE_CLASS_PATTERN = re.compile(r"^UA\d{17}_info$", re.IGNORECASE)
NON_SPATIAL_TABLE_FILE_SUFFIXES = {
    METADATA_CLASS_KEY: METADATA_FILE_SUFFIX,
    PLAN_CLASS_KEY: PLAN_FILE_SUFFIX,
    HROM_CHARACTERISTICS_CLASS_KEY: HROM_CHARACTERISTICS_FILE_SUFFIX,
    SETTL_CHARACTERISTICS_CLASS_KEY: SETTL_CHARACTERISTICS_FILE_SUFFIX,
    INFO_CLASS_KEY: INFO_FILE_SUFFIX,
}
PLAN_STRICT_PROPERTIES = (
    "sol_num",
    "solution",
    "ind_name",
    "unit",
    "ind_in",
    "ind_pr",
    "ind_pro",
    "ind_ext",
    "note",
)
HROM_CHARACTERISTICS_STRICT_PROPERTIES = (
    "name",
    "unit",
    "ind_in",
    "ind_pr",
    "ind_pro",
    "ind_ext",
    "note",
)
SETTL_CHARACTERISTICS_PROPERTIES = (
    "name",
    "unit",
    "ind_in",
    "ind_pr",
    "ind_pro",
    "ind_ext",
    "note",
)
INFO_STRICT_PROPERTIES = (
    "obj_guid",
    "name",
    "unit",
    "ind_in",
    "ind_pr",
    "ind_pro",
    "ind_ext",
    "note",
)
IGNORED_INTERNAL_PROPERTIES = {"fid"}
YEAR_TERM_FIELDS = {"existing_term", "prime_term", "project_term"}
YEAR_TERM_PLACEHOLDER = "РРРР"
METADATA_LEGACY_FIELD_MAP = {
    "title": "Title",
    "doc_type": "Doc_type",
    "region": "Region",
    "region_koatuu": "Region_KOATUU",
    "region_katotth": "Region_KATOTTH",
    "hromada": "Hromada",
    "hromada_koatuu": "Hromada_KOATUU",
    "hromada_katotth": "Hromada_KATOTTH",
    "settlement": "Settlement",
    "settl_koatuu": "Settl_KOATUU",
    "settl_katotth": "Settl_KATOTTH",
    "company_name": "Company_Name",
    "edrpou": "EDRPOU",
    "address": "Address",
    "phone": "Phone",
    "email": "Email",
    "arch_lastname": "Arch_LastName",
    "arch_firstname": "Arch_FirstName",
    "arch_middlename": "Arch_MiddleName",
    "arch_certificate_number": "Arch_Certificate_Number",
    "arch_certificate_date": "Arch_Certificate_Date",
    "surv_last_name": "Surv_Last_Name",
    "surv_first_name": "Surv_First_Name",
    "surv_middle_name": "Surv_Middle_Name",
    "surv_certificate_number": "Surv_Certificate_Number",
    "surv_certificate_date": "Surv_Certificate_Date",
    "decision_authority": "Decision_Authority",
    "decision_date": "Decision_Date",
    "decision_number": "Decision_Number",
    "expertise_authority": "Expertise_Authority",
    "expertise_date": "Expertise_Date",
    "expertise_number": "Expertise_Number",
    "approval_authority": "Approval_Authority",
    "approval_date": "Approval_Date",
    "approval_number": "Approval_Number",
}

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


class _SizeGuardEventFilter(QObject):
    def __init__(self, plugin):
        super().__init__()
        self._plugin = plugin

    def eventFilter(self, obj, event):
        try:
            self._plugin._handle_size_guard_event(obj, event)
        except Exception:
            pass
        return False


class _DirectoriesOnlyProxyModel(QIdentityProxyModel):
    def flags(self, index):
        flags = super().flags(index)
        if not index.isValid():
            return flags
        source_model = self.sourceModel()
        source_index = self.mapToSource(index)
        is_dir_method = getattr(source_model, "isDir", None)
        if not callable(is_dir_method):
            return flags
        try:
            if is_dir_method(source_index):
                return flags
        except Exception:
            return flags
        return flags & ~(Qt.ItemIsEnabled | Qt.ItemIsSelectable)


class _GeoJsonParseTask(QgsTask):
    def __init__(self, source_path: str, class_key: str):
        super().__init__(f"json_ua_parse_{class_key}", QgsTask.CanCancel)
        self.source_path = source_path
        self.class_key = class_key
        self.records = []
        self.source_field_names = set()
        self.geometry_type = None
        self.error_message = ""
        self.success = False

    def run(self):
        try:
            with open(self.source_path, "r", encoding="utf-8-sig") as handle:
                data = json.load(handle)
        except Exception:
            try:
                with open(self.source_path, "r", encoding="cp1251") as handle:
                    data = json.load(handle)
            except Exception as exc:
                self.error_message = str(exc)
                return False
        if not isinstance(data, dict):
            self.error_message = "РќРµРєРѕСЂРµРєС‚РЅРёР№ С„РѕСЂРјР°С‚ GeoJSON (РѕС‡С–РєСѓРІР°РІСЃСЏ РѕР±'С”РєС‚)."
            return False
        features = data.get("features")
        if not isinstance(features, list):
            self.error_message = "РќРµРєРѕСЂРµРєС‚РЅРёР№ С„РѕСЂРјР°С‚ GeoJSON (РІС–РґСЃСѓС‚РЅС–Р№ РјР°СЃРёРІ features)."
            return False
        total = max(1, len(features))
        records = []
        field_names = set()
        first_geom_type = None
        for idx, feature in enumerate(features, start=1):
            if self.isCanceled():
                return False
            if not isinstance(feature, dict):
                continue
            geometry = feature.get("geometry")
            properties = feature.get("properties")
            if not isinstance(properties, dict):
                properties = {}
            if first_geom_type is None and isinstance(geometry, dict):
                geom_type = geometry.get("type")
                if isinstance(geom_type, str):
                    first_geom_type = geom_type
            field_names.update(properties.keys())
            records.append(
                {
                    "geometry": geometry if isinstance(geometry, dict) else None,
                    "properties": properties,
                }
            )
            if idx % 500 == 0 or idx == total:
                self.setProgress(100.0 * float(idx) / float(total))
        self.records = records
        self.source_field_names = field_names
        self.geometry_type = first_geom_type
        self.success = True
        return True


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._date_fields_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()
        self._normative_style_enabled = False
        self._layer_theme_cache = {}
        self.syntax_validator = SyntaxValidator(self)
        self.topology_validator = TopologyValidator(self)
        self._sketch_icon = None
        self._style_manager_icon = None

        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
        self._canvas_context_menu_connected = False
        self._show_current_project_label = False
        self._enable_push_notifications = False
        common.set_log_enabled(self.debug_enabled)
        autosave_on_edit_value = settings.value("json_ua/autosave_on_edit", False)
        if isinstance(autosave_on_edit_value, str):
            self._autosave_on_edit_enabled = autosave_on_edit_value.strip().lower() in ("1", "true", "yes", "on")
        else:
            self._autosave_on_edit_enabled = bool(autosave_on_edit_value)
        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 = {}
        self._suppress_layer_remove_prompt = False
        self._project_close_in_progress = False
        self._layer_remove_prompted_ids = set()
        self._pending_layer_commit_save = set()
        self._qgis_save_layer_action = None
        self._qgis_save_all_edits_action = None
        self._pending_qgis_button_save_ids = set()
        self._layer_autosave_queued = set()
        self._scheduled_commit_autosaves = set()
        self._bulk_load_in_progress = False
        self._size_guard_filter = None
        self._size_guard_enabled = False
        self._size_guard_user_resize_until = 0.0
        self._size_guard_restore_in_progress = False
        self._guarded_main_size = None
        self._guarded_layer_panel_width = None
        self._layer_panel_dock = None


    # 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(main_window)
        self._init_normative_style_icons(style)
        self._update_normative_style_action()
        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)
        self._connect_qgis_save_edit_actions()
        self._connect_canvas_context_menu()
        QTimer.singleShot(0, self._sync_existing_project_group_layers)

    #--------------------------------------------------------------------------
    def _init_normative_style_icons(self, style: QStyle) -> None:
        if self._sketch_icon is None:
            self._sketch_icon = self._create_sketch_icon()
        if self._style_manager_icon is None:
            self._style_manager_icon = self._create_style_manager_icon()

    def _create_sketch_icon(self) -> QIcon:
        size = 16
        pixmap = QPixmap(size, size)
        pixmap.fill(Qt.transparent)
        painter = QPainter(pixmap)
        pen = QPen(Qt.black)
        pen.setWidth(1)
        painter.setPen(pen)
        painter.setBrush(Qt.NoBrush)
        padding = 2
        painter.drawRect(padding, padding, size - (padding * 2) - 1, size - (padding * 2) - 1)
        painter.end()
        return QIcon(pixmap)

    def _create_style_manager_icon(self) -> QIcon:
        size = 16
        pixmap = QPixmap(size, size)
        pixmap.fill(Qt.transparent)
        painter = QPainter(pixmap)
        painter.setRenderHint(QPainter.Antialiasing, True)
        pen = QPen(Qt.black)
        pen.setWidth(2)
        painter.setPen(pen)
        painter.setBrush(Qt.red)
        padding = 2
        left = padding
        right = size - padding - 1
        top = padding
        bottom = size - padding - 1
        x1 = left
        y1 = bottom
        x2 = right
        y2 = bottom
        x3 = (left + right) / 2.0
        y3 = top
        painter.drawPolygon(
            [
                QPointF(x1, y1),
                QPointF(x2, y2),
                QPointF(x3, y3),
            ]
        )
        painter.end()
        return QIcon(pixmap)

    def _update_normative_style_action(self) -> None:
        if getattr(self, "action_normative_style", None) is None:
            return
        if self._normative_style_enabled:
            self.action_normative_style.setText(self.tr(u"Ескіз"))
            if self._sketch_icon is not None:
                self.action_normative_style.setIcon(self._sketch_icon)
        else:
            self.action_normative_style.setText(self.tr(u"Умовні знаки"))
            if self._style_manager_icon is not None:
                self.action_normative_style.setIcon(self._style_manager_icon)

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

    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
        self._disconnect_qgis_save_edit_actions()
        self._disconnect_canvas_context_menu()

        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})")
        if not getattr(self, "_enable_push_notifications", False):
            return
        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 not getattr(self, "_show_current_project_label", True):
            return None
        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:
        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 = self._ensure_current_project_label()
        if label is not None:
            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 _required_doc_content_keys(self, doc_type: str) -> list:
        section = (doc_type or "").strip().upper()
        if section not in {"GP", "KP", "DPT"}:
            return []
        ini_path = os.path.join(self.plugin_dir, "ini", "docs_contents.ini")
        parser = configparser.ConfigParser(interpolation=None)
        parser.optionxform = str
        try:
            with open(ini_path, "r", encoding="utf-8") as handle:
                parser.read_file(handle)
        except UnicodeDecodeError:
            try:
                with open(ini_path, "r", encoding="cp1251") as handle:
                    parser.read_file(handle)
            except Exception:
                return []
        except Exception:
            return []
        if not parser.has_section(section):
            return []
        return [key.strip() for key in parser[section].keys() if key and key.strip()]

    def _project_composition_errors(
        self,
        folder_path: str,
        folder_name: str = "",
        include_required_files: bool = False,
        report_missing_required: bool = True,
    ) -> list:
        if not folder_path or not os.path.isdir(folder_path):
            return []
        candidate_name = (folder_name or os.path.basename(folder_path) or "").strip().upper()
        project_match = PROJECT_FOLDER_PATTERN.fullmatch(candidate_name)
        if not project_match:
            return []
        try:
            geojson_files = [
                name for name in os.listdir(folder_path)
                if name.lower().endswith(".geojson")
            ]
        except Exception:
            return []
        errors = []
        metadata_files = [
            name for name in geojson_files
            if name.lower().endswith(METADATA_FILE_SUFFIX)
        ]
        if report_missing_required and not metadata_files:
            errors.append(self.tr(u"Відсутній обов'язковий файл метаданих з шаблоном <10_цифр>_metadata.geojson."))
        valid_metadata_files = [
            name for name in metadata_files
            if METADATA_FILE_NAME_PATTERN.fullmatch(name)
        ]
        if metadata_files and not valid_metadata_files:
            errors.append(self.tr(u"Назва файла метаданих має відповідати шаблону <10_цифр>_metadata.geojson."))
        doc_type = project_match.group(1).upper()
        plan_files = [
            name for name in geojson_files
            if name.lower().endswith(PLAN_FILE_SUFFIX)
        ]
        valid_plan_files = [
            name for name in plan_files
            if PLAN_FILE_NAME_PATTERN.fullmatch(name)
        ]
        if report_missing_required and not plan_files:
            errors.append(
                self.tr(u"Відсутній обов'язковий файл з шаблоном UA<17_цифр>_plan.geojson для виду документації {0}.").format(doc_type)
            )
        elif not valid_plan_files:
            errors.append(self.tr(u"Назва файла плану має відповідати шаблону UA<17_цифр>_plan.geojson."))

        settl_characteristics_files = [
            name for name in geojson_files
            if name.lower().endswith(SETTL_CHARACTERISTICS_FILE_SUFFIX)
        ]
        valid_settl_characteristics_files = [
            name for name in settl_characteristics_files
            if SETTL_CHARACTERISTICS_FILE_NAME_PATTERN.fullmatch(name)
        ]
        if report_missing_required and not settl_characteristics_files:
            errors.append(
                self.tr(u"Відсутній обов'язковий файл з шаблоном UA<17_цифр>_settl_characteristics.geojson для виду документації {0}.").format(doc_type)
            )
        elif not valid_settl_characteristics_files:
            errors.append(self.tr(u"Назва файла характеристик населеного пункту має відповідати шаблону UA<17_цифр>_settl_characteristics.geojson."))

        info_files = [
            name for name in geojson_files
            if name.lower().endswith(INFO_FILE_SUFFIX)
        ]
        valid_info_files = [
            name for name in info_files
            if INFO_FILE_NAME_PATTERN.fullmatch(name)
        ]
        if info_files and not valid_info_files:
            errors.append(self.tr(u"Назва файла характеристик громади має відповідати шаблону UA<17_цифр>_info.geojson."))

        if include_required_files and report_missing_required:
            required_keys = self._required_doc_content_keys(doc_type)
            if required_keys:
                existing_stems = {
                    os.path.splitext(name)[0].strip().lower()
                    for name in geojson_files
                }
                for key in sorted(required_keys, key=lambda item: item.lower()):
                    if key.lower() not in existing_stems:
                        errors.append(
                            self.tr(u"Відсутній обов'язковий файл {0}.geojson для виду документації {1}.").format(key, doc_type)
                        )
        return errors

    def _ensure_default_metadata_geojson(self, folder_path: str, katotth: str = "") -> tuple:
        if not folder_path or not os.path.isdir(folder_path):
            return "", False, ""
        try:
            geojson_files = [
                name for name in os.listdir(folder_path)
                if name.lower().endswith(".geojson")
            ]
        except Exception:
            return "", False, ""
        metadata_files = [
            name for name in geojson_files
            if name.lower().endswith(METADATA_FILE_SUFFIX)
        ]
        valid_metadata_files = [
            name for name in metadata_files
            if METADATA_FILE_NAME_PATTERN.fullmatch(name)
        ]
        if valid_metadata_files:
            selected = sorted(valid_metadata_files, key=lambda item: item.lower())[0]
            return os.path.join(folder_path, selected), False, ""
        if metadata_files:
            return "", False, self.tr(u"Назва файла метаданих має відповідати шаблону <10_цифр>_metadata.geojson.")

        metadata_path = os.path.join(folder_path, DEFAULT_METADATA_FILE_NAME)
        schema, common_schema = self._load_schema_cache()
        properties = {}
        if schema and f"{METADATA_CLASS_KEY}Feature" in schema.get("$defs", {}):
            properties = self._build_feature_properties(METADATA_CLASS_KEY, katotth, schema, common_schema)
        properties = self._strip_internal_properties(properties)

        payload = {
            "type": "FeatureCollection",
            "name": "_metadata",
            "features": [
                {
                    "type": "Feature",
                    "properties": properties,
                    "geometry": None,
                }
            ],
        }
        try:
            with open(metadata_path, "w", encoding="utf-8") as handle:
                json.dump(payload, handle, ensure_ascii=False, indent=2)
                handle.write("\n")
        except Exception as exc:
            return "", False, self.tr(u"Не вдалося створити файл метаданих {0}: {1}").format(DEFAULT_METADATA_FILE_NAME, str(exc))
        return metadata_path, True, ""

    def _prompt_katotth_digits(self, parent=None, default_katotth: str = "", file_suffix: str = "plan") -> str:
        default_digits = ""
        default_upper = (default_katotth or "").strip().upper()
        if re.fullmatch(r"UA\d{17}", default_upper):
            default_digits = default_upper[2:]
        parent_widget = parent or (self.iface.mainWindow() if self.iface is not None else None)
        file_suffix = (file_suffix or "plan").strip()
        while True:
            text, accepted = QInputDialog.getText(
                parent_widget,
                self.tr(u"Код КАТОТТГ"),
                self.tr(u"Введіть 17-значний цифровий код КАТОТТГ для створення файла <КАТОТТГ>_{0}.geojson:").format(file_suffix),
                text=default_digits,
            )
            if not accepted:
                return ""
            digits = re.sub(r"\s+", "", text or "")
            if re.fullmatch(r"\d{17}", digits):
                return digits
            QMessageBox.warning(
                parent_widget,
                self.tr(u"Код КАТОТТГ"),
                self.tr(u"Код повинен містити рівно 17 цифр."),
            )

    def _ensure_default_plan_geojson(self, folder_path: str, katotth: str = "", parent=None) -> tuple:
        if not folder_path or not os.path.isdir(folder_path):
            return "", False, ""
        try:
            geojson_files = [
                name for name in os.listdir(folder_path)
                if name.lower().endswith(".geojson")
            ]
        except Exception:
            return "", False, ""
        plan_files = [
            name for name in geojson_files
            if name.lower().endswith(PLAN_FILE_SUFFIX)
        ]
        valid_plan_files = [
            name for name in plan_files
            if PLAN_FILE_NAME_PATTERN.fullmatch(name)
        ]
        if valid_plan_files:
            selected = sorted(valid_plan_files, key=lambda item: item.lower())[0]
            return os.path.join(folder_path, selected), False, ""
        if plan_files:
            return "", False, self.tr(u"Назва файла плану має відповідати шаблону UA<17_цифр>_plan.geojson.")

        digits = self._prompt_katotth_digits(parent=parent, default_katotth=katotth, file_suffix="plan")
        if not digits:
            return "", False, self.tr(u"Створення файла плану скасовано користувачем.")
        katotth_code = f"UA{digits}"
        plan_file_name = f"{katotth_code}_plan.geojson"
        plan_path = os.path.join(folder_path, plan_file_name)
        schema, common_schema = self._load_schema_cache()
        properties = {}
        if schema and f"{HROM_CHARACTERISTICS_CLASS_KEY}Feature" in schema.get("$defs", {}):
            properties = self._build_feature_properties(PLAN_CLASS_KEY, katotth_code, schema, common_schema)
        properties = self._strip_internal_properties(properties)
        payload = {
            "type": "FeatureCollection",
            "name": f"{katotth_code}_plan",
            "features": [
                {
                    "type": "Feature",
                    "properties": properties,
                    "geometry": None,
                }
            ],
        }
        try:
            with open(plan_path, "w", encoding="utf-8") as handle:
                json.dump(payload, handle, ensure_ascii=False, indent=2)
                handle.write("\n")
        except Exception as exc:
            return "", False, self.tr(u"Не вдалося створити файл плану {0}: {1}").format(plan_file_name, str(exc))
        return plan_path, True, ""

    def _ensure_default_settl_characteristics_geojson(self, folder_path: str, katotth: str = "", parent=None) -> tuple:
        if not folder_path or not os.path.isdir(folder_path):
            return "", False, ""
        try:
            geojson_files = [
                name for name in os.listdir(folder_path)
                if name.lower().endswith(".geojson")
            ]
        except Exception:
            return "", False, ""
        settl_files = [
            name for name in geojson_files
            if name.lower().endswith(SETTL_CHARACTERISTICS_FILE_SUFFIX)
        ]
        valid_settl_files = [
            name for name in settl_files
            if SETTL_CHARACTERISTICS_FILE_NAME_PATTERN.fullmatch(name)
        ]
        if valid_settl_files:
            selected = sorted(valid_settl_files, key=lambda item: item.lower())[0]
            return os.path.join(folder_path, selected), False, ""
        if settl_files:
            return "", False, self.tr(u"Назва файла характеристик населеного пункту має відповідати шаблону UA<17_цифр>_settl_characteristics.geojson.")

        digits = self._prompt_katotth_digits(parent=parent, default_katotth=katotth, file_suffix="settl_characteristics")
        if not digits:
            return "", False, self.tr(u"Створення файла характеристик населеного пункту скасовано користувачем.")
        katotth_code = f"UA{digits}"
        settl_file_name = f"{katotth_code}_settl_characteristics.geojson"
        settl_path = os.path.join(folder_path, settl_file_name)
        schema, common_schema = self._load_schema_cache()
        properties = {}
        if schema and f"{SETTL_CHARACTERISTICS_CLASS_KEY}Feature" in schema.get("$defs", {}):
            properties = self._build_feature_properties(SETTL_CHARACTERISTICS_CLASS_KEY, katotth_code, schema, common_schema)
        properties = self._strip_internal_properties(properties)
        payload = {
            "type": "FeatureCollection",
            "name": f"{katotth_code}_settl_characteristics",
            "features": [
                {
                    "type": "Feature",
                    "properties": properties,
                    "geometry": None,
                }
            ],
        }
        try:
            with open(settl_path, "w", encoding="utf-8") as handle:
                json.dump(payload, handle, ensure_ascii=False, indent=2)
                handle.write("\n")
        except Exception as exc:
            return "", False, self.tr(u"Не вдалося створити файл характеристик населеного пункту {0}: {1}").format(settl_file_name, str(exc))
        return settl_path, True, ""

    def _ensure_default_info_geojson(self, folder_path: str, katotth: str = "", parent=None) -> tuple:
        if not folder_path or not os.path.isdir(folder_path):
            return "", False, ""
        try:
            geojson_files = [
                name for name in os.listdir(folder_path)
                if name.lower().endswith(".geojson")
            ]
        except Exception:
            return "", False, ""
        info_files = [
            name for name in geojson_files
            if name.lower().endswith(INFO_FILE_SUFFIX)
        ]
        valid_info_files = [
            name for name in info_files
            if INFO_FILE_NAME_PATTERN.fullmatch(name)
        ]
        if valid_info_files:
            selected = sorted(valid_info_files, key=lambda item: item.lower())[0]
            return os.path.join(folder_path, selected), False, ""
        if info_files:
            return "", False, self.tr(u"Назва файла характеристик громади має відповідати шаблону UA<17_цифр>_info.geojson.")

        digits = self._prompt_katotth_digits(parent=parent, default_katotth=katotth, file_suffix="info")
        if not digits:
            return "", False, self.tr(u"Створення файла характеристик громади скасовано користувачем.")
        katotth_code = f"UA{digits}"
        info_file_name = f"{katotth_code}_info.geojson"
        info_path = os.path.join(folder_path, info_file_name)
        schema, common_schema = self._load_schema_cache()
        properties = {}
        if schema and f"{INFO_CLASS_KEY}Feature" in schema.get("$defs", {}):
            properties = self._build_feature_properties(INFO_CLASS_KEY, katotth_code, schema, common_schema)
        properties = self._strip_internal_properties(properties)
        payload = {
            "type": "FeatureCollection",
            "name": f"{katotth_code}_info",
            "features": [
                {
                    "type": "Feature",
                    "properties": properties,
                    "geometry": None,
                }
            ],
        }
        try:
            with open(info_path, "w", encoding="utf-8") as handle:
                json.dump(payload, handle, ensure_ascii=False, indent=2)
                handle.write("\n")
        except Exception as exc:
            return "", False, self.tr(u"Не вдалося створити файл характеристик громади {0}: {1}").format(info_file_name, str(exc))
        return info_path, True, ""

    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"):
                group = root.insertGroup(0, project_name)
                try:
                    group.setExpanded(True)
                except Exception:
                    pass
                return group
            group = root.addGroup(project_name)
            try:
                root.insertChildNode(0, group)
            except Exception:
                pass
            try:
                group.setExpanded(True)
            except Exception:
                pass
            return group
        except Exception:
            return None

    def _select_group_in_tree(self, group) -> None:
        if group is None:
            return
        try:
            group.setExpanded(True)
        except Exception:
            pass
        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
        try:
            view.expand(index)
        except Exception:
            pass

    def _choose_directory_with_disabled_files(self, parent, caption: str, start_dir: str = "") -> str:
        dialog = QFileDialog(parent, caption, start_dir or "")
        dialog.setFileMode(QFileDialog.Directory)
        dialog.setAcceptMode(QFileDialog.AcceptOpen)
        dialog.setOption(QFileDialog.DontUseNativeDialog, True)
        dialog.setOption(QFileDialog.ShowDirsOnly, False)
        proxy_model = _DirectoriesOnlyProxyModel(dialog)
        try:
            dialog.setProxyModel(proxy_model)
        except Exception:
            pass
        if dialog.exec_() != QDialog.Accepted:
            return ""
        selected = dialog.selectedFiles()
        if not selected:
            return ""
        selected_dir = selected[0]
        if not os.path.isdir(selected_dir):
            return ""
        return selected_dir

    def _align_progress_dialog_left(self, progress) -> None:
        if progress is None:
            return
        try:
            labels = progress.findChildren(QLabel)
        except Exception:
            labels = []
        for label in labels:
            try:
                label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
            except Exception:
                pass

    def _pump_progress_ui(self, progress, force: bool = False) -> None:
        if progress is None:
            return
        try:
            if force or not progress.isVisible():
                progress.show()
            if force:
                progress.raise_()
                progress.activateWindow()
            progress.repaint()
        except Exception:
            pass
        try:
            QCoreApplication.processEvents(QEventLoop.AllEvents, 25)
        except Exception:
            QCoreApplication.processEvents()
        try:
            QApplication.sendPostedEvents()
        except Exception:
            pass

    def _enter_cpu_boost_mode(self):
        # CPU boost disabled by request: keep normal OS scheduler behavior.
        return {"enabled": False}

    def _leave_cpu_boost_mode(self, token) -> None:
        return

    def _refresh_cpu_boost_mode(self, token) -> None:
        return

    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 not None:
            try:
                signal.connect(self._on_project_read)
            except Exception:
                pass
        about_to_clear_signal = getattr(project, "aboutToBeCleared", None)
        if about_to_clear_signal is not None:
            try:
                about_to_clear_signal.connect(self._on_project_about_to_be_cleared)
            except Exception:
                pass
        cleared_signal = getattr(project, "cleared", None)
        if cleared_signal is not None:
            try:
                cleared_signal.connect(self._on_project_cleared)
            except Exception:
                pass
        layers_added_signal = getattr(project, "layersAdded", None)
        if layers_added_signal is not None:
            try:
                layers_added_signal.connect(self._on_project_layers_added)
            except Exception:
                pass
        removed_signal = getattr(project, "layersWillBeRemoved", None)
        if removed_signal is not None:
            try:
                removed_signal.connect(self._on_layers_will_be_removed)
            except Exception:
                pass
        try:
            root = project.layerTreeRoot()
        except Exception:
            root = None
        if root is not None:
            will_remove_children = getattr(root, "willRemoveChildren", None)
            if will_remove_children is not None:
                try:
                    will_remove_children.connect(self._on_layer_tree_will_remove_children)
                except Exception:
                    pass
            added_children = getattr(root, "addedChildren", None)
            if added_children is not None:
                try:
                    added_children.connect(self._on_layer_tree_added_children)
                except Exception:
                    pass

    def _on_project_read(self, *args) -> None:
        self._project_close_in_progress = False
        self._rename_existing_project_groups()
        try:
            self.opened_projects.sync_existing_project_groups()
        except Exception:
            pass
        self._refresh_current_project_label()
        QTimer.singleShot(0, self._sync_existing_project_group_layers)

    def _on_project_about_to_be_cleared(self, *args) -> None:
        self._project_close_in_progress = True

    def _on_project_cleared(self, *args) -> None:
        QTimer.singleShot(0, self._finish_project_close)

    def _extract_layer_ids_from_signal_args(self, *args):
        if not args:
            return []
        first = args[0]
        if isinstance(first, (list, tuple)):
            values = list(first)
        else:
            values = [first]
        layer_ids = []
        for value in values:
            if isinstance(value, str):
                layer_ids.append(value)
                continue
            layer_id_getter = getattr(value, "id", None)
            if callable(layer_id_getter):
                try:
                    layer_id = layer_id_getter()
                except Exception:
                    layer_id = None
                if layer_id:
                    layer_ids.append(layer_id)
        return layer_ids

    def _extract_layers_from_signal_args(self, *args):
        if not args:
            return []
        first = args[0]
        values = list(first) if isinstance(first, (list, tuple)) else [first]
        layers = []
        for value in values:
            if isinstance(value, QgsVectorLayer):
                layers.append(value)
                continue
            is_valid = getattr(value, "isValid", None)
            layer_id_getter = getattr(value, "id", None)
            if callable(is_valid) and callable(layer_id_getter):
                try:
                    if value.isValid():
                        layers.append(value)
                        continue
                except Exception:
                    pass
            if isinstance(value, str):
                try:
                    layer = QgsProject.instance().mapLayer(value)
                except Exception:
                    layer = None
                if layer is not None:
                    layers.append(layer)
        return layers

    def _project_info_for_layer(self, layer):
        if layer is None:
            return None
        try:
            root = QgsProject.instance().layerTreeRoot()
        except Exception:
            root = None
        if root is None:
            return None
        try:
            node = root.findLayer(layer.id())
        except Exception:
            node = None
        current = node
        while current is not None:
            parent_getter = getattr(current, "parent", None)
            parent = parent_getter() if callable(parent_getter) else None
            if parent is None:
                break
            try:
                parent_name = parent.name() or ""
            except Exception:
                parent_name = ""
            if self._is_katotth_group_name(parent_name):
                return self._project_info_by_group_name(parent_name)
            current = parent
        return None

    def _preferred_geojson_path_for_layer(self, layer, project_dir: str) -> str:
        if layer is None or not project_dir:
            return ""
        try:
            class_name = (layer.name() or "").strip()
        except Exception:
            class_name = ""
        if not class_name:
            return ""
        return os.path.join(project_dir, f"{class_name}.geojson")

    def _is_auxiliary_project_layer(self, layer, source_path: str = "") -> bool:
        layer_name = ""
        if layer is not None:
            try:
                layer_name = (layer.name() or "").strip().lower()
            except Exception:
                layer_name = ""
        if layer_name.endswith("_check") or layer_name.endswith("_err"):
            return True
        normalized_source = ""
        if source_path:
            try:
                normalized_source = os.path.normpath(str(source_path)).lower()
            except Exception:
                normalized_source = str(source_path).lower()
        if normalized_source:
            if "\\syntax\\" in normalized_source or "\\topology\\" in normalized_source or "\\errors\\" in normalized_source:
                return True
            if normalized_source.endswith("_check.geojson") or normalized_source.endswith("_err.geojson"):
                return True
        if layer is None:
            return False
        try:
            root = QgsProject.instance().layerTreeRoot()
            node = root.findLayer(layer.id()) if root is not None else None
        except Exception:
            node = None
        current = node
        while current is not None:
            parent_getter = getattr(current, "parent", None)
            parent = parent_getter() if callable(parent_getter) else None
            if parent is None:
                break
            try:
                group_name = (parent.name() or "").strip()
            except Exception:
                group_name = ""
            if group_name in ("Syntax", "Topology", "Errors"):
                return True
            current = parent
        return False

    def _sync_layer_file_binding(self, layer) -> bool:
        if layer is None or not isinstance(layer, QgsVectorLayer):
            return False
        if self._is_auxiliary_project_layer(layer):
            self._remove_layer_registry_entry(layer.id())
            return False
        info = self._project_info_for_layer(layer)
        project_dir = self._resolve_project_dir(info)
        if not project_dir:
            return False
        source_path = self._preferred_geojson_path_for_layer(layer, project_dir)
        if not source_path:
            return False
        layer_id = layer.id()
        meta = self.layer_registry.get(layer_id)
        should_write_file = True
        if meta is None:
            try:
                wkb_type = layer.wkbType()
            except Exception:
                wkb_type = QgsWkbTypes.Unknown
            loaded_from_disk = os.path.isfile(source_path)
            self._register_layer(layer, layer.name(), source_path, wkb_type, loaded_from_disk=loaded_from_disk)
            self.build_index(layer)
            if loaded_from_disk:
                should_write_file = False
        else:
            old_class = meta.get("class_name")
            if old_class and self.class_layers.get(old_class) == layer_id and old_class != layer.name():
                self.class_layers.pop(old_class, None)
            meta["class_name"] = layer.name()
            meta["source_path"] = source_path
            self.class_layers[layer.name()] = layer_id
            if bool(meta.get("loaded_from_disk")) and not bool(meta.get("dirty")) and os.path.isfile(source_path):
                should_write_file = False
        if not should_write_file:
            return True
        return self.save_layer(layer_id, prompt_add_file=False)

    def _schedule_layer_binding_retries(self, layer_id: str) -> None:
        if not layer_id:
            return
        for delay_ms in (0, 150, 600):
            QTimer.singleShot(
                delay_ms,
                lambda lid=layer_id: self._sync_layer_file_binding(QgsProject.instance().mapLayer(lid)),
            )

    def _sync_existing_project_group_layers(self) -> None:
        try:
            layers = list(QgsProject.instance().mapLayers().values())
        except Exception:
            layers = []
        for layer in layers:
            if layer is None or not isinstance(layer, QgsVectorLayer):
                continue
            try:
                layer_id = layer.id()
            except Exception:
                continue
            self._schedule_layer_binding_retries(layer_id)

    def _on_project_layers_added(self, *args) -> None:
        for layer in self._extract_layers_from_signal_args(*args):
            if layer is None:
                continue
            try:
                layer_id = layer.id()
            except Exception:
                continue
            self._schedule_layer_binding_retries(layer_id)

    def _remove_layer_registry_entry(self, layer_id: str) -> None:
        if not layer_id:
            return
        meta = self.layer_registry.pop(layer_id, None)
        self._layer_theme_cache.pop(layer_id, None)
        if not meta:
            return
        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)

    def _layer_id_in_group(self, layer_id: str, group) -> bool:
        if not layer_id or group is None:
            return False
        try:
            for node in group.findLayers():
                try:
                    layer = node.layer()
                except Exception:
                    layer = None
                if layer is not None and layer.id() == layer_id:
                    return True
        except Exception:
            return False
        return False

    def _on_layers_will_be_removed(self, *args) -> None:
        if self._suppress_layer_remove_prompt or self._project_close_in_progress:
            return
        layer_ids = self._extract_layer_ids_from_signal_args(*args)
        if not layer_ids:
            return
        for layer_id in layer_ids:
            meta = self.layer_registry.get(layer_id)
            try:
                layer = QgsProject.instance().mapLayer(layer_id)
            except Exception:
                layer = None
            if meta:
                self._delete_layer_geojson_file(layer_id, layer=layer)
            if not meta:
                if layer is not None:
                    info = self._project_info_for_layer(layer)
                    project_dir = self._resolve_project_dir(info)
                    source_path = self._preferred_geojson_path_for_layer(layer, project_dir)
                    if source_path and os.path.isfile(source_path):
                        try:
                            os.remove(source_path)
                        except Exception:
                            pass
                continue
            if layer_id in self._layer_remove_prompted_ids:
                self._layer_remove_prompted_ids.discard(layer_id)
                self._remove_layer_registry_entry(layer_id)
                continue
            self._remove_layer_registry_entry(layer_id)
        self._update_save_action()

    def _resolve_layer_source_path(self, layer_id: str, layer=None) -> str:
        meta = self.layer_registry.get(layer_id)
        if meta is not None:
            source_path = meta.get("source_path", "")
            if source_path:
                return source_path
        info = self._current_project_info()
        project_dir = self._resolve_project_dir(info)
        if not project_dir:
            return ""
        class_name = meta.get("class_name", "") if isinstance(meta, dict) else ""
        if not class_name and layer is not None:
            try:
                class_name = layer.name() or ""
            except Exception:
                class_name = ""
        if not class_name:
            return ""
        source_path = os.path.join(project_dir, f"{class_name}.geojson")
        if isinstance(meta, dict):
            meta["source_path"] = source_path
        return source_path

    def _is_memory_vector_layer(self, layer) -> bool:
        try:
            return isinstance(layer, QgsVectorLayer) and layer.providerType().lower() == "memory"
        except Exception:
            return False

    def _prompt_delete_geojson_for_memory_layer(self, layer_id: str, layer=None, parent=None) -> bool:
        if self._suppress_layer_remove_prompt:
            return False
        if layer is not None and not self._is_memory_vector_layer(layer):
            return False
        source_path = self._resolve_layer_source_path(layer_id, layer=layer)
        if not source_path or not os.path.exists(source_path):
            return False
        try:
            os.remove(source_path)
        except Exception:
            return False
        return True

    def _delete_layer_geojson_file(self, layer_id: str, layer=None) -> bool:
        if self._suppress_layer_remove_prompt:
            return False
        source_path = self._resolve_layer_source_path(layer_id, layer=layer)
        if not source_path or not os.path.isfile(source_path):
            return False
        try:
            os.remove(source_path)
        except Exception:
            return False
        return True

    def _extract_removed_tree_nodes(self, args) -> list:
        nodes = []
        if not args:
            return nodes
        for arg in args:
            if isinstance(arg, (list, tuple)):
                for node in arg:
                    if node is not None:
                        nodes.append(node)
                if nodes:
                    return nodes
        if len(args) >= 3:
            parent = args[0]
            idx_from = args[1]
            idx_to = args[2]
            try:
                idx_from = int(idx_from)
                idx_to = int(idx_to)
            except Exception:
                return nodes
            if parent is None or idx_to < idx_from:
                return nodes
            try:
                children = parent.children()
            except Exception:
                return nodes
            if not isinstance(children, list):
                return nodes
            end = min(idx_to, len(children) - 1)
            if end < idx_from:
                return nodes
            for idx in range(idx_from, end + 1):
                node = children[idx]
                if node is not None:
                    nodes.append(node)
        return nodes

    def _project_info_by_group_name(self, group_name: str):
        if not group_name:
            return None
        for info in getattr(self.opened_projects, "projects", []):
            if getattr(info, "folder_name", "") == group_name:
                return info
        return None

    def _find_project_group_for_node(self, node):
        current = node
        while current is not None:
            try:
                name = current.name()
            except Exception:
                name = ""
            if self._is_katotth_group_name(name):
                return current
            parent_getter = getattr(current, "parent", None)
            current = parent_getter() if callable(parent_getter) else None
        return None

    def _on_layer_tree_will_remove_children(self, *args) -> None:
        if self._suppress_layer_remove_prompt or self._project_close_in_progress:
            return
        nodes = self._extract_removed_tree_nodes(args)
        if not nodes:
            return
        for node in nodes:
            is_group = getattr(node, "isGroup", None)
            if callable(is_group) and is_group():
                try:
                    subgroup_name = node.name() or ""
                except Exception:
                    subgroup_name = ""
                if not subgroup_name:
                    continue
                if self._is_katotth_group_name(subgroup_name):
                    continue
                project_group = self._find_project_group_for_node(node)
                if project_group is None:
                    continue
                try:
                    project_group_name = project_group.name() or ""
                except Exception:
                    project_group_name = ""
                if not project_group_name:
                    continue
                info = self._project_info_by_group_name(project_group_name)
                project_dir = self._resolve_project_dir(info)
                if not project_dir:
                    continue
                subfolder_path = os.path.join(project_dir, subgroup_name)
                if not os.path.isdir(subfolder_path):
                    continue
                try:
                    shutil.rmtree(subfolder_path)
                except Exception:
                    pass
                continue

            # Layer file deletion is handled by QgsProject.layersWillBeRemoved,
            # where exact layer ids are provided. Avoid deleting here to prevent
            # accidental multi-file removal from tree index ranges.

    def _on_layer_tree_added_children(self, *args) -> None:
        nodes = self._extract_removed_tree_nodes(args)
        if not nodes:
            return
        for node in nodes:
            layer_getter = getattr(node, "layer", None)
            if not callable(layer_getter):
                continue
            try:
                layer = layer_getter()
            except Exception:
                layer = None
            if layer is None:
                continue
            try:
                layer_id = layer.id()
            except Exception:
                continue
            self._schedule_layer_binding_retries(layer_id)

    def _on_layer_name_changed(self, layer_id: str) -> None:
        if not layer_id:
            return
        layer = QgsProject.instance().mapLayer(layer_id)
        if layer is None or not isinstance(layer, QgsVectorLayer):
            return
        info = self._project_info_for_layer(layer)
        project_dir = self._resolve_project_dir(info)
        if not project_dir:
            return
        meta = self.layer_registry.get(layer_id)
        old_source = ""
        old_class = ""
        if isinstance(meta, dict):
            old_source = meta.get("source_path", "")
            old_class = meta.get("class_name", "")
        if not old_source:
            old_source = self._resolve_layer_source_path(layer_id, layer=layer)
        new_source = self._preferred_geojson_path_for_layer(layer, project_dir)
        if not new_source:
            return
        try:
            new_class = (layer.name() or "").strip()
        except Exception:
            new_class = ""
        if not new_class:
            return
        if old_source and old_source != new_source and os.path.isfile(old_source):
            try:
                if os.path.exists(new_source):
                    os.remove(new_source)
                os.replace(old_source, new_source)
            except Exception:
                pass
        if meta is None:
            try:
                wkb_type = layer.wkbType()
            except Exception:
                wkb_type = QgsWkbTypes.Unknown
            self._register_layer(layer, new_class, new_source, wkb_type, loaded_from_disk=os.path.isfile(new_source))
        else:
            if old_class and self.class_layers.get(old_class) == layer_id and old_class != new_class:
                self.class_layers.pop(old_class, None)
            meta["class_name"] = new_class
            meta["source_path"] = new_source
            self.class_layers[new_class] = layer_id
        self.save_layer(layer_id, prompt_add_file=False)

    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:
        if not getattr(self, "_enable_push_notifications", False):
            return
        try:
            self.iface.messageBar().pushMessage(
                self.tr(u"json_ua"),
                message,
                level=level,
                duration=duration,
            )
        except Exception:
            pass

    def _show_status(self, message: str):
        if not getattr(self, "_enable_push_notifications", False):
            return None
        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:
        # Disabled intentionally: plugin must not mutate QGIS window geometry.
        return

    def _find_layer_panel_dock(self):
        try:
            view = self.iface.layerTreeView()
        except Exception:
            return None
        widget = view
        while widget is not None:
            if widget.__class__.__name__ == "QDockWidget":
                return widget
            try:
                widget = widget.parentWidget()
            except Exception:
                return None
        return None

    def _capture_size_baseline(self) -> None:
        try:
            window = self.iface.mainWindow()
        except Exception:
            window = None
        if window is not None:
            try:
                size = window.size()
                self._guarded_main_size = (size.width(), size.height())
            except Exception:
                pass
        self._layer_panel_dock = self._find_layer_panel_dock()
        if self._layer_panel_dock is not None:
            try:
                self._guarded_layer_panel_width = self._layer_panel_dock.width()
            except Exception:
                pass

    def _mark_user_resize_intent(self, ttl_seconds: float = 1.2) -> None:
        self._size_guard_user_resize_until = time.monotonic() + max(0.1, float(ttl_seconds))

    def _is_user_resize_intent_active(self) -> bool:
        return time.monotonic() <= self._size_guard_user_resize_until

    def _restore_main_window_size(self) -> None:
        if self._size_guard_restore_in_progress:
            return
        if not self._guarded_main_size:
            return
        try:
            window = self.iface.mainWindow()
        except Exception:
            window = None
        if window is None:
            return
        self._size_guard_restore_in_progress = True
        try:
            width, height = self._guarded_main_size
            window.resize(width, height)
        except Exception:
            pass
        self._size_guard_restore_in_progress = False

    def _restore_layer_panel_width(self) -> None:
        if self._size_guard_restore_in_progress:
            return
        dock = self._layer_panel_dock or self._find_layer_panel_dock()
        if dock is None or self._guarded_layer_panel_width is None:
            return
        self._size_guard_restore_in_progress = True
        try:
            dock.resize(self._guarded_layer_panel_width, dock.height())
        except Exception:
            pass
        self._size_guard_restore_in_progress = False

    def _handle_size_guard_event(self, obj, event) -> None:
        if not self._size_guard_enabled or event is None:
            return
        etype = event.type()
        non_client_press = getattr(QEvent, "NonClientAreaMouseButtonPress", None)
        if etype == QEvent.MouseButtonPress:
            class_name = obj.__class__.__name__ if obj is not None else ""
            if "QSplitterHandle" in class_name:
                self._mark_user_resize_intent(2.0)
        elif non_client_press is not None and etype == non_client_press:
            self._mark_user_resize_intent(2.0)

        if etype != QEvent.Resize:
            return
        if self._size_guard_restore_in_progress:
            return
        try:
            window = self.iface.mainWindow()
        except Exception:
            window = None
        if window is not None and obj is window:
            try:
                size = window.size()
                current = (size.width(), size.height())
            except Exception:
                return
            if self._guarded_main_size is None:
                self._guarded_main_size = current
                return
            if self._is_user_resize_intent_active():
                self._guarded_main_size = current
            elif current != self._guarded_main_size:
                QTimer.singleShot(0, self._restore_main_window_size)
            return

        dock = self._layer_panel_dock or self._find_layer_panel_dock()
        if dock is not None and obj is dock:
            current_width = dock.width()
            if self._guarded_layer_panel_width is None:
                self._guarded_layer_panel_width = current_width
                return
            if self._is_user_resize_intent_active():
                self._guarded_layer_panel_width = current_width
            elif current_width != self._guarded_layer_panel_width:
                QTimer.singleShot(0, self._restore_layer_panel_width)

    def _enable_size_guard(self) -> None:
        # Disabled intentionally: plugin must not mutate/restore QGIS geometry.
        return

    def _disable_size_guard(self) -> None:
        # Disabled intentionally.
        return

    def _find_qgis_action(self, object_names):
        try:
            main_window = self.iface.mainWindow()
        except Exception:
            return None
        if main_window is None:
            return None
        for object_name in object_names:
            try:
                action = main_window.findChild(QAction, object_name)
            except Exception:
                action = None
            if action is not None:
                return action
        return None

    def _connect_qgis_save_edit_actions(self) -> None:
        self._disconnect_qgis_save_edit_actions()
        self._qgis_save_layer_action = self._find_qgis_action(
            ["mActionSaveLayerEdits", "mActionSaveEdits"]
        )
        self._qgis_save_all_edits_action = self._find_qgis_action(["mActionSaveAllEdits"])
        if self._qgis_save_layer_action is not None:
            try:
                self._qgis_save_layer_action.triggered.connect(self._on_qgis_save_layer_button_triggered)
            except Exception:
                pass
        if self._qgis_save_all_edits_action is not None:
            try:
                self._qgis_save_all_edits_action.triggered.connect(self._on_qgis_save_layer_button_triggered)
            except Exception:
                pass

    def _disconnect_qgis_save_edit_actions(self) -> None:
        if self._qgis_save_layer_action is not None:
            try:
                self._qgis_save_layer_action.triggered.disconnect(self._on_qgis_save_layer_button_triggered)
            except Exception:
                pass
        if self._qgis_save_all_edits_action is not None:
            try:
                self._qgis_save_all_edits_action.triggered.disconnect(self._on_qgis_save_layer_button_triggered)
            except Exception:
                pass
        self._qgis_save_layer_action = None
        self._qgis_save_all_edits_action = None

    def _connect_canvas_context_menu(self) -> None:
        if self._canvas_context_menu_connected:
            return
        if self.iface is None:
            return
        try:
            canvas = self.iface.mapCanvas()
        except Exception:
            canvas = None
        if canvas is None:
            return
        signal = getattr(canvas, "contextMenuAboutToShow", None)
        if signal is None:
            return
        try:
            signal.connect(self._on_canvas_context_menu_about_to_show)
            self._canvas_context_menu_connected = True
        except Exception:
            self._canvas_context_menu_connected = False

    def _disconnect_canvas_context_menu(self) -> None:
        if not self._canvas_context_menu_connected:
            return
        if self.iface is None:
            self._canvas_context_menu_connected = False
            return
        try:
            canvas = self.iface.mapCanvas()
        except Exception:
            canvas = None
        if canvas is None:
            self._canvas_context_menu_connected = False
            return
        signal = getattr(canvas, "contextMenuAboutToShow", None)
        if signal is not None:
            try:
                signal.disconnect(self._on_canvas_context_menu_about_to_show)
            except Exception:
                pass
        self._canvas_context_menu_connected = False

    def _context_menu_candidates_with_selection(self):
        candidates = []
        try:
            active = self.iface.activeLayer() if self.iface is not None else None
        except Exception:
            active = None
        if isinstance(active, QgsVectorLayer):
            meta = self.layer_registry.get(active.id())
            if meta is not None:
                try:
                    if active.selectedFeatureCount() > 0:
                        candidates.append(active)
                except Exception:
                    pass
        for layer_id in list(self.layer_registry.keys()):
            layer = QgsProject.instance().mapLayer(layer_id)
            if layer is None or not isinstance(layer, QgsVectorLayer):
                continue
            if layer in candidates:
                continue
            try:
                if layer.selectedFeatureCount() > 0:
                    candidates.append(layer)
            except Exception:
                continue
        return candidates

    def _pick_selected_feature_at_context_event(self, event):
        candidates = self._context_menu_candidates_with_selection()
        if not candidates:
            return None, None
        if event is None:
            return candidates[0], None
        map_point = None
        try:
            map_point = event.mapPoint()
        except Exception:
            map_point = None
        if map_point is None:
            return candidates[0], None
        point_geom = QgsGeometry.fromPointXY(map_point)
        try:
            canvas = self.iface.mapCanvas() if self.iface is not None else None
            map_units_per_pixel = canvas.mapUnitsPerPixel() if canvas is not None else 1.0
        except Exception:
            map_units_per_pixel = 1.0
        tolerance = max(0.01, float(map_units_per_pixel) * 4.0)
        rect = QgsRectangle(
            map_point.x() - tolerance,
            map_point.y() - tolerance,
            map_point.x() + tolerance,
            map_point.y() + tolerance,
        )
        for layer in candidates:
            request = QgsFeatureRequest().setFilterRect(rect)
            try:
                selected_iter = layer.getSelectedFeatures(request)
            except Exception:
                selected_iter = []
            for feature in selected_iter:
                geom = feature.geometry()
                if geom is None or geom.isEmpty():
                    continue
                try:
                    if geom.intersects(point_geom) or geom.boundingBox().intersects(rect):
                        return layer, feature
                except Exception:
                    continue
        return candidates[0], None

    def _on_canvas_context_menu_about_to_show(self, menu, event=None) -> None:
        if menu is None:
            return
        layer, feature = self._pick_selected_feature_at_context_event(event)
        if layer is None:
            return
        action = QAction(self.tr(u"Виправити синтаксис"), menu)
        feature_id = None
        try:
            feature_id = feature.id() if feature is not None else None
        except Exception:
            feature_id = None
        action.triggered.connect(lambda _checked=False, lid=layer.id(), fid=feature_id: self._fix_syntax_for_selected_layer(lid, fid))
        menu.addSeparator()
        menu.addAction(action)

    def _qgis_save_targets(self):
        targets = []
        project = QgsProject.instance()
        for layer_id in list(self.layer_registry.keys()):
            layer = project.mapLayer(layer_id)
            if layer is None:
                continue
            try:
                is_memory = isinstance(layer, QgsVectorLayer) and layer.providerType().lower() == "memory"
            except Exception:
                is_memory = False
            if not is_memory:
                continue
            targets.append(layer_id)
        return targets

    def _on_qgis_save_layer_button_triggered(self, *_args) -> None:
        for layer_id in self._qgis_save_targets():
            self._pending_qgis_button_save_ids.add(layer_id)
            QTimer.singleShot(120, lambda lid=layer_id: self._autosave_after_qgis_save_button(lid))
            QTimer.singleShot(600, lambda lid=layer_id: self._autosave_after_qgis_save_button(lid))

    def _autosave_after_qgis_save_button(self, layer_id: str) -> None:
        if layer_id not in self._pending_qgis_button_save_ids:
            return
        if layer_id not in self.layer_registry:
            self._pending_qgis_button_save_ids.discard(layer_id)
            return
        project = QgsProject.instance()
        layer = project.mapLayer(layer_id)
        if layer is None:
            self._pending_qgis_button_save_ids.discard(layer_id)
            return
        # Ensure both geometry and attributes are committed before GeoJSON write.
        try:
            if layer.isEditable() and layer.isModified():
                try:
                    layer.commitChanges(False)
                except TypeError:
                    layer.commitChanges()
                try:
                    layer.startEditing()
                except Exception:
                    pass
        except Exception:
            pass
        ok = self._autosave_to_geojson(layer_id)
        if ok:
            self._pending_qgis_button_save_ids.discard(layer_id)

    def _on_layer_edit_command_ended(self, layer_id: str) -> None:
        if not layer_id or layer_id not in self.layer_registry:
            return
        if not self._autosave_on_edit_enabled:
            return
        if layer_id in self._layer_autosave_queued:
            return
        self._layer_autosave_queued.add(layer_id)
        QTimer.singleShot(180, lambda lid=layer_id: self._autosave_layer_after_form_edit(lid))

    def _autosave_layer_after_form_edit(self, layer_id: str) -> None:
        self._layer_autosave_queued.discard(layer_id)
        if not layer_id or layer_id not in self.layer_registry:
            return
        project = QgsProject.instance()
        layer = project.mapLayer(layer_id)
        if layer is None:
            return
        try:
            is_memory = isinstance(layer, QgsVectorLayer) and layer.providerType().lower() == "memory"
        except Exception:
            is_memory = False
        if not is_memory:
            return
        self._autosave_to_geojson(layer_id)

    def _show_status_debug(self, message: str, timeout_ms: int = 2500) -> None:
        if not getattr(self, "_enable_push_notifications", False):
            return
        try:
            status_bar = self.iface.mainWindow().statusBar() if self.iface is not None else None
        except Exception:
            status_bar = None
        if status_bar is None:
            return
        try:
            status_bar.showMessage(message, int(timeout_ms))
        except Exception:
            pass

    def _autosave_to_geojson(self, layer_id: str) -> bool:
        meta = self.layer_registry.get(layer_id)
        if meta is None:
            return False
        project = QgsProject.instance()
        layer = project.mapLayer(layer_id)
        if layer is None:
            return False
        source_path = meta.get("source_path", "")
        if self._is_auxiliary_project_layer(layer, source_path):
            return False
        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
        target_name = os.path.basename(source_path) if source_path else "?"
        self._show_status_debug(f"autosave {layer.name()} -> {target_name}")
        return self.save_layer(layer_id, prompt_add_file=False)

    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 _schema_class_key(self, class_key: str) -> str:
        if not class_key:
            return class_key
        key_text = str(class_key).strip()
        key_lower = key_text.lower()
        if key_lower == PLAN_CLASS_KEY:
            return PLAN_CLASS_KEY
        if METADATA_FILE_CLASS_PATTERN.fullmatch(key_text) or key_lower.endswith(f"_{METADATA_CLASS_KEY}"):
            return METADATA_CLASS_KEY
        if PLAN_FILE_CLASS_PATTERN.fullmatch(key_text) or key_lower.endswith(f"_{PLAN_CLASS_KEY}"):
            return PLAN_CLASS_KEY
        if HROM_CHARACTERISTICS_FILE_CLASS_PATTERN.fullmatch(key_text) or key_lower.endswith(f"_{HROM_CHARACTERISTICS_CLASS_KEY}"):
            return HROM_CHARACTERISTICS_CLASS_KEY
        if SETTL_CHARACTERISTICS_FILE_CLASS_PATTERN.fullmatch(key_text) or key_lower.endswith(f"_{SETTL_CHARACTERISTICS_CLASS_KEY}"):
            return SETTL_CHARACTERISTICS_CLASS_KEY
        if INFO_FILE_CLASS_PATTERN.fullmatch(key_text) or key_lower.endswith(f"_{INFO_CLASS_KEY}"):
            return INFO_CLASS_KEY
        return class_key

    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 _metadata_doc_type_mapping(self):
        return {
            "РљРѕРјРїР»РµРєСЃРЅРёР№ РїР»Р°РЅ РїСЂРѕСЃС‚РѕСЂРѕРІРѕРіРѕ СЂРѕР·РІРёС‚РєСѓ С‚РµСЂРёС‚РѕСЂС–С— С‚РµСЂРёС‚РѕСЂС–Р°Р»СЊРЅРѕС— РіСЂРѕРјР°РґРё": "1",
            "Р“РµРЅРµСЂР°Р»СЊРЅРёР№ РїР»Р°РЅ РЅР°СЃРµР»РµРЅРѕРіРѕ РїСѓРЅРєС‚Сѓ": "2",
            "Р”РµС‚Р°Р»СЊРЅРёР№ РїР»Р°РЅ С‚РµСЂРёС‚РѕСЂС–С—": "3",
        }

    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
        schema_class_key = self._schema_class_key(class_key)
        if schema_class_key in self._enums_cache:
            return self._enums_cache[schema_class_key]
        schema, _ = self._load_schema_cache()
        props_schema = self._collect_properties_schema(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
                else:
                    fallback_mapping = self._attributes_enum_mapping(field_name)
                    if fallback_mapping:
                        enums[field_name] = fallback_mapping
        if schema_class_key == METADATA_CLASS_KEY:
            enums["Doc_type"] = self._metadata_doc_type_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={schema_class_key}): fields={len(enums)}")
        self._enums_cache[schema_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 _normalize_metadata_properties(self, props: dict) -> dict:
        if not isinstance(props, dict):
            return props
        normalized = dict(props)
        changed = False
        for key in list(props.keys()):
            legacy_key = str(key).strip().lower()
            target_key = METADATA_LEGACY_FIELD_MAP.get(legacy_key)
            if not target_key:
                continue
            if target_key in normalized:
                if key != target_key:
                    normalized.pop(key, None)
                    changed = True
                continue
            normalized[target_key] = normalized.pop(key)
            changed = True
        return normalized if changed else props

    def _schema_is_date_field(self, schema_node, schema: dict, common_schema: dict) -> bool:
        if not isinstance(schema_node, dict):
            return False
        if schema_node.get("format") == "date":
            return True
        if "$ref" in schema_node:
            return self._schema_is_date_field(
                self._resolve_ref(schema_node["$ref"], schema, common_schema),
                schema,
                common_schema,
            )
        for key in ("allOf", "anyOf", "oneOf"):
            parts = schema_node.get(key)
            if not isinstance(parts, list):
                continue
            for part in parts:
                if self._schema_is_date_field(part, schema, common_schema):
                    return True
        return False

    def _load_date_fields_cache(self, class_key: str = None):
        if self._date_fields_cache is None:
            self._date_fields_cache = {}
        if not class_key:
            return self._date_fields_cache
        schema_class_key = self._schema_class_key(class_key)
        if schema_class_key in self._date_fields_cache:
            return self._date_fields_cache[schema_class_key]
        schema, common_schema = self._load_schema_cache()
        props_schema = self._collect_properties_schema(schema_class_key, schema)
        date_fields = set()
        if props_schema:
            for field_name, prop_schema in props_schema.items():
                if self._schema_is_date_field(prop_schema, schema, common_schema):
                    date_fields.add(field_name)
        if schema_class_key == METADATA_CLASS_KEY:
            date_fields.update(
                {
                    "Arch_Certificate_Date",
                    "Surv_Certificate_Date",
                    "Decision_Date",
                    "Expertise_Date",
                    "Approval_Date",
                }
            )
        self._date_fields_cache[schema_class_key] = date_fields
        return date_fields

    def _is_internal_qgis_property(self, key: str) -> bool:
        if not key:
            return False
        normalized = self._normalize_cyrillic_key(str(key)).lower()
        return normalized in IGNORED_INTERNAL_PROPERTIES

    def _strip_internal_properties(self, props):
        if not isinstance(props, dict):
            return {}
        cleaned = {}
        for key, value in props.items():
            if self._is_internal_qgis_property(key):
                continue
            cleaned[key] = value
        return cleaned

    def _allowed_props_for_class(self, class_key: str):
        return self.syntax_validator.allowed_props_for_class(class_key)

    def _find_cyrillic_property_keys(self, data: dict, allowed_props: set) -> bool:
        return self.syntax_validator.find_cyrillic_property_keys(data, allowed_props)

    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):
        return self.topology_validator.load_rules()
        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
                area = 0.0
                perimeter = 0.0
                try:
                    area = float(hole_geom.area())
                except Exception:
                    area = 0.0
                try:
                    perimeter = float(hole_geom.length())
                except Exception:
                    perimeter = 0.0
                if area > 0.0 and perimeter > 0.0:
                    form_factor = (perimeter * perimeter) / (4.0 * math.pi * area)
                else:
                    form_factor = float("inf")
                message = self.tr(u"Топологія: проміжок, площа {0:.3f} м², форм-фактор {1:.6E}.").format(
                    area,
                    form_factor,
                )
                entries.append(self._topo_error_entry(layer_a.name(), hole_geom, message))
        return entries

    def _run_topology_validation(self, rules=None, progress=None):
        return self.topology_validator.run_validation(rules=rules, progress=progress)
        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 _apply_date_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
        date_fields = self._load_date_fields_cache(class_key) if class_key else set()
        if not date_fields:
            return
        try:
            fields = layer.fields()
        except Exception:
            return
        date_widget_config = {
            "allow_null": True,
            "calendar_popup": True,
            "display_format": "yyyy-MM-dd",
            "field_format": "yyyy-MM-dd",
            "field_iso_format": False,
        }
        for index, field in enumerate(fields):
            if field.name() not in date_fields:
                continue
            try:
                setup = QgsEditorWidgetSetup("DateTime", date_widget_config)
                layer.setEditorWidgetSetup(index, setup)
            except Exception:
                continue

    def _apply_text_memo_widget(self, layer, field_name: str) -> None:
        if layer is None or not field_name:
            return
        try:
            fields = layer.fields()
        except Exception:
            return
        for index, field in enumerate(fields):
            if field.name() != field_name:
                continue
            try:
                setup = QgsEditorWidgetSetup(
                    "TextEdit",
                    {
                        "IsMultiline": True,
                        "UseHtml": False,
                    },
                )
                layer.setEditorWidgetSetup(index, setup)
            except Exception:
                return
            return

    def _apply_year_term_placeholders(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
        if self._schema_class_key(class_key) != METADATA_CLASS_KEY:
            return
        try:
            fields = layer.fields()
        except Exception:
            return
        config = {
            "IsMultiline": False,
            "UseHtml": False,
            "PlaceholderText": YEAR_TERM_PLACEHOLDER,
        }
        for index, field in enumerate(fields):
            if field.name() not in YEAR_TERM_FIELDS:
                continue
            try:
                layer.setEditorWidgetSetup(index, QgsEditorWidgetSetup("TextEdit", config))
            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):
        raw_class_key = class_key
        class_key = self._schema_class_key(class_key)
        raw_text = str(raw_class_key or "").strip().lower()
        is_plan_profile = raw_text == PLAN_CLASS_KEY or bool(PLAN_FILE_CLASS_PATTERN.fullmatch(str(raw_class_key or "")))
        defs = schema.get("$defs", {})
        base_props = defs.get("BaseProperties", {}).get("properties", {})
        include_base_props = class_key not in {METADATA_CLASS_KEY, PLAN_CLASS_KEY, HROM_CHARACTERISTICS_CLASS_KEY, SETTL_CHARACTERISTICS_CLASS_KEY, INFO_CLASS_KEY}
        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":
                        if include_base_props:
                            _merge(base_props)
                    elif "properties" in part:
                        _merge(part.get("properties", {}))
            elif "properties" in props_schema:
                _merge(props_schema.get("properties", {}))

        if include_base_props and base_props:
            _merge(base_props)
        if is_plan_profile:
            return {
                name: combined[name]
                for name in PLAN_STRICT_PROPERTIES
                if name in combined
            }
        if class_key == HROM_CHARACTERISTICS_CLASS_KEY:
            selected = {}
            for name in HROM_CHARACTERISTICS_STRICT_PROPERTIES:
                if name in combined:
                    selected[name] = combined[name]
                elif name == "name" and "ind_name" in combined:
                    selected[name] = combined["ind_name"]
            return selected
        if class_key == SETTL_CHARACTERISTICS_CLASS_KEY:
            selected = {}
            for name in SETTL_CHARACTERISTICS_PROPERTIES:
                if name in combined:
                    selected[name] = combined[name]
                elif name == "name" and "ind_name" in combined:
                    selected[name] = combined["ind_name"]
            return selected
        return combined

    def _collect_required_properties_schema(self, class_key: str, schema: dict, common_schema: dict):
        raw_class_key = class_key
        class_key = self._schema_class_key(class_key)
        raw_text = str(raw_class_key or "").strip().lower()
        is_plan_profile = raw_text == PLAN_CLASS_KEY or bool(PLAN_FILE_CLASS_PATTERN.fullmatch(str(raw_class_key or "")))
        if is_plan_profile:
            return set(PLAN_STRICT_PROPERTIES)
        if class_key == HROM_CHARACTERISTICS_CLASS_KEY:
            return set(HROM_CHARACTERISTICS_STRICT_PROPERTIES)
        if class_key == SETTL_CHARACTERISTICS_CLASS_KEY:
            return set(SETTL_CHARACTERISTICS_PROPERTIES)
        if class_key == INFO_CLASS_KEY:
            return set(INFO_STRICT_PROPERTIES)
        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,
    ):
        return self.syntax_validator.validate_geojson_file(
            source_path=source_path,
            class_key=class_key,
            project_dir=project_dir,
            schema=schema,
            common_schema=common_schema,
            data=data,
        )
        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_syntax_dir(self, project_dir: str) -> str:
        if not project_dir:
            return ""
        syntax_dir = os.path.join(project_dir, "Syntax")
        try:
            os.makedirs(syntax_dir, exist_ok=True)
            return syntax_dir
        except Exception:
            return ""

    def _ensure_syntax_errors_dir(self, project_dir: str) -> str:
        syntax_dir = self._ensure_syntax_dir(project_dir)
        if not syntax_dir:
            return ""
        errors_dir = os.path.join(syntax_dir, "Errors")
        try:
            os.makedirs(errors_dir, exist_ok=True)
            return errors_dir
        except Exception:
            return ""

    def _reset_project_aux_dirs(self, project_dir: str) -> bool:
        if not project_dir:
            return False
        for folder_name in ("Syntax", "Topology"):
            target = os.path.join(project_dir, folder_name)
            try:
                if os.path.isdir(target):
                    shutil.rmtree(target)
                os.makedirs(target, exist_ok=True)
            except Exception:
                return False
        legacy_errors_dir = os.path.join(project_dir, "Errors")
        try:
            if os.path.isdir(legacy_errors_dir):
                shutil.rmtree(legacy_errors_dir)
        except Exception:
            pass
        return True

    def _ensure_syntax_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() == "Syntax":
                    existing = child
                    break
        except Exception:
            existing = None
        if existing is not None:
            try:
                existing.setExpanded(True)
            except Exception:
                pass
            return existing
        try:
            group = parent.insertGroup(0, "Syntax")
            try:
                group.setExpanded(True)
            except Exception:
                pass
            return group
        except Exception:
            try:
                group = parent.addGroup("Syntax")
                try:
                    group.setExpanded(True)
                except Exception:
                    pass
                return group
            except Exception:
                return None

    def _ensure_syntax_errors_group(self, syntax_group):
        if syntax_group is None:
            return None
        existing = None
        try:
            for child in syntax_group.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:
                existing.setExpanded(True)
            except Exception:
                pass
            return existing
        try:
            group = syntax_group.insertGroup(0, "Errors")
            try:
                group.setExpanded(True)
            except Exception:
                pass
            return group
        except Exception:
            try:
                group = syntax_group.addGroup("Errors")
                try:
                    group.setExpanded(True)
                except Exception:
                    pass
                return group
            except Exception:
                return None

    def _clear_group_layers(self, group) -> None:
        if group is None:
            return
        try:
            project = QgsProject.instance()
        except Exception:
            return
        try:
            layer_nodes = list(group.findLayers())
        except Exception:
            layer_nodes = []
        for node in layer_nodes:
            try:
                layer = node.layer()
            except Exception:
                layer = None
            if layer is None:
                continue
            try:
                project.removeMapLayer(layer.id())
            except Exception:
                continue

    def _write_check_protocol_geojson(self, file_path: str, records) -> bool:
        if not file_path:
            return False
        features = []
        for idx, record in enumerate(records or [], start=1):
            message = record.get("message", "")
            if isinstance(message, (list, tuple)):
                message = "\n".join(str(item) for item in message if str(item).strip())
            else:
                message = str(message)
            # Force multi-line wrapping so long error texts stay readable in check GeoJSON.
            message_lines = []
            for part in message.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
                if not part:
                    message_lines.append("")
                    continue
                wrapped = textwrap.wrap(
                    part,
                    width=120,
                    break_long_words=False,
                    break_on_hyphens=False,
                )
                message_lines.extend(wrapped or [part])
            message = "\n".join(message_lines)
            features.append(
                {
                    "type": "Feature",
                    "geometry": None,
                    "properties": {
                        "id": idx,
                        "check": record.get("check", ""),
                        "file": record.get("file", ""),
                        "status": record.get("status", ""),
                        "message": message,
                    },
                }
            )
        payload = {
            "type": "FeatureCollection",
            "features": features,
        }
        try:
            with open(file_path, "w", encoding="utf-8") as handle:
                json.dump(payload, handle, ensure_ascii=False, indent=2)
            return True
        except Exception:
            return False

    def _add_syntax_layers(self, layers, syntax_group) -> None:
        if not layers or syntax_group is None:
            return
        try:
            project = QgsProject.instance()
        except Exception:
            return
        for layer_name, file_path in layers:
            if not file_path or not os.path.exists(file_path):
                continue
            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():
                try:
                    layer = QgsVectorLayer(f"{file_path}|geometrytype=NoGeometry", layer_name, "ogr")
                except Exception:
                    layer = None
            if layer is None or not layer.isValid():
                continue
            self._apply_text_memo_widget(layer, "message")
            project.addMapLayer(layer, False)
            try:
                syntax_group.addLayer(layer)
            except Exception:
                try:
                    syntax_group.insertLayer(0, layer)
                except Exception:
                    project.addMapLayer(layer)
        try:
            syntax_group.setExpanded(True)
        except Exception:
            pass
        try:
            parent = syntax_group.parent()
            if parent is not None:
                parent.setExpanded(True)
        except Exception:
            pass

    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):
                    child_name = child.name()
                    if child_name in ("Topology", "Errors"):
                        existing = child
                        break
        except Exception:
            existing = None
        if existing is not None:
            try:
                if existing.name() != "Topology":
                    existing.setName("Topology")
            except Exception:
                pass
            return existing
        try:
            return parent.insertGroup(0, "Topology")
        except Exception:
            try:
                return parent.addGroup("Topology")
            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):
        schema_class_key = self._schema_class_key(class_key)
        props_schema = self._collect_properties_schema(schema_class_key, schema)
        if not props_schema:
            return {}
        props = {}
        for name, prop_schema in props_schema.items():
            if self._is_internal_qgis_property(name):
                continue
            props[name] = self._default_value(prop_schema, schema, common_schema)
        if self._uses_state_change(schema_class_key):
            props.setdefault("state", 0)
            props.setdefault("change", 0)
        if schema_class_key not in {PLAN_CLASS_KEY, HROM_CHARACTERISTICS_CLASS_KEY, SETTL_CHARACTERISTICS_CLASS_KEY, INFO_CLASS_KEY}:
            props["class"] = schema_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_class_key_for_creation(self, class_key: str, info=None) -> str:
        normalized = (class_key or "").strip()
        if normalized != INFO_CLASS_KEY:
            return normalized
        if info is None:
            info = self._current_project_info()
        katotth = ""
        if info is not None:
            katotth = str(getattr(info, "katotth", "") or "").strip().upper()
            if not re.fullmatch(r"UA\d{17}", katotth):
                folder_name = str(getattr(info, "folder_name", "") or "")
                katotth = self._extract_katotth(folder_name)
        if not katotth:
            return normalized
        return f"{katotth}_{INFO_CLASS_KEY}"

    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):
        class_key = self._schema_class_key(class_key)
        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 QgsWkbTypes.NoGeometry
        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]
        if geom_name in ("None", "NoGeometry", "Null", "null"):
            return QgsWkbTypes.NoGeometry
        mapping = {
            "Point": QgsWkbTypes.Point,
            "LineString": QgsWkbTypes.LineString,
            "Polygon": QgsWkbTypes.Polygon,
            "MultiPoint": QgsWkbTypes.MultiPoint,
            "MultiLineString": QgsWkbTypes.MultiLineString,
            "MultiPolygon": QgsWkbTypes.MultiPolygon,
        }
        return mapping.get(geom_name, QgsWkbTypes.NoGeometry)

    def _layer_category_from_wkb(self, wkb_type) -> str:
        if wkb_type is None:
            return "metadata"
        try:
            geom_type = QgsWkbTypes.geometryType(wkb_type)
        except Exception:
            return "metadata"
        if geom_type == QgsWkbTypes.PointGeometry:
            return "points"
        if geom_type == QgsWkbTypes.LineGeometry:
            return "lines"
        if geom_type == QgsWkbTypes.PolygonGeometry:
            return "polygons"
        return "metadata"

    def _detect_geojson_category_from_file(self, source_path: str) -> str:
        data = self._read_json_file(source_path)
        if not isinstance(data, dict):
            return "metadata"
        features = data.get("features")
        if not isinstance(features, list):
            return "metadata"
        for feature in features:
            if not isinstance(feature, dict):
                continue
            geometry = feature.get("geometry")
            if not isinstance(geometry, dict):
                continue
            geom_type = geometry.get("type")
            if geom_type in ("Point", "MultiPoint"):
                return "points"
            if geom_type in ("LineString", "MultiLineString"):
                return "lines"
            if geom_type in ("Polygon", "MultiPolygon"):
                return "polygons"
            if geom_type == "GeometryCollection":
                geometries = geometry.get("geometries", [])
                if not isinstance(geometries, list):
                    continue
                for entry in geometries:
                    if not isinstance(entry, dict):
                        continue
                    entry_type = entry.get("type")
                    if entry_type in ("Point", "MultiPoint"):
                        return "points"
                    if entry_type in ("LineString", "MultiLineString"):
                        return "lines"
                    if entry_type in ("Polygon", "MultiPolygon"):
                        return "polygons"
        return "metadata"

    def _categorize_geojson_file(self, class_key: str, schema: dict, common_schema: dict, source_path: str) -> str:
        wkb_type = self._schema_geometry_wkb(class_key, schema, common_schema)
        category = self._layer_category_from_wkb(wkb_type)
        if category != "metadata":
            return category
        return self._detect_geojson_category_from_file(source_path)

    def _format_duration_hhmmss(self, seconds: float) -> str:
        try:
            total = int(max(0, round(float(seconds))))
        except Exception:
            total = 0
        hours, rest = divmod(total, 3600)
        minutes, secs = divmod(rest, 60)
        return f"{hours:02d}:{minutes:02d}:{secs:02d}"

    def _wkb_from_geojson_geometry_type(self, geom_type_name: str):
        mapping = {
            "Point": QgsWkbTypes.Point,
            "LineString": QgsWkbTypes.LineString,
            "Polygon": QgsWkbTypes.Polygon,
            "MultiPoint": QgsWkbTypes.MultiPoint,
            "MultiLineString": QgsWkbTypes.MultiLineString,
            "MultiPolygon": QgsWkbTypes.MultiPolygon,
        }
        return mapping.get(geom_type_name)

    def _geojson_geometry_to_wkt(self, geometry_data: dict):
        if not isinstance(geometry_data, dict):
            return None
        geom_type = geometry_data.get("type")
        if not isinstance(geom_type, str):
            return None
        coords = geometry_data.get("coordinates")

        def fmt_num(value):
            try:
                return f"{float(value):.15g}"
            except Exception:
                return None

        def point_wkt(point):
            if not isinstance(point, (list, tuple)) or len(point) < 2:
                return None
            x = fmt_num(point[0])
            y = fmt_num(point[1])
            if x is None or y is None:
                return None
            return f"{x} {y}"

        def linestring_wkt(line):
            if not isinstance(line, (list, tuple)) or not line:
                return None
            pts = []
            for point in line:
                p = point_wkt(point)
                if p is None:
                    return None
                pts.append(p)
            return ", ".join(pts)

        def polygon_wkt(poly):
            if not isinstance(poly, (list, tuple)) or not poly:
                return None
            rings = []
            for ring in poly:
                ls = linestring_wkt(ring)
                if ls is None:
                    return None
                rings.append(f"({ls})")
            return ", ".join(rings)

        if geom_type == "Point":
            p = point_wkt(coords)
            return f"POINT ({p})" if p else None
        if geom_type == "LineString":
            ls = linestring_wkt(coords)
            return f"LINESTRING ({ls})" if ls else None
        if geom_type == "Polygon":
            poly = polygon_wkt(coords)
            return f"POLYGON ({poly})" if poly else None
        if geom_type == "MultiPoint":
            if not isinstance(coords, (list, tuple)) or not coords:
                return None
            points = []
            for point in coords:
                p = point_wkt(point)
                if p is None:
                    return None
                points.append(f"({p})")
            return f"MULTIPOINT ({', '.join(points)})"
        if geom_type == "MultiLineString":
            if not isinstance(coords, (list, tuple)) or not coords:
                return None
            lines = []
            for line in coords:
                ls = linestring_wkt(line)
                if ls is None:
                    return None
                lines.append(f"({ls})")
            return f"MULTILINESTRING ({', '.join(lines)})"
        if geom_type == "MultiPolygon":
            if not isinstance(coords, (list, tuple)) or not coords:
                return None
            polygons = []
            for poly in coords:
                pw = polygon_wkt(poly)
                if pw is None:
                    return None
                polygons.append(f"({pw})")
            return f"MULTIPOLYGON ({', '.join(polygons)})"
        if geom_type == "GeometryCollection":
            geometries = geometry_data.get("geometries")
            if not isinstance(geometries, (list, tuple)) or not geometries:
                return None
            parts = []
            for entry in geometries:
                wkt = self._geojson_geometry_to_wkt(entry)
                if not wkt:
                    continue
                parts.append(wkt)
            if not parts:
                return None
            return f"GEOMETRYCOLLECTION ({', '.join(parts)})"
        return None

    def _geometry_from_geojson_dict(self, geometry_data: dict):
        if not isinstance(geometry_data, dict):
            return None
        wkt = self._geojson_geometry_to_wkt(geometry_data)
        if not wkt:
            return None
        try:
            geometry = QgsGeometry.fromWkt(wkt)
        except Exception:
            return None
        try:
            if geometry is None or geometry.isNull() or geometry.isEmpty():
                return None
        except Exception:
            pass
        return geometry

    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():
            if self._is_internal_qgis_property(name):
                continue
            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
        try:
            layer.editCommandEnded.connect(lambda lid=layer_id: self._on_layer_edit_command_ended(lid))
        except Exception:
            pass
        try:
            layer.beforeCommitChanges.connect(lambda _stop=False, lid=layer_id: self._on_layer_before_commit(lid))
        except Exception:
            pass
        try:
            layer.afterCommitChanges.connect(lambda lid=layer_id: self._on_layer_after_commit(lid))
        except Exception:
            pass
        # Provider-level commit notifications (attributes/geometry/features) are
        # the most reliable trigger after user presses QGIS "Save Layer Edits".
        try:
            layer.committedAttributeValuesChanges.connect(
                lambda *_args, lid=layer_id: self._schedule_layer_commit_save(lid)
            )
        except Exception:
            pass
        try:
            layer.committedGeometriesChanges.connect(
                lambda *_args, lid=layer_id: self._schedule_layer_commit_save(lid)
            )
        except Exception:
            pass
        try:
            layer.committedFeaturesAdded.connect(
                lambda *_args, lid=layer_id: self._schedule_layer_commit_save(lid)
            )
        except Exception:
            pass
        try:
            layer.committedFeaturesRemoved.connect(
                lambda *_args, lid=layer_id: self._schedule_layer_commit_save(lid)
            )
        except Exception:
            pass
        try:
            layer.editingStopped.connect(lambda lid=layer_id: self._on_layer_editing_stopped(lid))
        except Exception:
            pass
        try:
            layer.afterRollBack.connect(lambda lid=layer_id: self._on_layer_after_rollback(lid))
        except Exception:
            pass
        try:
            layer.nameChanged.connect(lambda *_args, lid=layer_id: self._on_layer_name_changed(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, source_wkb=None):
        existing = self._get_layer_for_class(class_key)
        if existing is not None:
            self._apply_enum_widgets(existing, class_key)
            self._apply_date_widgets(existing, class_key)
            self._apply_year_term_placeholders(existing, class_key)
            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_wkb is not None:
            wkb_type = source_wkb
        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):
        if wkb_type == QgsWkbTypes.NoGeometry:
            uri = "None"
        else:
            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:
            if wkb_type != QgsWkbTypes.NoGeometry:
                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)
        self._apply_date_widgets(layer, class_key)
        self._apply_year_term_placeholders(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 _fix_syntax_for_selected_layer(self, layer_id: str, feature_id=None) -> None:
        if not layer_id:
            return
        layer = QgsProject.instance().mapLayer(layer_id)
        if layer is None or not isinstance(layer, QgsVectorLayer):
            self._push_message(self.tr(u"Шар для виправлення синтаксису не знайдено."), level=Qgis.Warning)
            return
        meta = self.layer_registry.get(layer_id)
        class_key = (meta or {}).get("class_name") if isinstance(meta, dict) else None
        if not class_key:
            try:
                class_key = layer.name()
            except Exception:
                class_key = None
        if not class_key:
            self._push_message(self.tr(u"Не вдалося визначити клас шару для перевірки синтаксису."), level=Qgis.Warning)
            return

        schema, common_schema = self._load_schema_cache()
        props_schema = self._collect_properties_schema(class_key, schema)
        if not props_schema:
            self._push_message(self.tr(u"Схема для шару {0} не знайдена.").format(class_key), level=Qgis.Warning)
            return

        expected = {}
        expected_order = []
        for field_name, schema_node in props_schema.items():
            normalized = self._normalize_cyrillic_key(field_name)
            if normalized.lower() in IGNORED_INTERNAL_PROPERTIES:
                continue
            if normalized in expected:
                continue
            expected[normalized] = (field_name, schema_node)
            expected_order.append(normalized)
        if self._uses_state_change(class_key):
            for extra_name in ("state", "change"):
                if extra_name not in expected:
                    expected[extra_name] = (extra_name, {"type": "string"})
                    expected_order.append(extra_name)
        expected_norm = set(expected.keys())

        existing_fields = layer.fields()
        normalized_to_index = {}
        extra_indices = []
        for index in range(existing_fields.count()):
            name = existing_fields.at(index).name()
            normalized = self._normalize_cyrillic_key(name)
            if normalized.lower() in IGNORED_INTERNAL_PROPERTIES:
                continue
            if normalized in expected_norm:
                normalized_to_index.setdefault(normalized, index)
            else:
                extra_indices.append(index)

        add_fields = []
        added_field_names = []
        for normalized in expected_order:
            if normalized in normalized_to_index:
                continue
            canonical_name, schema_node = expected[normalized]
            add_fields.append(QgsField(canonical_name, self._qvariant_for_schema(schema_node, schema, common_schema)))
            added_field_names.append(canonical_name)

        try:
            if not layer.isEditable():
                layer.startEditing()
        except Exception:
            pass

        added_count = 0
        removed_count = 0
        if add_fields:
            try:
                if layer.dataProvider().addAttributes(add_fields):
                    layer.updateFields()
                    added_count = len(add_fields)
            except Exception:
                pass
        if extra_indices:
            try:
                if layer.dataProvider().deleteAttributes(sorted(extra_indices, reverse=True)):
                    layer.updateFields()
                    removed_count = len(extra_indices)
            except Exception:
                pass

        # Prefill newly added attributes on the selected feature with schema defaults.
        if feature_id is not None and added_field_names:
            info = self._current_project_info()
            katotth = getattr(info, "katotth", "") if info is not None else ""
            defaults = self._build_feature_properties(class_key, katotth, schema, common_schema) or {}
            attrs = {}
            current_fields = layer.fields()
            for field_name in added_field_names:
                idx = current_fields.lookupField(field_name)
                if idx < 0:
                    continue
                attrs[idx] = defaults.get(field_name)
            if attrs:
                try:
                    layer.dataProvider().changeAttributeValues({feature_id: attrs})
                except Exception:
                    pass

        self.build_index(layer)
        self.mark_dirty(layer.id(), True)
        if added_count or removed_count:
            self._push_info(
                self.tr(u"Синтаксис виправлено для шару {0}: додано полів {1}, видалено полів {2}.").format(
                    class_key,
                    added_count,
                    removed_count,
                )
            )
        else:
            self._push_info(self.tr(u"Синтаксис шару {0} вже коректний.").format(class_key))

    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

        schema, common_schema = self._load_schema_cache()
        source_layer = self._find_source_layer_for_selection()
        try:
            selected = source_layer.selectedFeatures() if source_layer is not None else []
        except Exception:
            selected = []

        target_class_key = self._resolve_class_key_for_creation(class_key, info=info)
        target_layer = self._ensure_memory_layer_for_class(target_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

        target_is_table = QgsWkbTypes.geometryType(target_layer.wkbType()) == QgsWkbTypes.NullGeometry
        if source_layer is None and not target_is_table:
            self._push_message(self.tr(u"Немає активного векторного шару з виділенням."), level=Qgis.Warning)
            return
        if not selected and not target_is_table:
            self._push_message(self.tr(u"Немає виділених об'єктів для копіювання."), level=Qgis.Warning)
            return

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

        katotth = getattr(info, "katotth", "") if info is not None else ""
        props_template = {}
        if schema and f"{self._schema_class_key(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 = []
        source_features = selected if selected else ([None] if target_is_table else [])
        for feature in source_features:
            props = dict(props_template)

            new_feature = QgsFeature(target_fields)
            if not target_is_table and feature is not None:
                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:
            if source_layer is not None:
                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
        file_names = sorted(
            name for name in os.listdir(folder_path)
            if name.lower().endswith(".geojson")
        )
        if not file_names:
            return

        schema, common_schema = self._load_schema_cache()
        categories = {key: [] for key in ("metadata", "points", "lines", "polygons")}
        total_kb = 0
        for name in file_names:
            source_path = os.path.join(folder_path, name)
            class_key = os.path.splitext(name)[0]
            try:
                size_bytes = os.path.getsize(source_path)
            except Exception:
                size_bytes = 0
            size_kb = max(1, int((size_bytes + 1023) // 1024))
            category = self._categorize_geojson_file(class_key, schema, common_schema, source_path)
            categories.setdefault(category, []).append(
                {
                    "name": name,
                    "class_key": class_key,
                    "source_path": source_path,
                    "size_kb": size_kb,
                    "category": category,
                }
            )
            total_kb += size_kb

        for key in categories:
            categories[key].sort(key=lambda item: item["name"].lower())
        ordered_files = []
        for key in ("metadata", "points", "lines", "polygons"):
            ordered_files.extend(categories.get(key, []))
        if not ordered_files:
            return

        progress = QProgressDialog(
            self.tr(u"Відкриття проєкту..."),
            self.tr(u"Скасувати"),
            0,
            max(total_kb, 1),
            self.iface.mainWindow(),
        )
        progress.setWindowModality(Qt.ApplicationModal)
        progress.setMinimumDuration(0)
        progress.setAutoClose(False)
        progress.setAutoReset(False)
        progress.setValue(0)
        self._align_progress_dialog_left(progress)
        self._pump_progress_ui(progress, force=True)

        loaded = 0
        elapsed_start = time.monotonic()
        processed_kb = 0.0
        last_ui_pump_at = 0.0

        def update_progress(file_info, stage_text: str, fraction: float) -> None:
            nonlocal last_ui_pump_at
            if file_info is None:
                return
            size_kb = float(max(1, int(file_info.get("size_kb", 1))))
            fraction = max(0.0, min(1.0, float(fraction)))
            done_kb = min(float(total_kb), processed_kb + size_kb * fraction)
            elapsed = max(0.0, time.monotonic() - elapsed_start)
            if done_kb > 0.0:
                remaining_kb = max(0.0, float(total_kb) - done_kb)
                eta = (elapsed / done_kb) * remaining_kb
            else:
                eta = 0.0
            line_1 = self.tr(u"Минуло: {0} | Очікується: {1}").format(
                self._format_duration_hhmmss(elapsed),
                self._format_duration_hhmmss(eta),
            )
            line_2 = self.tr(u"{0} {1} кБ {2}").format(
                file_info.get("name", ""),
                int(size_kb),
                stage_text,
            )
            progress.setLabelText(f"{line_1}\n{line_2}")
            progress.setValue(int(max(0.0, min(float(total_kb), done_kb))))
            now = time.monotonic()
            if (now - last_ui_pump_at) >= 0.12 or fraction >= 0.999:
                self._pump_progress_ui(progress)
                last_ui_pump_at = now

        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
        cpu_boost_token = self._enter_cpu_boost_mode()
        self._bulk_load_in_progress = True

        try:
            for file_info in ordered_files:
                if progress.wasCanceled():
                    break
                name = file_info["name"]
                class_key = file_info["class_key"]
                file_size_kb = int(file_info.get("size_kb", 0))
                if file_size_kb > 500:
                    self._refresh_cpu_boost_mode(cpu_boost_token)
                if self._get_layer_for_class(class_key) is not None:
                    processed_kb += float(file_info["size_kb"])
                    update_progress(file_info, self.tr(u"Пропуск (вже додано)"), 1.0)
                    continue

                source_path = file_info["source_path"]
                update_progress(file_info, self.tr(u"Читання з диску"), 0.10)
                item = None
                parse_task = _GeoJsonParseTask(source_path, class_key)
                parse_state = {
                    "done": False,
                    "success": False,
                    "records": [],
                    "source_field_names": set(),
                    "geometry_type": None,
                    "error_message": "",
                }

                def _on_parse_completed():
                    try:
                        parse_state["success"] = bool(parse_task.success)
                        parse_state["records"] = list(parse_task.records or [])
                        parse_state["source_field_names"] = set(parse_task.source_field_names or set())
                        parse_state["geometry_type"] = parse_task.geometry_type
                        parse_state["error_message"] = parse_task.error_message or ""
                    except Exception:
                        parse_state["success"] = False
                    parse_state["done"] = True

                def _on_parse_terminated():
                    parse_state["done"] = True
                    parse_state["success"] = False

                try:
                    parse_task.taskCompleted.connect(_on_parse_completed)
                except Exception:
                    pass
                try:
                    parse_task.taskTerminated.connect(_on_parse_terminated)
                except Exception:
                    pass
                try:
                    QgsApplication.taskManager().addTask(parse_task)
                except Exception:
                    parse_task = None
                parse_started_at = time.monotonic()
                while parse_task is not None and not parse_state["done"]:
                    if progress.wasCanceled():
                        try:
                            parse_task.cancel()
                        except Exception:
                            pass
                        break
                    parse_progress = 0.0
                    try:
                        parse_progress = float(parse_task.progress())
                    except Exception:
                        parse_progress = 0.0
                    update_progress(file_info, self.tr(u"Синтаксичний аналіз"), 0.55 + 0.20 * min(1.0, parse_progress / 100.0))
                    if file_size_kb > 500 and (time.monotonic() - parse_started_at) >= 0.35:
                        self._refresh_cpu_boost_mode(cpu_boost_token)
                        parse_started_at = time.monotonic()
                    # Avoid busy-wait loop while task is running.
                    time.sleep(0.01)
                if progress.wasCanceled():
                    break
                if parse_task is None or not parse_state["success"]:
                    self._close_status(item)
                    processed_kb += float(file_info["size_kb"])
                    update_progress(file_info, self.tr(u"Помилка читання"), 1.0)
                    continue

                update_progress(file_info, self.tr(u"Синтаксичний аналіз"), 0.55)
                source_wkb = self._wkb_from_geojson_geometry_type(parse_state["geometry_type"])
                target_layer = self._ensure_memory_layer_for_class(
                    class_key,
                    folder_path,
                    source_layer=None,
                    source_path=source_path,
                    loaded_from_disk=True,
                    source_wkb=source_wkb,
                )
                if target_layer is None:
                    self._close_status(item)
                    processed_kb += float(file_info["size_kb"])
                    update_progress(file_info, self.tr(u"Помилка створення шару"), 1.0)
                    continue
                props_template = {}
                if schema and f"{self._schema_class_key(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 = {
                    name for name in set(parse_state["source_field_names"] or set())
                    if not self._is_internal_qgis_property(name)
                }
                new_features = []
                prepared_records = list(parse_state["records"] or [])
                feature_total = len(prepared_records)
                chunk_size = 2000 if file_size_kb > 500 else 5000
                chunk_add_failed = False
                last_boost_at = time.monotonic()

                def flush_features_chunk() -> bool:
                    nonlocal new_features, chunk_add_failed, last_boost_at
                    if not new_features:
                        return True
                    try:
                        if not target_layer.isEditable():
                            target_layer.startEditing()
                    except Exception:
                        pass
                    try:
                        target_layer.dataProvider().addFeatures(new_features)
                        new_features = []
                        if file_size_kb > 500:
                            now_boost = time.monotonic()
                            if (now_boost - last_boost_at) >= 0.35:
                                self._refresh_cpu_boost_mode(cpu_boost_token)
                                last_boost_at = now_boost
                        return True
                    except Exception:
                        chunk_add_failed = True
                        return False

                for feature_index, record in enumerate(prepared_records, start=1):
                    new_feature = QgsFeature(target_fields)
                    geometry_obj = self._geometry_from_geojson_dict(record.get("geometry"))
                    geometry = self._coerce_geometry(geometry_obj, target_layer.wkbType())
                    if geometry is not None:
                        new_feature.setGeometry(geometry)
                    properties = record.get("properties")
                    if not isinstance(properties, dict):
                        properties = {}
                    if self._schema_class_key(class_key) == METADATA_CLASS_KEY:
                        properties = self._normalize_metadata_properties(properties)
                    properties = self._strip_internal_properties(properties)
                    if props_template:
                        props = dict(props_template)
                        for field_name in source_field_names:
                            if field_name in props:
                                try:
                                    props[field_name] = properties.get(field_name)
                                except Exception:
                                    pass
                        self._apply_property_aliases(class_key, props, source_field_names, properties)
                        attributes = [props.get(field.name()) for field in target_fields]
                    else:
                        attributes = [properties.get(field.name()) for field in target_fields]
                    new_feature.setAttributes(attributes)
                    new_features.append(new_feature)
                    if len(new_features) >= chunk_size:
                        if not flush_features_chunk():
                            break
                    if feature_index % 500 == 0:
                        if feature_total > 0:
                            progress_fraction = 0.55 + 0.25 * min(1.0, float(feature_index) / float(feature_total))
                        else:
                            progress_fraction = 0.70
                        update_progress(file_info, self.tr(u"Синтаксичний аналіз"), progress_fraction)
                    if file_size_kb > 500 and feature_index % 2000 == 0:
                        self._refresh_cpu_boost_mode(cpu_boost_token)
                if not chunk_add_failed and not flush_features_chunk():
                    chunk_add_failed = True
                if chunk_add_failed:
                    self._close_status(item)
                    processed_kb += float(file_info["size_kb"])
                    update_progress(file_info, self.tr(u"Помилка додавання об'єктів"), 1.0)
                    continue
                target_layer.updateExtents()
                update_progress(file_info, self.tr(u"Індексація"), 0.85)
                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
                processed_kb += float(file_info["size_kb"])
                update_progress(file_info, self.tr(u"Готово"), 1.0)
        finally:
            self._bulk_load_in_progress = False
            self._leave_cpu_boost_mode(cpu_boost_token)
            if canvas is not None and prev_render is not None:
                try:
                    canvas.setRenderFlag(prev_render)
                except Exception:
                    pass
        progress.setValue(max(total_kb, 1))
        try:
            progress.setCancelButtonText(self.tr(u"Закрити"))
        except Exception:
            pass
        self._pump_progress_ui(progress)
        try:
            progress.close()
            progress.deleteLater()
        except Exception:
            pass
        if loaded:
            pass

    def _on_layer_after_commit(self, layer_id: str) -> None:
        if not layer_id:
            return
        self._schedule_layer_commit_save(layer_id)

    def _on_layer_before_commit(self, layer_id: str) -> None:
        if not layer_id:
            return
        self._pending_layer_commit_save.add(layer_id)

    def _on_layer_after_rollback(self, layer_id: str) -> None:
        if not layer_id:
            return
        self._pending_layer_commit_save.discard(layer_id)

    def _on_layer_editing_stopped(self, layer_id: str) -> None:
        # In some QGIS setups afterCommitChanges may be skipped/delayed.
        # If commit was requested and editing stopped, persist anyway.
        if not layer_id:
            return
        if layer_id in self._pending_layer_commit_save:
            self._schedule_layer_commit_save(layer_id)

    def _schedule_layer_commit_save(self, layer_id: str) -> None:
        if not layer_id:
            return
        if self._bulk_load_in_progress:
            return
        self._pending_layer_commit_save.discard(layer_id)
        if layer_id in self._scheduled_commit_autosaves:
            return
        self._scheduled_commit_autosaves.add(layer_id)
        QTimer.singleShot(120, lambda lid=layer_id: self._autosave_committed_layer(lid))

    def _autosave_committed_layer(self, layer_id: str) -> None:
        self._scheduled_commit_autosaves.discard(layer_id)
        if not layer_id or layer_id not in self.layer_registry:
            return
        self._autosave_to_geojson(layer_id)

    def save_layer(self, layer_id: str, prompt_add_file: bool = False) -> 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 self._is_auxiliary_project_layer(layer, source_path):
            meta["dirty"] = False
            return True
        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 = None
        options = QgsVectorFileWriter.SaveVectorOptions()
        options.driverName = "GeoJSON"
        options.fileEncoding = "UTF-8"
        options.actionOnExistingFile = QgsVectorFileWriter.CreateOrOverwriteFile
        try:
            # Prevent GDAL/QGIS from writing/deriving "fid" into properties.
            options.layerOptions = ["ID_FIELD="]
        except Exception:
            pass
        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)))
        if prompt_add_file:
            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:
        layers_to_save = self._collect_layers_to_save()
        if not layers_to_save:
            self._push_info(self.tr(u"Немає незбережених змін."))
            return True
        failed = []
        for layer_id in layers_to_save:
            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:
        layers_to_save = self._collect_layers_to_save()
        if not layers_to_save:
            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 _collect_layers_to_save(self):
        layers = []
        project = QgsProject.instance()
        for layer_id, meta in self.layer_registry.items():
            layer = project.mapLayer(layer_id)
            if layer is None:
                continue
            is_dirty = bool(meta.get("dirty"))
            if not is_dirty:
                try:
                    is_dirty = bool(layer.isModified())
                except Exception:
                    is_dirty = False
            if is_dirty:
                layers.append(layer_id)
        return layers

    def _clear_registry_for_group(self, group) -> None:
        if group is None:
            return
        group_name = ""
        try:
            group_name = group.name() or ""
        except Exception:
            group_name = ""
        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)
            self._layer_theme_cache.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)
        if group_name:
            prefix = f"{group_name}::"
            for key in list(self._layer_theme_cache.keys()):
                if isinstance(key, str) and key.startswith(prefix):
                    self._layer_theme_cache.pop(key, None)
        self._update_save_action()

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

    def _has_prefixed_table_file(self, class_key: str, project_dir: str) -> bool:
        suffix = NON_SPATIAL_TABLE_FILE_SUFFIXES.get((class_key or "").strip())
        if not suffix or not project_dir:
            return False
        lower_suffix = suffix.lower()
        try:
            names = os.listdir(project_dir)
        except Exception:
            return False
        for name in names:
            if not str(name).lower().endswith(lower_suffix):
                continue
            prefix = str(name)[:-len(suffix)]
            if prefix:
                return True
        return False

    def _should_ignore_non_spatial_table_double_click(self, class_key: str) -> bool:
        normalized = (class_key or "").strip()
        if normalized not in NON_SPATIAL_TABLE_FILE_SUFFIXES:
            return False
        if self._get_layer_for_class(normalized) is not None:
            return False
        info = self._current_project_info()
        project_dir = self._resolve_project_dir(info)
        if not project_dir:
            return False
        return self._has_prefixed_table_file(normalized, project_dir)

    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 = self._choose_directory_with_disabled_files(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))
        group = self._create_project_group(project_name)
        self.opened_projects.new_project(project_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_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 = self._choose_directory_with_disabled_files(parent, caption, last_dir)
        if not project_dir:
            return
        self._reset_project_aux_dirs(project_dir)
        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
        katotth = self._extract_katotth(folder_name)

        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

        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")
            and not name.lower().endswith("_check.geojson")
            and not name.lower().endswith("_err.geojson")
        )
        folder_name = os.path.basename(project_dir)
        composition_errors = self._project_composition_errors(
            project_dir,
            folder_name,
            include_required_files=True,
        )
        if not files and not composition_errors:
            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

        syntax_group = self._ensure_syntax_group(parent_group)
        if syntax_group is not None:
            self._clear_group_layers(syntax_group)
        syntax_errors_group = self._ensure_syntax_errors_group(syntax_group) if syntax_group is not None else None
        if syntax_errors_group is not None:
            self._clear_group_layers(syntax_errors_group)
        topology_group = self._ensure_errors_group(parent_group)
        if topology_group is not None:
            self._clear_group_layers(topology_group)

        syntax_dir = self._ensure_syntax_dir(project_dir)
        syntax_errors_dir = self._ensure_syntax_errors_dir(project_dir)
        try:
            for name in os.listdir(project_dir):
                if not name.lower().endswith("_check.geojson"):
                    continue
                path = os.path.join(project_dir, name)
                if os.path.isfile(path):
                    try:
                        os.remove(path)
                    except Exception:
                        pass
        except Exception:
            pass
        if syntax_dir:
            try:
                os.makedirs(syntax_dir, exist_ok=True)
                for name in os.listdir(syntax_dir):
                    path = os.path.join(syntax_dir, name)
                    if os.path.isfile(path) and (name.lower().endswith(".geojson") or name.lower().endswith(".json")):
                        try:
                            os.remove(path)
                        except Exception:
                            pass
            except Exception:
                pass
        if syntax_errors_dir:
            try:
                os.makedirs(syntax_errors_dir, exist_ok=True)
                for name in os.listdir(syntax_errors_dir):
                    path = os.path.join(syntax_errors_dir, name)
                    if os.path.isfile(path) and (name.lower().endswith(".geojson") or name.lower().endswith(".json")):
                        try:
                            os.remove(path)
                        except Exception:
                            pass
            except Exception:
                pass
        topology_dir = os.path.join(project_dir, "Topology")
        try:
            if os.path.isdir(topology_dir):
                for name in os.listdir(topology_dir):
                    path = os.path.join(topology_dir, name)
                    if os.path.isfile(path) and name.lower().endswith(".geojson"):
                        try:
                            os.remove(path)
                        except Exception:
                            pass
        except Exception:
            pass

        check_records_by_key = {}
        if composition_errors:
            for message in composition_errors:
                self._push_message(message, level=Qgis.Warning)
            check_records_by_key["composition"] = [
                {
                    "check": "project_composition",
                    "file": folder_name,
                    "status": "error",
                    "message": message,
                }
                for message in composition_errors
            ]
        else:
            check_records_by_key["composition"] = [
                {
                    "check": "project_composition",
                    "file": folder_name,
                    "status": "ok",
                    "message": "Склад проєкту відповідає вимогам.",
                }
            ]

        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)
        self._align_progress_dialog_left(progress)

        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)
                syntax_errors, error_entries = self._validate_geojson_file(
                    source_path,
                    class_key,
                    project_dir,
                    schema,
                    common_schema,
                    data=data,
                )

                protocol_records = []
                object_name = name
                if syntax_errors and not error_entries:
                    for message in syntax_errors:
                        protocol_records.append(
                            {
                                "check": "geojson_schema_validation",
                                "file": object_name,
                                "status": "error",
                                "message": message,
                            }
                        )
                elif not error_entries:
                    protocol_records.append(
                        {
                            "check": "geojson_schema_validation",
                            "file": object_name,
                            "status": "ok",
                            "message": "Перевірка структури GeoJSON та відповідності схемі пройдена.",
                        }
                    )

                if error_entries:
                    check_syntax_messages = set()
                    for entry in (error_entries or []):
                        entry_messages = [str(message) for message in (entry.get("messages", []) or []) if str(message).strip()]
                        if not entry_messages:
                            continue
                        multiline_message = "\n".join(entry_messages)
                        if multiline_message in check_syntax_messages:
                            continue
                        check_syntax_messages.add(multiline_message)
                        protocol_records.append(
                            {
                                "check": "geojson_schema_validation",
                                "file": object_name,
                                "status": "error",
                                "message": multiline_message,
                            }
                        )

                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
                    protocol_records.append(
                        {
                            "check": "attribute_names_latin",
                            "file": object_name,
                            "status": "error",
                            "message": "Виявлено кириличні символи в іменах атрибутів.",
                        }
                    )

                check_records_by_key[class_key] = protocol_records
        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 = {}
        topo_rule_records = []
        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)
            self._align_progress_dialog_left(topo_progress)
            topo_errors_by_class, topo_rule_records = self._run_topology_validation(topo_rules, topo_progress)
        else:
            topo_errors_by_class, topo_rule_records = self._run_topology_validation(topo_rules, None)

        for class_key, entries in topo_errors_by_class.items():
            error_entries_by_class.setdefault(class_key, []).extend(entries)

        check_protocol_layers = []
        check_error_layers = []
        syntax_dir_ready = bool(syntax_dir)
        syntax_errors_dir_ready = bool(syntax_errors_dir)
        if syntax_dir_ready:
            try:
                os.makedirs(syntax_dir, exist_ok=True)
            except Exception:
                syntax_dir_ready = False
                syntax_dir = ""
        if syntax_errors_dir_ready:
            try:
                os.makedirs(syntax_errors_dir, exist_ok=True)
            except Exception:
                syntax_errors_dir_ready = False
                syntax_errors_dir = ""
        if check_records_by_key and syntax_dir_ready:
            for key, records in sorted(check_records_by_key.items(), key=lambda item: item[0].lower()):
                layer_name = f"{key}_check"
                file_path = os.path.join(syntax_dir, f"{layer_name}.geojson")
                if self._write_check_protocol_geojson(file_path, records):
                    check_protocol_layers.append((layer_name, file_path))
        if check_records_by_key and syntax_errors_dir_ready:
            for key, records in sorted(check_records_by_key.items(), key=lambda item: item[0].lower()):
                error_records = [
                    record for record in (records or [])
                    if str(record.get("status", "")).strip().lower() == "error"
                ]
                if not error_records:
                    continue
                layer_name = f"{key}_check"
                file_path = os.path.join(syntax_errors_dir, f"{layer_name}.json")
                if self._write_check_protocol_geojson(file_path, error_records):
                    check_error_layers.append((layer_name, file_path))

        if syntax_group is not None and check_protocol_layers:
            self._add_syntax_layers(check_protocol_layers, syntax_group)
            try:
                syntax_group.setExpanded(True)
            except Exception:
                pass
        if syntax_errors_group is not None and check_error_layers:
            self._add_syntax_layers(check_error_layers, syntax_errors_group)
            try:
                syntax_errors_group.setExpanded(True)
            except Exception:
                pass

        topology_layers = []
        topology_dir_ready = bool(topology_dir)
        if topology_dir_ready:
            try:
                os.makedirs(topology_dir, exist_ok=True)
            except Exception:
                topology_dir_ready = False
        if topology_dir_ready:
            topo_check_path = os.path.join(topology_dir, "topo_check.geojson")
            if self._write_check_protocol_geojson(topo_check_path, topo_rule_records):
                topology_layers.append(("topo_check", topo_check_path))
        if error_entries_by_class and topology_dir_ready:
            for class_key, entries in error_entries_by_class.items():
                topology_layers.extend(self._write_error_geojson_files(entries, topology_dir, class_key))

        if topology_group is not None and topology_layers:
            self._add_syntax_layers([("topo_check", path) for name, path in topology_layers if name == "topo_check"], topology_group)
            self._add_error_layers([(name, path) for name, path in topology_layers if name != "topo_check"], topology_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:
            self._project_close_in_progress = True
            self._suppress_layer_remove_prompt = True
            try:
                try:
                    root = QgsProject.instance().layerTreeRoot()
                except Exception:
                    root = None
                if root is not None:
                    try:
                        root.removeChildNode(group)
                    except Exception:
                        pass
            finally:
                self._suppress_layer_remove_prompt = False
                QTimer.singleShot(0, self._finish_project_close)
            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 _finish_project_close(self) -> None:
        self._project_close_in_progress = False

    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 _theme_cache_key(self, layer: QgsVectorLayer, group=None) -> str:
        if layer is None:
            return ""
        layer_name = ""
        try:
            layer_name = layer.name() or ""
        except Exception:
            layer_name = ""
        group_name = ""
        target_group = group if group is not None else getattr(self.opened_projects, "current_project", None)
        if target_group is not None:
            try:
                group_name = target_group.name() or ""
            except Exception:
                group_name = ""
        if group_name:
            return f"{group_name}::{layer_name}"
        return layer_name

    def _clone_renderer(self, renderer):
        if renderer is None:
            return None
        try:
            return renderer.clone()
        except Exception:
            return None

    def _build_sketch_renderer(self, layer: QgsVectorLayer):
        if layer is None:
            return None
        try:
            geom_type = QgsWkbTypes.geometryType(layer.wkbType())
        except Exception:
            return None
        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 None
        return QgsSingleSymbolRenderer(symbol)

    def _apply_normative_style(self, group, enabled: bool) -> None:
        if group is None:
            return
        layers = self._group_layers(group)
        vector_layers = []
        for layer in layers:
            if not isinstance(layer, QgsVectorLayer):
                continue
            try:
                if layer.providerType().lower() != "memory":
                    continue
            except Exception:
                continue
            vector_layers.append(layer)
        total = len(vector_layers)
        if total == 0:
            return
        progress = QProgressDialog(
            self.tr(u"Застосування теми..."),
            self.tr(u"Скасувати"),
            0,
            total,
            self.iface.mainWindow() if self.iface is not None else None,
        )
        progress.setWindowModality(Qt.ApplicationModal)
        progress.setMinimumDuration(0)
        progress.setAutoClose(False)
        progress.setAutoReset(False)
        progress.setValue(0)
        self._align_progress_dialog_left(progress)
        self._pump_progress_ui(progress, force=True)
        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
        started_at = time.monotonic()
        cache_hits = 0
        file_reads = 0
        sketch_fallbacks = 0

        def update_theme_progress(layer_name: str, index_value: int, stage_text: str) -> None:
            elapsed = max(0.0, time.monotonic() - started_at)
            done = float(max(1, index_value - 1))
            remaining = float(max(0, total - (index_value - 1)))
            eta = (elapsed / done) * remaining if done > 0.0 else 0.0
            progress.setLabelText(
                self.tr(
                    u"РњРёРЅСѓР»Рѕ: {0} | РћС‡С–РєСѓС”С‚СЊСЃСЏ: {1} | РљРµС€: {2} | Р¤Р°Р№Р»: {3} | Р•СЃРєС–Р·: {4}\n{5} ({6}/{7}) - {8}"
                ).format(
                    self._format_duration_hhmmss(elapsed),
                    self._format_duration_hhmmss(eta),
                    cache_hits,
                    file_reads,
                    sketch_fallbacks,
                    layer_name,
                    index_value,
                    total,
                    stage_text,
                )
            )
            progress.setValue(index_value - 1)
            self._pump_progress_ui(progress)

        for index, layer in enumerate(vector_layers, start=1):
            if progress.wasCanceled():
                break
            update_theme_progress(layer.name(), index, self.tr(u"Підготовка"))
            cache_key = self._theme_cache_key(layer, group)
            cache = self._layer_theme_cache.setdefault(cache_key, {}) if cache_key else {}
            if not enabled:
                cache_hits += 1
                update_theme_progress(layer.name(), index, self.tr(u"Тема Ескіз (з кешу/шаблону)"))
                self._apply_sketch_style(layer, trigger_repaint=False)
                continue
            cached_normative = None
            if cache is not None:
                cached_normative = cache.get("normative")
            if cached_normative is False:
                cache_hits += 1
                sketch_fallbacks += 1
                update_theme_progress(layer.name(), index, self.tr(u"Умовні знаки: нема стилю, Ескіз (з кешу)"))
                self._apply_sketch_style(layer, trigger_repaint=False)
                continue
            if cached_normative is not None:
                cache_hits += 1
                update_theme_progress(layer.name(), index, self.tr(u"Умовні знаки (з кешу)"))
                try:
                    layer.setRenderer(self._clone_renderer(cached_normative))
                except Exception:
                    sketch_fallbacks += 1
                    update_theme_progress(layer.name(), index, self.tr(u"Помилка кешу, Ескіз"))
                    self._apply_sketch_style(layer, trigger_repaint=False)
                continue
            style_path = self._normative_style_path(layer.name())
            if not style_path:
                if cache is not None:
                    cache["normative"] = False
                sketch_fallbacks += 1
                update_theme_progress(layer.name(), index, self.tr(u"Нема стилю, Ескіз (кешовано)"))
                self._apply_sketch_style(layer, trigger_repaint=False)
                continue
            if not os.path.exists(style_path):
                if cache is not None:
                    cache["normative"] = False
                sketch_fallbacks += 1
                update_theme_progress(layer.name(), index, self.tr(u"Файл стилю відсутній, Ескіз (кешовано)"))
                self._apply_sketch_style(layer, trigger_repaint=False)
                continue
            try:
                file_reads += 1
                update_theme_progress(layer.name(), index, self.tr(u"Читання файлу стилю"))
                self._load_symbology_only(layer, style_path)
                renderer = None
                try:
                    renderer = layer.renderer()
                except Exception:
                    renderer = None
                renderer_clone = self._clone_renderer(renderer)
                if cache is not None:
                    cache["normative"] = renderer_clone if renderer_clone is not None else False
                update_theme_progress(layer.name(), index, self.tr(u"Умовні знаки (з файлу, кешовано)"))
            except Exception:
                if cache is not None:
                    cache["normative"] = False
                sketch_fallbacks += 1
                update_theme_progress(layer.name(), index, self.tr(u"Помилка стилю, Ескіз"))
                self._apply_sketch_style(layer, trigger_repaint=False)
                continue
        if canvas is not None and prev_render is not None:
            try:
                canvas.setRenderFlag(prev_render)
            except Exception:
                pass
            try:
                canvas.refresh()
            except Exception:
                pass
        progress.setValue(total)
        try:
            progress.setCancelButtonText(self.tr(u"Закрити"))
        except Exception:
            pass
        self._pump_progress_ui(progress)
        try:
            progress.close()
            progress.deleteLater()
        except Exception:
            pass

    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, trigger_repaint: bool = True) -> None:
        if layer is None:
            return
        cache_key = self._theme_cache_key(layer)
        cache = self._layer_theme_cache.setdefault(cache_key, {}) if cache_key else {}
        sketch_renderer = self._clone_renderer(cache.get("sketch")) if cache else None
        if sketch_renderer is None:
            sketch_renderer = self._build_sketch_renderer(layer)
            if sketch_renderer is None:
                return
            if cache is not None:
                cache["sketch"] = self._clone_renderer(sketch_renderer)
        if sketch_renderer is None:
            return
        try:
            layer.setRenderer(sketch_renderer)
            if trigger_repaint:
                layer.triggerRepaint()
        except Exception:
            return

    def on_toggle_normative_style(self):
        new_enabled = not self._normative_style_enabled
        if common.LOG:
            common.log_calls(common.logFile, f"GeoJsonUa.on_toggle_normative_style(enabled={new_enabled})")
        self._normative_style_enabled = new_enabled
        group = getattr(self.opened_projects, "current_project", None)
        self._apply_normative_style(group, self._normative_style_enabled)
        self._update_normative_style_action()

    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.dockwidget.show()
            return

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


