"""
/***************************************************************************
 ComputeV0Dialog
                                 A QGIS plugin
 Topaze
                             -------------------
        begin                : 2026-07-13
        git sha              : $Format:%H$
        copyright            : (C) 2026 by Jean-Marie ARSAC
        email                : jmarsac@azimut.fr
 ***************************************************************************/

/***************************************************************************
 *                                                                         *
 *   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 json
import os
import re
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Sequence

from qgis.core import QgsProject
from qgis.PyQt import QtWidgets, uic
from qgis.PyQt.QtCore import QDir, Qt, QUrl

from topaze.calc.compute_v0 import DEFAULT_TOLERANCE_GON, TEMP_FILENAME, ComputeV0
from topaze.file_utils import FileUtils
from topaze.ptopo import PtopoConst
from topaze.toolbelt import PlgLogger
from topaze.toolbelt.i18n import tr
from topaze.topaze_config import TopazeConfig
from topaze.topaze_utils import TopazeUtils

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


def _qt_value(old_name: str, enum_group: str, new_name: str):
    """Return a Qt 5 or Qt 6 enum value through the neutral QGIS binding."""
    if hasattr(Qt, old_name):
        return getattr(Qt, old_name)
    return getattr(getattr(Qt, enum_group), new_name)


QT_USER_ROLE = _qt_value("UserRole", "ItemDataRole", "UserRole")
QT_CHECKED = _qt_value("Checked", "CheckState", "Checked")
QT_UNCHECKED = _qt_value("Unchecked", "CheckState", "Unchecked")
QT_ITEM_IS_ENABLED = _qt_value("ItemIsEnabled", "ItemFlag", "ItemIsEnabled")
QT_ITEM_IS_USER_CHECKABLE = _qt_value(
    "ItemIsUserCheckable", "ItemFlag", "ItemIsUserCheckable"
)


class ComputeV0Dialog(QtWidgets.QDialog, FORM_CLASS):
    def __init__(self, parent=None):
        super(ComputeV0Dialog, self).__init__(parent)
        self.setupUi(self)

        self.iface = None
        self._obs_array = None
        self._ptopo_array = None
        self._ptopo_layer = None
        self._storage_mode = "file"
        self._get_observations_callback = None
        self._set_observations_callback = None
        self._station_blocks: List[Dict[str, Any]] = []
        self._last_result: Optional[Dict[str, Any]] = None
        self._last_station_key = None

        self.comboBox_compute_station.currentIndexChanged.connect(
            self.load_current_station
        )
        self.listWidget_references.itemChanged.connect(self.clear_result)
        self.checkBox_force_references.stateChanged.connect(self.clear_result)
        self.checkBox_weight_by_distance.stateChanged.connect(self.clear_result)
        self.pushButton_compute.clicked.connect(self.on_pushbutton_compute_clicked)
        self.pushButton_save.clicked.connect(self.on_pushbutton_save_clicked)
        self.pushButton_graphical.clicked.connect(self.on_pushbutton_graphical_clicked)
        self.pushButton_ok.clicked.connect(self.on_pushbutton_ok_clicked)
        self.pushButton_quit.clicked.connect(self.on_pushbutton_quit_clicked)

        self.pushButton_save.setEnabled(False)

        self.pushButton_graphical.setEnabled(False)
        self.pushButton_graphical.setVisible(False)

    def set_iface(self, iface) -> None:
        self.iface = iface

    def showDialog(
        self,
        obs_array,
        ptopo_array=None,
        ptopo_layer=None,
        get_observations_callback: Optional[Callable[..., str]] = None,
        set_observations_callback: Optional[Callable[..., None]] = None,
        storage_mode: str = "file",
    ) -> None:
        self._obs_array = obs_array
        self._ptopo_array = ptopo_array
        self._ptopo_layer = ptopo_layer
        self._get_observations_callback = get_observations_callback
        self._set_observations_callback = set_observations_callback
        self._storage_mode = storage_mode
        self._station_blocks = self._find_station_blocks(obs_array)
        self._last_result = None
        self._last_station_key = None

        self.checkBox_force_references.setChecked(False)
        self.checkBox_weight_by_distance.setChecked(True)
        self.lineEdit_v0.clear()
        self.pushButton_save.setEnabled(False)

        self.comboBox_compute_station.blockSignals(True)
        self.comboBox_compute_station.clear()
        for block in self._station_blocks:
            label = "{} ({})".format(block["matricule"], block["occurrence"])
            self.comboBox_compute_station.addItem(label, block)
        self.comboBox_compute_station.blockSignals(False)

        if not self._station_blocks:
            PlgLogger.log(
                tr("No station was found in the observation file."),
                log_level=2,
                push=True,
            )
            return

        self.comboBox_compute_station.setCurrentIndex(0)
        self.load_current_station()
        self.show()
        self.raise_()
        self.activateWindow()

    @staticmethod
    def _find_station_blocks(obs_array) -> List[Dict[str, Any]]:
        starts: List[Dict[str, Any]] = []
        occurrences: Dict[str, int] = {}

        for index, observation in enumerate(obs_array or []):
            if getattr(observation, "type", None) != "st":
                continue
            matricule = str(getattr(observation, "matricule", "") or "")
            if not matricule:
                continue
            occurrences[matricule] = occurrences.get(matricule, 0) + 1
            starts.append(
                {
                    "matricule": matricule,
                    "occurrence": occurrences[matricule],
                    "start_index": index,
                    "end_index": len(obs_array),
                    "v0_before": getattr(observation, "v0", None),
                }
            )

        for index, block in enumerate(starts[:-1]):
            block["end_index"] = starts[index + 1]["start_index"]
        return starts

    def _current_block(self) -> Optional[Dict[str, Any]]:
        index = self.comboBox_compute_station.currentIndex()
        if index < 0:
            return None
        block = self.comboBox_compute_station.itemData(index)
        return block if isinstance(block, dict) else None

    def _point_from_observations(
        self,
        matricule: str,
        block: Optional[Dict[str, Any]] = None,
    ) -> Optional[Dict[str, Optional[float]]]:
        if block and self._obs_array:
            for observation in self._obs_array[
                block["start_index"] : block["end_index"]
            ]:
                if (
                    getattr(observation, "type", None) == "xyz"
                    and getattr(observation, "matricule", None) == matricule
                ):
                    return {
                        "x": getattr(observation, "x", None),
                        "y": getattr(observation, "y", None),
                        "z": self._valid_z(getattr(observation, "z", None)),
                    }

        point = TopazeUtils.get_ptopo_by_matricule_in_obs_array(
            matricule, self._obs_array
        )
        if point is None:
            point = TopazeUtils.get_ptopo_by_matricule(
                matricule, self._ptopo_array, self._ptopo_layer
            )
        if point is None:
            return None
        return {
            "x": point.x,
            "y": point.y,
            "z": self._valid_z(point.z),
        }

    @staticmethod
    def _valid_z(value) -> Optional[float]:
        if value is None:
            return None
        try:
            number = float(value)
        except (TypeError, ValueError):
            return None
        if number <= PtopoConst.UNKNOWN_Z:
            return None
        return number

    @staticmethod
    def _format_coordinate(value) -> str:
        if value is None:
            return ""
        try:
            return "{:.4f}".format(float(value))
        except (TypeError, ValueError):
            return ""

    def load_current_station(self) -> None:
        block = self._current_block()
        self.clear_result()
        self.listWidget_references.blockSignals(True)
        self.listWidget_references.clear()

        if block is None:
            self._clear_coordinate_fields()
            self.listWidget_references.blockSignals(False)
            self.pushButton_compute.setEnabled(False)
            return

        station_point = self._point_from_observations(block["matricule"], block)
        if station_point:
            self.lineEdit_x_raw.setText(self._format_coordinate(station_point.get("x")))
            self.lineEdit_y_raw.setText(self._format_coordinate(station_point.get("y")))
            self.lineEdit_z_raw.setText(self._format_coordinate(station_point.get("z")))
        else:
            self._clear_coordinate_fields()

        references: Dict[str, Dict[str, Any]] = {}
        for observation in self._obs_array[
            block["start_index"] + 1 : block["end_index"]
        ]:
            if getattr(observation, "type", None) != "ref":
                continue
            reference_id = str(getattr(observation, "matricule", "") or "")
            if not reference_id:
                continue
            reference = references.setdefault(
                reference_id, {"has_horizontal_reading": False}
            )
            if getattr(observation, "ah", None) is not None:
                reference["has_horizontal_reading"] = True

        usable_count = 0
        for reference_id, reference in references.items():
            point = self._point_from_observations(reference_id)
            usable = bool(
                point
                and point.get("x") is not None
                and point.get("y") is not None
                and reference["has_horizontal_reading"]
            )
            item = QtWidgets.QListWidgetItem(reference_id)
            item.setData(QT_USER_ROLE, reference_id)
            item.setFlags(item.flags() | QT_ITEM_IS_USER_CHECKABLE)
            if usable:
                item.setCheckState(QT_CHECKED)
                usable_count += 1
            else:
                item.setCheckState(QT_UNCHECKED)
                item.setFlags(item.flags() & ~QT_ITEM_IS_ENABLED)
                item.setToolTip(
                    tr("Missing planimetric coordinates or horizontal reading.")
                )
            self.listWidget_references.addItem(item)

        self.listWidget_references.blockSignals(False)
        station_usable = bool(
            station_point
            and station_point.get("x") is not None
            and station_point.get("y") is not None
        )
        self.pushButton_compute.setEnabled(station_usable and usable_count > 0)
        self.pushButton_graphical.setEnabled(bool(references))

    def _clear_coordinate_fields(self) -> None:
        self.lineEdit_x_raw.clear()
        self.lineEdit_y_raw.clear()
        self.lineEdit_z_raw.clear()

    def clear_result(self, *args) -> None:
        del args
        self.lineEdit_v0.clear()
        self.pushButton_save.setEnabled(False)
        self._last_result = None
        self._last_station_key = None

    def _selected_reference_ids(self) -> List[str]:
        selected: List[str] = []
        for index in range(self.listWidget_references.count()):
            item = self.listWidget_references.item(index)
            if item.checkState() == QT_CHECKED:
                selected.append(str(item.data(QT_USER_ROLE)))
        return selected

    @staticmethod
    def _v0_tolerance_from_config() -> float:
        config = TopazeConfig.config_dict() or {}
        candidates = [
            config.get("v0_tolerance_gon"),
            config.get("common", {}).get("v0", {}).get("tolerance_gon"),
            config.get("common", {}).get("observations", {}).get("v0_tolerance_gon"),
        ]
        for value in candidates:
            try:
                tolerance = float(value)
            except (TypeError, ValueError):
                continue
            if tolerance >= 0.0:
                return tolerance
        return DEFAULT_TOLERANCE_GON

    def save_data_to_compute_v0(
        self,
        tmp_filename: str,
        selected_references: Sequence[str],
    ) -> str:
        block = self._current_block()
        if block is None:
            raise RuntimeError(tr("No station is currently selected."))

        station_id = block["matricule"]
        station_point = self._point_from_observations(station_id, block)
        if (
            not station_point
            or station_point.get("x") is None
            or station_point.get("y") is None
        ):
            raise RuntimeError(
                tr("Station {station} has no known planimetric coordinates.").format(
                    station=station_id
                )
            )

        stations = [
            {
                "matricule": station_id,
                "x": station_point.get("x"),
                "y": station_point.get("y"),
                "z": station_point.get("z"),
            }
        ]
        for reference_id in selected_references:
            point = self._point_from_observations(reference_id)
            if point is None:
                point = {"x": None, "y": None, "z": None}
            stations.append(
                {
                    "matricule": reference_id,
                    "x": point.get("x"),
                    "y": point.get("y"),
                    "z": point.get("z"),
                }
            )

        observations = []
        for observation in self._obs_array[
            block["start_index"] + 1 : block["end_index"]
        ]:
            if getattr(observation, "type", None) != "ref":
                continue
            reference_id = str(getattr(observation, "matricule", "") or "")
            if reference_id not in selected_references:
                continue
            row = {
                "origine": station_id,
                "occurrence": block["occurrence"],
                "type": "reference",
                "cible": reference_id,
                "ah": getattr(observation, "ah", None),
                "av": getattr(observation, "av", None),
                "di": getattr(observation, "di", None),
                "hp": getattr(observation, "hp", None),
            }
            face = getattr(observation, "face", None)
            if face is not None:
                row["face"] = face
            observations.append(row)

        project = QgsProject.instance()
        crs = project.crs()
        observation_path = self._observations_file_path()
        data = {
            "meta": {
                "project_name": project.baseName(),
                "project_path": project.fileName(),
                "observations_path": str(observation_path),
                "crs_authid": crs.authid(),
                "crs_description": crs.description(),
                "date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
            },
            "config": {
                "weight_by_distance": self.checkBox_weight_by_distance.isChecked(),
                "force_references": self.checkBox_force_references.isChecked(),
                "tolerance_gon": self._v0_tolerance_from_config(),
            },
            "calcul": [station_id],
            "occurrence": block["occurrence"],
            "v0_before": block.get("v0_before"),
            "references": list(selected_references),
            "stations": stations,
            "obs": observations,
        }
        return FileUtils.create_temp_file(tmp_filename, json.dumps(data, indent=4))

    def on_pushbutton_compute_clicked(self) -> None:
        try:
            block = self._current_block()
            if block is None:
                raise RuntimeError(tr("No station is currently selected."))

            selected_references = self._selected_reference_ids()
            if not selected_references:
                raise RuntimeError(tr("Select at least one reference point."))

            json_path = self.save_data_to_compute_v0(TEMP_FILENAME, selected_references)
            if not json_path:
                raise RuntimeError(tr("Failed to save the V0 calculation data."))

            output_dir = Path(QgsProject.instance().homePath()) / "rapport"
            result = ComputeV0.compute_v0(output_dir=output_dir)
            self._last_result = result
            self._last_station_key = (
                block["matricule"],
                block["occurrence"],
            )
            self.lineEdit_v0.setText("{:.5f}".format(result["v0_after"]))
            self.pushButton_save.setEnabled(True)

            report_paths = result.get("report_paths", {})
            report_path = report_paths.get("pdf") or report_paths.get("md")
            report_dir = (
                str(Path(report_path).parent) if report_path else str(output_dir)
            )
            message = tr("V0 calculation of station {station} ({occurrence})").format(
                station=block["matricule"], occurrence=block["occurrence"]
            )
            report_message = tr(
                ' — report in folder <a href="{url}">{path}</a>'
            ).format(
                url=QUrl.fromLocalFile(report_dir).toString(),
                path=QDir.toNativeSeparators(report_dir),
            )
            PlgLogger.log(message + report_message, log_level=3, push=True)

            if not result["within_tolerance"] and not result["force_references"]:
                PlgLogger.log(
                    tr(
                        "The retained reference discrepancies exceed the configured tolerance."
                    ),
                    log_level=1,
                    push=True,
                )
        except Exception as error:
            PlgLogger.log(str(error), log_level=2, push=True)

    def on_pushbutton_save_clicked(self) -> None:
        try:
            block = self._current_block()
            if block is None or self._last_result is None:
                raise RuntimeError(tr("Compute a V0 value before saving it."))

            current_key = (block["matricule"], block["occurrence"])
            if current_key != self._last_station_key:
                raise RuntimeError(
                    tr("The displayed V0 does not belong to the selected station.")
                )

            v0_value = float(self._last_result["v0_after"])
            observations_text = self._read_observations_text()
            new_text = self._replace_station_v0(
                observations_text,
                station_id=block["matricule"],
                occurrence=block["occurrence"],
                v0_value=v0_value,
            )
            self._write_observations_text(new_text)

            block["v0_before"] = v0_value
            station_observation = self._obs_array[block["start_index"]]
            station_observation.v0 = v0_value
            self.pushButton_save.setEnabled(False)

            PlgLogger.log(
                tr("V0 {v0:.5f}g saved for station {station} ({occurrence}).").format(
                    v0=v0_value,
                    station=block["matricule"],
                    occurrence=block["occurrence"],
                ),
                log_level=3,
                push=True,
            )
        except Exception as error:
            PlgLogger.log(str(error), log_level=2, push=True)

    def _observations_file_path(self) -> Path:
        project = QgsProject.instance()
        return Path(project.homePath()) / "db" / (project.baseName() + ".obs")

    def _read_observations_text(self) -> str:
        if self._get_observations_callback is not None:
            try:
                text = self._get_observations_callback(mode=self._storage_mode)
            except TypeError:
                text = self._get_observations_callback(self._storage_mode)
            if text is None:
                raise RuntimeError(tr("Unable to read the observation data."))
            return text

        if self._storage_mode != "file":
            raise RuntimeError(
                tr("No observation reader was provided for the current storage mode.")
            )
        path = self._observations_file_path()
        return path.read_text(encoding="utf-8")

    def _write_observations_text(self, text: str) -> None:
        if self._set_observations_callback is not None:
            try:
                self._set_observations_callback(text, mode=self._storage_mode)
            except TypeError:
                self._set_observations_callback(text, self._storage_mode)
            return

        if self._storage_mode != "file":
            raise RuntimeError(
                tr("No observation writer was provided for the current storage mode.")
            )
        path = self._observations_file_path()
        path.write_text(text, encoding="utf-8")

    @staticmethod
    def _replace_station_v0(
        observations_text: str,
        station_id: str,
        occurrence: int,
        v0_value: float,
    ) -> str:
        lines = observations_text.splitlines(keepends=True)
        current_occurrence = 0
        station_token = "st={}".format(station_id)
        v0_token = "v0={:.5f}".format(v0_value)
        v0_pattern = re.compile(r"(?<!\S)v0=\S+")

        for index, line in enumerate(lines):
            body = line.rstrip("\r\n")
            ending = line[len(body) :]
            tokens = body.strip().split()
            if not tokens or tokens[0] != station_token:
                continue

            # Coordinate records (st=... x=... y=...) do not start an
            # observation occurrence and must not be counted.
            has_x = any(token.startswith("x=") for token in tokens[1:])
            has_y = any(token.startswith("y=") for token in tokens[1:])
            if has_x and has_y:
                continue

            current_occurrence += 1
            if current_occurrence != occurrence:
                continue

            if v0_pattern.search(body):
                body = v0_pattern.sub(v0_token, body, count=1)
            else:
                body = body.rstrip() + " " + v0_token
            lines[index] = body + ending
            return "".join(lines)

        raise RuntimeError(
            tr(
                "Station {station}, occurrence {occurrence}, was not found in the observation data."
            ).format(station=station_id, occurrence=occurrence)
        )

    def on_pushbutton_graphical_clicked(self) -> None:
        try:
            block = self._current_block()
            if block is None:
                return
            if self._ptopo_layer is None or self.iface is None:
                raise RuntimeError(tr("The topographic point layer is not available."))

            matricules = set(self._selected_reference_ids())
            matricules.add(block["matricule"])
            feature_ids = []
            for feature in self._ptopo_layer.getFeatures():
                try:
                    matricule = str(feature["matricule"])
                except Exception:
                    continue
                if matricule in matricules:
                    feature_ids.append(feature.id())

            if not feature_ids:
                raise RuntimeError(
                    tr("No selected station or reference was found in the point layer.")
                )

            self._ptopo_layer.selectByIds(feature_ids)
            self.iface.mapCanvas().zoomToSelected(self._ptopo_layer)
            self.iface.mapCanvas().refresh()
        except Exception as error:
            PlgLogger.log(str(error), log_level=2, push=True)

    def _remove_temp_file(self) -> None:
        if FileUtils.exists_temp_file(TEMP_FILENAME):
            FileUtils.remove_temp_file(TEMP_FILENAME)

    def on_pushbutton_ok_clicked(self) -> None:
        self._remove_temp_file()
        self.accept()

    def on_pushbutton_quit_clicked(self) -> None:
        self._remove_temp_file()
        self.reject()

    def closeEvent(self, event) -> None:
        self._remove_temp_file()
        super(ComputeV0Dialog, self).closeEvent(event)
