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

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

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

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

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


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

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

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

        # initialize plugin directory
        self.plugin_dir = os.path.dirname(__file__)
        self._schema_cache = None
        self._enums_cache = None
        self._normative_style_cache = None
        self._missing_style_reported = set()

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

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

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

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

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

        #print "** INITIALIZING GeoJsonUa"

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


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

        We implement this ourselves since we do not inherit QObject.

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

        if add_to_toolbar:
            self.toolbar.addAction(action)

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

        self.actions.append(action)

        return action


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

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

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

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

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

        self.tools_menu = QMenu(main_window)

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

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

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

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

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

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

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

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

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

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

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

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

        #print "** CLOSING GeoJsonUa"

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

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

        self.pluginIsActive = False


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

        #print "** UNLOAD GeoJsonUa"

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

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

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

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

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

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

    def _is_katotth_prefix(self, name: str) -> bool:
        if not name:
            return False
        name = name.strip().upper()
        if len(name) < 19:
            return False
        return name.startswith("UA") and name[2:19].isdigit()

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        if base_props:
            _merge(base_props)
        return combined

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

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

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

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

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

    def _validate_geojson_file(self, source_path: str, class_key: str, project_dir: str, schema: dict, common_schema: dict):
        errors = []
        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} не знайдена."]
        allowed_props = set(props_schema.keys())
        for index, feature in enumerate(features, start=1):
            if not isinstance(feature, dict):
                errors.append(f"[{index}] Feature не є об'єктом.")
                continue
            props = feature.get("properties")
            if not isinstance(props, dict):
                errors.append(f"[{index}] Відсутній або некоректний 'properties'.")
                continue
            for key in props.keys():
                if key not in allowed_props:
                    errors.append(f"[{index}] Невідоме поле '{key}'.")
            for key in required_props:
                if key not in props:
                    errors.append(f"[{index}] Відсутнє обов'язкове поле '{key}'.")
            for key, value in props.items():
                if key not in props_schema:
                    continue
                schema_node = props_schema.get(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):
                        errors.append(f"[{index}] Поле '{key}' має бути integer, але отримано {type(value).__name__}.")
                    elif schema_type == "number" and not isinstance(value, (int, float)):
                        errors.append(f"[{index}] Поле '{key}' має бути number, але отримано {type(value).__name__}.")
                    elif schema_type == "boolean" and not isinstance(value, bool):
                        errors.append(f"[{index}] Поле '{key}' має бути boolean, але отримано {type(value).__name__}.")
                    elif schema_type == "string" and not isinstance(value, str):
                        errors.append(f"[{index}] Поле '{key}' має бути string, але отримано {type(value).__name__}.")
                    elif schema_type == "array" and not isinstance(value, list):
                        errors.append(f"[{index}] Поле '{key}' має бути array, але отримано {type(value).__name__}.")
                    elif schema_type == "object" and not isinstance(value, dict):
                        errors.append(f"[{index}] Поле '{key}' має бути object, але отримано {type(value).__name__}.")
                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:
                    errors.append(f"[{index}] Поле '{key}' має значення {value!r}, якого немає у списку enum.")
        if errors and project_dir:
            base_name = os.path.splitext(os.path.basename(source_path))[0]
            log_path = os.path.join(project_dir, f"{base_name}_errors.txt")
            try:
                with open(log_path, "w", encoding="utf-8") as handle:
                    handle.write("\n".join(errors))
            except Exception:
                pass
        return errors

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    def load_folder(self, folder_path: str) -> None:
        if not folder_path or not os.path.isdir(folder_path):
            self._push_message(self.tr(u"Папка не знайдена: {0}").format(folder_path), level=Qgis.Warning)
            return
        files = sorted(
            name for name in os.listdir(folder_path)
            if name.lower().endswith(".geojson")
        )
        if not files:
            self._push_message(self.tr(u"У папці немає .geojson файлів."), level=Qgis.Info)
            return
        schema, common_schema = self._load_schema_cache()
        loaded = 0
        for name in files:
            class_key = os.path.splitext(name)[0]
            if self._get_layer_for_class(class_key) is not None:
                continue
            source_path = os.path.join(folder_path, name)
            errors = self._validate_geojson_file(source_path, class_key, folder_path, schema, common_schema)
            if errors:
                self._push_message(
                    self.tr(u"Виявлено невідповідності схемі у файлі {0}. Деталі у {1}_errors.txt.")
                    .format(name, os.path.splitext(name)[0]),
                    level=Qgis.Warning,
                )
            item = self._show_status(self.tr(u"{0}: парсинг geojson").format(name))
            source_layer = QgsVectorLayer(source_path, class_key, "ogr")
            if not source_layer.isValid():
                self._close_status(item)
                self._push_message(self.tr(u"Не вдалося відкрити файл: {0}").format(name), level=Qgis.Warning)
                continue
            target_layer = self._ensure_memory_layer_for_class(
                class_key,
                folder_path,
                source_layer=source_layer,
                source_path=source_path,
                loaded_from_disk=True,
            )
            if target_layer is None:
                self._close_status(item)
                continue
            props_template = {}
            if schema and f"{class_key}Feature" in schema.get("$defs", {}):
                props_template = self._build_feature_properties(class_key, "", schema, common_schema)
            target_fields = target_layer.fields()
            source_field_names = set()
            try:
                source_field_names = set(source_layer.fields().names())
            except Exception:
                source_field_names = set()
            new_features = []
            for feature in source_layer.getFeatures():
                new_feature = QgsFeature(target_fields)
                geometry = self._coerce_geometry(feature.geometry(), target_layer.wkbType())
                if geometry is not None:
                    new_feature.setGeometry(geometry)
                if props_template:
                    props = dict(props_template)
                    for name in source_field_names:
                        if name in props:
                            try:
                                props[name] = feature[name]
                            except Exception:
                                pass
                    self._apply_property_aliases(class_key, props, source_field_names, feature)
                    attributes = [props.get(field.name()) for field in target_fields]
                else:
                    attributes = feature.attributes()
                new_feature.setAttributes(attributes)
                new_features.append(new_feature)
            try:
                if not target_layer.isEditable():
                    target_layer.startEditing()
            except Exception:
                pass
            try:
                target_layer.dataProvider().addFeatures(new_features)
            except Exception:
                self._close_status(item)
                self._push_message(self.tr(u"Не вдалося імпортувати об'єкти з {0}.").format(name), level=Qgis.Warning)
                continue
            target_layer.updateExtents()
            self._update_status(item, self.tr(u"{0}: побудова просторового індекса").format(name))
            self.build_index(target_layer)
            self._close_status(item)
            self.mark_dirty(target_layer.id(), False)
            loaded += 1
        if loaded:
            self._push_info(self.tr(u"Імпортовано шарів: {0}").format(loaded))

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

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

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

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

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

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

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

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

    def _is_katotth_group_name(self, name: str) -> bool:
        if not name:
            return False
        base = name.split("_", 1)[0].upper()
        return len(base) == 19 and base.startswith("UA") and base[2:].isdigit()

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

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

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

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

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

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

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

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

            layout = QVBoxLayout(dialog)

            label = QLabel(self.tr(u"КАТОТТГ проекту:"))
            layout.addWidget(label)

            line_edit = QLineEdit()
            line_edit.setPlaceholderText("UAООРРГГГПППММУУУУУ")
            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

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

            if len(katotth) == 19 and katotth.startswith("UA") and katotth[2:].isdigit():
                project_name = katotth
                break

            retry = QMessageBox.warning(
                parent,
                self.tr(u"Новий проект"),
                self.tr(u"Код має починатися з UA і містити ще 17 цифр."),
                QMessageBox.Retry | QMessageBox.Cancel,
                QMessageBox.Retry,
            )
            if retry != QMessageBox.Retry:
                return

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

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

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

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

        folder_name = os.path.basename(project_dir)
        if not self._is_katotth_prefix(folder_name):
            QMessageBox.warning(
                parent,
                self.tr(u"Відкрити проект"),
                self.tr(u"Назва папки має починатися з коду КАТОТТГ (UA + 17 цифр)."),
            )
            return

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

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

        katotth = folder_name.strip().upper()[:19]
        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_save(self):
        if common.LOG:
            common.log_calls(common.logFile, "GeoJsonUa.on_save()")
        self.save_all_dirty()

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

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

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

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

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

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

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

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

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

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

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

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