# SPDX-FileCopyrightText: 2025 XLeitstelle Planen und Bauen <xleitstelle@gv.hamburg.de>
# SPDX-FileContributor: Tobias Kraft <tobias.kraft@gv.hamburg.de>
# SPDX-FileContributor: Anton Jacobsson <anton.jacobsson@init.de>
# SPDX-FileContributor: Johannes Sommer <jsommer@geocledian.com>
#
# SPDX-License-Identifier: EUPL-1.2

"""Plugin settings persistence and configuration.

Provides a thin wrapper around `QgsSettings` for reading and writing all
XMAS-Plugin user preferences (webapp URL, database connection, app schema,
layer config path, …) under the `PLUGIN_NAME/` namespace key.

Key public API:

- `save_setting()` / `load_setting()` — generic key/value store.
- `show_settings_dialog()` — opens the settings `QDialog` built from
  `ui/settings_dialog.ui`.
- `get_appschema()` — returns the active schema descriptor (type string and
  version) derived from the selected `APPSCHEMAS` entry.
- `get_normalized_url()` — formats the webapp URL for settings storage.

`APPSCHEMAS` maps human-readable schema names (*XPlanung*, *XTrasse*,
*XWärmeplan*) to their internal type strings and supported versions.
"""

import logging
import tomllib
from pathlib import Path
from typing import Literal, Optional, get_args
from urllib.parse import urlparse

from qgis.core import (
    Qgis,
    QgsApplication,
    QgsMessageLog,
    QgsProviderRegistry,
    QgsSettings,
)
from qgis.PyQt import QtWidgets, uic
from qgis.PyQt.QtCore import QDir, QStringListModel
from qgis.PyQt.QtWidgets import QDialog, QDialogButtonBox, QFileDialog
from qgis.utils import iface

from xmas_plugin.util import db
from xmas_plugin.util.helpers import get_plugin_root
from xmas_plugin.util.metadata import PLUGIN_DIR_NAME, PLUGIN_NAME

logger = logging.getLogger(PLUGIN_DIR_NAME)

APPSCHEMAS = {
    "XPlanung": {"type": "xplan", "versions": ["4.1", "5.4", "6.0", "6.1"]},
    "XTrasse": {"type": "xtrasse", "versions": ["2.0"]},
    "XWärmeplan": {"type": "xwp", "versions": ["0.9"]},
}

DEFAULT_LAYER_CONFIG_PATH = str(
    Path(get_plugin_root()) / "resources" / "config" / "layers.toml"
)

GEOMETRY_TYPES = Literal["point", "line", "polygon", "nogeom"]
LAYER_TYPES = Literal[
    "plan",
    "text",
    "section",
    "presentation",
    "subject",
]


def save_setting(key, value) -> None:
    """
    Save a single setting with namespacing.
    """
    settings = QgsSettings()
    if key == "webapp_url":
        value = normalize_url(value)
    settings.setValue(f"{PLUGIN_NAME}/{key}", value)


def load_setting(key, default_value=None) -> str | None:
    """
    Load a single setting with namespacing.
    """
    settings = QgsSettings()
    return settings.value(f"{PLUGIN_NAME}/{key}", default_value)


def normalize_url(url_str: str) -> str | None:
    """
    Normalize a URL
    """
    if not url_str:
        return None
    if "://" not in url_str:
        url_str = "http://" + url_str
    return url_str


def get_normalized_url() -> Optional[str]:
    """
    Gets a normalized form of the XPlan server URL
    """
    raw_url = load_setting("webapp_url", "")
    base_url = normalize_url(raw_url)
    if not base_url:
        logger.warning("No URL provided in settings. Please set a URL.")
    return base_url


def show_settings_dialog(parent) -> None:
    """
    Open the settings dialog as a dialog.
    """
    dialog = SettingsDialog(parent)
    return dialog.exec()


def get_connection_type() -> Literal["Postgres", "OAPIF"]:
    """
    Load the connection type from plugin settings.
    """
    connection_type = load_setting("connection_type")
    if connection_type is None:
        logger.warning("Connection type setting not found; returning None.")
    return connection_type


