"""
/***************************************************************************
 EditTopazeConfigFileDialog
                                 A QGIS plugin
 Topaze
                             -------------------
        begin                : 2026-02-09
        git sha              : $Format:%H$
 ***************************************************************************/

/***************************************************************************
 *                                                                         *
 *   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.                                   *
 *                                                                         *
 ***************************************************************************/
"""

from __future__ import annotations

import ast
import os
import re
from pathlib import Path
from typing import Any, Dict, List, Optional

import yaml
from qgis.core import Qgis
from qgis.PyQt import QtWidgets, uic
from qgis.PyQt.QtWidgets import QMessageBox
from yaml.loader import SafeLoader

from topaze.toolbelt import PlgLogger
from topaze.toolbelt.i18n import tr

FORM_CLASS, _ = uic.loadUiType(
    os.path.join(os.path.dirname(__file__), "edit_topaze_config_file_dialog.ui")
)


class EditTopazeConfigFileDialog(QtWidgets.QDialog, FORM_CLASS):
    """Dialog to edit Topaze YAML configuration.

    The dialog edits the YAML file ``topaze.yml`` (default location:
    ``<plugin>/resources/settings/topaze.yml``).

    Notes
    -----
    - Widgets follow Qt Designer naming conventions:
      ``lineEdit_<key>``, ``checkBox_<key>``, ``comboBox_<key>``.
    - The dialog supports:
      - floats stored in line edits (comma accepted as decimal separator),
      - booleans stored in checkboxes,
      - list nodes stored in combo boxes (e.g. ``devices``),
      - choice nodes declared with ``# one of [...]`` comments in YAML
        stored in combo boxes.
    """

    def __init__(self, parent=None, config_path: Optional[str] = None):
        """Constructor.

        Parameters
        ----------
        parent : QWidget, optional
            Parent widget.
        config_path : str, optional
            Path to the YAML file. If not provided, uses the shipped default.
        """
        super().__init__(parent)
        self.setupUi(self)
        self.setWindowTitle(tr("Topaze settings"))

        self._config_path = config_path or self._default_config_path()

        # Raw YAML doc and config subtree
        self._yaml_doc: Optional[Dict[str, Any]] = None
        self._cfg: Optional[Dict[str, Any]] = None  # points to doc["topaze"]

        # "one of" choices parsed from YAML comments
        self._choices_by_key: Dict[str, List[str]] = {}
        self._yaml_choices_dict = {}

        # Devices (list of dicts with keys: device, description)
        self._devices: List[Dict[str, Any]] = []
        self._device_index_by_name: Dict[str, int] = {}
        self._current_device_name: Optional[str] = None

        # Connections
        self.buttonBox.accepted.connect(self._on_save_clicked)
        self.buttonBox.rejected.connect(self.reject)

        self.comboBox_devices.currentTextChanged.connect(self._on_device_changed)
        self.lineEdit_description.editingFinished.connect(
            self._store_current_device_description
        )

        # text in topaze.yml to be translated in interface, but not in YAML file itself
        self.fr_treatment_dict = dict()
        self.fr_treatment_dict["average"] = tr("average")
        self.fr_treatment_dict["keep"] = tr("keep")
        self.fr_treatment_dict["replace"] = tr("replace")

        # Load & populate
        self.load_config()

    # ---------------------------------------------------------------------
    # Paths / IO
    # ---------------------------------------------------------------------

    def _default_config_path(self) -> str:
        """Return default shipped config path."""
        plugin_root = Path(__file__).resolve().parents[1]  # <plugin>/gui/.. -> <plugin>
        return str(plugin_root / "resources" / "settings" / "topaze.yml")

    def load_config(self) -> None:
        """Load YAML file and populate widgets."""
        if not os.path.exists(self._config_path):
            PlgLogger.log(
                tr("Configuration file not found: {}.").format(self._config_path),
                log_level=Qgis.MessageLevel.Critical,
                push=True,
            )
            return

        try:
            txt = Path(self._config_path).read_text(encoding="utf-8")
            self._choices_by_key = self._parse_one_of_choices(txt)
            self._yaml_doc = yaml.load(txt, Loader=SafeLoader)
        except Exception as err:
            PlgLogger.log(
                tr("Failed to read configuration: {}.").format(err),
                log_level=Qgis.MessageLevel.Critical,
                push=True,
            )
            return

        if not isinstance(self._yaml_doc, dict) or "topaze" not in self._yaml_doc:
            PlgLogger.log(
                tr("Invalid configuration format: missing 'topaze' root node."),
                log_level=Qgis.MessageLevel.Critical,
                push=True,
            )
            self._yaml_doc = None
            self._cfg = None
            return

        self._cfg = self._yaml_doc.get("topaze") or {}
        self._populate_widgets()

    def _write_config(self) -> bool:
        """Write current config to YAML file.

        Returns
        -------
        bool
            True if written successfully.
        """
        if self._yaml_doc is None or self._cfg is None:
            return False

        try:
            self._yaml_doc["topaze"] = self._cfg
            with open(self._config_path, "w", encoding="utf-8") as f:
                yaml.safe_dump(
                    self._yaml_doc,
                    f,
                    sort_keys=False,
                    default_flow_style=False,
                    allow_unicode=True,
                )
            return True
        except Exception as err:
            PlgLogger.log(
                tr("Failed to write configuration: {}.").format(err),
                log_level=Qgis.MessageLevel.Critical,
                push=True,
            )
            return False

    # ---------------------------------------------------------------------
    # YAML comment parsing
    # ---------------------------------------------------------------------

    @staticmethod
    def _parse_one_of_choices(yaml_text: str) -> Dict[str, List[str]]:
        """Parse ``# one of [...]`` comments from YAML text.

        Parameters
        ----------
        yaml_text : str
            Raw YAML file content.

        Returns
        -------
        dict
            Mapping key -> list of choices.
        """
        choices: Dict[str, List[str]] = {}
        # Example:
        # in_tolerance_xy: "average" # one of ['average','keep','replace']
        pattern = re.compile(
            r"^\s*([A-Za-z0-9_]+)\s*:\s*[^#]*#\s*one of\s*(\[[^\]]+\])\s*$"
        )
        for line in yaml_text.splitlines():
            m = pattern.match(line.strip("\n"))
            if not m:
                continue
            key = m.group(1)
            list_str = m.group(2)
            try:
                parsed = ast.literal_eval(list_str)
                if isinstance(parsed, list) and all(isinstance(x, str) for x in parsed):
                    choices[key] = parsed
            except Exception:
                continue
        return choices

    # ---------------------------------------------------------------------
    # UI population helpers
    # ---------------------------------------------------------------------

    def _populate_widgets(self) -> None:
        """Fill widgets from loaded config."""
        if self._cfg is None:
            return

        common = self._cfg.get("common") or {}

        # common.traverse_tolerances
        trav = common.get("traverse_tolerances") or {}
        self._set_line(
            self.lineEdit_distance_back_forward_tolerance,
            trav.get("distance_back_forward_tolerance"),
        )
        self._set_line(
            self.lineEdit_direction_back_forward_tolerance,
            trav.get("direction_back_forward_tolerance"),
        )
        self._set_line(
            self.lineEdit_vertical_back_forward_tolerance,
            trav.get("vertical_back_forward_tolerance"),
        )
        self._set_line(
            self.lineEdit_angular_closure_tolerance,
            trav.get("angular_closure_tolerance"),
        )
        self._set_line(
            self.lineEdit_planimetric_closure_tolerance,
            trav.get("planimetric_closure_tolerance"),
        )

        # common.observations
        obs = common.get("observations") or {}
        self._set_line(self.lineEdit_horizontal_sight, obs.get("horizontal_sight"))
        self._set_line(
            self.lineEdit_horizontal_angle_measured_known,
            obs.get("horizontal_angle_measured_known"),
        )
        self._set_line(self.lineEdit_distance_sight, obs.get("distance_sight"))
        self._set_line(self.lineEdit_zenithal_sight, obs.get("zenithal_sight"))
        self._set_line(
            self.lineEdit_elevation_difference, obs.get("elevation_difference")
        )

        # common.measured_calculated
        mc = common.get("measured_calculated") or {}
        self._set_line(self.lineEdit_meas_calc_bearing, mc.get("meas_calc_bearing"))
        self._set_line(self.lineEdit_meas_calc_distance, mc.get("meas_calc_distance"))
        self._set_line(
            self.lineEdit_meas_calc_zenithal_angle, mc.get("meas_calc_zenithal_angle")
        )
        self._set_line(
            self.lineEdit_meas_calc_elevation_difference,
            mc.get("meas_calc_elevation_difference"),
        )

        # duplicated
        dup = self._cfg.get("duplicated") or {}

        xy = dup.get("xy_treatment") or {}
        self._set_line(self.lineEdit_dup_tolerance_xy, xy.get("dup_tolerance_xy"))
        self._setup_choice_combo(
            self.comboBox_in_tolerance_xy,
            "in_tolerance_xy",
            self.fr_treatment_dict[xy.get("in_tolerance_xy")],
        )
        self._setup_choice_combo(
            self.comboBox_out_of_tolerance_xy,
            "out_of_tolerance_xy",
            self.fr_treatment_dict[xy.get("out_of_tolerance_xy")],
        )

        self.checkBox_special_treatment_for_z.setChecked(
            bool(dup.get("special_treatment_for_z", False))
        )

        zt = dup.get("z_treatment") or {}
        self._set_line(self.lineEdit_dup_tolerance_z, zt.get("dup_tolerance_z"))
        self._setup_choice_combo(
            self.comboBox_in_tolerance_z,
            "in_tolerance_z",
            self.fr_treatment_dict[zt.get("in_tolerance_z")],
        )
        self._setup_choice_combo(
            self.comboBox_out_of_tolerance_z,
            "out_of_tolerance_z",
            self.fr_treatment_dict[zt.get("out_of_tolerance_z")],
        )

        # field_data
        field_data = self._cfg.get("field_data") or {}
        code_ref = field_data.get("reference_sight_code", "3")
        self._set_line(self.lineEdit_reference_sight_code, code_ref)

        # devices
        self._devices = self._cfg.get("devices") or []
        if not isinstance(self._devices, list):
            self._devices = []

        self._device_index_by_name = {}
        device_names = []
        for i, d in enumerate(self._devices):
            if isinstance(d, dict) and "device" in d:
                name = str(d.get("device"))
                device_names.append(name)
                self._device_index_by_name[name] = i

        self.comboBox_devices.blockSignals(True)
        self.comboBox_devices.clear()
        self.comboBox_devices.addItems(device_names)
        self.comboBox_devices.blockSignals(False)

        if device_names:
            self.comboBox_devices.setCurrentIndex(0)
            self._current_device_name = self.comboBox_devices.currentText()
            self._refresh_device_description()
        else:
            self._current_device_name = None
            self.lineEdit_description.setText("")
            self.lineEdit_description.setEnabled(False)

        # codification
        codif = self._cfg.get("codification") or {}
        self._set_line(self.lineEdit_lsci, codif.get("lsci"))

        # helmert
        hel = self._cfg.get("helmert") or {}
        self._set_line(self.lineEdit_tolerance, hel.get("tolerance"))

    def _setup_choice_combo(
        self, combo: QtWidgets.QComboBox, key: str, current_value: Any
    ) -> None:
        """Fill a combo box with choices from YAML comments and set current value."""
        # choices = self._choices_by_key.get(key) or ["average", "keep", "replace"]
        choices = [tr("average"), tr("keep"), tr("replace")]
        self._yaml_choices_dict = {
            tr("average"): "average",
            tr("keep"): "keep",
            tr("replace"): "replace",
        }

        combo.blockSignals(True)
        combo.clear()
        combo.addItems([str(x) for x in choices])
        combo.blockSignals(False)

        if current_value is not None:
            idx = combo.findText(str(current_value))
            if idx >= 0:
                combo.setCurrentIndex(idx)

    @staticmethod
    def _set_line(widget: QtWidgets.QLineEdit, value: Any) -> None:
        widget.setText("" if value is None else str(value))

    # ---------------------------------------------------------------------
    # Devices handling
    # ---------------------------------------------------------------------

    def _on_device_changed(self, device_name: str) -> None:
        """Handle device selection changes."""
        self._store_current_device_description()
        self._current_device_name = device_name
        self._refresh_device_description()

    def _refresh_device_description(self) -> None:
        """Refresh description field for current device."""
        if not self._current_device_name:
            self.lineEdit_description.setText("")
            self.lineEdit_description.setEnabled(False)
            return

        idx = self._device_index_by_name.get(self._current_device_name)
        if idx is None:
            self.lineEdit_description.setText("")
            self.lineEdit_description.setEnabled(False)
            return

        d = self._devices[idx] if 0 <= idx < len(self._devices) else {}
        descr = d.get("description", "") if isinstance(d, dict) else ""
        self._set_line(self.lineEdit_description, descr)
        self.lineEdit_description.setEnabled(True)

    def _store_current_device_description(self) -> None:
        """Store current description into the devices list."""
        if not self._current_device_name:
            return
        idx = self._device_index_by_name.get(self._current_device_name)
        if idx is None or not (0 <= idx < len(self._devices)):
            return
        d = self._devices[idx]
        if not isinstance(d, dict):
            return
        d["description"] = self.lineEdit_description.text()
        self._devices[idx] = d

    # ---------------------------------------------------------------------
    # Save
    # ---------------------------------------------------------------------

    def _on_save_clicked(self) -> None:
        """Validate inputs and save the YAML file."""
        if self._cfg is None:
            PlgLogger.log(
                tr("No configuration loaded."),
                log_level=Qgis.MessageLevel.Warning,
                push=True,
            )
            return

        # persist last edited device description
        self._store_current_device_description()

        # Build nested dicts
        self._cfg.setdefault("common", {})
        self._cfg["common"].setdefault("traverse_tolerances", {})
        self._cfg["common"].setdefault("observations", {})
        self._cfg["common"].setdefault("measured_calculated", {})
        self._cfg.setdefault("duplicated", {})
        self._cfg["duplicated"].setdefault("xy_treatment", {})
        self._cfg["duplicated"].setdefault("z_treatment", {})
        self._cfg.setdefault("field_data", {})
        self._cfg.setdefault("codification", {})
        self._cfg.setdefault("helmert", {})

        ok = True

        # traverse tolerances
        ok &= self._set_float(
            self._cfg["common"]["traverse_tolerances"],
            "distance_back_forward_tolerance",
            self.lineEdit_distance_back_forward_tolerance.text(),
        )
        ok &= self._set_float(
            self._cfg["common"]["traverse_tolerances"],
            "direction_back_forward_tolerance",
            self.lineEdit_direction_back_forward_tolerance.text(),
        )
        ok &= self._set_float(
            self._cfg["common"]["traverse_tolerances"],
            "vertical_back_forward_tolerance",
            self.lineEdit_vertical_back_forward_tolerance.text(),
        )
        ok &= self._set_float(
            self._cfg["common"]["traverse_tolerances"],
            "angular_closure_tolerance",
            self.lineEdit_angular_closure_tolerance.text(),
        )
        ok &= self._set_float(
            self._cfg["common"]["traverse_tolerances"],
            "planimetric_closure_tolerance",
            self.lineEdit_planimetric_closure_tolerance.text(),
        )

        # observations
        ok &= self._set_float(
            self._cfg["common"]["observations"],
            "horizontal_sight",
            self.lineEdit_horizontal_sight.text(),
        )
        ok &= self._set_float(
            self._cfg["common"]["observations"],
            "horizontal_angle_measured_known",
            self.lineEdit_horizontal_angle_measured_known.text(),
        )
        ok &= self._set_float(
            self._cfg["common"]["observations"],
            "distance_sight",
            self.lineEdit_distance_sight.text(),
        )
        ok &= self._set_float(
            self._cfg["common"]["observations"],
            "zenithal_sight",
            self.lineEdit_zenithal_sight.text(),
        )
        ok &= self._set_float(
            self._cfg["common"]["observations"],
            "elevation_difference",
            self.lineEdit_elevation_difference.text(),
        )

        # measured_calculated
        ok &= self._set_float(
            self._cfg["common"]["measured_calculated"],
            "meas_calc_bearing",
            self.lineEdit_meas_calc_bearing.text(),
        )
        ok &= self._set_float(
            self._cfg["common"]["measured_calculated"],
            "meas_calc_distance",
            self.lineEdit_meas_calc_distance.text(),
        )
        ok &= self._set_float(
            self._cfg["common"]["measured_calculated"],
            "meas_calc_zenithal_angle",
            self.lineEdit_meas_calc_zenithal_angle.text(),
        )
        ok &= self._set_float(
            self._cfg["common"]["measured_calculated"],
            "meas_calc_elevation_difference",
            self.lineEdit_meas_calc_elevation_difference.text(),
        )

        # duplicated.xy_treatment
        ok &= self._set_float(
            self._cfg["duplicated"]["xy_treatment"],
            "dup_tolerance_xy",
            self.lineEdit_dup_tolerance_xy.text(),
        )
        self._cfg["duplicated"]["xy_treatment"][
            "in_tolerance_xy"
        ] = self._yaml_choices_dict.get(
            self.comboBox_in_tolerance_xy.currentText(), "average"
        )
        self._cfg["duplicated"]["xy_treatment"][
            "out_of_tolerance_xy"
        ] = self._yaml_choices_dict.get(
            self.comboBox_out_of_tolerance_xy.currentText(), "average"
        )

        # duplicated flags
        self._cfg["duplicated"]["special_treatment_for_z"] = bool(
            self.checkBox_special_treatment_for_z.isChecked()
        )

        # duplicated.z_treatment
        ok &= self._set_float(
            self._cfg["duplicated"]["z_treatment"],
            "dup_tolerance_z",
            self.lineEdit_dup_tolerance_z.text(),
        )
        self._cfg["duplicated"]["z_treatment"][
            "in_tolerance_z"
        ] = self._yaml_choices_dict.get(
            self.comboBox_in_tolerance_z.currentText(), "average"
        )
        self._cfg["duplicated"]["z_treatment"][
            "out_of_tolerance_z"
        ] = self._yaml_choices_dict.get(
            self.comboBox_out_of_tolerance_z.currentText(), "average"
        )

        # field data
        code_ref = self.lineEdit_reference_sight_code.text()
        if not code_ref:
            code_ref = "3"
        self._cfg["field_data"]["reference_sight_code"] = str(code_ref)

        # devices list
        self._cfg["devices"] = self._devices

        # codification
        self._cfg["codification"]["lsci"] = self.lineEdit_lsci.text()

        # helmert tolerance
        ok &= self._set_float(
            self._cfg["helmert"], "tolerance", self.lineEdit_tolerance.text()
        )

        if not ok:
            PlgLogger.log(
                tr("Some values are invalid. Please correct them and try again."),
                log_level=Qgis.MessageLevel.Warning,
                push=True,
            )
            return

        if self._write_config():
            PlgLogger.log(
                tr("Settings saved: {}.").format(self._config_path),
                log_level=Qgis.MessageLevel.Success,
                push=True,
            )
            self.accept()
        else:
            QMessageBox.critical(self, tr("Topaze"), tr("Failed to save settings."))

    @staticmethod
    def _set_float(target: Dict[str, Any], key: str, text_value: str) -> bool:
        """Parse a float from text and set it in target dict.

        Parameters
        ----------
        target : dict
            Destination dictionary.
        key : str
            Key to set.
        text_value : str
            User input text.

        Returns
        -------
        bool
            True if conversion succeeded.
        """
        txt = (text_value or "").strip()
        if txt == "":
            # keep existing if present, otherwise set 0.0
            if key not in target:
                target[key] = 0.0
            return True

        # accept commas as decimal separators
        txt = txt.replace(",", ".")
        try:
            target[key] = float(txt)
            return True
        except Exception:
            return False