def get_config_path() -> str:
    """
    Load the configuration path for layer structure from plugin settings.
    """
    config_path = load_setting("selected_config_toml")
    # TODO: daily None vs default path (db connection also returns None
    # but if config toml is None, QGIS Plugin wont load)
    if config_path is None:
        logger.warning("Config path setting not found; returning default path.")
        config_path = DEFAULT_LAYER_CONFIG_PATH
    return config_path


def load_db_connection_name() -> Optional[str]:
    """
    Load the database connection name from plugin settings.
    Returns the name, or None if not set.
    """
    db_conn = load_setting("db_connection")
    if db_conn is not None:
        logger.debug(f"[db_utils] Loaded DB connection from settings: {db_conn}")
    else:
        logger.info("[db_utils] No database connection name set in settings.")
    return db_conn


def get_appschema(appschema: str = "") -> (tuple)[str, str]:
    """Load and parse the application schema setting.

    Reads the "appschema" setting (default "XPlanung 6.0"), splits it into a name
    and version, and falls back to ("XPlanung", "6.0") if parsing fails. Then maps
    the name to its schema type via the APPSCHEMAS config.

    Args:
        appschema: an optional appschema string; if not provided, it's read from settings

    Returns:
        A tuple (schema_type, version) where:
        - schema_type: the schema type string from APPSCHEMAS
        - version: the version string parsed from the setting
    """
    if not appschema:
        appschema = load_setting("appschema", "XPlanung 6.0")

    try:
        name, version = appschema.split(" ", 1)
    except ValueError:
        # fallback
        name, version = "XPlanung", "6.0"

    schema_type = APPSCHEMAS.get(name, APPSCHEMAS["XPlanung"])["type"]

    return schema_type, version


def validate_layer_config(config_path: str, appschema: str) -> dict:
    """Tests if a layer config has required keys and allowed values."""
    appschema_shortname, _ = get_appschema(appschema)
    try:
        with open(config_path, "rb") as f:
            config = tomllib.load(f)
    except FileNotFoundError:
        msg = f"Layer configuration file not found: {config_path}"
        logger.error(msg)
        raise
    except Exception as e:
        msg = f"Failed to load or parse TOML at '{config_path}': {e}"
        logger.error(msg, exc_info=True)
        raise ValueError(msg)
    if not (appschema_config := config.get(appschema_shortname)):
        raise ValueError(f"No config section for appschema: {appschema_config}")
    if not (layers := appschema_config.get("layers")):
        raise ValueError(
            f"No layers defined for appschema {appschema_shortname!r} in configuration: {config_path}"
        )
    else:
        for layer in layers:
            if not all(
                [
                    layer.get("name"),
                    geometry in get_args(GEOMETRY_TYPES)
                    if (geometry := layer.get("geometry"))
                    else True,
                    (layer_type := layer.get("type"))
                    and layer_type in get_args(LAYER_TYPES),
                ]
            ):
                raise ValueError(
                    f"Invalid layer config {repr(config_path)} for layer with name {repr(layer.get('name', 'no name found'))}"
                )
    return config


def load_layer_config():
    """Returns a layer config dictionary."""
    config_path = get_config_path()

    logger.debug(f"Loading layer config from: {config_path}")
    config = validate_layer_config(
        config_path, load_setting("appschema", "XPlanung 6.0")
    )
    logger.info(f"Loaded config from {config_path}: {config}")
    return config


class SettingsDialog(QDialog):
    """
    SettingsDialog provides a modal dialog for configuring application-wide settings:
    - selecting a database connection
    - choosing an application schema
    - setting the GUI URL
    """

    def __init__(self, parent=None) -> None:
        """
        Initialize the SettingsDialog:
        1. Load the UI file.
        2. Populate database connections and schemas.
        3. Load any saved settings.
        4. Wire up signal handlers and input validation.
        """
        super().__init__(parent)
        try:
            # Use relative path to load the UI file
            ui_file = Path(get_plugin_root()) / "ui" / "settings.ui"
            uic.loadUi(str(ui_file), self)
        except Exception as e:
            QtWidgets.QMessageBox.critical(
                self, "Error", f"Failed to load settings dialog: {str(e)}"
            )
            return

        # Default directory for TOML config files
        self.default_dir_toml = str(Path(DEFAULT_LAYER_CONFIG_PATH).parent)

        self.selected_dir_toml = None
        self.selected_config_toml = None

        # Populate the ComboBox initially
        self.populate_db_connections()

        # Populate appschema
        self.populate_appschemas()

        # Load saved settings into the UI
        self.load_settings()

        # Connect signals
        self.buttonBox.accepted.connect(self.save_settings)
        self.buttonBox.rejected.connect(self.reject)

        # Disable OK button until all required fields are set
        self.selectDbConnection.currentTextChanged.connect(self.validate_inputs)
        self.connType.currentTextChanged.connect(self.validate_inputs)
        self.guiUrl.textChanged.connect(self.validate_inputs)
        self.validate_inputs()

        # Config File connections
        self.pb_choose_config_dir.clicked.connect(self.on_choose_config_dir)

        # Click event for default config file
        self.pb_reset_config_dir.clicked.connect(self.on_reset_config_dir)

        # Listen on changes for config file
        self.selectConfigToml.currentTextChanged.connect(self.on_select_config_toml)

    def populate_config_files(self) -> None:
        """
        Populates the combobox for configuration file names
        """
        directory = (
            self.default_dir_toml
            if self.selected_dir_toml is None
            else self.selected_dir_toml
        )

        # get list of all toml files in selected directory
        dir_ = QDir(directory)
        files = dir_.entryList(["*.toml", "*.TOML"], QDir.Files | QDir.NoDotAndDotDot)
        model = QStringListModel(files)

        # bind contents to combobox
        self.selectConfigToml.setModel(model)
        self.selected_dir_toml = directory

    def on_choose_config_dir(self) -> None:
        """
        Event handler for choosing the configuration directory
        """
        start_dir = (
            self.default_dir_toml
            if self.selected_dir_toml is None
            else self.selected_dir_toml
        )
        # open directory chooser dialog
        directory = QFileDialog.getExistingDirectory(
            self, caption="Verzeichnis auswählen", directory=start_dir
        )
        self.selected_dir_toml = directory

        self.populate_config_files()

    def on_reset_config_dir(self) -> None:
        """
        Event handler for resetting the configuration path for layer structure to the default path
        """
        self.selected_dir_toml = None
        self.populate_config_files()

    def on_select_config_toml(self) -> None:
        """
        Event handler on selecting a configuration file
        """
        self.validate_inputs()

    def pb_reset_config_dir(self):
        """
        Event handler for resetting the configuration default path
        """
        self.reset_config_dir()

    def load_settings(self) -> None:
        """
        Load saved settings and populate the UI.
        """
        db_connection = load_setting("db_connection", "")
        webapp_url = load_setting("webapp_url", "http://localhost:1337")
        connection_type = load_setting("connection_type", "")
        appschema = load_setting("appschema", "XPlanung 6.0")
        selected_config_toml = load_setting("selected_config_toml", get_config_path())

        logger.info(
            f"Loaded settings: db_connection={db_connection}, webapp_url={webapp_url}"
        )

        self.guiUrl.setText(webapp_url)

        if connection_type:
            index = self.connType.findText(connection_type)
            if index >= 0:
                self.connType.setCurrentIndex(index)

        if db_connection:
            index = self.selectDbConnection.findText(db_connection)
            if index >= 0:
                self.selectDbConnection.setCurrentIndex(index)

        if appschema:
            index = self.selectAppschema.findText(appschema)
            if index >= 0:
                self.selectAppschema.setCurrentIndex(index)

        if selected_config_toml:
            self.selected_dir_toml = str(Path(selected_config_toml).parent)
            self.selected_config_toml = str(Path(selected_config_toml))
            self.populate_config_files()
            filename = Path(selected_config_toml).name
            index = self.selectConfigToml.findText(filename)
            if index >= 0:
                self.selectConfigToml.setCurrentIndex(index)

    def populate_appschemas(self) -> None:
        """
        Populate the application schema selection combobox.

        Iterates over the global APPSCHEMAS dictionary, building a list of
        "<schema_name> <version>" strings for each version, and adds them
        to the `selectAppschema` combobox widget.
        """
        combobox = self.selectAppschema
        items = []
        for key, value in APPSCHEMAS.items():
            for version in value["versions"]:
                items.append(f"{key} {version}")
        combobox.addItems(items)

    def populate_db_connections(self) -> None:
        """
        Populate the ComboBox with existing Postgres connections.
        """
        provider_metadata = QgsProviderRegistry.instance().providerMetadata("postgres")

        # remember desired selection (prefer stored setting, fallback current)
        desired = (
            load_setting("db_connection", "") or self.selectDbConnection.currentText()
        )

        # Making sure QGIS has updated changes in the DSM
        QgsApplication.instance().processEvents()
        QgsSettings().sync()
        QgsApplication.instance().processEvents()

        if provider_metadata:
            connections = provider_metadata.connections()

            if connections:
                logger.info("Adding connections to ComboBox:")
                for name, conn in connections.items():
                    logger.info(f"Name: {repr(name)}, URI: {conn.uri()}")

                # Robustly re-populate combobox
                self.selectDbConnection.blockSignals(True)
                try:
                    self.selectDbConnection.clear()
                    self.selectDbConnection.addItems(list(connections.keys()))

                    # restore selection explicitly
                    idx = self.selectDbConnection.findText(desired)
                    if idx >= 0:
                        self.selectDbConnection.setCurrentIndex(idx)
                finally:
                    self.selectDbConnection.blockSignals(False)

                logger.info(
                    f"Number of items in ComboBox: {self.selectDbConnection.count()}"
                )
                for i in range(self.selectDbConnection.count()):
                    logger.info(
                        f"ComboBox Item {i}: {repr(self.selectDbConnection.itemText(i))}"
                    )
            else:
                # No connections found
                self.selectDbConnection.clear()
                QtWidgets.QMessageBox.warning(
                    self,
                    "Error",
                    "No database connections found. Please add one in the Data Source Manager.",
                )
        else:
            QtWidgets.QMessageBox.warning(
                self, "Error", "Postgres provider not found in QGIS."
            )

    def open_postgres_connection_dialog(self) -> None:
        """
        Open the Data Source Manager dialog focused on Postgres.
        """
        try:
            # Disable settings dialog to prevent user interaction while DSM is open
            self.setEnabled(False)

            # Open Data Source Manager to "Postgres" page
            iface.openDataSourceManagerPage("postgres")
        except Exception as e:
            QtWidgets.QMessageBox.critical(
                self, "Error", f"Failed to open Data Source Manager:\n{e}"
            )
            self.setEnabled(True)

    def validate_inputs(self) -> None:
        """
        Enable the OK button only if all required inputs are provided and TOML config is validated
        """
        logger.info(
            "[Settings] validate_inputs current=%r stored=%r",
            self.selectDbConnection.currentText(),
            load_setting("db_connection", ""),
        )
        if all(
            (
                self.selectDbConnection.currentText(),
                self.connType.currentText(),
                self.guiUrl.text(),
                self.selectAppschema.currentText(),
                self.selectConfigToml.currentText(),
            )
        ):
            # get full path from selected
            selected_config_toml = self.get_selected_config_full_path()

            try:
                self.layer_config = validate_layer_config(
                    selected_config_toml, self.selectAppschema.currentText()
                )
                # TODO validate TOML more detailed

                db_connection = self.selectDbConnection.currentText()

                if db_connection:
                    try:
                        uri = db.get_db_uri(db_connection)
                    except ValueError as e:
                        msg = f"Verbindungsparameter für {repr(db_connection)} konnten nicht verarbeitet werden"
                        iface.messageBar().pushCritical(PLUGIN_NAME, msg)
                        QgsMessageLog.logMessage(
                            message=f"{msg}\n{str(e)}",
                            tag=PLUGIN_NAME,
                            level=Qgis.Critical,
                        )
                        return
                    try:
                        db.test_db_connection(uri)
                    except Exception as e:
                        msg = f"Datenbankverbindung für {repr(db_connection)} konnte nicht hergestellt werden"
                        iface.messageBar().pushCritical(PLUGIN_NAME, msg)
                        QgsMessageLog.logMessage(
                            message=f"{msg}: {str(e)}",
                            tag=PLUGIN_NAME,
                            level=Qgis.Critical,
                        )
                        return
                    try:
                        db.verify_db_connection_schema(uri)
                    except ValueError:
                        msg = f"Datenbank {repr(db_connection)} enthält nicht die erforderlichen Tabellen; falls die Webapp durch das Plugin gestartet wird, werden diese beim Start erzeugt"
                        iface.messageBar().pushWarning(PLUGIN_NAME, msg)
                        QgsMessageLog.logMessage(
                            message=msg,
                            tag=PLUGIN_NAME,
                            level=Qgis.Warning,
                        )
                    except Exception as e:
                        msg = "Fehler beim Lesen der Datenbank"
                        iface.messageBar().pushWCritical(PLUGIN_NAME, msg)
                        QgsMessageLog.logMessage(
                            message=f"{msg}: {str(e)}",
                            tag=PLUGIN_NAME,
                            level=Qgis.Critical,
                        )
                        return

                self.buttonBox.button(QDialogButtonBox.Ok).setEnabled(True)

                # reset error in UI
                self.configFileLabel.setStyleSheet("QLabel { color: black;}")
                self.selectConfigToml.setStyleSheet("QComboBox { color: black;}")

            except Exception as e:
                self.buttonBox.button(QDialogButtonBox.Ok).setEnabled(False)

                # mark error in UI
                self.configFileLabel.setStyleSheet("QLabel { color: red;}")
                self.selectConfigToml.setStyleSheet("QComboBox { color: red;}")

                user_msg = "Die Konfigurationsdatei ist fehlerhaft! Bitte Einstellungen prüfen."
                msg = str(e)
                QgsMessageLog.logMessage(
                    message=f"{user_msg} {msg}",
                    tag=PLUGIN_NAME,
                    level=Qgis.MessageLevel.Critical,
                )
                iface.messageBar().pushMessage(
                    title=PLUGIN_NAME,
                    text=user_msg,
                    level=Qgis.Critical,
                )
                # open the log dock
                iface.openMessageLog(tabName=PLUGIN_NAME)

        else:
            self.buttonBox.button(QDialogButtonBox.Ok).setEnabled(False)

    def get_selected_config_full_path(self) -> str:
        """
        Return the full path of the selected configuration file.
        """
        selected_config_dir = (
            self.default_dir_toml
            if self.selected_dir_toml is None
            else self.selected_dir_toml
        )
        if selected_config_dir:
            selected_config_toml = str(
                (Path(selected_config_dir) / self.selectConfigToml.currentText())
            )
        else:
            selected_config_toml = None

        return selected_config_toml

    def save_settings(self) -> None:
        """
        Save settings and test the database connection & config file
        """
        db_connection = self.selectDbConnection.currentText()
        webapp_url = self.guiUrl.text().strip()
        connection_type = self.connType.currentText()
        appschema = self.selectAppschema.currentText()

        # get full path from selected
        selected_config_toml = self.get_selected_config_full_path()
        selected_config_dir = Path(selected_config_toml).parent

        has_config_changed = self.selected_config_toml != selected_config_toml

        # Save settings
        save_setting("db_connection", db_connection)
        save_setting("webapp_url", webapp_url)
        save_setting("connection_type", connection_type)
        save_setting("appschema", appschema)
        save_setting("selected_config_dir", selected_config_dir)
        save_setting("selected_config_toml", selected_config_toml)

        if parent := self.parent():
            if has_config_changed and (
                plan_manager := getattr(parent, "plan_manager", None)
            ):
                plan_manager.config = self.layer_config

            if init_pm := getattr(parent, "initialize_plan_manager", None):
                init_pm()

        logger.info(
            f"Saved settings: db_connection={db_connection}, webapp_url={webapp_url}"
        )

        # Construct URI using db_utils
        iface.messageBar().pushMessage(
            title=PLUGIN_NAME,
            text="Einstellungen gespeichert.",
            level=Qgis.MessageLevel.Success,
        )
        self.accept()


def parse_host_port(v: str, default_host="127.0.0.1", default_port=8000):
    if not v:
        return default_host, default_port
    if "://" not in v:
        v = "http://" + v
    u = urlparse(v)
    return (u.hostname or default_host, u.port or default_port)
